diff --git a/src/lib/components/reminders/ReminderSetup.svelte b/src/lib/components/reminders/ReminderSetup.svelte new file mode 100644 index 0000000..4a7d32e --- /dev/null +++ b/src/lib/components/reminders/ReminderSetup.svelte @@ -0,0 +1,338 @@ + + + + + Gentle nudges · Nexus Kitchen + + +
+
+ + +
+ +

When would you like a nudge?

+

Soft reminders to eat — never naggy, always easy to dismiss.

+
+ + {#if remindersError()} + + {/if} + + {#each reminderDrafts() as draft (draft.mealSlot)} +
+ +
+ +
+ updateDraft(draft.mealSlot, { reminderTime: e.currentTarget.value })} + /> + +
+
+ +
+ {/each} + +

+ Change any of this anytime — nudges are always easy + to dismiss. +

+ +
+ {#if remindersSaved()} +

+ Saved. Nudges will follow these times. +

+ {/if} + + +
+
+
+ + diff --git a/src/lib/components/today/TodayDashboard.svelte b/src/lib/components/today/TodayDashboard.svelte index d1bfedc..ceffcc8 100644 --- a/src/lib/components/today/TodayDashboard.svelte +++ b/src/lib/components/today/TodayDashboard.svelte @@ -194,13 +194,18 @@

{/if} - +
+ + + + +
{#if logError()} @@ -324,6 +329,31 @@ gap: var(--space-4); flex-wrap: wrap; } + .greet__actions { + display: flex; + align-items: center; + gap: var(--space-2); + } + .greet__bell { + width: 42px; + height: 42px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + display: grid; + place-items: center; + font-size: 20px; + text-decoration: none; + } + .greet__bell:hover { + background: var(--surface-2); + color: var(--text); + } + .greet__bell:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; + } .greet__txt { flex: 1 1 340px; min-width: 0; diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index 9eb3a49..d2494cc 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -137,6 +137,54 @@ export type Database = { }; Relationships: []; }; + meal_reminders: { + Row: { + created_at: string; + days_of_week: number[]; + household_id: string | null; + id: string; + is_enabled: boolean; + meal_slot: Database['public']['Enums']['meal_slot']; + name: string; + notification_type: Database['public']['Enums']['reminder_notification_type']; + owner_id: string; + pre_alert_minutes: number | null; + reminder_time: string; + timezone: string; + updated_at: string; + }; + Insert: { + created_at?: string; + days_of_week?: number[]; + household_id?: string | null; + id?: string; + is_enabled?: boolean; + meal_slot: Database['public']['Enums']['meal_slot']; + name: string; + notification_type?: Database['public']['Enums']['reminder_notification_type']; + owner_id?: string; + pre_alert_minutes?: number | null; + reminder_time: string; + timezone?: string; + updated_at?: string; + }; + Update: { + created_at?: string; + days_of_week?: number[]; + household_id?: string | null; + id?: string; + is_enabled?: boolean; + meal_slot?: Database['public']['Enums']['meal_slot']; + name?: string; + notification_type?: Database['public']['Enums']['reminder_notification_type']; + owner_id?: string; + pre_alert_minutes?: number | null; + reminder_time?: string; + timezone?: string; + updated_at?: string; + }; + Relationships: []; + }; pantry_items: { Row: { barcode: string | null; @@ -314,6 +362,13 @@ export type Database = { isOneToOne: false; referencedRelation: 'prepped_meals'; referencedColumns: ['id']; + }, + { + foreignKeyName: 'portion_events_triggered_by_fkey'; + columns: ['triggered_by']; + isOneToOne: false; + referencedRelation: 'meal_logs'; + referencedColumns: ['id']; } ]; }; @@ -576,6 +631,53 @@ export type Database = { }; Relationships: []; }; + reminder_deliveries: { + Row: { + created_at: string; + detail: string | null; + id: string; + kind: Database['public']['Enums']['reminder_delivery_kind']; + local_date: string; + owner_id: string; + reminder_id: string; + sent_at: string | null; + status: Database['public']['Enums']['reminder_delivery_status']; + updated_at: string; + }; + Insert: { + created_at?: string; + detail?: string | null; + id?: string; + kind: Database['public']['Enums']['reminder_delivery_kind']; + local_date: string; + owner_id: string; + reminder_id: string; + sent_at?: string | null; + status?: Database['public']['Enums']['reminder_delivery_status']; + updated_at?: string; + }; + Update: { + created_at?: string; + detail?: string | null; + id?: string; + kind?: Database['public']['Enums']['reminder_delivery_kind']; + local_date?: string; + owner_id?: string; + reminder_id?: string; + sent_at?: string | null; + status?: Database['public']['Enums']['reminder_delivery_status']; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'reminder_deliveries_reminder_id_fkey'; + columns: ['reminder_id']; + isOneToOne: false; + referencedRelation: 'meal_reminders'; + referencedColumns: ['id']; + } + ]; + }; shopping_list_items: { Row: { category: Database['public']['Enums']['shopping_category']; @@ -736,7 +838,23 @@ export type Database = { [_ in never]: never; }; Functions: { - [_ in never]: never; + due_meal_reminders: { + Args: { p_now?: string; p_window_minutes?: number }; + Returns: { + kind: Database['public']['Enums']['reminder_delivery_kind']; + local_date: string; + meal_slot: Database['public']['Enums']['meal_slot']; + name: string; + owner_id: string; + planned_meal_name: string; + planned_meal_source: Database['public']['Enums']['planned_meal_source']; + pre_alert_minutes: number; + reminder_id: string; + reminder_time: string; + }[]; + }; + invoke_send_meal_reminders: { Args: never; Returns: undefined }; + seed_demo_data: { Args: { p_owner?: string }; Returns: Json }; }; Enums: { defrost_state: 'NOT_APPLICABLE' | 'FROZEN' | 'DEFROSTING' | 'READY'; @@ -747,6 +865,9 @@ export type Database = { planned_meal_status: 'PLANNED' | 'LOGGED' | 'SKIPPED' | 'SWAPPED'; portion_event_kind: 'INITIALIZED' | 'CONSUMED' | 'ADJUSTED'; prepped_meal_origin: 'PREP_SESSION' | 'DIRECT_ENTRY' | 'STORE_BOUGHT'; + reminder_delivery_kind: 'MAIN' | 'PRE_ALERT'; + reminder_delivery_status: 'PENDING' | 'SENT' | 'FAILED'; + reminder_notification_type: 'PUSH' | 'SILENT' | 'NONE'; shopping_category: | 'PRODUCE' | 'DAIRY' @@ -896,6 +1017,9 @@ export const Constants = { planned_meal_status: ['PLANNED', 'LOGGED', 'SKIPPED', 'SWAPPED'], portion_event_kind: ['INITIALIZED', 'CONSUMED', 'ADJUSTED'], prepped_meal_origin: ['PREP_SESSION', 'DIRECT_ENTRY', 'STORE_BOUGHT'], + reminder_delivery_kind: ['MAIN', 'PRE_ALERT'], + reminder_delivery_status: ['PENDING', 'SENT', 'FAILED'], + reminder_notification_type: ['PUSH', 'SILENT', 'NONE'], shopping_category: [ 'PRODUCE', 'DAIRY', diff --git a/src/lib/reminders/index.ts b/src/lib/reminders/index.ts new file mode 100644 index 0000000..f2eef4f --- /dev/null +++ b/src/lib/reminders/index.ts @@ -0,0 +1,25 @@ +// Public surface of the reminders feature (007). UI imports only from here. + +export { + DEFAULT_DRAFTS, + deviceTimezone, + draftColumns, + mergeWithDefaults, + toMealReminder, + toTimeInputValue, + type MealReminder, + type NotificationType, + type ReminderDraft +} from './types'; +export { fetchReminders, saveReminders, RemindersError } from './remindersService'; +export { + clearRemindersError, + loadReminders, + reminderDrafts, + remindersError, + remindersLoading, + remindersSaved, + remindersSaving, + saveAll, + updateDraft +} from './remindersStore.svelte'; diff --git a/src/lib/reminders/remindersService.ts b/src/lib/reminders/remindersService.ts new file mode 100644 index 0000000..ab0cb14 --- /dev/null +++ b/src/lib/reminders/remindersService.ts @@ -0,0 +1,57 @@ +// Typed data-access layer for meal reminders, built on supabase-js / PostgREST. +// Every call runs under the caller's RLS context — no owner filters; the database +// enforces isolation. Errors surface as RemindersError with calm messages. + +import { supabase } from '$lib/supabaseClient'; +import { ensureSession } from '$lib/session/session.svelte'; +import { + deviceTimezone, + draftColumns, + toMealReminder, + type MealReminder, + type ReminderDraft +} from './types'; + +/** A normalized application error carrying a friendly message plus the original for logging. */ +export class RemindersError extends Error { + readonly cause?: unknown; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'RemindersError'; + this.cause = cause; + } +} + +/** Ensure a session exists, throwing a RemindersError if sign-in failed. */ +async function requireSession() { + const user = await ensureSession(); + if (!user) throw new RemindersError("We couldn't start a session. Try refreshing the page."); + return user; +} + +/** All of the caller's reminders (at most one per slot — DB unique). */ +export async function fetchReminders(): Promise { + await requireSession(); + const { data, error } = await supabase.from('meal_reminders').select('*'); + if (error) throw new RemindersError("We couldn't load your nudges. Please try again.", error); + return (data ?? []).map(toMealReminder); +} + +/** + * Save the whole setup screen in one batch: upsert on (owner, slot), stamping + * every row with the device's current time zone so server-side delivery math + * follows the clock the user actually lives on. + */ +export async function saveReminders(drafts: ReminderDraft[]): Promise { + await requireSession(); + const timezone = deviceTimezone(); + const { data, error } = await supabase + .from('meal_reminders') + .upsert( + drafts.map((d) => draftColumns(d, timezone)), + { onConflict: 'owner_id,meal_slot' } + ) + .select(); + if (error) throw new RemindersError("We couldn't save your nudges. Please try again.", error); + return (data ?? []).map(toMealReminder); +} diff --git a/src/lib/reminders/remindersStore.svelte.ts b/src/lib/reminders/remindersStore.svelte.ts new file mode 100644 index 0000000..2af93ca --- /dev/null +++ b/src/lib/reminders/remindersStore.svelte.ts @@ -0,0 +1,80 @@ +// Runes-based store for the reminder setup screen (feature 007). Holds the +// per-slot drafts the screen edits (persisted rows overlaid onto the design's +// defaults) and saves them as one batch. Preference data is tiny and +// non-destructive, so writes are a plain save with a saving flag — no +// optimistic bookkeeping needed. + +import { fetchReminders, saveReminders } from './remindersService'; +import { mergeWithDefaults, DEFAULT_DRAFTS, type ReminderDraft } from './types'; + +// --------------------------------------------------------------------------- +// Module-level reactive state (Svelte 5 runes) +// --------------------------------------------------------------------------- + +let _drafts = $state(DEFAULT_DRAFTS.map((d) => ({ ...d }))); +let _loading = $state(false); +let _saving = $state(false); +let _saved = $state(false); +let _error = $state(null); + +// --------------------------------------------------------------------------- +// Public accessors (getter functions so consumers can read reactive state) +// --------------------------------------------------------------------------- + +export function reminderDrafts(): ReminderDraft[] { + return _drafts; +} +export function remindersLoading(): boolean { + return _loading; +} +export function remindersSaving(): boolean { + return _saving; +} +/** True after a successful save, until the next edit. */ +export function remindersSaved(): boolean { + return _saved; +} +export function remindersError(): string | null { + return _error; +} +export function clearRemindersError(): void { + _error = null; +} + +// --------------------------------------------------------------------------- +// Loads & writes +// --------------------------------------------------------------------------- + +export async function loadReminders(): Promise { + _loading = true; + _error = null; + try { + _drafts = mergeWithDefaults(await fetchReminders()); + } catch (err) { + _error = err instanceof Error ? err.message : "We couldn't load your nudges."; + } finally { + _loading = false; + } +} + +/** Patch one slot's draft in place (marks the screen dirty). */ +export function updateDraft(slot: ReminderDraft['mealSlot'], patch: Partial): void { + _drafts = _drafts.map((d) => (d.mealSlot === slot ? { ...d, ...patch } : d)); + _saved = false; +} + +/** Persist all drafts. Returns true on success. */ +export async function saveAll(): Promise { + _saving = true; + _error = null; + try { + _drafts = mergeWithDefaults(await saveReminders(_drafts)); + _saved = true; + return true; + } catch (err) { + _error = err instanceof Error ? err.message : "We couldn't save your nudges."; + return false; + } finally { + _saving = false; + } +} diff --git a/src/lib/reminders/types.spec.ts b/src/lib/reminders/types.spec.ts new file mode 100644 index 0000000..e9c3cf9 --- /dev/null +++ b/src/lib/reminders/types.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_DRAFTS, + draftColumns, + mergeWithDefaults, + toMealReminder, + toTimeInputValue, + type MealReminderRow +} from './types'; + +function row(overrides: Partial = {}): MealReminderRow { + return { + id: 'r-1', + owner_id: 'u-1', + household_id: null, + name: 'Lunch', + meal_slot: 'LUNCH', + reminder_time: '12:30:00', + pre_alert_minutes: null, + is_enabled: true, + days_of_week: [1, 2, 3, 4, 5, 6, 7], + notification_type: 'PUSH', + timezone: 'America/New_York', + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + ...overrides + }; +} + +describe('DEFAULT_DRAFTS', () => { + it('covers all four slots with the mockup defaults (dinner pre-alert, snacks off)', () => { + expect(DEFAULT_DRAFTS.map((d) => d.mealSlot)).toEqual([ + 'BREAKFAST', + 'LUNCH', + 'DINNER', + 'SNACK' + ]); + const dinner = DEFAULT_DRAFTS.find((d) => d.mealSlot === 'DINNER'); + expect(dinner).toMatchObject({ reminderTime: '18:30', preAlertMinutes: 30, isEnabled: true }); + const snack = DEFAULT_DRAFTS.find((d) => d.mealSlot === 'SNACK'); + expect(snack?.isEnabled).toBe(false); + }); +}); + +describe('toTimeInputValue', () => { + it('trims DB time to the HH:MM input shape', () => { + expect(toTimeInputValue('08:00:00')).toBe('08:00'); + expect(toTimeInputValue('18:30')).toBe('18:30'); + }); +}); + +describe('mergeWithDefaults', () => { + it('returns fresh defaults when nothing is persisted', () => { + const drafts = mergeWithDefaults([]); + expect(drafts).toEqual(DEFAULT_DRAFTS); + expect(drafts[0]).not.toBe(DEFAULT_DRAFTS[0]); // copies, safe to mutate + }); + + it('overlays persisted rows onto their slot, keeping slot order', () => { + const persisted = toMealReminder( + row({ reminder_time: '13:15:00', is_enabled: false, pre_alert_minutes: 15 }) + ); + const drafts = mergeWithDefaults([persisted]); + expect(drafts.map((d) => d.mealSlot)).toEqual(['BREAKFAST', 'LUNCH', 'DINNER', 'SNACK']); + expect(drafts[1]).toMatchObject({ + mealSlot: 'LUNCH', + reminderTime: '13:15', + preAlertMinutes: 15, + isEnabled: false + }); + // untouched slots keep their defaults + expect(drafts[2]).toMatchObject({ mealSlot: 'DINNER', reminderTime: '18:30' }); + }); +}); + +describe('draftColumns', () => { + it('maps a draft to upsert columns with the captured timezone', () => { + const [breakfast] = DEFAULT_DRAFTS; + expect(draftColumns(breakfast, 'Europe/Berlin')).toEqual({ + name: 'Breakfast', + meal_slot: 'BREAKFAST', + reminder_time: '08:00', + pre_alert_minutes: null, + is_enabled: true, + timezone: 'Europe/Berlin' + }); + }); +}); + +describe('toMealReminder', () => { + it('maps snake_case row fields to the camelCase app shape', () => { + expect(toMealReminder(row({ pre_alert_minutes: 30 }))).toEqual({ + id: 'r-1', + ownerId: 'u-1', + name: 'Lunch', + mealSlot: 'LUNCH', + reminderTime: '12:30:00', + preAlertMinutes: 30, + isEnabled: true, + daysOfWeek: [1, 2, 3, 4, 5, 6, 7], + notificationType: 'PUSH', + timezone: 'America/New_York', + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-01T00:00:00Z' + }); + }); +}); diff --git a/src/lib/reminders/types.ts b/src/lib/reminders/types.ts new file mode 100644 index 0000000..1be5480 --- /dev/null +++ b/src/lib/reminders/types.ts @@ -0,0 +1,132 @@ +// Domain types for meal reminders (feature 007). CamelCase application shapes +// mapped from the snake_case rows in `$lib/database.types`; the service layer +// returns these. +// +// Canonical shape: Domain Specification `MealReminder` — one reminder per meal +// slot (the DB enforces UNIQUE (owner, slot)), a local wall-clock time plus the +// owner's IANA time zone, an optional pre-alert (REQ-MR-002), and ISO days of +// week (1 = Monday .. 7 = Sunday, INV-PL-011). Delivery happens server-side +// (pg_cron → Edge Function → Pushover); the client only manages preferences. + +import type { Enums, Tables, TablesInsert } from '$lib/database.types'; +import type { MealSlot } from '$lib/planning/types'; + +export type NotificationType = Enums<'reminder_notification_type'>; + +export type MealReminderRow = Tables<'meal_reminders'>; +export type MealReminderInsert = TablesInsert<'meal_reminders'>; + +export interface MealReminder { + id: string; + ownerId: string; + name: string; + mealSlot: MealSlot; + reminderTime: string; // 'HH:MM:SS' local wall-clock time + preAlertMinutes: number | null; + isEnabled: boolean; + daysOfWeek: number[]; // ISO, 1 = Monday .. 7 = Sunday + notificationType: NotificationType; + timezone: string; // IANA zone the wall-clock time is anchored to + createdAt: string; + updatedAt: string; +} + +/** What the setup screen edits — one draft per slot, saved as a batch. */ +export interface ReminderDraft { + mealSlot: MealSlot; + name: string; + reminderTime: string; // 'HH:MM' (input[type=time] value) + preAlertMinutes: number | null; + isEnabled: boolean; +} + +/** + * The setup screen's starting point, per design/screens/mobile-reminder-setup.html: + * breakfast 8:00, lunch 12:30, dinner 6:30 PM with a 30-minute pre-alert, snacks + * 3:00 PM off by default. Every day of the week until day-picking ships. + */ +export const DEFAULT_DRAFTS: readonly ReminderDraft[] = [ + { + mealSlot: 'BREAKFAST', + name: 'Breakfast', + reminderTime: '08:00', + preAlertMinutes: null, + isEnabled: true + }, + { + mealSlot: 'LUNCH', + name: 'Lunch', + reminderTime: '12:30', + preAlertMinutes: null, + isEnabled: true + }, + { + mealSlot: 'DINNER', + name: 'Dinner', + reminderTime: '18:30', + preAlertMinutes: 30, + isEnabled: true + }, + { + mealSlot: 'SNACK', + name: 'Snacks', + reminderTime: '15:00', + preAlertMinutes: null, + isEnabled: false + } +]; + +/** The device's IANA time zone — what saved reminders are anchored to. */ +export function deviceTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +} + +/** 'HH:MM[:SS]' → the input[type=time] value ('HH:MM'). */ +export function toTimeInputValue(time: string): string { + return time.slice(0, 5); +} + +/** Overlay persisted reminders onto the default drafts (slot order preserved). */ +export function mergeWithDefaults(persisted: MealReminder[]): ReminderDraft[] { + return DEFAULT_DRAFTS.map((draft) => { + const saved = persisted.find((r) => r.mealSlot === draft.mealSlot); + if (!saved) return { ...draft }; + return { + mealSlot: saved.mealSlot, + name: saved.name, + reminderTime: toTimeInputValue(saved.reminderTime), + preAlertMinutes: saved.preAlertMinutes, + isEnabled: saved.isEnabled + }; + }); +} + +/** Map a draft to upsert columns (timezone captured at save time). */ +export function draftColumns(draft: ReminderDraft, timezone: string): MealReminderInsert { + return { + name: draft.name, + meal_slot: draft.mealSlot, + reminder_time: draft.reminderTime, + pre_alert_minutes: draft.preAlertMinutes, + is_enabled: draft.isEnabled, + timezone + }; +} + +/** Map a DB row to the app shape. */ +export function toMealReminder(row: MealReminderRow): MealReminder { + return { + id: row.id, + ownerId: row.owner_id, + name: row.name, + mealSlot: row.meal_slot, + reminderTime: row.reminder_time, + preAlertMinutes: row.pre_alert_minutes, + isEnabled: row.is_enabled, + daysOfWeek: row.days_of_week, + notificationType: row.notification_type, + timezone: row.timezone, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 38e4fed..92afc1a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -20,12 +20,14 @@ { label: 'Recipes', icon: 'ph-book-open', href: resolve('/recipes') }, { label: 'Pantry', icon: 'ph-jar', href: resolve('/pantry') }, { label: 'Shopping', icon: 'ph-shopping-cart-simple', href: resolve('/shopping') }, + { label: 'Nudges', icon: 'ph-bell-simple-ringing', href: resolve('/reminders') }, { label: 'Meal prep', icon: 'ph-cooking-pot', href: null } ] as const; // Bottom tabs (mobile) — five slots per design/mobile-*.html. Shopping is live now, - // so it takes the fifth slot from the still-unbuilt Meal prep surface. - const TABS = NAV.filter((n) => n.label !== 'Meal prep'); + // so it takes the fifth slot from the still-unbuilt Meal prep surface. Nudges is + // reachable on mobile from the Today header bell instead of a tab. + const TABS = NAV.filter((n) => n.label !== 'Meal prep' && n.label !== 'Nudges'); function tabLabel(label: string): string { return label === 'Meal prep' ? 'Prep' : label; diff --git a/src/routes/reminders/+page.svelte b/src/routes/reminders/+page.svelte new file mode 100644 index 0000000..a18df6a --- /dev/null +++ b/src/routes/reminders/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/supabase/config.toml b/supabase/config.toml index f6fc631..52c2b28 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -385,6 +385,14 @@ deno_version = 2 # [edge_runtime.secrets] # secret_key = "env(SECRET_VALUE)" +# Feature 007: meal reminders (pg_cron → this function → Pushover). +# verify_jwt is off because the anon key is a valid, public JWT — it gates +# nothing. The function instead requires the x-cron-secret shared secret +# (fail-closed; see supabase/functions/README.md for secrets and local serving). +[functions.send-meal-reminders] +enabled = true +verify_jwt = false + [analytics] enabled = true port = 56327 diff --git a/supabase/functions/README.md b/supabase/functions/README.md new file mode 100644 index 0000000..26be2e9 --- /dev/null +++ b/supabase/functions/README.md @@ -0,0 +1,70 @@ +# Edge Functions + +First introduced by Feature 007 (meal reminders). Conventions: one folder per +function, Deno 2, `npm:` specifiers for dependencies, service-role access only +here (the service key never reaches the client). + +## send-meal-reminders + +Delivery layer for meal reminders: pg_cron fires every minute → +`invoke_send_meal_reminders()` (SQL, no-ops until Vault is configured) → +`POST /functions/v1/send-meal-reminders` → `due_meal_reminders()` RPC → +Pushover. Dedup lives in `reminder_deliveries` (UNIQUE per occurrence), so the +function is safe to invoke as often as you like. + +### Caller authorization + +`verify_jwt` is **off** for this function: the anon key is a valid Supabase JWT +and public by design, so a JWT check gates nothing. The real credential is a +random shared secret sent as the `x-cron-secret` header and compared +constant-time inside the function. Fail-closed: without the header (or with +`CRON_SHARED_SECRET` unset) every request gets 401. + +```bash +openssl rand -hex 32 # generate once per environment, use it in BOTH places below +``` + +### Secrets + +Function env (`SUPABASE_URL` / service key are injected automatically when +deployed): + +```bash +supabase secrets set CRON_SHARED_SECRET= \ + PUSHOVER_APP_TOKEN= PUSHOVER_USER_KEY= +``` + +Vault (read by the cron invoker; per environment, via SQL editor): + +```sql +select vault.create_secret('https://.supabase.co', 'project_url'); +select vault.create_secret('', 'send_meal_reminders_secret'); +``` + +Until both Vault secrets exist the cron tick is a no-op, and until the Pushover +secrets exist the function replies `{"skipped": "pushover secrets not +configured"}` — nothing errors in an unconfigured environment. + +### Local development + +The local stack is normally started with `-x edge-runtime` (Windows port +exclusions), so serve the function explicitly: + +```bash +printf 'CRON_SHARED_SECRET=...\nPUSHOVER_APP_TOKEN=...\nPUSHOVER_USER_KEY=...\n' \ + > supabase/functions/.env.local +supabase functions serve send-meal-reminders --env-file supabase/functions/.env.local +``` + +Invoke it manually: + +```bash +curl -X POST http://127.0.0.1:56321/functions/v1/send-meal-reminders \ + -H "x-cron-secret: $CRON_SHARED_SECRET" -H "Content-Type: application/json" -d '{}' +``` + +### Deploy + +```bash +supabase functions deploy send-meal-reminders +``` diff --git a/supabase/functions/send-meal-reminders/index.ts b/supabase/functions/send-meal-reminders/index.ts new file mode 100644 index 0000000..ce77c59 --- /dev/null +++ b/supabase/functions/send-meal-reminders/index.ts @@ -0,0 +1,196 @@ +// send-meal-reminders — the delivery layer for Feature 007 (issue #15). +// +// Invoked every minute by pg_cron (see 0010_meal_reminders.sql). All calendar, +// timezone and day-of-week logic lives in the due_meal_reminders() SQL function +// where pgTAP proves it; this function is only the dispatch loop: +// +// due occurrences → claim each via the deliveries UNIQUE key → Pushover → +// record SENT / FAILED. +// +// The claim step (upsert with ignoreDuplicates) is what makes overlapping or +// retried cron runs safe: only the run that inserts the delivery row sends. +// There is no retry of failures — a nudge that didn't make it is dropped, never +// re-sent late (REQ-MR-005), and stays visible in reminder_deliveries. +// +// Copy rules (REQ-MR-004/005, REQ-UX-006..011): soft, no urgency, no guilt. +// Tone reference: design/screens/mobile-reminder.html. + +import { createClient } from 'npm:@supabase/supabase-js@2'; + +/** + * Caller gate. verify_jwt is off for this function (the anon key is a valid + * JWT and public by design, so it gates nothing) — the real credential is a + * random shared secret known only to the cron invoker (Vault) and this + * function's env. Compared via SHA-256 digests so the comparison is + * constant-time and length-independent. Fails closed: no secret configured + * means no caller is authorized. + */ +async function authorized(req: Request): Promise { + const secret = Deno.env.get('CRON_SHARED_SECRET'); + if (!secret) return false; + const encoder = new TextEncoder(); + const [given, expected] = await Promise.all([ + crypto.subtle.digest('SHA-256', encoder.encode(req.headers.get('x-cron-secret') ?? '')), + crypto.subtle.digest('SHA-256', encoder.encode(secret)) + ]); + const a = new Uint8Array(given); + const b = new Uint8Array(expected); + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +type DueReminder = { + reminder_id: string; + owner_id: string; + kind: 'MAIN' | 'PRE_ALERT'; + name: string; + meal_slot: 'BREAKFAST' | 'LUNCH' | 'DINNER' | 'SNACK'; + reminder_time: string; + pre_alert_minutes: number | null; + local_date: string; + planned_meal_name: string | null; + planned_meal_source: 'RECIPE' | 'PREPPED' | 'STORE_BOUGHT' | 'QUICK' | null; +}; + +const SLOT_LABEL: Record = { + BREAKFAST: 'breakfast', + LUNCH: 'lunch', + DINNER: 'dinner', + SNACK: 'a snack' +}; + +const MAIN_TITLE: Record = { + BREAKFAST: "It's around breakfast time", + LUNCH: "It's around lunchtime", + DINNER: "It's around dinnertime", + SNACK: 'A little something?' +}; + +function composeMessage(due: DueReminder): { title: string; message: string } { + const meal = due.planned_meal_name; + const readyPhrase = + due.planned_meal_source === 'PREPPED' ? 'is prepped and ready' : 'is ready to happen'; + + if (due.kind === 'PRE_ALERT') { + const mins = due.pre_alert_minutes ?? 30; + return { + title: `${SLOT_LABEL[due.meal_slot][0].toUpperCase()}${SLOT_LABEL[due.meal_slot].slice(1)} is coming up`, + message: meal + ? `${meal} in about ${mins} minutes. If you feel like getting started, now's a nice moment — no pressure.` + : `In about ${mins} minutes, if you feel like it. A head start is nice, skipping it is fine too.` + }; + } + + return { + title: MAIN_TITLE[due.meal_slot], + message: meal + ? `Your ${meal} ${readyPhrase} whenever you are. No rush — just a gentle nudge.` + : `Whenever you're ready — something small counts too. No rush, just a gentle nudge.` + }; +} + +async function sendPushover( + token: string, + user: string, + title: string, + message: string +): Promise<{ ok: boolean; detail: string }> { + const body = new URLSearchParams({ token, user, title, message }); + try { + const res = await fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + const text = await res.text(); + return { ok: res.ok, detail: res.ok ? 'pushover accepted' : `HTTP ${res.status}: ${text}` }; + } catch (err) { + return { + ok: false, + detail: `fetch failed: ${err instanceof Error ? err.message : String(err)}` + }; + } +} + +Deno.serve(async (req) => { + if (!(await authorized(req))) { + return Response.json({ error: 'unauthorized' }, { status: 401 }); + } + + const pushoverToken = Deno.env.get('PUSHOVER_APP_TOKEN'); + const pushoverUser = Deno.env.get('PUSHOVER_USER_KEY'); + if (!pushoverToken || !pushoverUser) { + // Not an error: the cron fires in every environment; only configured + // ones deliver. The skip is visible in the function logs. + return Response.json({ due: 0, sent: 0, skipped: 'pushover secrets not configured' }); + } + + const supabase = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, + { auth: { persistSession: false } } + ); + + const { data: due, error: dueError } = await supabase.rpc('due_meal_reminders'); + if (dueError) { + console.error('due_meal_reminders failed:', dueError.message); + return Response.json({ error: dueError.message }, { status: 500 }); + } + const dueRows = (due ?? []) as DueReminder[]; + if (dueRows.length === 0) { + return Response.json({ due: 0, sent: 0 }); + } + + // Claim occurrences: ignoreDuplicates means the returned rows are exactly + // the ones THIS run inserted — anything already claimed elsewhere drops out. + const { data: claimed, error: claimError } = await supabase + .from('reminder_deliveries') + .upsert( + dueRows.map((d) => ({ + reminder_id: d.reminder_id, + owner_id: d.owner_id, + kind: d.kind, + local_date: d.local_date + })), + { onConflict: 'reminder_id,kind,local_date', ignoreDuplicates: true } + ) + .select('id, reminder_id, kind, local_date'); + if (claimError) { + console.error('claiming deliveries failed:', claimError.message); + return Response.json({ error: claimError.message }, { status: 500 }); + } + + let sent = 0; + let failed = 0; + for (const claim of claimed ?? []) { + const row = dueRows.find( + (d) => + d.reminder_id === claim.reminder_id && + d.kind === claim.kind && + d.local_date === claim.local_date + ); + if (!row) continue; + + const { title, message } = composeMessage(row); + const result = await sendPushover(pushoverToken, pushoverUser, title, message); + if (result.ok) { + sent++; + } else { + failed++; + console.error(`delivery ${claim.id} failed: ${result.detail}`); + } + + const { error: markError } = await supabase + .from('reminder_deliveries') + .update({ + status: result.ok ? 'SENT' : 'FAILED', + detail: result.detail.slice(0, 500), + sent_at: result.ok ? new Date().toISOString() : null + }) + .eq('id', claim.id); + if (markError) console.error(`marking delivery ${claim.id} failed: ${markError.message}`); + } + + return Response.json({ due: dueRows.length, claimed: (claimed ?? []).length, sent, failed }); +}); diff --git a/supabase/migrations/0010_meal_reminders.sql b/supabase/migrations/0010_meal_reminders.sql new file mode 100644 index 0000000..e40d58d --- /dev/null +++ b/supabase/migrations/0010_meal_reminders.sql @@ -0,0 +1,308 @@ +-- Migration: 0010_meal_reminders +-- Feature: 007-meal-reminders (Supabase Cron → Edge Function → Pushover) +-- Creates the MealReminder entity (Domain Specification §MealReminder), the +-- delivery/dedup ledger the Edge Function writes, the due-computation RPC the +-- Edge Function calls, and the pg_cron schedule that invokes it every minute. +-- +-- Invariants enforced here (docs/nexus-kitchen-invariants.md): +-- INV-PL-010 (valid reminder time — guaranteed by the `time` type), +-- INV-PL-011 (enabled reminder has >= 1 day selected — CHECK). +-- REQ-MR-001..003 (recurring reminders, pre-alerts, per-slot timing) shape the +-- schema; REQ-MR-004/005 + REQ-UX-010 (guilt-free copy) live in the Edge Function. +-- +-- Timezone note: the domain entity stores a LocalTime with no zone, but Cron +-- runs in UTC — each reminder therefore carries the owner's IANA time zone +-- (captured client-side from Intl) and all due-time math happens in SQL via +-- `AT TIME ZONE`, where Postgres handles DST correctly. + +-- --------------------------------------------------------------------------- +-- Enums (meal_slot is reused from 0005_meal_plans.sql) +-- --------------------------------------------------------------------------- +CREATE TYPE reminder_notification_type AS ENUM ('PUSH', 'SILENT', 'NONE'); +CREATE TYPE reminder_delivery_kind AS ENUM ('MAIN', 'PRE_ALERT'); +CREATE TYPE reminder_delivery_status AS ENUM ('PENDING', 'SENT', 'FAILED'); + +-- --------------------------------------------------------------------------- +-- Table: meal_reminders +-- One reminder per (owner, meal slot) — the setup screen manages exactly one +-- row per slot (design/screens/mobile-reminder-setup.html). The domain model +-- would allow several per slot; the UNIQUE can be dropped if that ever ships. +-- --------------------------------------------------------------------------- +CREATE TABLE meal_reminders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id uuid NOT NULL DEFAULT auth.uid() REFERENCES auth.users (id) ON DELETE CASCADE, + household_id uuid, -- no FK until Household feature + + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100), + meal_slot meal_slot NOT NULL, -- reminders target a specific band (domain §MealSlot note) + + reminder_time time NOT NULL, -- INV-PL-010: `time` cannot hold an invalid clock value + pre_alert_minutes integer CHECK (pre_alert_minutes IS NULL OR pre_alert_minutes BETWEEN 1 AND 1440), -- REQ-MR-002; NULL = no pre-alert + + is_enabled boolean NOT NULL DEFAULT true, + -- ISO day numbers, 1 = Monday .. 7 = Sunday (matches extract(isodow ...)). + days_of_week smallint[] NOT NULL DEFAULT '{1,2,3,4,5,6,7}' + CHECK (days_of_week <@ ARRAY[1, 2, 3, 4, 5, 6, 7]::smallint[]), + CONSTRAINT meal_reminders_enabled_needs_days -- INV-PL-011 + CHECK (NOT is_enabled OR cardinality(days_of_week) >= 1), + + notification_type reminder_notification_type NOT NULL DEFAULT 'PUSH', + timezone text NOT NULL DEFAULT 'UTC' CHECK (char_length(timezone) BETWEEN 1 AND 64), + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT meal_reminders_owner_slot_unique UNIQUE (owner_id, meal_slot) +); + +CREATE INDEX meal_reminders_owner_idx ON meal_reminders (owner_id); +CREATE INDEX meal_reminders_enabled_idx ON meal_reminders (is_enabled) WHERE is_enabled; + +-- --------------------------------------------------------------------------- +-- Shape validation + normalization (pattern: 0009). Sorts/dedups days_of_week +-- so the INV-PL-011 cardinality check counts distinct days, and rejects time +-- zone names Postgres does not know (a CHECK cannot catch the raised error, +-- and a bad zone would silently break every due-time computation). +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION meal_reminders_validate() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + NEW.days_of_week := COALESCE( + (SELECT array_agg(DISTINCT d ORDER BY d) FROM unnest(NEW.days_of_week) AS d), + '{}'::smallint[] + ); + BEGIN + PERFORM now() AT TIME ZONE NEW.timezone; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'timezone must be a valid IANA time zone name' + USING ERRCODE = 'check_violation'; + END; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_meal_reminders_validate + BEFORE INSERT OR UPDATE ON meal_reminders + FOR EACH ROW EXECUTE FUNCTION meal_reminders_validate(); + +CREATE TRIGGER trg_meal_reminders_updated_at + BEFORE UPDATE ON meal_reminders + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- Row-Level Security (P7: enabled, default-deny, owner-scoped). Full CRUD for +-- the owner — a reminder is plain preference data with no append-only rules. +-- --------------------------------------------------------------------------- +ALTER TABLE meal_reminders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "meal_reminders_owner_all" + ON meal_reminders FOR ALL + USING (owner_id = auth.uid()) + WITH CHECK (owner_id = auth.uid()); + +ALTER PUBLICATION supabase_realtime ADD TABLE meal_reminders; + +GRANT SELECT, INSERT, UPDATE, DELETE ON meal_reminders TO authenticated; +GRANT ALL ON meal_reminders TO service_role; +-- anon gets SELECT capability only; RLS still yields zero rows pre-login. +GRANT SELECT ON meal_reminders TO anon; + +-- --------------------------------------------------------------------------- +-- Table: reminder_deliveries — the dedup ledger. One row per attempted send; +-- the UNIQUE key is what makes Cron retries and overlapping runs safe (the +-- Edge Function claims an occurrence with INSERT .. ON CONFLICT DO NOTHING). +-- +-- Written ONLY by the Edge Function (service_role): authenticated gets no +-- INSERT/UPDATE/DELETE grant and no write policy. That is deliberate — it also +-- means the table needs NO SECURITY DEFINER ownership guard on reminder_id +-- (the trigger pattern from 0007/0009 that issue #14 flags as blocking +-- service-role writes, because auth.uid() is NULL there). No client can write +-- a cross-user reference through a table it cannot write to at all. +-- --------------------------------------------------------------------------- +CREATE TABLE reminder_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + reminder_id uuid NOT NULL REFERENCES meal_reminders (id) ON DELETE CASCADE, + -- Denormalized (no auth.uid() default — the writer is service_role, where + -- auth.uid() is NULL; the Edge Function supplies it from the reminder row). + owner_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + + kind reminder_delivery_kind NOT NULL, + local_date date NOT NULL, -- the occurrence's calendar date in the reminder's own time zone + + status reminder_delivery_status NOT NULL DEFAULT 'PENDING', + detail text CHECK (detail IS NULL OR char_length(detail) <= 500), + sent_at timestamptz, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- The dedup key: one attempt per reminder occurrence. No retry loop in v1 — + -- a failed send stays FAILED (visible here + in function logs) and the next + -- occurrence proceeds normally; a missed nudge is never re-sent late (REQ-MR-005). + CONSTRAINT reminder_deliveries_occurrence_unique UNIQUE (reminder_id, kind, local_date) +); + +CREATE INDEX reminder_deliveries_owner_date_idx ON reminder_deliveries (owner_id, local_date DESC); + +CREATE TRIGGER trg_reminder_deliveries_updated_at + BEFORE UPDATE ON reminder_deliveries + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE reminder_deliveries ENABLE ROW LEVEL SECURITY; + +-- Owners may see their delivery history (e.g. future "you got a nudge" UI); +-- deliberately NO INSERT/UPDATE/DELETE policy — writes are service_role only. +CREATE POLICY "reminder_deliveries_owner_select" + ON reminder_deliveries FOR SELECT + USING (owner_id = auth.uid()); + +GRANT SELECT ON reminder_deliveries TO authenticated; +GRANT ALL ON reminder_deliveries TO service_role; +GRANT SELECT ON reminder_deliveries TO anon; +REVOKE INSERT, UPDATE, DELETE ON reminder_deliveries FROM authenticated, anon; + +-- --------------------------------------------------------------------------- +-- due_meal_reminders(): everything time-zone- and calendar-shaped lives here, +-- in SQL, where pgTAP can prove it — the Edge Function is just a dispatch loop. +-- +-- A reminder occurrence (MAIN at reminder_time, PRE_ALERT at reminder_time − +-- pre_alert_minutes) is due when, in the reminder's own time zone, it falls +-- inside (now − window, now] on a selected ISO day, and no delivery row exists +-- for it yet. The window is a catch-up allowance for Cron jitter, sized small +-- on purpose: a reminder the scheduler missed by more than ~10 minutes is +-- dropped, never delivered stale (REQ-MR-005). Occurrences whose window spans +-- local midnight (including pre-alerts of an after-midnight meal) are a known +-- non-goal — meal reminders live in daytime. +-- +-- planned_meal_name/source personalize the copy ("Your Marinara pasta is +-- prepped and ready…") from the owner's still-PLANNED meal in that slot today. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.due_meal_reminders( + p_now timestamptz DEFAULT now(), + p_window_minutes integer DEFAULT 10 +) +RETURNS TABLE ( + reminder_id uuid, + owner_id uuid, + kind reminder_delivery_kind, + name text, + meal_slot meal_slot, + reminder_time time, + pre_alert_minutes integer, + local_date date, + planned_meal_name text, + planned_meal_source planned_meal_source +) +LANGUAGE sql STABLE +SET search_path = public +AS $$ + WITH occurrences AS ( + SELECT + r.id, r.owner_id, r.name, r.meal_slot, r.reminder_time, r.pre_alert_minutes, + k.kind, + (p_now AT TIME ZONE r.timezone) AS local_now, + ((p_now AT TIME ZONE r.timezone)::date + r.reminder_time + - CASE k.kind + WHEN 'PRE_ALERT'::reminder_delivery_kind THEN make_interval(mins => r.pre_alert_minutes) + ELSE interval '0' + END) AS occurs_at_local, + (p_now AT TIME ZONE r.timezone)::date AS local_date, + r.days_of_week + FROM meal_reminders r + CROSS JOIN LATERAL ( + VALUES ('MAIN'::reminder_delivery_kind), ('PRE_ALERT'::reminder_delivery_kind) + ) AS k (kind) + WHERE r.is_enabled + AND r.notification_type = 'PUSH' + AND (k.kind = 'MAIN' OR r.pre_alert_minutes IS NOT NULL) + ) + SELECT + o.id, o.owner_id, o.kind, o.name, o.meal_slot, o.reminder_time, o.pre_alert_minutes, + o.local_date, + pm.display_name, pm.source + FROM occurrences o + LEFT JOIN LATERAL ( + SELECT + COALESCE(p.recipe_title_snapshot, p.prepped_name_snapshot, + p.store_bought_name, p.quick_meal_name) AS display_name, + p.source + FROM planned_meals p + JOIN meal_plans mp ON mp.id = p.meal_plan_id + WHERE mp.owner_id = o.owner_id + AND p.date = o.local_date + AND p.meal_slot = o.meal_slot + AND p.status = 'PLANNED' + ORDER BY p.sort_order + LIMIT 1 + ) pm ON true + WHERE o.occurs_at_local <= o.local_now + AND o.occurs_at_local > o.local_now - make_interval(mins => p_window_minutes) + AND extract(isodow FROM o.local_date)::smallint = ANY (o.days_of_week) + AND NOT EXISTS ( + SELECT 1 FROM reminder_deliveries d + WHERE d.reminder_id = o.id AND d.kind = o.kind AND d.local_date = o.local_date + ); +$$; + +-- Server-only: reads across all users, so it must never be callable through +-- the Data API with a user JWT. (SECURITY INVOKER: as service_role it bypasses +-- RLS; there is no definer privilege lying around for anyone else.) +REVOKE EXECUTE ON FUNCTION public.due_meal_reminders(timestamptz, integer) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.due_meal_reminders(timestamptz, integer) TO service_role; + +-- --------------------------------------------------------------------------- +-- Cron wiring: pg_cron ticks every minute → invoke_send_meal_reminders() → +-- pg_net POST to the send-meal-reminders Edge Function. The project URL and +-- the caller credential live in Vault (per-environment, never in a migration): +-- +-- select vault.create_secret('https://.supabase.co', 'project_url'); +-- select vault.create_secret('', 'send_meal_reminders_secret'); +-- +-- The credential is a random shared secret (openssl rand -hex 32), sent as +-- x-cron-secret and checked by the function against its CRON_SHARED_SECRET +-- env — NOT the anon key: any valid Supabase JWT (the anon key is one, and it +-- is public) would satisfy the gateway's verify_jwt, gating nothing. +-- +-- Until both secrets exist (e.g. fresh local stack) the function no-ops, so +-- the schedule is safe to create unconditionally. Repeat sends are impossible +-- regardless of how often this fires — dedup is the deliveries UNIQUE key. +-- --------------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS pg_cron; +CREATE EXTENSION IF NOT EXISTS pg_net; + +CREATE OR REPLACE FUNCTION public.invoke_send_meal_reminders() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_url text; + v_secret text; +BEGIN + SELECT decrypted_secret INTO v_url FROM vault.decrypted_secrets WHERE name = 'project_url'; + SELECT decrypted_secret INTO v_secret FROM vault.decrypted_secrets WHERE name = 'send_meal_reminders_secret'; + IF v_url IS NULL OR v_secret IS NULL THEN + RETURN; -- not configured in this environment — nothing to invoke + END IF; + PERFORM net.http_post( + url := v_url || '/functions/v1/send-meal-reminders', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-cron-secret', v_secret + ), + body := '{}'::jsonb, + timeout_milliseconds := 8000 + ); +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.invoke_send_meal_reminders() FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.invoke_send_meal_reminders() TO service_role; + +-- cron.schedule upserts by job name, so db reset / re-push stays idempotent. +SELECT cron.schedule( + 'send-meal-reminders', + '* * * * *', + 'SELECT public.invoke_send_meal_reminders()' +); diff --git a/supabase/tests/reminders_rls.test.sql b/supabase/tests/reminders_rls.test.sql new file mode 100644 index 0000000..147c18a --- /dev/null +++ b/supabase/tests/reminders_rls.test.sql @@ -0,0 +1,270 @@ +-- RLS isolation + integrity tests for meal_reminders / reminder_deliveries / +-- due_meal_reminders (feature 007, REQ-MR-001..003, INV-PL-010/011, P7/P14). +-- Run with: `supabase test db`. Requires 0010_meal_reminders applied. +-- +-- Proves (1) owner-only visibility and mutation of reminders, (2) the schema +-- invariants (days subset + INV-PL-011, pre-alert bounds, timezone validity, +-- one-per-slot, day normalization), (3) the deliveries ledger is read-only for +-- clients and deduped by occurrence, (4) due_meal_reminders is server-only and +-- its timezone/window/day/dedup logic is correct, (5) the cron job exists and +-- the invoker no-ops safely without Vault secrets. + +begin; +select plan(31); + +insert into auth.users (id, email) +values + ('11111111-1111-1111-1111-111111111111', 'alice@test.local'), + ('22222222-2222-2222-2222-222222222222', 'bob@test.local') +on conflict (id) do nothing; + +-- ── Act as Alice ─────────────────────────────────────────────────────────── +set local role authenticated; +select set_config( + 'request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', + true +); + +-- Friday 2026-07-10, 18:30 in New York = 22:30 UTC. Days deliberately messy +-- ({5,5,1}) to prove normalization; Friday (isodow 5) is selected. +insert into public.meal_reminders (id, name, meal_slot, reminder_time, pre_alert_minutes, timezone, days_of_week) +values ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Dinner time', 'DINNER', '18:30', 30, 'America/New_York', '{5,5,1}'); + +insert into public.meal_reminders (id, name, meal_slot, reminder_time, timezone) +values ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Lunch time', 'LUNCH', '12:30', 'Asia/Tokyo'); + +select is( + (select count(*) from public.meal_reminders), + 2::bigint, + 'Alice sees her two reminders' +); + +select is( + (select days_of_week from public.meal_reminders where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'), + '{1,5}'::smallint[], + 'days_of_week is deduped and sorted on write' +); + +-- Schema invariants +select throws_ok( + $$insert into public.meal_reminders (name, meal_slot, reminder_time, is_enabled, days_of_week) + values ('No days', 'BREAKFAST', '08:00', true, '{}')$$, + '23514', null, + 'INV-PL-011: enabled reminder with zero days is rejected' +); +select throws_ok( + $$insert into public.meal_reminders (name, meal_slot, reminder_time, days_of_week) + values ('Bad day', 'BREAKFAST', '08:00', '{8}')$$, + '23514', null, + 'days_of_week outside ISO 1..7 is rejected' +); +select throws_ok( + $$insert into public.meal_reminders (name, meal_slot, reminder_time, timezone) + values ('Bad zone', 'BREAKFAST', '08:00', 'Mars/Olympus_Mons')$$, + '23514', null, + 'unknown IANA time zone is rejected' +); +select throws_ok( + $$insert into public.meal_reminders (name, meal_slot, reminder_time, pre_alert_minutes) + values ('Bad prealert', 'BREAKFAST', '08:00', 0)$$, + '23514', null, + 'pre_alert_minutes = 0 is rejected (NULL means no pre-alert)' +); +select throws_ok( + $$insert into public.meal_reminders (name, meal_slot, reminder_time) + values ('Second dinner', 'DINNER', '20:00')$$, + '23505', null, + 'one reminder per (owner, slot): duplicate DINNER is rejected' +); + +-- Disabled reminder may have empty days (INV-PL-011 only binds when enabled) +insert into public.meal_reminders (name, meal_slot, reminder_time, is_enabled, days_of_week) +values ('Snack maybe', 'SNACK', '15:00', false, '{}'); +select is( + (select count(*) from public.meal_reminders where is_enabled = false), + 1::bigint, + 'disabled reminder with no days is allowed' +); + +-- Clients cannot write the delivery ledger at all (no grant, no policy) +select throws_ok( + $$insert into public.reminder_deliveries (reminder_id, owner_id, kind, local_date) + values ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'MAIN', '2026-07-10')$$, + '42501', null, + 'authenticated cannot INSERT reminder_deliveries' +); + +-- due_meal_reminders is server-only +select throws_ok( + $$select * from public.due_meal_reminders()$$, + '42501', null, + 'authenticated cannot execute due_meal_reminders' +); + +-- ── Act as Bob ───────────────────────────────────────────────────────────── +select set_config( + 'request.jwt.claims', + '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', + true +); + +select is( + (select count(*) from public.meal_reminders), + 0::bigint, + 'Bob sees none of Alice''s reminders' +); + +select lives_ok( + $$update public.meal_reminders set is_enabled = false + where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$, + 'Bob''s update of Alice''s reminder is silently scoped away by RLS' +); +select lives_ok( + $$delete from public.meal_reminders + where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$, + 'Bob''s delete of Alice''s reminder is silently scoped away by RLS' +); + +-- ── Server context (postgres, as the Edge Function's service_role would) ─── +reset role; + +select is( + (select count(*) from public.meal_reminders + where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' and is_enabled), + 1::bigint, + 'Alice''s reminder survives Bob''s update/delete attempts untouched' +); + +-- Alice has a still-PLANNED quick meal for Friday dinner → personalizes copy +insert into public.meal_plans (id, owner_id, start_date, end_date) +values ('cccccccc-cccc-cccc-cccc-cccccccccccc', '11111111-1111-1111-1111-111111111111', '2026-07-06', '2026-07-12'); +insert into public.planned_meals (id, meal_plan_id, date, meal_slot, source, quick_meal_name, servings, sort_order) +values ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '2026-07-10', 'DINNER', 'QUICK', 'Marinara pasta', 1, 0); + +-- MAIN occurrence: 18:30 America/New_York on Friday = 22:30 UTC +select is( + (select count(*) from public.due_meal_reminders('2026-07-10T22:31:00Z')), + 1::bigint, + 'MAIN dinner reminder is due one minute after its local time' +); +select is( + (select kind from public.due_meal_reminders('2026-07-10T22:31:00Z')), + 'MAIN'::reminder_delivery_kind, + 'the due occurrence is the MAIN one' +); +select is( + (select planned_meal_name from public.due_meal_reminders('2026-07-10T22:31:00Z')), + 'Marinara pasta', + 'the due row carries the planned meal name for personalized copy' +); +select is( + (select local_date from public.due_meal_reminders('2026-07-10T22:31:00Z')), + '2026-07-10'::date, + 'local_date is the occurrence date in the reminder''s zone' +); + +-- PRE_ALERT occurrence: 18:00 local = 22:00 UTC (REQ-MR-002) +select is( + (select kind from public.due_meal_reminders('2026-07-10T22:05:00Z')), + 'PRE_ALERT'::reminder_delivery_kind, + 'PRE_ALERT is due pre_alert_minutes before the meal' +); + +-- Window edges: too early / too late (never delivered stale, REQ-MR-005) +select is( + (select count(*) from public.due_meal_reminders('2026-07-10T22:29:00Z')), + 0::bigint, + 'nothing due one minute before reminder time (pre-alert window passed)' +); +select is( + (select count(*) from public.due_meal_reminders('2026-07-10T22:45:00Z')), + 0::bigint, + 'a reminder missed by more than the window is dropped, not sent late' +); + +-- Day-of-week: Saturday 2026-07-11 (isodow 6) is not in {1,5} +select is( + (select count(*) from public.due_meal_reminders('2026-07-11T22:31:00Z')), + 0::bigint, + 'reminder does not fire on an unselected day' +); + +-- Timezone math: Alice''s Tokyo lunch reminder — 12:30 Asia/Tokyo on Friday +-- 2026-07-10 = 03:30 UTC the same day +select is( + (select name from public.due_meal_reminders('2026-07-10T03:35:00Z')), + 'Lunch time', + 'due computation respects each reminder''s own time zone' +); + +-- Suppression: disabled and non-PUSH reminders never come due +update public.meal_reminders set is_enabled = false +where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +select is( + (select count(*) from public.due_meal_reminders('2026-07-10T22:31:00Z')), + 0::bigint, + 'disabled reminder is never due' +); +update public.meal_reminders set is_enabled = true, notification_type = 'SILENT' +where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +select is( + (select count(*) from public.due_meal_reminders('2026-07-10T22:31:00Z')), + 0::bigint, + 'SILENT reminder is never pushed' +); +update public.meal_reminders set notification_type = 'PUSH' +where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +-- Dedup: a delivery row for the occurrence excludes it from due results +insert into public.reminder_deliveries (reminder_id, owner_id, kind, local_date) +values ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'MAIN', '2026-07-10'); +select is( + (select count(*) from public.due_meal_reminders('2026-07-10T22:31:00Z')), + 0::bigint, + 'an occurrence with a delivery row is not due again' +); +select throws_ok( + $$insert into public.reminder_deliveries (reminder_id, owner_id, kind, local_date) + values ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'MAIN', '2026-07-10')$$, + '23505', null, + 'the occurrence UNIQUE key rejects a duplicate claim' +); + +-- Owner-scoped read of the ledger +set local role authenticated; +select set_config( + 'request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', + true +); +select is( + (select count(*) from public.reminder_deliveries), + 1::bigint, + 'Alice sees her own delivery history' +); +select set_config( + 'request.jwt.claims', + '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', + true +); +select is( + (select count(*) from public.reminder_deliveries), + 0::bigint, + 'Bob sees none of Alice''s deliveries' +); +reset role; + +-- Cron wiring +select is( + (select count(*) from cron.job where jobname = 'send-meal-reminders' and schedule = '* * * * *'), + 1::bigint, + 'the send-meal-reminders cron job is scheduled every minute' +); +select lives_ok( + $$select public.invoke_send_meal_reminders()$$, + 'invoker no-ops safely when Vault secrets are not configured' +); + +select * from finish(); +rollback; diff --git a/tests/reminders.e2e.ts b/tests/reminders.e2e.ts new file mode 100644 index 0000000..4c80fe9 --- /dev/null +++ b/tests/reminders.e2e.ts @@ -0,0 +1,171 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Reminders — the "Gentle nudges" setup screen (feature 007, REQ-MR-001..003). +// +// Same in-Node mock backend approach as today.e2e.ts, reduced to the one table +// this screen touches. The mock's POST implements PostgREST upsert semantics on +// (meal_slot) — one row per slot, matching the DB's UNIQUE (owner, slot). +// Delivery itself is server-side (Cron → Edge Function → Pushover) and out of +// e2e scope; what matters here is that preferences round-trip. +// --------------------------------------------------------------------------- + +interface Row { + [key: string]: unknown; + id: string; +} + +const USER_ID = 'user-e2e'; + +function makeSession() { + return { + access_token: 'header.payload.signature', + token_type: 'bearer', + expires_in: 86400, + expires_at: Math.floor(Date.now() / 1000) + 86400, + refresh_token: 'refresh-e2e', + user: { + id: USER_ID, + aud: 'authenticated', + role: 'authenticated', + email: '', + is_anonymous: true, + app_metadata: {}, + user_metadata: {}, + created_at: '2026-01-01T00:00:00Z' + } + }; +} + +function fulfillJson(route: Route, body: unknown, status = 200) { + return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); +} + +async function mockBackend(page: Page): Promise<{ meal_reminders: Row[] }> { + const db: { meal_reminders: Row[] } = { meal_reminders: [] }; + let idCounter = 0; + + await page.route(/auth\/v1\/(signup|token|user)/, (route) => { + if (route.request().url().includes('/user')) { + return fulfillJson(route, makeSession().user); + } + return fulfillJson(route, makeSession()); + }); + + await page.route(/rest\/v1\/meal_reminders(\?|$)/, async (route) => { + const request = route.request(); + const method = request.method(); + + if (method === 'GET') { + return fulfillJson(route, db.meal_reminders); + } + + if (method === 'POST') { + // Bulk upsert on (owner, meal_slot) — the saveReminders batch. + const body = request.postDataJSON() as Row | Row[]; + const incoming = Array.isArray(body) ? body : [body]; + const nowISO = new Date().toISOString(); + const saved = incoming.map((cols) => { + const existing = db.meal_reminders.find((r) => r.meal_slot === cols.meal_slot); + if (existing) { + Object.assign(existing, cols, { updated_at: nowISO }); + return existing; + } + const created: Row = { + owner_id: USER_ID, + household_id: null, + pre_alert_minutes: null, + is_enabled: true, + days_of_week: [1, 2, 3, 4, 5, 6, 7], + notification_type: 'PUSH', + created_at: nowISO, + updated_at: nowISO, + ...cols, + id: `rem-${++idCounter}` + }; + db.meal_reminders.push(created); + return created; + }); + return fulfillJson(route, saved, 201); + } + + return fulfillJson(route, []); + }); + + return db; +} + +test.describe('Reminders — Gentle nudges setup (REQ-MR-001..003)', () => { + test('renders the four slot rows with the design defaults', async ({ page }) => { + await mockBackend(page); + await page.goto('/reminders'); + + await expect(page.getByRole('heading', { name: 'When would you like a nudge?' })).toBeVisible(); + await expect(page.getByLabel('Breakfast', { exact: true })).toHaveValue('08:00'); + await expect(page.getByLabel('Lunch', { exact: true })).toHaveValue('12:30'); + await expect(page.getByLabel('Dinner', { exact: true })).toHaveValue('18:30'); + await expect(page.getByLabel('Snacks', { exact: true })).toHaveValue('15:00'); + + // Mockup defaults: dinner carries the 30-minute pre-alert, snacks start off. + await expect(page.getByLabel('Dinner pre-alert')).toHaveValue('30'); + await expect(page.getByRole('button', { name: 'Breakfast reminder' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(page.getByRole('button', { name: 'Snacks reminder' })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + }); + + test('Set reminders persists all four slots and confirms calmly', async ({ page }) => { + const db = await mockBackend(page); + await page.goto('/reminders'); + await expect(page.getByRole('heading', { name: 'When would you like a nudge?' })).toBeVisible(); + + await page.getByRole('button', { name: 'Set reminders' }).click(); + await expect(page.getByText('Saved. Nudges will follow these times.')).toBeVisible(); + + expect(db.meal_reminders).toHaveLength(4); + const dinner = db.meal_reminders.find((r) => r.meal_slot === 'DINNER'); + expect(dinner).toMatchObject({ reminder_time: '18:30', pre_alert_minutes: 30 }); + // The device's IANA zone rides along so server-side delivery math follows it. + expect(typeof dinner?.timezone).toBe('string'); + expect((dinner?.timezone as string).length).toBeGreaterThan(0); + }); + + test('edits round-trip: toggle snacks on, change its time, save, reload', async ({ page }) => { + const db = await mockBackend(page); + await page.goto('/reminders'); + await expect(page.getByRole('heading', { name: 'When would you like a nudge?' })).toBeVisible(); + + await page.getByRole('button', { name: 'Snacks reminder' }).click(); + await page.getByLabel('Snacks', { exact: true }).fill('16:00'); + await page.getByRole('button', { name: 'Set reminders' }).click(); + await expect(page.getByText('Saved. Nudges will follow these times.')).toBeVisible(); + + const snack = db.meal_reminders.find((r) => r.meal_slot === 'SNACK'); + expect(snack).toMatchObject({ is_enabled: true, reminder_time: '16:00' }); + + await page.reload(); + await expect(page.getByLabel('Snacks', { exact: true })).toHaveValue('16:00'); + await expect(page.getByRole('button', { name: 'Snacks reminder' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + test('is reachable from the Today header bell, and Maybe later exits back', async ({ page }) => { + await mockBackend(page); + // The bell needs no other Today data — empty states are fine. + await page.route(/rest\/v1\/(?!meal_reminders)/, (route) => fulfillJson(route, [])); + await page.goto('/today'); + + await page.getByRole('link', { name: 'Gentle nudges' }).click(); + await page.waitForURL(/\/reminders$/); + await expect(page.getByRole('heading', { name: 'When would you like a nudge?' })).toBeVisible(); + + await page.getByRole('button', { name: 'Maybe later' }).click(); + await page.waitForURL(/\/today$/); + }); +});