Skip to content

17-jd/snapcal

Repository files navigation

InviteReader

For developers / future-AI sessions: read docs/ARCHITECTURE.md first. It maps every URL → file, explains how the reminder chain / OAuth / calendar integrations fit together, and lists every external service + env var. Skim that and you'll have enough context to navigate the codebase without grepping.


Type it. Snap it. We'll calendar it.

InviteReader turns natural language and screenshots into calendar events using Google Gemini, then writes them to your Google Calendar (or hands you a .ics file).

  • Frontend: Next.js 14 (App Router) + TypeScript + Tailwind + shadcn-style primitives
  • AI: Google Gemini (gemini-2.0-flash-exp) for text + image extraction
  • Backend: Next.js API routes (Node runtime), Supabase (Postgres + Auth + Storage)
  • Auth: Supabase Auth → Google OAuth (with calendar.events scope). Apple is one config entry away.

1. Local development

pnpm install   # or npm/yarn — package-lock generation is skipped here
pnpm dev       # http://localhost:3000

You'll need a .env.local (see .env.example):

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...           # used server-side to read screenshots
GEMINI_API_KEY=...
NEXT_PUBLIC_APP_URL=http://localhost:3000

2. Supabase setup

  1. Create a project at https://supabase.com.
  2. Run the migrations (Supabase SQL Editor → paste each file):
    • supabase/migrations/0001_init.sqlprofiles, events, RLS, signup trigger
    • supabase/migrations/0002_storage.sqlscreenshots bucket + RLS
  3. Enable the Google provider under Authentication → Providers → Google:
    • Set the Google OAuth client_id and client_secret from a Google Cloud project (see step 3).
    • Under Additional scopes, add: https://www.googleapis.com/auth/calendar.events.
  4. Add redirect URLs under Authentication → URL Configuration:
    • http://localhost:3000/auth/callback
    • https://your-project.vercel.app/auth/callback
  5. Copy keys from Project Settings → API:
    • NEXT_PUBLIC_SUPABASE_URL ← Project URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEYanon public key
    • SUPABASE_SERVICE_ROLE_KEYservice_role JWT (server only, keep secret)

3. Google OAuth (for Calendar API)

  1. Open Google Cloud Console → APIs & Services → Credentials.
  2. Enable the Google Calendar API under Library.
  3. Create an OAuth 2.0 Client ID of type Web application.
  4. Authorized redirect URIs must include the Supabase callback:
    • https://<your-project-ref>.supabase.co/auth/v1/callback
  5. OAuth consent screen → Scopes → add .../auth/calendar.events.
  6. Paste the client ID/secret into Supabase (step 2.3 above).

prompt=consent + access_type=offline are forced in lib/auth-providers.ts, which is what makes Google return a refresh_token on first sign-in. Don't remove them or token expiry will end the session every hour.


4a. Microsoft / Outlook OAuth (optional — only if you want Outlook integration)

Step 1 — Register the app in Azure

  1. Go to https://entra.microsoft.comApplications → App registrations → New registration.
  2. Name: InviteReader (whatever).
  3. Supported account types: "Accounts in any organizational directory and personal Microsoft accounts" (gives you both work and personal Outlook users).
  4. Redirect URI: Web → https://<your-supabase-ref>.supabase.co/auth/v1/callback
  5. Register.

Step 2 — Add API permissions

  1. In the new app → API permissions → Add a permission → Microsoft Graph → Delegated.
  2. Add: Calendars.ReadWrite, User.Read, offline_access, email, openid, profile.
  3. (Optional) Click Grant admin consent for — only needed if you want a smoother first-run UX for users in that tenant.

Step 3 — Generate a client secret

  1. Certificates & secrets → Client secrets → New client secret. Pick max expiry (24 months).
  2. Copy the secret value immediately — you can't see it again.

Step 4 — Wire it up in Supabase

  1. Open https://supabase.com/dashboard/project/<your-ref>/auth/providers.
  2. Find Azure → enable it.
  3. Paste:
    • Application (client) ID from your Azure app's Overview page
    • Secret value from step 3
    • Azure tenant URL (or leave the default common for personal + work accounts).
  4. Save.

Step 5 — Add env vars (only needed for the manual refresh path)

Supabase auto-refreshes the session, but the Microsoft provider access token expires every ~1h and needs a manual refresh on the server. For that we need the client ID + secret available to our API route:

AZURE_CLIENT_ID=...
AZURE_CLIENT_SECRET=...
AZURE_TENANT_ID=common   # or your tenant id if you locked it down

Add these to .env.local and to Vercel's project env vars (Production + Preview).


4d. Reminder chain (Phase 5 — Twilio SMS, Resend email, Vercel Cron)

Auto-fires the SMS → call → SMS → call → email escalation when an event approaches. Builds on top of the Twilio Verify integration (4c) and vapi.ai (4b).

Step 1 — Buy a Twilio number for outbound + inbound SMS

Twilio's Verify integration uses a shared sender; SMS reminders need your own number.

  1. Twilio Console → Phone Numbers → Buy a Number (US, ~$1.15/month).
  2. After purchase, configure the number's Messaging webhook under "A MESSAGE COMES IN":
    • Webhook URL: https://invitereader.com/api/sms/inbound
    • HTTP method: POST
  3. Copy the number (E.164 format, e.g. +18005551234) → that's TWILIO_FROM_NUMBER.

For production, prefer a Messaging Service: Console → Messaging → Services → Create. Add your number to the service. The Service SID becomes TWILIO_MESSAGING_SERVICE_SID (overrides TWILIO_FROM_NUMBER). Messaging Services handle opt-out compliance, A2P 10DLC registration, and let you swap senders without code changes.

Step 2 — Set up Resend for fallback emails

  1. Sign up at https://resend.com (free tier: 3,000 emails/month).
  2. Domains → Add Domain → enter invitereader.com → Resend gives you SPF, DKIM, and a DMARC record. Add them at AWS Route 53 (same place you added the Vercel records). Verification takes ~5 min.
  3. API Keys → Create API Key with full access. Copy → that's RESEND_API_KEY.
  4. Until your domain is verified, send from onboarding@resend.dev (works immediately for testing).

Step 3 — Generate a cron secret

openssl rand -hex 32

That string is your CRON_SECRET. Vercel automatically passes it as Authorization: Bearer <CRON_SECRET> to the cron endpoint, so requests without it return 401.

Step 4 — Add env vars to Vercel

https://vercel.com/hirenfire/snapcal/settings/environment-variables (Production + Preview):

TWILIO_FROM_NUMBER=+1...                   # or TWILIO_MESSAGING_SERVICE_SID=MGxxx
RESEND_API_KEY=re_...
EMAIL_FROM=InviteReader Reminders <reminders@invitereader.com>
CRON_SECRET=<the random hex from step 3>

Step 5 — Run migration 0008

In the Supabase SQL editor, paste and run supabase/migrations/0008_reminder_chain.sql. Adds default_reminder_schedule to profiles, extends reminder_jobs.channel to allow email, and adds events.reminder_email_sent_at.

Step 6 — Set up an external every-minute cron (Hobby plan workaround)

Vercel Hobby cron is limited to once per day, which isn't fast enough for the reminder chain. The fix: use a free external service to hit our endpoint every minute.

cron-job.org (free, no card, reliable):

  1. Sign up at https://console.cron-job.org/signup.
  2. Create cronjob with:
    • Title: InviteReader reminder fire
    • URL: https://invitereader.com/api/cron/fire-reminders
    • Schedule: Every minute (* * * * *)
    • Method: GET
    • Header: Authorization: Bearer <your CRON_SECRET>
  3. Save. The job fires once a minute; the dashboard shows execution history.

Alternatives: Upstash QStash (500 messages/day free), EasyCron, GitHub Actions schedule: cron: '*/5 * * * *' (5-min minimum).

The vercel.json we ship registers a once-daily backup cron at 9 AM UTC, so even if the external pinger goes down for a day, any pending reminders get drained on the daily catch-up. Upgrade to Vercel Pro ($20/mo) to remove this dependency entirely — change "schedule" in vercel.json to "* * * * *" and skip the external cron.

Step 7 — Redeploy

Vercel reads vercel.json at build time to register the daily safety-net cron. Project → Logs → Runtime will show [cron] /api/cron/fire-reminders ✓ once a day from Vercel and (if you set up cron-job.org) once per minute from there.

How the chain works

When a user creates an event, extract-event schedules a chain of reminder_jobs rows:

Step Channel Default fire_at
1 SMS event ‒ 60 min
2 Voice (vapi.ai) event ‒ 45 min (15 min after SMS-1, if no reply)
3 SMS event ‒ 40 min (5 min after Call-1, if missed)
4 Voice event ‒ 30 min (final voice attempt)
5 Email (Resend) event ‒ 25 min (if everything else fails)

Three ways the chain ends early:

  1. User taps the link in an SMS → /r/{token} → marks acknowledged → cancels remaining jobs.
  2. User replies to the SMS → /api/sms/inbound → marks acknowledged.
  3. User says "got it" on the call → vapi webhook detects → cancels remaining.

Past-due steps are skipped: if you create an event 20 minutes out (defaults), SMS-1 / Call-1 / SMS-2 are all in the past at scheduling time and get dropped — only Call-2 and the email fire.

Customizing per user / per event

  • User defaults: /settings → Reminder schedule. Change any of the 5 timings + toggle email fallback.
  • Per event: on the Review Event card, expand Reminder schedule and edit. Click "Use my defaults" to clear the override.

Pricing in production

  • SMS: ~$0.0083/segment (US)
  • Voice: ~$0.05/minute (vapi.ai) — only fires if SMS unacknowledged
  • Email: free under 3,000/month (Resend)
  • Vercel Cron: free on Hobby tier (1 cron, every minute)

A typical event that's acknowledged on SMS-1: ~$0.01. A worst-case 4-step escalation that hits the email: ~$0.20.

Testing the chain end-to-end

  1. Create an event 7 minutes out with custom schedule {lead: 6, sms_window: 1, call_to_sms2: 1, sms2_to_call2: 1, call2_to_email: 1, email_fallback_enabled: true}.
  2. ~1 min: SMS arrives.
  3. Don't reply. ~1 min: phone rings. Decline.
  4. ~1 min: 2nd SMS. Tap link → "Got it!" page → DB shows reminder_acknowledged_at, all chain jobs cancelled.

To test the full miss path, repeat without acknowledging anywhere — the email lands in your inbox at minute 5.


4c. Twilio Verify (Phase 4 — phone verification)

Required so users can't trigger voice calls to phone numbers they don't own. Twilio sends a 6-digit SMS code; the user enters it; we mark phone_verified_at. Voice and demo calls refuse to fire to unverified numbers.

Step 1 — Sign up

https://www.twilio.com/try-twilio. The free trial gives you ~$15 in credit, enough to verify ~150 phone numbers (each SMS is ~$0.05–$0.10 depending on country).

Step 2 — Create a Verify Service

Console → Verify → Services → Create new Service.

  • Friendly name: InviteReader (this is what appears in the SMS — "Your InviteReader verification code is 123456").
  • Default code length (6) and TTL (10 minutes) are fine.

Copy the Service SID (starts with VA…).

Step 3 — Find your Account SID + Auth Token

Console → home page (https://console.twilio.com). The two values are in the Account Info card on the right.

Step 4 — Add env vars

Locally and on Vercel (Production + Preview):

TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Redeploy. The "Send verification code" button in /settings and the new verify-then-call flow on the public demo button now work.

Trial-account caveat

While your Twilio account is in Trial mode, Verify will only send SMS to numbers you've explicitly added under Phone Numbers → Verified Caller IDs. To send to anyone, you have to upgrade (which costs ~$20 and removes the prefix "Sent from your Twilio trial account" from messages).

Pricing in production

  • SMS via Verify: ~$0.05/verification in the US, more elsewhere
  • Twilio Verify enforces 5 sends per number per 10 minutes by default; we layer a tighter phone_verifications table check on top so abuse stops before reaching Twilio at all

4b. vapi.ai voice reminders (Phase 6 — optional)

vapi.ai handles the outbound voice calls. The flow: our /api/voice-reminder builds a system prompt with the event context, calls vapi.ai, vapi places the phone call and runs the conversation, then posts a summary to /api/vapi-webhook.

Step 1 — Create a vapi.ai account

Sign up at https://dashboard.vapi.ai. Free tier includes test minutes; production pricing is ~$0.05/minute.

Step 2 — Add an LLM provider key

In Provider Keys, paste an OpenAI key (or Anthropic, Groq, etc.). vapi uses this to drive the conversation. The system prompt we send each call lives in lib/voice-prompt.ts.

Step 3 — Buy a phone number

Phone Numbers → Buy Number. Pick a US number ($1/month at the time of writing). Copy its Phone Number ID (shown in the URL or detail panel) — that's your VAPI_PHONE_NUMBER_ID.

Step 4 — Create an assistant

Assistants → Create. Name it "InviteReader Reminder". Pick:

  • Model: gpt-4o-mini is fine and fast.
  • Voice: any natural-sounding voice (PlayHT, ElevenLabs, etc.).
  • First message: leave blank — we override per call.
  • System prompt: leave blank — we override per call.
  • Server URL (under Advanced → Webhook): https://invitereader.com/api/vapi-webhook. Subscribe at minimum to end-of-call-report.

Save and copy the assistant's ID → that's VAPI_ASSISTANT_ID.

Step 5 — Get your API key

API Keys → Private Key. Copy → VAPI_API_KEY.

Step 6 — Add the env vars

Locally in .env.local and on Vercel (Production + Preview):

VAPI_API_KEY=...
VAPI_ASSISTANT_ID=...
VAPI_PHONE_NUMBER_ID=...

Redeploy. The "Send test call" button on /settings and the "Call me about this" button on event previews will now place real calls.

⚠️ Calls go to whatever phone number is in the user's profile. There's no verification yet (Phase 4 — Twilio Verify), so a typo in the phone field will dial whoever owns that number. For dev, only test against your own phone.


4. Gemini API key

  1. Go to https://aistudio.google.com/app/apikey and create a key.
  2. Add it as GEMINI_API_KEY in .env.local and on Vercel.

5. Deploy to Vercel

  1. Push the repo and import it at https://vercel.com/new.
  2. Add all env vars from .env.example to Project → Settings → Environment Variables.
  3. Set NEXT_PUBLIC_APP_URL to your production URL (e.g. https://invitereader.com).
  4. Add the production callback https://<your-app>.vercel.app/auth/callback to:
    • Supabase → Auth → URL Configuration
  5. Deploy.

6. Project structure

app/
  page.tsx                  Hybrid input flow (auth-guarded)
  events/page.tsx           My Events history
  login/page.tsx            Sign-in
  auth/callback/route.ts    OAuth callback → persists Google tokens
  auth/error/page.tsx
  api/
    extract-event/          POST: text/image → structured event JSON
    create-google-event/    POST: writes event to Google Calendar
    generate-ics/           POST: returns .ics download
    events/                 GET/POST: list & save events (RLS-scoped)
components/                 HybridInput, EventPreviewCard, SuccessState, EventList, AuthButton, …
lib/
  gemini.ts                 Prompt + JSON validation (1 stricter retry)
  ics.ts                    .ics generation with RRULE + alarms
  google-calendar.ts        googleapis client + GoogleAuthRequiredError
  auth-providers.ts         <-- single source of truth; add Apple by appending an entry
  supabase/{client,server,middleware,types}.ts
middleware.ts               Auth gate (everything except /login, /auth/*)
supabase/migrations/        SQL for tables, RLS, storage

7. Adding Apple sign-in later

  1. Configure Apple in Supabase → Auth → Providers → Apple.
  2. Append one entry to AUTH_PROVIDERS in lib/auth-providers.ts:
    { id: "apple", label: "Continue with Apple" }

That's it — AuthButtonList maps over the array, the callback is provider-agnostic, and there is nothing else to change.


8. Notes & tradeoffs

  • Timezone: detected via Intl.DateTimeFormat().resolvedOptions().timeZone on first render of the home page and synced to profiles.timezone. The Gemini prompt always receives the current ISO date/time and the user's timezone.
  • Recurrence: stored as a bare RRULE (e.g. FREQ=WEEKLY;BYDAY=WE). Google Calendar receives it prefixed with RRULE: in the recurrence array; the ics package takes the bare form.
  • Service-role usage: SUPABASE_SERVICE_ROLE_KEY is only used server-side in /api/extract-event to download screenshots through Storage. Never expose it to the client.
  • Edge runtime: API routes that touch Gemini, googleapis, or ics use runtime = "nodejs". Page routes are server components on the default runtime.

About

AI-powered tool that converts natural language and screenshots into calendar events using Gemini. Next.js 14 + Supabase + Google Calendar.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages