Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/sporty-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Sporty sync

# Azure Static Web Apps managed functions only run HTTP triggers — the timer
# trigger in app/api/sportySync.js never fires in production. This scheduled
# workflow drives the sync externally by POSTing to /api/sporty-sync.
#
# Required repository secrets:
# SPORTY_SYNC_URL — full endpoint URL, e.g. https://workout.umulig.org/api/sporty-sync
# SPORTY_SYNC_API_KEY — same value as the SPORTY_SYNC_API_KEY app setting in Azure
on:
schedule:
# 04:00, 11:00, 14:00 and 22:00 UTC daily. 22:00 UTC = midnight Oslo
# (CEST/UTC+2) — captures the next day's sessions while Sporty still
# returns them as "tomorrow". GitHub cron is best-effort and may be
# delayed under load; the 7-day lookback makes each run self-healing.
- cron: '0 4,11,14,22 * * *'
workflow_dispatch:
inputs:
daysBack:
description: 'Days to look back (self-heal window)'
required: false
default: '7'

# Don't pile up overlapping syncs if a run is slow.
concurrency:
group: sporty-sync
cancel-in-progress: false

jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 5
name: Trigger sporty.no sync
steps:
- name: POST /api/sporty-sync
env:
SPORTY_SYNC_URL: ${{ secrets.SPORTY_SYNC_URL }}
SPORTY_SYNC_API_KEY: ${{ secrets.SPORTY_SYNC_API_KEY }}
DAYS_BACK: ${{ github.event.inputs.daysBack || '7' }}
run: |
set -euo pipefail
if [ -z "${SPORTY_SYNC_URL:-}" ] || [ -z "${SPORTY_SYNC_API_KEY:-}" ]; then
echo "::error::SPORTY_SYNC_URL and SPORTY_SYNC_API_KEY repository secrets must be set"
exit 1
fi
http=$(curl -sS -o response.json -w '%{http_code}' \
--retry 3 --retry-delay 5 --retry-all-errors --max-time 120 \
-X POST "$SPORTY_SYNC_URL" \
-H "Content-Type: application/json" \
-H "X-Api-Key: $SPORTY_SYNC_API_KEY" \
--data "{\"daysBack\": ${DAYS_BACK}}")
echo "HTTP $http"
cat response.json || true
echo
if [ "$http" != "200" ]; then
echo "::error::sporty.no sync failed with HTTP $http"
exit 1
fi
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ All notable changes to Workout Lens are documented here.

## [Unreleased]

## [1.5.17] — 2026-06-25

### Developer / Infrastructure
- **Fix sporty.no sync never running — timer trigger unsupported on SWA managed functions** — the automatic calendar sync was implemented as an Azure Functions timer trigger (`app.timer('sportySyncTimer', ...)`) in `sportySync.js`. Azure Static Web Apps **managed functions run HTTP triggers only** — timer/cron triggers are silently ignored and never register, so the scheduled sync never fired in production (the secondary `AZURE_FUNCTIONS_ENVIRONMENT === 'Production'` guard was moot). Fix: removed the dead timer and drive the sync externally with a new GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to `/api/sporty-sync` at 04:00, 11:00, 14:00 and 22:00 UTC with a 7-day self-healing lookback. The `POST /api/sporty-sync` endpoint now accepts machine auth (`X-Api-Key: <SPORTY_SYNC_API_KEY>`) in addition to the existing Supabase JWT (`X-Supabase-Token`) for manual kicks. **Setup: add `SPORTY_SYNC_URL` and `SPORTY_SYNC_API_KEY` as GitHub Actions repo secrets.** Documented as pitfall #270.
- **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
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Skeleton dark-mode tokens must be added to `[data-theme="g100"]` in `carbon-toke

**Azure Functions entry:** `app/api/index.js` must import every new function file — Azure v4 only loads what `main` references. API files must use raw `fetch` to Supabase REST — never `import { createClient } from '@supabase/supabase-js'`.

**Sporty sync:** `sportySync.js` timer triggers 04:00, 11:00, 14:00 UTC. Timer guarded by `AZURE_FUNCTIONS_ENVIRONMENT === 'Production'` — skipped in local dev.
**Sporty sync:** SWA managed functions run **HTTP triggers only** — Azure Functions timer triggers never fire in production (see pitfall #270). The sync is driven by a GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to `/api/sporty-sync` at 04:00, 11:00, 14:00, 22:00 UTC with `{"daysBack":7}`. The endpoint accepts either `X-Api-Key: <SPORTY_SYNC_API_KEY>` (automation) or `X-Supabase-Token: <JWT>` (manual kick from a signed-in user). Do NOT re-add an `app.timer(...)` — it is dead code on this platform.

**Recs cache:** Bump `RECS_PROMPT_VERSION` in both `prompts.js` AND `recsCacheCleanup.js` whenever the recommendation prompt or model changes. A CI test (`recsVersion.test.js`) fails if they drift.

Expand Down Expand Up @@ -184,3 +184,6 @@ Default `GRANT ALL` gives anon TRUNCATE (bypasses RLS). **Only grant what PostgR

### #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.

### #270 — SWA managed functions ignore timer triggers
Azure Static Web Apps **managed** functions (`api_location: "app/api"` in `ci.yml`) run **HTTP triggers only** — `app.timer(...)` and every other non-HTTP trigger is silently dropped, never registers, and never fires. This is why the sporty.no sync never ran: it was an `app.timer('sportySyncTimer', ...)`. **Fix: drive scheduled work from outside** — a GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to the HTTP endpoint. Never schedule recurring work with `app.timer` on this platform; the only escape hatch is "bring your own Functions app". [Docs](https://learn.microsoft.com/azure/static-web-apps/apis-functions#constraints).
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ app/
api/
index.js # Entry point — imports all Azure Functions
claude.js # Azure Function — proxies requests to Anthropic API
sportySync.js # Azure Function — timer (04:00+11:00 UTC) + HTTP trigger for sporty.no sync
sportySync.js # Azure Function — HTTP triggers for sporty.no sync (driven by GitHub Actions cron; SWA managed functions can't run timers)
sportyUtils.js # Pure utility — normalizeName() (no Azure SDK dep; unit-tested)
host.json # Azure Functions runtime config
package.json # API dependencies
Expand Down Expand Up @@ -161,13 +161,19 @@ Hosted on **Azure Static Web Apps** — every push to `master` triggers a build

Live URL: `https://workout.umulig.org`

### Scheduled sporty.no sync

Azure SWA managed functions run **HTTP triggers only** — Azure Functions timer/cron triggers are silently ignored ([docs](https://learn.microsoft.com/azure/static-web-apps/apis-functions#constraints)). The sporty.no calendar sync is therefore driven by a GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to `/api/sporty-sync` at 04:00, 11:00, 14:00 and 22:00 UTC with a 7-day self-healing lookback. It authenticates with the `SPORTY_SYNC_API_KEY` secret. Run it on demand via **Actions → Sporty sync → Run workflow**.

### Required secrets (GitHub Actions)

| Secret | Purpose |
|---|---|
| `VITE_SUPABASE_URL` | Injected into frontend bundle via `env:` block on the build step |
| `VITE_SUPABASE_ANON_KEY` | Injected into frontend bundle via `env:` block on the build step |
| `AZURE_STATIC_WEB_APPS_API_TOKEN_<YOUR_SWA_NAME>` | Azure deploy token (the exact name is generated by Azure when you create the SWA resource; find it in the deployment workflow Azure downloads to your repo) |
| `SPORTY_SYNC_URL` | Full sync endpoint URL (e.g. `https://workout.umulig.org/api/sporty-sync`) — used by the `Sporty sync` cron workflow |
| `SPORTY_SYNC_API_KEY` | Machine auth for the `Sporty sync` cron workflow — must match the `SPORTY_SYNC_API_KEY` Azure app setting |

### Required app settings (Azure SWA)

Expand All @@ -176,8 +182,8 @@ Live URL: `https://workout.umulig.org`
| `ANTHROPIC_API_KEY` | Used by the Claude proxy function — never exposed to browser |
| `SUPABASE_URL` | Used by the Claude proxy (JWT verification) and sporty.no sync function |
| `VITE_SUPABASE_ANON_KEY` | Used by the Claude proxy to verify Supabase JWTs — same value as the GitHub Actions secret |
| `SUPABASE_SERVICE_ROLE_KEY` | Used by the sporty.no sync function (bypasses RLS — timer has no auth user) |
| `SPORTY_SYNC_API_KEY` | Required `x-api-key` header for `GET /api/sporty-health` (external monitoring check) — `POST /api/sporty-sync` now uses Supabase JWT auth |
| `SUPABASE_SERVICE_ROLE_KEY` | Used by the sporty.no sync function (bypasses RLS — sync has no auth user) |
| `SPORTY_SYNC_API_KEY` | Required `X-Api-Key` header for `GET /api/sporty-health` (monitoring) **and** the scheduled `POST /api/sporty-sync` (GitHub Actions cron). `POST /api/sporty-sync` also accepts a Supabase JWT (`X-Supabase-Token`) for manual kicks from a signed-in user. |

> **Note:** The frontend is built in the GitHub Actions runner (not by Oryx inside Azure SWA's Docker container). Oryx strips `VITE_*` env vars before spawning Vite, so they would never reach the bundle if built there. The workflow pre-builds `app/dist/` and the Azure SWA action uploads it directly via `app_location: "app/dist"`. Do not revert this.

Expand Down
54 changes: 33 additions & 21 deletions app/api/sportySync.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,15 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) {
return { ok: true, upserted: rows.length };
}

// ── Timer trigger: 22:00, 04:00, 11:00, and 14:00 UTC daily ──────────
// ── No timer trigger ──────────────────────────────────────────────────
// Azure Static Web Apps managed functions support HTTP triggers ONLY — timer
// (cron) triggers are silently ignored and never register. The scheduled sync
// is therefore driven externally by a GitHub Actions cron workflow
// (.github/workflows/sporty-sync.yml) that POSTs to /api/sporty-sync at
// 04:00, 11:00, 14:00 and 22:00 UTC with {"daysBack": 7}.
// 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,22 * * *',
handler: async (myTimer, context) => {
await syncGymCalendar(context, { daysBack: 7 });
},
});
}
// Sporty still returns them as "tomorrow".
// Docs: https://learn.microsoft.com/azure/static-web-apps/apis-functions#constraints

// ── HTTP trigger: health check ────────────────────────────────────────
// GET /api/sporty-health → returns most-recent gym_calendar row + count
Expand Down Expand Up @@ -228,23 +225,38 @@ app.http('sportySyncHealth', {
},
});

// ── HTTP trigger: manual kick + optional backfill ─────────────────────
// ── HTTP trigger: scheduled sync (cron) + manual kick + optional backfill ──
// POST /api/sporty-sync → sync today
// POST /api/sporty-sync {"daysBack":7} → self-healing 7-day lookback (cron default)
// POST /api/sporty-sync {"shiftDays":-7} → duplicate current data 7 days back
// Requires header: X-Supabase-Token: <valid Supabase JWT>
// (Azure SWA hijacks the Authorization header — never use it for app JWTs)
//
// Two auth paths are accepted:
// 1. Automation (GitHub Actions cron): header X-Api-Key: <SPORTY_SYNC_API_KEY>
// SWA managed functions only run HTTP triggers — no timer trigger ever fires
// in production (see .github/workflows/sporty-sync.yml), so an external
// scheduler drives the sync via this endpoint.
// 2. Manual kick from a signed-in user: header X-Supabase-Token: <valid JWT>
// (Azure SWA hijacks the Authorization header — never use it for app JWTs)
app.http('sportySyncHttp', {
methods: ['POST'],
route: 'sporty-sync',
authLevel: 'anonymous',
handler: async (request, context) => {
const token = request.headers.get('x-supabase-token');
const userId = await verifySupabaseJwt(
token,
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
);
if (!userId) {
const apiKey = request.headers.get('x-api-key');
const expectedKey = process.env.SPORTY_SYNC_API_KEY;
let authorized = Boolean(expectedKey && apiKey === expectedKey);

if (!authorized) {
const token = request.headers.get('x-supabase-token');
const userId = await verifySupabaseJwt(
token,
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
);
authorized = Boolean(userId);
}

if (!authorized) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "workout-lens",
"private": true,
"version": "1.5.16",
"version": "1.5.17",
"author": "Christopher Rotnes",
"license": "MIT",
"repository": {
Expand Down