From 3f1f36e470e99370285005e6e0dc9f73c8529a8e Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Wed, 27 May 2026 22:34:07 +0200 Subject: [PATCH 1/2] fix(#266): add midnight Oslo sync run and safe cleanup of stale future sessions Sporty's API ignores period_start and always returns sessions from tomorrow onwards, so same-day sessions could only ever be captured the night before. Adding a 22:00 UTC timer run (midnight Oslo CEST) ensures next-day sessions are captured while Sporty still treats them as "tomorrow". The sync now also deletes future gym_calendar rows within the sync window before each upsert, removing cancelled/rescheduled sessions that Sporty has dropped. The cleanup only runs when Sporty returns data (guards against API outages) and never touches rows before the next Oslo midnight (preserves same-day sessions from the 22:00 UTC run across later runs in the same day). Backfill runs (shiftDays != 0) skip the cleanup entirely. Co-Authored-By: Claude Sonnet 4.6 --- app/api/sportySync.js | 69 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/app/api/sportySync.js b/app/api/sportySync.js index d917671..d038c42 100644 --- a/app/api/sportySync.js +++ b/app/api/sportySync.js @@ -7,19 +7,42 @@ import { normalizeName } from './sportyUtils.js'; const SPORTY_BASE_URL = 'https://sporty.no/api/v1/businessunits/8/groupactivities'; -function buildSportyUrl(daysAhead = 10, daysBack = 0) { - // sporty.no uses Oslo midnight = previous day T22:00:00.000Z (CEST / UTC+2) +const SYNC_DAYS_AHEAD = 10; + +function buildSportyUrl(daysBack = 0) { + // sporty.no uses Oslo midnight = previous day T22:00:00.000Z (CEST / UTC+2). + // NOTE: sporty.no ignores period_start and always returns sessions from tomorrow + // onwards — the period_start param is kept for intent documentation only. const start = new Date(); start.setUTCHours(22, 0, 0, 0); start.setUTCDate(start.getUTCDate() - 1 - daysBack); - const end = new Date(); - end.setUTCHours(22, 0, 0, 0); - end.setUTCDate(end.getUTCDate() - 1 + daysAhead); + const end = new Date(start); + end.setUTCDate(end.getUTCDate() + SYNC_DAYS_AHEAD); return `${SPORTY_BASE_URL}?period_start=${encodeURIComponent(start.toISOString())}&period_end=${encodeURIComponent(end.toISOString())}`; } +// Returns the next Oslo midnight as a UTC Date (22:00 UTC in CEST, 23:00 in CET). +// Used as the delete floor: we never remove sessions before this point so that +// same-day sessions captured by the 22:00 UTC run are preserved by later runs. +function nextOsloMidnightUTC() { + const d = new Date(); + d.setUTCHours(22, 0, 0, 0); + if (d <= new Date()) d.setUTCDate(d.getUTCDate() + 1); + return d; +} + +// Returns the sync window end (same arithmetic as buildSportyUrl). +function syncWindowEnd() { + const start = new Date(); + start.setUTCHours(22, 0, 0, 0); + start.setUTCDate(start.getUTCDate() - 1); + const end = new Date(start); + end.setUTCDate(end.getUTCDate() + SYNC_DAYS_AHEAD); + return end; +} + function shiftRow(row, shiftMs) { if (!shiftMs) return row; return { @@ -42,7 +65,7 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) { let sportyData; try { - const res = await fetch(buildSportyUrl(10, daysBack), { + const res = await fetch(buildSportyUrl(daysBack), { headers: { 'User-Agent': 'WorkoutLens/1.0 sporty-sync (Azure Functions)' }, }); if (!res.ok) throw new Error(`sporty.no returned ${res.status}`); @@ -63,7 +86,7 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) { })).filter(r => r.start_time && r.end_time); if (rows.length === 0) { - context.log('No sessions returned from sporty.no'); + context.log('No sessions returned from sporty.no — skipping cleanup to preserve existing data'); return { ok: true, upserted: 0 }; } @@ -74,6 +97,32 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) { context.log(`Shifting ${rows.length} rows by ${shiftDays} days`); } + // Safe cleanup: delete future gym_calendar rows within the sync window so + // cancelled/rescheduled sessions don't accumulate. Only runs for normal syncs + // (not backfills) and only when Sporty returned data (guards against outages). + // Rows before nextOsloMidnight are never touched — this preserves same-day + // sessions captured by the 22:00 UTC run from being lost by later runs. + if (shiftDays === 0) { + const floor = nextOsloMidnightUTC(); + const ceiling = syncWindowEnd(); + const cleanupRes = await fetch( + `${supabaseUrl}/rest/v1/gym_calendar?start_time=gte.${floor.toISOString()}&start_time=lte.${ceiling.toISOString()}`, + { + method: 'DELETE', + headers: { + 'apikey': serviceKey, + 'Authorization': `Bearer ${serviceKey}`, + 'Prefer': 'return=minimal', + }, + } + ); + if (cleanupRes.ok) { + context.log(`Cleaned up future gym_calendar rows in window ${floor.toISOString()} → ${ceiling.toISOString()}`); + } else { + context.warn('gym_calendar cleanup failed (non-fatal):', await cleanupRes.text()); + } + } + const upsertRes = await fetch( `${supabaseUrl}/rest/v1/gym_calendar?on_conflict=sporty_id`, { @@ -98,11 +147,13 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) { return { ok: true, upserted: rows.length }; } -// ── Timer trigger: 04:00, 11:00, and 14:00 UTC daily ───────────────── +// ── Timer trigger: 22:00, 04:00, 11:00, and 14:00 UTC daily ────────── +// 22:00 UTC = midnight Oslo (CEST/UTC+2) — captures next day's sessions while +// Sporty still returns them as "tomorrow". Later runs keep the schedule fresh. // Skipped locally — SWA CLI only supports HTTP triggers. if (process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Production') { app.timer('sportySyncTimer', { - schedule: '0 4,11,14 * * *', + schedule: '0 4,11,14,22 * * *', handler: async (myTimer, context) => { await syncGymCalendar(context, { daysBack: 7 }); }, From 64d2552b8698b2e307ba5baac7a8597cec0a3d79 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Fri, 19 Jun 2026 23:44:23 +0200 Subject: [PATCH 2/2] fix(sportySync): add User-Agent to Supabase write requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase rejects POST/DELETE with sb_secret service role key when no User-Agent is present — treats the request as a browser call. Azure Functions native fetch sends no User-Agent by default, so the cleanup DELETE and upsert POST were silently failing after each sync run. The cleanup wiped future gym_calendar rows; the upsert then failed to re-insert them, leaving gym_calendar empty from June 6 onwards while GET requests (health check) still worked. Added User-Agent header to both write requests. Post-deploy a manual backfill sync is needed to restore June 6+ data. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 5 +++++ CLAUDE.md | 3 +++ app/api/sportySync.js | 2 ++ 3 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfafa17..5381f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Workout Lens are documented here. +## [Unreleased] + +### Developer / Infrastructure +- **Fix sporty sync writes blocked by missing User-Agent** — Supabase rejects POST and DELETE requests from the `sb_secret` service role key when no `User-Agent` header is present (treats the request as a browser). Azure Functions' built-in `fetch` sends no User-Agent, so the cleanup DELETE and upsert POST in `sportySync.js` were silently failing after each sync — the cleanup wiped future rows, then the upsert failed to re-insert them, leaving `gym_calendar` empty from June 6 onwards. Added `User-Agent: WorkoutLens/1.0 sporty-sync (Azure Functions)` to both requests. A post-deploy manual backfill is needed to restore June data. + ## [1.5.16] — 2026-05-19 ### Accessibility diff --git a/CLAUDE.md b/CLAUDE.md index fbdf86c..4ebe317 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,3 +181,6 @@ Preview branches apply all migrations on a fresh empty DB. Delta migrations fail ### #237 — Excess anon grants + duplicate RLS policies Default `GRANT ALL` gives anon TRUNCATE (bypasses RLS). **Only grant what PostgREST needs.** Always `DROP POLICY IF EXISTS` old policies when replacing them. + +### #268 — Supabase blocks sb_secret writes without User-Agent +Supabase treats POST/DELETE with an `sb_secret` service role key and no `User-Agent` as a browser request and returns 403 "Forbidden use of secret API key in browser". Azure Functions' built-in `fetch` sends no User-Agent by default. **Always add `'User-Agent': 'WorkoutLens/1.0 sporty-sync (Azure Functions)'` to every write request (POST, DELETE, PATCH) that uses the service role key.** GET requests are unaffected. diff --git a/app/api/sportySync.js b/app/api/sportySync.js index d038c42..5e36f41 100644 --- a/app/api/sportySync.js +++ b/app/api/sportySync.js @@ -113,6 +113,7 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) { 'apikey': serviceKey, 'Authorization': `Bearer ${serviceKey}`, 'Prefer': 'return=minimal', + 'User-Agent': 'WorkoutLens/1.0 sporty-sync (Azure Functions)', }, } ); @@ -132,6 +133,7 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) { 'apikey': serviceKey, 'Authorization': `Bearer ${serviceKey}`, 'Prefer': 'resolution=merge-duplicates,return=minimal', + 'User-Agent': 'WorkoutLens/1.0 sporty-sync (Azure Functions)', }, body: JSON.stringify(rows), }