{"id":50863347,"url":"https://github.com/asm0dey/calit","last_synced_at":"2026-07-01T20:00:55.200Z","repository":{"id":363389053,"uuid":"1263137035","full_name":"asm0dey/calit","owner":"asm0dey","description":"Self-hosted, multi-user Calendly alternative — scheduling you actually own. Quarkus + Postgres, per-user booking pages, Google Calendar/Meet sync, approval flows. Server-rendered, no SaaS, no per-seat pricing.","archived":false,"fork":false,"pushed_at":"2026-06-29T19:41:30.000Z","size":2696,"stargazers_count":1,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-29T21:24:41.734Z","etag":null,"topics":["appointment-scheduling","booking","calendar","calendly-alternative","daisyui","google-calendar","java","multi-tenant","open-source","postgresql","quarkus","scheduling","self-hosted","tailwindcss"],"latest_commit_sha":null,"homepage":"https://asm0dey.github.io/calit/","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"agpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/asm0dey.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-08T16:54:25.000Z","updated_at":"2026-06-29T19:41:07.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/asm0dey/calit","commit_stats":null,"previous_names":["asm0dey/calit"],"tags_count":16,"template":false,"template_full_name":null,"purl":"pkg:github/asm0dey/calit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asm0dey%2Fcalit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asm0dey%2Fcalit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asm0dey%2Fcalit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asm0dey%2Fcalit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/asm0dey","download_url":"https://codeload.github.com/asm0dey/calit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asm0dey%2Fcalit/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35020872,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["appointment-scheduling","booking","calendar","calendly-alternative","daisyui","google-calendar","java","multi-tenant","open-source","postgresql","quarkus","scheduling","self-hosted","tailwindcss"],"created_at":"2026-06-14T23:03:10.368Z","updated_at":"2026-07-01T20:00:55.179Z","avatar_url":"https://github.com/asm0dey.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# calit — self-hosted Calendly alternative\n\n[![CI](https://github.com/asm0dey/calit/actions/workflows/ci.yml/badge.svg)](https://github.com/asm0dey/calit/actions/workflows/ci.yml)\n[![Release](https://img.shields.io/github/v/release/asm0dey/calit?sort=semver)](https://github.com/asm0dey/calit/releases/latest)\n[![Container](https://img.shields.io/badge/ghcr.io-asm0dey%2Fcalit-2496ED?logo=docker\u0026logoColor=white)](https://github.com/asm0dey/calit/pkgs/container/calit)\n[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE)\n[![Quarkus](https://img.shields.io/badge/Quarkus-3.36-4695EB?logo=quarkus\u0026logoColor=white)](https://quarkus.io)\n[![Java](https://img.shields.io/badge/Java-25-orange?logo=openjdk\u0026logoColor=white)](https://bell-sw.com/libericajdk/)\n\nA **multi-user** scheduling app built on Quarkus — each user runs their own independent\nscheduling page: isolated meeting types, availability, bookings, settings, and Google account, served\nfrom a personal public URL `/\u003cusername\u003e/\u003cslug\u003e`. You publish bookable meeting types; invitees pick\na slot and book. Bookings sync to Google Calendar (optional), auto-create a Google Meet link, and\nemail both parties. Includes per-type buffers, min-notice/booking-horizon, date-specific availability\noverrides, an approval workflow, custom booking-form fields, reminders, and public-form abuse\nprotection (Cloudflare Turnstile + honeypot + per-email daily cap).\n\n**Users \u0026 isolation.** Every user is an `app_user` row (passwords hashed with **argon2id**). All\ntenant data carries an `owner_id`, and every query is owner-scoped — one user can never see or edit\nanother's meeting types, bookings, settings, or calendar. Site admins (`is_admin`) manage users at\n`/me/users`: create users (with a one-time temporary password), grant/revoke admin, and lock/unlock\naccounts (a locked account can no longer log in, and its existing session cookie stops working).\n\nIt runs as **N identical stateless replicas** behind a load balancer — there is no in-process session\nstate, all shared state lives in Postgres, and background work (reminders, pending-booking expiry) is\nmulti-node-safe via Postgres `SELECT … FOR UPDATE SKIP LOCKED` with no leader election.\n\n---\n\n## Screenshots\n\n| Public landing | Booking page |\n|---|---|\n| ![A user's public landing page listing their bookable meeting types](src/main/resources/META-INF/resources/img/product-landing.png) | ![Booking page: a monthly calendar of available days beside a column of bookable time slots](src/main/resources/META-INF/resources/img/product-booking.png) |\n\n| Owner dashboard | Booking confirmation |\n|---|---|\n| ![Owner dashboard showing upcoming bookings and a side navigation](src/main/resources/META-INF/resources/img/product-dashboard.png) | ![Booking confirmation screen shown to the invitee after they pick a time](src/main/resources/META-INF/resources/img/product-confirmation.png) |\n\n---\n\n## User accounts \u0026 onboarding\n\n- **First run (bootstrap).** When the database has no users, every request redirects to `/setup`,\n  which creates the first user as a **site admin**. Once any user exists, `/setup` returns 404.\n- **Admin-created users.** A site admin creates accounts at `/me/users` with a username and a\n  temporary password. The new user must change that password and complete the settings wizard on\n  first login.\n- **Opt-in self-service sign-up.** Public `/signup` is **off by default**. Set `SIGNUP_ENABLED=true`\n  to let anyone register (username + their own password); when off, `/signup` returns 404. Changing\n  the flag requires a restart — there is no runtime toggle.\n- **Sign in with Google.** When a Google OAuth client is configured, `/login` shows a\n  \"Sign in with Google\" button. A returning user (matched by the Google account's stable id, or\n  auto-linked on first use when their *verified* Google email matches exactly one existing account)\n  is logged straight in. An unknown Google account is provisioned a new user **only when\n  `SIGNUP_ENABLED=true`** (otherwise sign-in is refused), and is sent through the first-login wizard\n  with their email pre-filled. Register **both** `${APP_BASE_URL}/api/google/callback` (calendar) and\n  `${APP_BASE_URL}/api/google/login/callback` (sign-in) as authorized redirect URIs in Google. The\n  sign-in consent requests only your identity (email), not calendar access.\n- **First-login wizard (`/me/setup`).** On first login a user is sent to `/me/setup` and kept there\n  until onboarding is done: set a new password (only for admin-created temp-password accounts) and\n  fill in display name, email, and timezone. After that they land on `/me`.\n\n### URL scheme\n\n| Path | Audience |\n|---|---|\n| `/me`, `/me/meeting-types`, `/me/availability`, `/me/settings`, … | The logged-in user's own management UI. |\n| `/me/users` | Site admins only — user management. |\n| `/me/setup` | First-login onboarding wizard. |\n| `/{username}` | A user's public landing page (their active meeting types). |\n| `/{username}/{slug}` | Public booking page for one meeting type. |\n| `/setup` | First-run bootstrap (404 once a user exists). |\n| `/signup` | Self-service registration (404 unless `SIGNUP_ENABLED=true`). |\n| `/privacy`, `/terms` | Public privacy policy and terms of service (operator-customizable; required for Google OAuth verification). |\n\n---\n\n## Requirements\n\n- **Java 25** and **Maven** to build (the Maven wrapper `./mvnw` is included). The Docker image builds\n  and runs on **BellSoft Liberica JDK/JRE 26**.\n- **Bun** to compile the stylesheet. The UI is styled with **Tailwind CSS v4 + daisyUI 5** (custom\n  `calit-light` theme); `bun run css:build` compiles `src/main/css/input.css` into the self-hosted\n  `/calit.css` — there is **no runtime CDN dependency** (web fonts aside). No JavaScript ships at runtime.\n- **PostgreSQL** at runtime. (For local dev/tests, Quarkus Dev Services starts a throwaway Postgres in\n  **Docker** automatically — Docker must be running to run the test suite or `quarkus:dev`.)\n- An **SMTP** account for outbound email.\n- *(Optional)* A **Google Cloud** OAuth client to sync each user's calendar + create Meet links.\n- *(Optional)* A **Cloudflare Turnstile** widget to harden the public booking form.\n\n---\n\n## Quick start (local dev)\n\n```bash\n# Docker must be running (Dev Services provisions Postgres + a mock mailbox).\nbun install            # once\nbun run css:watch \u0026    # compiles src/main/css/input.css -\u003e /calit.css and rebuilds on change\nmvn quarkus:dev\n```\n\n(`/calit.css` is gitignored, so build it at least once or the pages render unstyled.)\n\n`bun install` also wires a [lefthook](https://github.com/evilmartians/lefthook) pre-commit hook that\nauto-formats staged files: Java with **Spotless + palantir-java-format**, and JS/CSS with **Prettier**\n(run `bun run format` to format the whole tree manually; `mvn verify` fails on unformatted Java).\n\n- Public booking site: \u003chttp://localhost:8080/\u003e\n- Management UI: \u003chttp://localhost:8080/me\u003e (form login at `/login`). On a fresh database, visit any\n  page and you'll be redirected to `/setup` to create the first (admin) user — there is **no** default\n  password.\n- Health: `/q/health/live`, `/q/health/ready`.\n\nIn dev/test the mailer is mocked (no real email is sent) and Google/Turnstile are disabled by default,\nso you can exercise the whole booking flow with no external accounts.\n\nRun the tests (Docker required):\n\n```bash\nmvn test\n```\n\n## Run with Docker Compose (recommended for self-hosting)\n\nThe repo ships a `Dockerfile` (multi-stage: Bun compiles the CSS, BellSoft **Liberica JDK 26** builds\nthe app, **Liberica JRE 26** runs it) and a `docker-compose.yml` that runs the app plus its Postgres.\n\n```bash\ncp .env.example .env          # then edit .env — at minimum set DB_PASSWORD, SESSION_ENCRYPTION_KEY,\n                              # APP_BASE_URL, and the MAIL_* values\ndocker compose up --build -d\n```\n\nThe app image builds from source (tests are skipped in the image — run `mvn test` on the host with\nDocker first), waits for a healthy Postgres, and Flyway applies the `V1…V14` migrations at boot. The\nDB is persisted in the `calit-db` volume. Reach it at `http://localhost:${APP_PORT:-8080}/`.\n\nScale the stateless app behind your own load balancer:\n\n```bash\ndocker compose up -d --scale app=3\n```\n\n(Everything below also applies to the compose deployment — the same env vars, set in `.env`.)\n\n### Or run the prebuilt image (no local build)\n\nReleased versions are published as multi-arch images (linux/amd64 + linux/arm64) to GitHub\nContainer Registry: **`ghcr.io/asm0dey/calit`** (tags: `latest`, `1.8.0`, `1.8`). To deploy without\nbuilding from source, drop the `build:` and pull the image instead. Save this as `compose.yaml`:\n\n```yaml\nservices:\n  db:\n    image: postgres:18\n    environment:\n      POSTGRES_DB: ${DB_NAME:-calit}\n      POSTGRES_USER: ${DB_USER:-calit}\n      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}\n    volumes:\n      - calit-db:/var/lib/postgresql   # postgres:18 default PGDATA moved under here\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U ${DB_USER:-calit} -d ${DB_NAME:-calit}\"]\n      interval: 5s\n      timeout: 5s\n      retries: 10\n    restart: unless-stopped\n\n  app:\n    image: ghcr.io/asm0dey/calit:1.14.1   # or :latest (native variant: :1.14.1-native)\n    depends_on:\n      db:\n        condition: service_healthy\n    env_file:\n      - path: .env\n        required: false\n    environment:\n      DB_URL: jdbc:postgresql://db:5432/${DB_NAME:-calit}\n      DB_USER: ${DB_USER:-calit}\n      DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}\n    ports:\n      - \"${APP_PORT:-8080}:8080\"\n    restart: unless-stopped\n\nvolumes:\n  calit-db:\n```\n\n```bash\ncp .env.example .env   # set DB_PASSWORD, SESSION_ENCRYPTION_KEY, APP_BASE_URL, MAIL_*\ndocker compose up -d   # pulls the image; Flyway migrates on boot\n```\n\nThe image is public — no `docker login` needed. (If you fork and keep the package private, run\n`docker login ghcr.io` with a token that has `read:packages` first.)\n\n## Build \u0026 run for production\n\n```bash\nmvn package\njava -Dquarkus.profile=prod -jar target/quarkus-app/quarkus-run.jar\n```\n\nThe schema is created and kept up to date automatically: **Flyway runs the `V1…V14` migrations at\nboot** (`quarkus.flyway.migrate-at-start=true`), and Hibernate validates the entities against it.\nPoint all replicas at the same database; each can serve any request.\n\n---\n\n## Configuration (environment variables)\n\nAll production config is supplied via environment variables (12-factor). Everything is read at startup;\nthe same values must be present on every replica.\n\n### Required\n\n| Variable | Purpose |\n|---|---|\n| `DB_PASSWORD` | Postgres password. |\n| `SESSION_ENCRYPTION_KEY` | Encrypts the login cookie (\u003e=16 chars). Must be the same on every replica. Generate with `openssl rand -hex 32`. **Required in prod.** |\n| `TOKEN_ENCRYPTION_KEY` | AES-256-GCM key for Google OAuth tokens at rest. 64 hex chars (`openssl rand -hex 32`). Must be the same on every replica; keep it stable. **Required in prod.** |\n| `APP_BASE_URL` | Public origin, e.g. `https://book.example.com`. Used to build invitee manage links in emails and the Google OAuth redirect; must match what users hit. |\n| `MAIL_HOST`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM` | SMTP server + the \"from\" address. |\n\n### Common / defaulted\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `DB_URL` | `jdbc:postgresql://localhost:5432/calit` | JDBC URL. |\n| `DB_USER` | `calit` | Postgres user. |\n| `MAIL_PORT` | `587` | SMTP port. `587` for STARTTLS, `465` for implicit TLS. |\n| `MAIL_START_TLS` | `REQUIRED` | STARTTLS policy (`REQUIRED`/`OPTIONAL`/`DISABLED`). Use `REQUIRED` on port 587. |\n| `MAIL_TLS` | `false` | Implicit TLS (SMTPS). Set `true` for port 465; keep `false` for STARTTLS on 587. |\n| `REMINDER_LEAD_MINUTES` | `1440` | How long before a meeting the reminder email fires (24h). Also shown on the `/me/settings` page. |\n| `APPROVAL_HOLD_HOURS` | `24` | How long an approval-mode booking is held as PENDING before it auto-declines (or until its start, whichever comes first). |\n| `SCHEDULER_GRACE_SECONDS` | `30` | Treat reminder / pending-expiry rows as due up to N seconds early, so replicas on unsynchronised tick timers fire on time. `0` = exact. |\n| `PER_EMAIL_DAILY_CAP` | `10` | Max bookings one invitee email may create per day (abuse guard). |\n| `SIGNUP_ENABLED` | `false` | Allow public self-service sign-up at `/signup`. When `false`, `/signup` returns 404. |\n\n### Public site \u0026 Google verification (optional)\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `GOOGLE_SITE_VERIFICATION` | _(empty)_ | Google Search Console domain-verification token. When set, every page renders `\u003cmeta name=\"google-site-verification\"\u003e`. Leave empty to verify via DNS TXT instead. |\n| `OPERATOR_NAME` | `APP_BASE_URL` | Legal entity shown as the data controller on `/privacy` and `/terms`. |\n| `PRIVACY_CONTACT_EMAIL` | _(empty)_ | Contact address shown on `/privacy` for privacy/data requests. Hidden when unset. |\n\n### Google Calendar sync (optional)\n\nLeave these unset to run in **degraded mode**: bookings still work, but no calendar events or Meet\nlinks are created and the app emails the invitee directly (instead of Google sending the invite).\n\n| Variable | Purpose |\n|---|---|\n| `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` | OAuth client credentials (see below). |\n| `GOOGLE_OAUTH_REDIRECT_URI` | **Optional.** Calendar-connect redirect URI. Defaults to `${APP_BASE_URL}/api/google/callback` — set it only to override (e.g. an unusual proxy path). Must match an authorized redirect URI registered with Google. |\n| `GOOGLE_OAUTH_LOGIN_REDIRECT_URI` | **Optional.** \"Sign in with Google\" redirect URI (separate from the calendar one). Defaults to `${APP_BASE_URL}/api/google/login/callback` — set it only to override. Must be registered as an authorized redirect URI in the same Google OAuth client. |\n| `GOOGLE_OAUTH_STATE_SECRET` | A strong random string shared by all replicas (signs the stateless OAuth CSRF token). Generate e.g. `openssl rand -hex 32`. |\n| `GOOGLE_PROBE_INTERVAL` | **Optional.** How often connected Google accounts are checked for disconnection, and how often the \"reconnect your Google\" alert email is (re-)evaluated. Duration string. Default `1h`. If an account's grant has died, calit fails the booking page closed (no slots) and emails the owner once per outage. |\n\n### Cloudflare Turnstile (optional, public-form bot protection)\n\nOne switch turns on **both** the booking-form widget and server-side verification.\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `TURNSTILE_ENABLED` | `false` | Enable the widget + server verification together. |\n| `TURNSTILE_SITE_KEY` | — | Public site key (rendered into the booking page). |\n| `TURNSTILE_SECRET_KEY` | — | Secret key (server-side verification only; never rendered). |\n\n\u003e The honeypot field and the per-email daily cap are always on and need no configuration.\n\n---\n\n## How to obtain the keys\n\n### Google OAuth client (Calendar + Meet)\n\n1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and create (or pick) a project.\n2. **APIs \u0026 Services → Library → enable the \"Google Calendar API\"**.\n3. **APIs \u0026 Services → OAuth consent screen**: configure it (External or Internal), add each user's\n   Google account as a test user if the app stays in \"testing\", and add the scope\n   `https://www.googleapis.com/auth/calendar`.\n4. **APIs \u0026 Services → Credentials → Create Credentials → OAuth client ID → Web application**.\n5. Under **Authorized redirect URIs** add exactly `${APP_BASE_URL}/api/google/callback`\n   (e.g. `https://book.example.com/api/google/callback`).\n6. Copy the **Client ID** and **Client secret** into `GOOGLE_OAUTH_CLIENT_ID` /\n   `GOOGLE_OAUTH_CLIENT_SECRET`.\n7. After deploy, each user connects their calendar **once** from the management UI (`/me/google` →\n   Connect Google), grants offline access, and selects which calendars to read for busy time and which\n   one to write events to. The refresh token is stored in Postgres, so any replica can call Google.\n\nFor Google to remove the \"unverified app\" warning and lift the 100-user cap, complete OAuth verification in Google Cloud Console: set `OPERATOR_NAME` and `PRIVACY_CONTACT_EMAIL`, link `${APP_BASE_URL}/privacy` as the consent-screen privacy policy, and verify domain ownership (via `GOOGLE_SITE_VERIFICATION` or a DNS TXT record). Calendar scopes are *sensitive* (not *restricted*), so no third-party security assessment is required.\n\n### Cloudflare Turnstile\n\n1. In the [Cloudflare dashboard → Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile),\n   add a site/widget for your booking domain.\n2. Copy the **Site Key** → `TURNSTILE_SITE_KEY` and the **Secret Key** → `TURNSTILE_SECRET_KEY`, then\n   set `TURNSTILE_ENABLED=true`.\n\n### SMTP\n\nUse any provider (e.g. a transactional-email service or your own server). Set `MAIL_HOST`,\n`MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, and the encryption mode to match it:\n\n- **Port 587 (STARTTLS)** — `MAIL_PORT=587`, `MAIL_START_TLS=REQUIRED`, `MAIL_TLS=false`.\n- **Port 465 (implicit TLS / SMTPS)** — `MAIL_PORT=465`, `MAIL_TLS=true`, `MAIL_START_TLS=OPTIONAL`.\n\nThe port number alone does **not** pick the mode — set `MAIL_TLS` explicitly for 465.\n\n---\n\n## First-run checklist\n\n1. Deploy with at least the **required** env vars set (DB, `SESSION_ENCRYPTION_KEY`, SMTP, `APP_BASE_URL`).\n2. Visit any page; you'll be redirected to `/setup`. Create the first user — they become a **site admin**.\n   There is no default password.\n3. On first login you're sent to the **`/me/setup`** wizard: set your password (if applicable) and fill\n   in display name, email, and IANA **timezone** (the canonical zone for all stored-time interpretation,\n   emails, your management pages, and Google events). Then you land on `/me`.\n4. *(Optional)* **Google** (`/me/google`): connect the calendar and choose read/write calendars.\n5. **Availability**: set weekly work hours (global and/or per meeting type); add date-specific overrides.\n6. **Meeting types**: create your bookable types (duration, buffers, min-notice, horizon, slot interval,\n   location type — Google Meet / phone / in-person / custom, approval-required, and `secret` for\n   link-only types).\n7. *(Optional)* **Booking fields**: add custom questions to the booking form.\n8. *(Admins)* Add more users at **`/me/users`** (each with a temporary password), or enable public\n   `/signup` with `SIGNUP_ENABLED=true`.\n9. Share your booking links. Your public types appear on `/{username}`; secret types are reachable only\n   by direct link `/{username}/{slug}`.\n\n---\n\n## Notes \u0026 operational details\n\n- **Authentication:** users live in the `app_user` table with **argon2id**-hashed passwords — there is\n  **no** `ADMIN_PASSWORD` or embedded/env user. Login is via the `/login` form; the session is an\n  encrypted **stateless cookie** (no server-side session store), so any replica can validate it. Account\n  locks are enforced at authentication time — a locked user can't log in and their existing cookie stops\n  working.\n- **Per-user isolation:** every tenant table carries an `owner_id` and all queries are owner-scoped, so\n  no user can see or edit another's data. `meeting_type.slug` is unique **per user**, so two users can\n  both have e.g. `intro-call`.\n- **Timezones:** each user's timezone is authoritative for storage interpretation, emails, and Google\n  events. Invitee-facing pages additionally relabel times into the *viewer's* local timezone in the\n  browser (the booked instant is unchanged). Invitee timezones are never stored.\n- **Degraded mode:** with Google not connected, bookings are confirmed without a calendar event/Meet\n  link and the app emails the invitee directly. When connected, Google emails the invite/change/cancel\n  (`sendUpdates=all`) and the app suppresses the duplicate invitee mail; the user always gets the app\n  email (unless opted out).\n- **Background jobs** run on every replica every 60s and are single-delivery via `FOR UPDATE SKIP\n  LOCKED` — reminder dispatch and pending-booking auto-expiry. No clustered scheduler is needed.\n- **Double-booking** is prevented at the database level by a Postgres exclusion constraint covering\n  PENDING+CONFIRMED bookings, so concurrent replicas cannot both win the same slot.\n- **TLS / reverse proxy:** calit listens on plain HTTP and expects to run behind a TLS-terminating\n  reverse proxy in production. The `%prod` profile trusts `X-Forwarded-*` headers\n  (`quarkus.http.proxy.proxy-address-forwarding=true`) so the request scheme is seen as HTTPS — which\n  is what marks the login cookie `Secure`. Only expose calit through that proxy; if it can be reached\n  directly, restrict trust with `quarkus.http.proxy.trusted-proxies=\u003cproxy CIDR\u003e`.\n- **Migrations** are plain SQL under `src/main/resources/db/migration` (`V1`…`V14`) and run at boot.\n\n### Health probes\n\n- `GET /q/health/live` — liveness: the process is up. Does **not** check SMTP or Google (a flapping\n  external dependency must not get a healthy replica restarted).\n- `GET /q/health/ready` — readiness. Includes **informational** SMTP and Google checks: they always\n  report `UP` and expose reachability under `data.state` (`reachable` / `unreachable` /\n  `mocked-or-unconfigured` / `not-configured`). They never mark a replica `DOWN` — a down mail\n  server doesn't pull the replica from rotation, because outgoing mail falls back to the outbox.\n\n### Email delivery \u0026 SMTP outages\n\nMail is sent synchronously. If an SMTP send fails, the mail is parked in the `email_outbox` table\ninstead of being lost; a background tick (every 60s, on every replica, `FOR UPDATE SKIP LOCKED` so\nit is multi-node-safe) retries with exponential backoff (1 min, doubling up to 1 h, capped at 10\nattempts). Booking and password-reset flows never fail because SMTP is unavailable. No configuration\nrequired.\n\n---\n\n## Upgrading\n\n### ⚠️ BREAKING in v1.4.0 — set `TOKEN_ENCRYPTION_KEY` before deploying\n\n**Applies when upgrading from v1.3.x (or earlier) to v1.4.0.** v1.4.0 encrypts stored Google OAuth\ntokens at rest and adds a **new required production secret**, `TOKEN_ENCRYPTION_KEY`. A `%prod`\ndeployment **fails to boot** until it is set (fail-closed, by design — the app will not serve traffic\nwith no key).\n\n**Migration steps for existing operators:**\n\n1. Generate a key — **once**, and keep it forever-stable:\n   ```bash\n   openssl rand -hex 32      # 64 hex characters = 32 bytes (AES-256)\n   ```\n2. Set `TOKEN_ENCRYPTION_KEY` to that value in the environment of **every replica** (same value\n   everywhere, exactly like `SESSION_ENCRYPTION_KEY`).\n3. Deploy the new image. On first boot the app transparently **encrypts existing plaintext tokens in\n   place** — no user has to reconnect; every already-connected Google Calendar keeps working.\n\n**Do not rotate this key.** Changing it makes all previously-encrypted tokens undecryptable, which\ndisconnects every user's calendar and forces a full reconnect. Treat it as a permanent secret.\nIf you do not use Google Calendar at all the key is still required in prod (one fewer way to\nmisconfigure), but it is never used.\n\n---\n\n## License\n\nLicensed under the **GNU Affero General Public License v3.0** (AGPL-3.0). If you run a modified version\nto provide a network service, you must offer its complete source to that service's users. See\n[LICENSE](LICENSE) for the full text.\n\n### Trademarks\n\n\"Calendly\" is a trademark of Calendly LLC. calit is an independent, self-hosted project and is **not\naffiliated with, endorsed by, or sponsored by Calendly**. The name is used only descriptively, to\nindicate the category of tool calit replaces.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fasm0dey%2Fcalit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fasm0dey%2Fcalit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fasm0dey%2Fcalit/lists"}