Internal tool for the GenOS marketing team to create, review, schedule, and track social media posts (LinkedIn, Instagram, X). Does not publish anything to those platforms — it's a shared tracking board, not an autoposter.
This file is the operational doc — how to run it, deploy it, and recover if something breaks or a key person is unreachable.
Next.js (App Router, TypeScript) · Supabase (Postgres, Auth, Storage, Realtime) · Azure App Service · shadcn/ui · @hello-pangea/dnd
Node 22. next requires >=20.9.0 and @supabase/supabase-js now warns it is dropping Node 20, so 22 is what CI and App Service both run. Older Node fails in a way that is easy to miss rather than loudly: npm treats sharp as an optional dependency and skips it on an unsupported Node, which breaks next/image at runtime rather than at build, and npm 8 quietly strips libc fields out of package-lock.json.
nvm use 22
npm ci
npm run devCopy .env.local.example to .env.local and fill it in (never commit the filled-in version — see .gitignore):
| Variable | What it's for |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase project URL |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY |
Supabase public/anon key |
SUPABASE_SERVICE_ROLE_KEY |
Server-only — bypasses RLS; used by the MCP server and cron jobs |
GOOGLE_OAUTH_CLIENT_ID |
GA4 Data API access (analytics sync) |
GOOGLE_OAUTH_CLIENT_SECRET |
GA4 Data API access |
GOOGLE_OAUTH_REFRESH_TOKEN |
GA4 Data API access |
GA4_PROPERTY_IDS |
Comma-separated GA4 property IDs to sync from |
CRON_SECRET |
Shared secret the two cron routes check for, so they can't be triggered by anyone else |
Run every file in supabase/ in this exact order, in the Supabase Dashboard → SQL Editor — the folder only contains files that are actually needed; superseded/one-off ones have been deleted.
schema.sqlteam-notes-read.sqlstorage-setup.sqlcomment-reactions.sqlauth-setup.sqlenable-realtime-comments.sqlcomment-replies.sqladd-post-number.sqlmarketing-role-and-suggestions.sqlsoft-delete-posts.sqlkeep-media.sqlboard-stages.sqlsuggestions-read.sqlpost-platform-published-urls.sqlpost-history.sqlshared-link-profile.sqlcomment-guest-name.sqlpost-analytics-content.sqlpost-analytics-conversions.sqlpost-analytics-geo.sqlpost-analytics-site.sqlpost-needs-changes-timestamp.sqlexternal-marketing-emails.sql
After the schema is in place, promote whoever needs marketing/board access with a one-off SQL update (update public.profiles set is_marketing = true where email = '...') — this is intentionally not exposed anywhere in the app UI.
Target: Azure App Service (Linux, Node 22, B1). git push to main runs
.github/workflows/deploy.yml, which builds on a GitHub runner and ships the
result to App Service. Migrated off Vercel — see "Why the build is shaped this
way" below, because the build has non-obvious requirements that are easy to
break by accident.
Env vars live in more than one place, and all of them are required:
- Azure Application Settings — everything in the table above, at runtime.
- GitHub repo variables —
NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, plusAPP_BASE_URL. The twoNEXT_PUBLIC_*values are inlined into the browser bundle at build time, so setting them only in Azure ships a client bundle containingundefined— the app boots, and login fails with nothing in the server logs to explain it. - GitHub repo secrets —
CRON_SECRET(same value as Azure's) andAZURE_WEBAPP_PUBLISH_PROFILE.
next.config.ts sets output: 'standalone', which emits a self-contained
.next/standalone/ with only traced dependencies. Three things about it bite:
public/and.next/static/are not copied in. The deploy workflow does it explicitly. Skip that and the app serves every page with no CSS, JS or images — and returns HTTP 200 the whole time.sharpmust be in the bundle (next/imageneeds it) and it is a native binary, so it must be installed on the same OS/arch that serves it. That is why the build runs onubuntu-latestrather than on someone's laptop. The workflow hard-fails if it is missing rather than shipping a broken bundle.- The startup command must be
node server.js, set explicitly..next/standalone/package.jsonis a verbatim copy of the real one, still declaring"start": "next start"and every devDependency. Left to its own devices App Service runs thatstartscript against a tree that cannot serve it, and Oryx tries to rebuild the app on the B1 box.SCM_DO_BUILD_DURING_DEPLOYMENT=falseis what stops the second half of that.
Check whether the latest run in the repo's Actions tab matches your latest
commit. To deploy by hand, re-run deploy.yml from the Actions tab
(workflow_dispatch is enabled) rather than building locally — a local build
produces a macOS sharp binary that will not run on App Service.
Two jobs, in .github/workflows/cron.yml:
| Path | Schedule (UTC) | What it does |
|---|---|---|
/api/cron/purge-old-media |
daily, 03:00 | Deletes old media for posts past their storage-purge window |
/api/cron/sync-ga4-analytics |
daily, 04:00 | Pulls the latest GA4 session data per post/platform |
Both are plain GETs authenticated with Authorization: Bearer $CRON_SECRET,
which is exactly what Vercel's scheduler used to send — the routes themselves
never changed, only the caller.
Things worth knowing before trusting these:
- Both handlers return HTTP 200 even when individual operations fail, with
the detail in an
errorsarray. The workflow parses the body and fails the run on a non-empty array; status code alone would stay green forever on a permanently broken job. - GitHub disables scheduled workflows after 60 days of repo inactivity, and scheduled runs only fire from the default branch. Runs can also start late. Fine for daily maintenance; do not build anything minute-exact on them.
- App Service drops requests idle for 230s, which the workflow's
--max-timematches.syncGa4Analyticsdoes sequential GA4 fetches and per-post upserts in nested loops, so this is a real ceiling as the board grows. The fix when it hits is chunking the job, not raising the timeout.
Either job can be run on demand from the Actions tab.
None of these should ever depend on one person alone being reachable. Status as of this writing:
- GitHub — repo lives in the
DareDataorganization (github.com/DareData/Social-Media-Post-Manager). Repo-level settings (visibility, collaborators) need an org Owner/Admin to grant — ask whoever administers the DareData GitHub org. - Supabase — currently a personal organization (not yet moved into a company-owned one). A second Owner should be added directly in that org's Team page (Organization → Team → Invite members) so access doesn't depend on one account — do this regardless of whether/when a "real" company org gets created, since project ownership can be transferred into one later (Project Settings → Transfer, works between any two orgs the transferring user is at least a Member of).
- Azure — holds the App Service the app runs on. Like Vercel before it, it holds the deploy pipeline and nothing irreplaceable: not the code (GitHub), not the data (Supabase). If access is lost, the app can be recreated from this repo with the
azcommands and the env var table above. That only works if the env var values are stored somewhere the company can reach — Azure's own Application Settings are not a backup of themselves, and the twoNEXT_PUBLIC_*values additionally live as GitHub repo variables. Confirm both are recorded somewhere durable. - Vercel — the previous host. Kept as a rollback path until the Azure cutover is confirmed working;
vercel.json(its cron config, now superseded) is still in the repo for the same reason. Delete both once Azure is trusted.