+
+
diff --git a/src/components/modals/ShareModal.vue b/src/components/modals/ShareModal.vue
index fda282e..caf0b2d 100644
--- a/src/components/modals/ShareModal.vue
+++ b/src/components/modals/ShareModal.vue
@@ -16,6 +16,10 @@ const cover = computed(() => {
return COVERS[p.cover] ?? COVERS[0];
});
+// Built from the origin the app is actually served from, so a preview
+// deployment, a self-hosted domain and the hosted app each hand out a link that
+// points back at themselves. The `/i/` route that resolves these is still to
+// come — see docs/adr/0001-cloudflare-tiers.md.
const inviteLink = computed(() => {
const p = party.value;
if (!p) return '';
@@ -24,7 +28,14 @@ const inviteLink = computed(() => {
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'party';
- return `bottlecount.app/i/${slug}-${p.id}`;
+ const origin =
+ typeof window === 'undefined'
+ ? 'bottlecount.pages.dev'
+ : window.location.host;
+ // BASE_URL, not a bare `/`: a build served under a path prefix would
+ // otherwise hand out links that miss the prefix entirely.
+ const base = import.meta.env.BASE_URL as string;
+ return `${origin}${base}i/${slug}-${p.id}`;
});
const venueWhere = computed(() => {
diff --git a/src/components/tabs/GuestsTab.vue b/src/components/tabs/GuestsTab.vue
index 2d28975..2ec20ce 100644
--- a/src/components/tabs/GuestsTab.vue
+++ b/src/components/tabs/GuestsTab.vue
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue';
import { useStore } from '../../lib/store';
import Icon from '../Icon.vue';
+import ProLock from '../ProLock.vue';
const store = useStore();
@@ -119,446 +120,410 @@ const isPhone = computed(() => store.state.device === 'phone');
animation: bcFadeUp 0.3s ease both;
"
>
-
-
+
-
+
-
+
-
-
+
+
+
+
+ RSVP funnel
+
+
+
+
+
- RSVP funnel
+ Share your invite link — anyone who opens it RSVPs
+ with their own name
+ and lands in the guest list below. You never type them in.
-
-
-
-
- Share your invite link — anyone who opens it RSVPs
- with their own name
- and lands in the guest list below. You never type them in.
-
-
-
-
-
+
-
-
-
+
+
+
+ Reached
+
+
Reached
+ {{ invites.length }}
+
+
+
- {{ invites.length }}
-
-
-
-
-
-
-
-
+
+ Confirmed
+
+
Confirmed
+ {{ accepted.length }}
+
+
+
- {{ accepted.length }}
-
-
-
-
-
-
-
-
+
+ Maybe
+
+
Maybe
+ {{ pending.length }}
+
+
+
- {{ pending.length }}
+
+
+ Declined
+
+
+ {{ declined.length }}
+
-
+
+
+
+ Filling {{ capacity }} expected toward a {{ maxCap }} cap — green is
+ on target, amber is past expected
+
+
+ Against {{ capacity }} capacity — solid is confirmed, faded is still
+ maybe
+
+
-
-
- Declined
-
+
+
+
+
+
+
+
- {{ declined.length }}
-
+ >
-
-
-
-
-
- Filling {{ capacity }} expected toward a {{ maxCap }} cap — green is
- on target, amber is past expected
-
-
- Against {{ capacity }} capacity — solid is confirmed, faded is still
- maybe
-
-
- on, your guests can forward the invite to their own friends — anyone
- they bring lands in this tier.
+ With
+ "let guests invite friends"
+ on, your guests can forward the invite to their own friends — anyone
+ they bring lands in this tier.
+
-
+
;
+ return (
+ typeof dto.authenticated === 'boolean' &&
+ typeof dto.tier === 'string' &&
+ typeof dto.features === 'object' &&
+ dto.features !== null
+ );
+}
+
+/**
+ * Asks the Worker who the caller is.
+ *
+ * Never throws and never rejects. Every failure — no backend deployed, a 501
+ * from the proxy, a network error, a body that isn't a session — resolves to
+ * {@link ANONYMOUS_SESSION}, because the alternative is an app that refuses to
+ * start when the part of it that is meant to be optional is missing.
+ */
+export async function fetchSession(): Promise {
+ try {
+ const res = await fetch('/api/session', {
+ credentials: 'include',
+ headers: { accept: 'application/json' },
+ });
+ if (!res.ok) return ANONYMOUS_SESSION;
+ const body: unknown = await res.json();
+ return isSessionDTO(body) ? body : ANONYMOUS_SESSION;
+ } catch {
+ return ANONYMOUS_SESSION;
+ }
+}
+
+/** Clears the httpOnly session cookie, which only the server can do. */
+export async function logout(): Promise {
+ try {
+ await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
+ } catch {
+ /* Already effectively signed out as far as the user is concerned. */
+ }
+}
+
+export interface RedeemResult {
+ ok: boolean;
+ error?: string;
+}
+
+/** Exchanges a purchased licence code for the `pro` tier. */
+export async function redeemLicence(code: string): Promise {
+ try {
+ const res = await fetch('/api/licences/redeem', {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ code }),
+ });
+ if (res.ok) return { ok: true };
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
+ return { ok: false, error: body.error ?? `http_${res.status}` };
+ } catch {
+ return { ok: false, error: 'network_error' };
+ }
+}
diff --git a/src/lib/store.ts b/src/lib/store.ts
index b30e410..0b1b77c 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -7,6 +7,9 @@ import type {
Settings,
} from './types';
import { db } from './db';
+import { fetchSession, ANONYMOUS_SESSION } from './session';
+import type { SessionDTO } from '../../shared/session';
+import type { Feature } from '../../shared/tiers';
import { loadCatalog, defaultExtras } from './catalog';
import { calculate } from './core';
import { rebalance, menuErrorKeys } from './menu';
@@ -71,6 +74,13 @@ export const SIMPLE: string[] = ['Beer', 'Wine'];
interface StoreState {
ready: boolean;
+ /**
+ * Who the browser is and what it may do. Starts anonymous, so the app is
+ * usable before — and without — an answer from the server.
+ */
+ session: SessionDTO;
+ /** True while the first /api/session call is in flight. */
+ sessionLoading: boolean;
route: 'home' | 'party';
activeId: number | null;
tab: 'plan' | 'menu' | 'shop' | 'guests';
@@ -89,6 +99,8 @@ interface StoreState {
doorOpen: boolean;
scanResult: { ok: boolean; name: string; sub?: string } | null;
scanHistory: { name: string; time: string }[];
+ /** The feature whose upgrade prompt is open, or null. */
+ upgradeFor: Feature | null;
// ui
expandedCat: string | null;
expandedSpirit: string | null;
@@ -98,6 +110,8 @@ interface StoreState {
const state = reactive({
ready: false,
+ session: ANONYMOUS_SESSION,
+ sessionLoading: true,
route: 'home',
activeId: null,
tab: 'plan',
@@ -115,6 +129,7 @@ const state = reactive({
doorOpen: false,
scanResult: null,
scanHistory: [],
+ upgradeFor: null,
expandedCat: null,
expandedSpirit: null,
});
@@ -154,10 +169,48 @@ function update(mutator: (p: Party) => void): void {
});
}
+/**
+ * Whether the current session may use a paid feature.
+ *
+ * Every gate in the UI goes through here rather than reading `tier` directly,
+ * so that self-hosting and a future third tier stay a change to
+ * `shared/tiers.ts` instead of a hunt through components.
+ */
+function can(feature: Feature): boolean {
+ return state.session.features[feature] === true;
+}
+
+/** Opens the "this needs BottleCount Pro" prompt for a locked feature. */
+function requestUpgrade(feature: Feature): void {
+ state.upgradeFor = feature;
+}
+
+function closeUpgrade(): void {
+ state.upgradeFor = null;
+}
+
+/**
+ * Re-reads the session. Called on load, and again after signing in or
+ * redeeming a licence, since both change what the app may do.
+ */
+async function refreshSession(): Promise {
+ state.sessionLoading = true;
+ try {
+ state.session = await fetchSession();
+ } finally {
+ state.sessionLoading = false;
+ }
+}
+
async function load(): Promise {
state.catalog = await loadCatalog();
state.parties = await db.parties.toArray();
+ // Deliberately not awaited: the planner is local-first and must render
+ // without waiting on a network round-trip that may never come back. Paid
+ // features stay locked until it does, which is the correct default.
+ void refreshSession();
+
// Backfill fields added after a party was first saved.
for (const p of state.parties) {
if (p.settings.max_capacity === undefined) p.settings.max_capacity = null;
@@ -522,7 +575,17 @@ function closeIngMgr(): void {
state.ingMgrOpen = false;
}
+/**
+ * The invite link is the paid feature here, and the share sheet is the only way
+ * to reach it — so the gate lives on the opener rather than inside the modal.
+ * A second caller added later inherits it for free, which a check in the
+ * component would not give us.
+ */
function openShare(): void {
+ if (!can('inviteLink')) {
+ requestUpgrade('inviteLink');
+ return;
+ }
state.shareOpen = true;
}
function closeShare(): void {
@@ -596,6 +659,11 @@ export const store = {
reloadCatalog,
// loader
load,
+ // session & entitlements
+ can,
+ refreshSession,
+ requestUpgrade,
+ closeUpgrade,
// invites
addInvite,
setInviteStatus,
diff --git a/src/pages/auth/callback.astro b/src/pages/auth/callback.astro
new file mode 100644
index 0000000..0f7e126
--- /dev/null
+++ b/src/pages/auth/callback.astro
@@ -0,0 +1,44 @@
+---
+import '../../styles/theme.css';
+const base = import.meta.env.BASE_URL;
+---
+
+
+
+
+
+
+ Signing you in — BottleCount
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/pricing.astro b/src/pages/pricing.astro
new file mode 100644
index 0000000..016cfdc
--- /dev/null
+++ b/src/pages/pricing.astro
@@ -0,0 +1,296 @@
+---
+import '../styles/global.css';
+import NavBar from '../components/NavBar.astro';
+import Footer from '../components/Footer.astro';
+const base = import.meta.env.BASE_URL;
+
+// The three ways to run BottleCount. Everything in `planning` is unconditional;
+// the paid line is drawn at the features that need a server to exist at all.
+// Keep this table in step with shared/tiers.ts — it is the sales copy for the
+// same decisions that file encodes.
+const plans = [
+ {
+ key: 'browser',
+ name: 'Browser',
+ price: 'Free',
+ priceNote: 'no account, forever',
+ pitch:
+ 'The whole planner, running entirely in your browser. Nothing is uploaded, because there is nowhere to upload it to.',
+ cta: { label: 'Open the app', href: `${base}app` },
+ featured: false,
+ has: [
+ 'Auto-balancing drink menu',
+ 'Shopping list with price ranges',
+ 'Live budget and break-even',
+ 'Custom ingredients and cocktails',
+ 'Guest list you type yourself',
+ 'Signed QR tickets',
+ 'Door scanner on one device',
+ ],
+ hasnt: [
+ 'Shareable invite link',
+ 'RSVP funnel and spread view',
+ 'Co-organisers',
+ 'Sync across your devices',
+ ],
+ },
+ {
+ key: 'hosted',
+ name: 'Hosted',
+ price: '€29',
+ priceNote: 'one payment, no subscription',
+ pitch:
+ 'Everything above, plus the parts that need a server: links other people can open, a party two of you can run, and your data on more than one device.',
+ cta: { label: 'Sign in to get started', href: '/auth/google' },
+ featured: true,
+ has: [
+ 'Everything in Browser',
+ 'Shareable invite link — guests add themselves',
+ 'RSVP funnel and friends-of-friends spread',
+ 'Co-organisers on the same party',
+ 'Your parties on every device you sign in on',
+ 'Several phones scanning the same door',
+ ],
+ hasnt: [],
+ },
+ {
+ key: 'selfhost',
+ name: 'Self-hosted',
+ price: 'Free',
+ priceNote: 'your Cloudflare account',
+ pitch:
+ 'The same code, deployed to your own Cloudflare account. Every paid feature is on, because there is nobody to pay — you are running the server.',
+ cta: {
+ label: 'Read the setup guide',
+ href: 'https://github.com/Fre0Grella/BottleCount#self-hosting',
+ },
+ featured: false,
+ has: [
+ 'Everything in Hosted',
+ 'Your own D1 database',
+ 'Sign in without a Google client, if you prefer',
+ 'Free tier of Cloudflare is enough for a party',
+ ],
+ hasnt: ['Support from us', 'Updates unless you deploy them'],
+ },
+];
+---
+
+
+
+
+
+
+ Pricing · BottleCount
+
+
+
+
+
+
+
+
Three ways to run it
+
+ The planning side is free and always will be — it never needed a
+ server. What you pay for is the part that does: a link your guests can
+ open, a party two people can run, and your data somewhere other than
+ one browser. If you would rather run that server yourself, the code is
+ the same and it costs nothing.
+
+ No. It has no expiry, no account and no card. If planning a party in
+ one browser is all you need, that is the finished product and you are
+ done.
+
+
+
What does "one payment" mean?
+
+ You pay once and the features stay on. There is no renewal to forget
+ and nothing to cancel.
+
+
+
What happens to my existing parties if I sign in?
+
+ Nothing, for now. Parties still live in your browser on every plan;
+ moving them to your account is the next thing being built, and it will
+ import what you already have rather than start you over.
+
+
+
Why is self-hosting free when hosting costs money?
+
+ Because hosting is the thing being sold, not the software. Running the
+ Worker, the database and the door-scanner sync is what the payment
+ covers. Do that yourself and there is nothing left to charge for.
+
- This Privacy Policy explains how BottleCount ("the app", "we", "us")
- uses your data when you use the BottleCount web application.
-
-
-
1. What BottleCount does
-
- BottleCount is a small web app that helps you plan parties and manage
- tickets using a Google Sheets spreadsheet as your data storage. The
- app connects to your Google account with your consent and reads/writes
- data only to the spreadsheet(s) you explicitly connect to BottleCount.
-
-
-
2. Data we access via Google APIs
-
- When you authorize BottleCount with your Google account, we may
- receive limited access to:
-
-
-
- Google Sheets data: the content of the spreadsheet
- you use with BottleCount (for example, tabs such as
- parties, tickets, and config). The app
- reads and writes rows in these tabs in order to create and update
- party and ticket information.
-
-
- Google Drive file metadata: basic information such
- as file name, ID, and type, used only to find the correct
- BottleCount spreadsheet. The app does not download or process the
- full content of other files.
-
-
-
-
3. How we use your data
-
- We use the data we access only to provide BottleCount's core
- functionality:
-
-
-
Creating a "BottleCount" spreadsheet if none exists yet.
-
- Reading party and ticket rows from your spreadsheet to display them
- in the app.
-
-
- Writing new parties and tickets, and updating ticket status (such as
- "used") in your spreadsheet.
-
-
-
- We do not use your data for advertising, profiling, or selling to
- third parties.
-
-
-
4. Where your data is stored
-
-
- Your party and ticket data are stored in your own Google Sheets file
- in your Google account.
-
-
- BottleCount does not maintain a separate backend database containing
- your spreadsheet content. Some configuration (for example, the
- selected spreadsheet ID or settings) may be stored locally in your
- browser.
-
-
-
-
5. Local browser data
-
- BottleCount may store some information in your browser using local
- storage or similar mechanisms (for example, the spreadsheet ID or app
- settings). This data is not shared with third parties by the app.
-
-
-
6. Third parties and Google APIs
-
- BottleCount uses Google APIs to access your Google Sheets and Google
- Drive metadata. BottleCount's use and transfer of information received
- from Google APIs adheres to the
-
- Google API Services User Data Policy
- , including the Limited Use requirements. We do not sell your data
- or share your spreadsheet content with advertising networks.
-
-
-
7. Revoking access
-
- You can revoke BottleCount's access at any time from your Google
- Account:
-
-
-
-
-
-
-
-
\ No newline at end of file
+---
+import '../styles/global.css';
+import NavBar from '../components/NavBar.astro';
+import Footer from '../components/Footer.astro';
+const base = import.meta.env.BASE_URL;
+---
+
+
+
+
+
+
+ Privacy Policy · BottleCount
+
+
+
+
+
+
+
Privacy Policy
+
Last updated: September 18, 2026
+
+
+ This Privacy Policy explains how BottleCount ("the app", "we", "us")
+ uses your data when you use the BottleCount web application.
+
+
+
1. What BottleCount does
+
+ BottleCount helps you plan parties and manage tickets. It comes in
+ three forms, and they differ in exactly one way that matters here:
+ whether we hold any of your data at all.
+
+
+
+ Browser (free): no account, no sign-in, and no
+ server. Everything you enter stays in your browser.
+
+
+ Hosted: you sign in with Google, and we store an
+ account record for you.
+
+
+ Self-hosted: you run the server. Nothing described
+ below reaches us — your deployment's operator is the data
+ controller, and that is you.
+
+
+
+
2. Data stored in your browser
+
+ On every tier, your parties, drink menus, custom ingredients,
+ cocktails, generated tickets, theme preference and app settings are
+ stored locally in your browser (IndexedDB and local storage). On the
+ free tier this is the only place any of it exists: we cannot read it,
+ recover it, or hand it to anyone, and clearing your browser data
+ deletes it permanently.
+
+
+
3. Data we hold when you sign in
+
Signing in with Google gives us the following, and nothing else:
+
+
+ Your Google account identifier, email address, display name and
+ avatar URL, taken from the OpenID openid,
+ email and profile scopes.
+
+
+ Which plan your account is on, and the licence code you redeemed to
+ get there.
+
+
+
+ We request no access to your Google Drive, Sheets, contacts, calendar
+ or any other Google service. This record is stored in Cloudflare D1.
+
+
+ Your session is a signed token in an httpOnly cookie named
+ session_token, valid for seven days. It is a strictly
+ necessary cookie: it exists only to keep you signed in, and the app
+ sets no analytics or advertising cookies.
+
+
+
4. How we use it
+
+ Only to sign you in, to tell the app which features your plan
+ includes, and to answer you if you contact support. We do not use your
+ data for advertising or profiling, and we do not sell it or share it
+ with third parties.
+
+
+
5. Processors
+
+ Cloudflare, Inc. hosts the application and the database. Google LLC
+ provides sign-in. Both act as processors on our behalf; neither
+ receives your data for their own purposes from us.
+
+
+
6. Retention and deletion
+
+ We keep your account record for as long as your account exists. Email
+ us to have it deleted and we will remove it, along with any licence
+ association, within 30 days. Data held only in your browser is deleted
+ by clearing your browser's storage for this site, which happens
+ without involving us.
+
+
+
7. Your rights and revoking access
+
+ If you are in the EU or the UK, you have the right to access, correct,
+ export, restrict, object to and erase your personal data, and to
+ complain to your local data protection authority. Email us to exercise
+ any of them.
+
+
+ You can revoke BottleCount's access to your Google account at any
+ time:
+
+ BottleCount's use and transfer of information received from Google
+ APIs adheres to the
+
+ Google API Services User Data Policy
+ , including the Limited Use requirements.
+
+
+
8. Children's privacy
+
+ BottleCount is not directed to children under 13. If you are under 13,
+ please do not use this app without parental supervision and consent.
+
+
+
9. Changes
+
+ We may update this Privacy Policy from time to time. When we do, we
+ will update the "Last updated" date at the top of this page.
+
+
+
+
+
+
+
+
diff --git a/src/pages/terms.astro b/src/pages/terms.astro
index 80a48bc..56082a5 100644
--- a/src/pages/terms.astro
+++ b/src/pages/terms.astro
@@ -1,145 +1,198 @@
----
-import '../styles/global.css';
-import NavBar from '../components/NavBar.astro';
-import Footer from '../components/Footer.astro';
-const base = import.meta.env.BASE_URL;
----
-
-
-
-
-
-
- Terms of Service · BottleCount
-
-
-
-
-
-
-
Terms of Service
-
Last updated: April 27, 2026
-
-
1. Acceptance of terms
-
- By accessing or using BottleCount ("the app"), you agree to be bound
- by these Terms of Service. If you do not agree, please do not use the
- app.
-
-
-
2. Description of the service
-
- BottleCount is a web application that helps you plan parties and
- manage tickets by reading and writing data in a Google Sheets
- spreadsheet that you control. The app is provided for convenience
- only and may change or be discontinued at any time.
-
-
-
3. Your responsibilities
-
-
Maintaining the security of your Google account and devices.
-
- Ensuring you have the right to use any data stored in your
- BottleCount spreadsheet.
-
-
- Using BottleCount in compliance with applicable laws and Google's
- terms of service.
-
-
-
-
4. Use of Google APIs
-
- BottleCount uses Google APIs to access your Google Sheets and Google
- Drive metadata with your permission. BottleCount's use and transfer of
- information received from Google APIs adheres to the
-
- Google API Services User Data Policy
- , including the Limited Use requirements.
-
-
-
5. No warranties
-
- BottleCount is provided "as is" and "as available", without warranties
- of any kind. We do not guarantee the app will be error‑free,
- uninterrupted, or suitable for your specific requirements.
-
-
-
6. Limitation of liability
-
- To the maximum extent permitted by law, the developer of BottleCount
- shall not be liable for any indirect, incidental, special,
- consequential, or punitive damages, or any loss of data, revenue, or
- profits arising from your use of the app.
-
-
-
7. Changes to the service
-
- We reserve the right to modify, suspend, or discontinue BottleCount at
- any time, with or without notice.
-
-
-
8. Changes to these Terms
-
- We may update these Terms from time to time. When we do, we will
- update the "Last updated" date above. Your continued use of BottleCount
- after changes are posted constitutes your acceptance of the updated
- Terms.
-
-
-
9. Governing law
-
- These Terms are governed by the laws of Italy, without regard to its
- conflict of law principles.
-
-
-
-
-
-
-
-
\ No newline at end of file
+---
+import '../styles/global.css';
+import NavBar from '../components/NavBar.astro';
+import Footer from '../components/Footer.astro';
+const base = import.meta.env.BASE_URL;
+---
+
+
+
+
+
+
+ Terms of Service · BottleCount
+
+
+
+
+
+
+
Terms of Service
+
Last updated: September 18, 2026
+
+
1. Acceptance of terms
+
+ By accessing or using BottleCount ("the app"), you agree to be bound
+ by these Terms of Service. If you do not agree, please do not use the
+ app.
+
+
+
2. Description of the service
+
+ BottleCount is a web application that helps you plan parties and
+ manage tickets. It is offered in three forms:
+
+
+
+ Browser — free, with no account. It runs entirely in
+ your browser and stores your data there.
+
+
+ Hosted — a one-time payment unlocks the features that
+ require a server. It requires signing in with Google.
+
+
+ Self-hosted — you deploy the software to your own infrastructure
+ under its licence. These Terms cover the service we operate, not a deployment
+ you run; for that, you are the operator.
+
+
+
+
3. Accounts
+
+ The hosted plan requires a Google account. You are responsible for
+ keeping access to it secure, and for everything done through your
+ BottleCount account. You may close your account at any time by asking
+ us to delete it.
+
+
+
4. Payment, licences and refunds
+
+
+ The hosted plan is a one-time payment. There is no
+ subscription and nothing renews.
+
+
+ Payment entitles you to a licence code, which you redeem in the app
+ to unlock the paid features on one account. A code can be redeemed
+ once.
+
+
+ Codes are for your own use. Reselling or sharing them is not
+ permitted.
+
+
+ If the hosted service is discontinued, the software remains
+ available to self-host under its licence at no cost.
+
+
+ If you are a consumer in the EU, you have a 14-day right of
+ withdrawal. Because access is granted immediately, redeeming your
+ code is a request to begin performance at once, and you acknowledge
+ that doing so ends that right. An unredeemed code can be refunded
+ within those 14 days — just email us.
+
+
+
+
5. Your data and backups
+
+ On every plan, your parties, menus and tickets are stored in your
+ browser. Clearing your browser's storage deletes them, and we cannot
+ recover them. Export your data if it matters to you. How we handle the
+ account information we do hold is described in our
+ Privacy Policy.
+
+
+
6. Acceptable use
+
+ Do not use BottleCount to break the law, to attack or overload the
+ service, or to circumvent its paid features. We may suspend an account
+ that does.
+
+
+
7. Use of Google APIs
+
+ BottleCount uses Google Sign-In to identify you, and requests no
+ access to your Google Drive, Sheets or any other Google service. Its
+ use and transfer of information received from Google APIs adheres to
+ the
+
+ Google API Services User Data Policy
+ , including the Limited Use requirements.
+
+
+
8. No warranties
+
+ BottleCount is provided "as is" and "as available", without warranties
+ of any kind. We do not guarantee the app will be error‑free,
+ uninterrupted, or suitable for your specific requirements.
+
+
+
9. Limitation of liability
+
+ To the maximum extent permitted by law, the developer of BottleCount
+ shall not be liable for any indirect, incidental, special,
+ consequential, or punitive damages, or any loss of data, revenue, or
+ profits arising from your use of the app.
+
+
+
10. Changes to the service
+
+ We reserve the right to modify, suspend, or discontinue BottleCount at
+ any time, with or without notice.
+
+
+
11. Changes to these Terms
+
+ We may update these Terms from time to time. When we do, we will
+ update the "Last updated" date above. Your continued use of
+ BottleCount after changes are posted constitutes your acceptance of
+ the updated Terms.
+
+
+
12. Governing law
+
+ These Terms are governed by the laws of Italy, without regard to its
+ conflict of law principles.
+
+
+
+
+
+
+
+
diff --git a/tsconfig.json b/tsconfig.json
index d1ef7de..f38641e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,5 +8,10 @@
"jsx": "preserve",
"jsxImportSource": "vue",
"verbatimModuleSyntax": true
- }
+ },
+ // `backend/` is a separate package with its own tsconfig and its own globals
+ // (D1Database and friends come from @cloudflare/workers-types, which the
+ // frontend must not load). `shared/` is deliberately left in: both sides
+ // compile it, which is what keeps the tier model honest.
+ "exclude": ["dist", "backend", ".astro", ".wrangler"]
}
diff --git a/wrangler.toml b/wrangler.toml
new file mode 100644
index 0000000..47932bc
--- /dev/null
+++ b/wrangler.toml
@@ -0,0 +1,27 @@
+# Cloudflare Pages config for the frontend.
+#
+# The service binding is what makes /api/* and /auth/* same-origin — see
+# functions/_backend.ts. Omit it (or deploy without it) and the app still runs:
+# the proxy answers 501, the frontend reads that as "no backend" and stays on
+# the free, browser-only tier.
+name = "bottlecount"
+pages_build_output_dir = "dist"
+compatibility_date = "2026-03-17"
+
+[[services]]
+binding = "BACKEND"
+service = "bottlecount-backend"
+
+[env.preview.vars]
+PUBLIC_BACKEND = "true"
+
+[[env.preview.services]]
+binding = "BACKEND"
+service = "bottlecount-backend-preview"
+
+[env.production.vars]
+PUBLIC_BACKEND = "true"
+
+[[env.production.services]]
+binding = "BACKEND"
+service = "bottlecount-backend"
From 3f4d15da92b1f2bcde47f90efcf853bcc4d346df Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 18 Sep 2026 12:09:52 +0000
Subject: [PATCH 2/6] feat: invite links, and a funnel that counts something
real
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The funnel shipped as a UI over a list the host typed in themselves, so its
columns were fiction: "Reached" counted people the host had entered, and
"Maybe" meant "not heard back", which a local array cannot know. The invite
link next to it copied a URL nothing served. This makes both real.
Backend:
- migration 0002: published parties and invites. The published party is the
invitation, not the party — name, date, venue, cover, forwarding, capacity.
Menu, shopping list and costs are never uploaded
- POST /invite/:slug/open writes a row on open, before any answer. That is
what makes "reached" a number rather than a guess
- POST /invite/:slug/answer takes confirmed or declined. Answering again
overwrites, so changing your mind is not a second guest
- Depth comes from the referrer's token: the host's link is 0, each forward
adds one. A forward token is only issued once a guest confirms, or someone
who never replied could seed a referral tree
- Capacity is checked inside the UPDATE, so two guests racing for the last
place cannot both take it. Declining is never refused — a full party is
still one you can say no to
- PATCH /api/parties/:id/invites/:id lets the host override an answer. Without
it the host's Accept button would be overwritten by the next poll
- /invite/* is mounted outside /api/* (guests have no account) behind an
IP-keyed rate limit — the only routes that write without an account
Frontend:
- InviteScreen: the RSVP page. No account, no IndexedDB; it knows only what
the link and two endpoints tell it. functions/i/[[slug]].ts rewrites the
/i/* space onto it, since slugs are minted long after the build
- mergeFunnel folds the server's invites into party.invites, matching on
remoteId and leaving hand-typed guests alone — they work on every tier and
must survive a refresh that has never heard of them
- the share sheet publishes on open, so a renamed party reaches guests; the
slug survives, so links already sent keep working
- statuses renamed accepted/pending -> confirmed/opened, with a backfill on
load: "pending" meant "host is waiting", "opened" means "they looked"
Tests: 65 backend (depth chains, capacity, owner isolation, returning
browsers) and 12 frontend covering the merge, which is the piece that could
silently delete a host's guest list. The flow was also driven by hand against
a local D1 — see the ADR for what the fakes still cannot cover.
Privacy policy, docs and pricing updated: D1 now holds guest RSVPs, and
saying otherwise would have been wrong in a way that matters.
See docs/adr/0002-invite-links-and-the-funnel.md.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9
---
.github/workflows/check.yml | 5 +-
README.md | 5 +-
backend/README.md | 40 +-
.../migrations/0002_parties_and_invites.sql | 66 +++
backend/src/app.ts | 12 +
backend/src/appEnv.ts | 6 +
backend/src/lib/tokens.ts | 40 ++
backend/src/repositories/d1/index.ts | 4 +
.../src/repositories/d1/inviteRepositoryD1.ts | 244 ++++++++
.../src/repositories/d1/partyRepositoryD1.ts | 140 +++++
backend/src/repositories/inviteRepository.ts | 74 +++
backend/src/repositories/partyRepository.ts | 42 ++
backend/src/repositories/repositories.ts | 4 +
backend/src/routes/invites.ts | 179 ++++++
backend/src/routes/parties.ts | 208 +++++++
backend/src/tests/routes/funnel.spec.ts | 283 ++++++++++
backend/src/tests/routes/invites.spec.ts | 422 ++++++++++++++
backend/src/tests/support/fakeInvites.ts | 236 ++++++++
backend/src/tests/support/fakeRepositories.ts | 5 +-
backend/wrangler.jsonc | 72 +++
docs/adr/0002-invite-links-and-the-funnel.md | 110 ++++
functions/i/[[slug]].ts | 34 ++
functions/invite/[[catchall]].ts | 9 +
package-lock.json | 283 +++++++++-
package.json | 6 +-
shared/invites.ts | 137 +++++
src/components/HomeScreen.vue | 2 +-
src/components/InviteScreen.vue | 534 ++++++++++++++++++
src/components/modals/DoorScannerModal.vue | 4 +-
src/components/modals/ShareModal.vue | 53 +-
src/components/tabs/GuestsTab.vue | 169 ++++--
src/lib/invites.spec.ts | 164 ++++++
src/lib/invites.ts | 184 ++++++
src/lib/store.ts | 191 ++++++-
src/lib/types.ts | 45 +-
src/pages/docs.astro | 26 +-
src/pages/i/index.astro | 38 ++
src/pages/pricing.astro | 15 +-
src/pages/privacy.astro | 35 +-
vitest.config.ts | 19 +
40 files changed, 4027 insertions(+), 118 deletions(-)
create mode 100644 backend/migrations/0002_parties_and_invites.sql
create mode 100644 backend/src/lib/tokens.ts
create mode 100644 backend/src/repositories/d1/inviteRepositoryD1.ts
create mode 100644 backend/src/repositories/d1/partyRepositoryD1.ts
create mode 100644 backend/src/repositories/inviteRepository.ts
create mode 100644 backend/src/repositories/partyRepository.ts
create mode 100644 backend/src/routes/invites.ts
create mode 100644 backend/src/routes/parties.ts
create mode 100644 backend/src/tests/routes/funnel.spec.ts
create mode 100644 backend/src/tests/routes/invites.spec.ts
create mode 100644 backend/src/tests/support/fakeInvites.ts
create mode 100644 docs/adr/0002-invite-links-and-the-funnel.md
create mode 100644 functions/i/[[slug]].ts
create mode 100644 functions/invite/[[catchall]].ts
create mode 100644 shared/invites.ts
create mode 100644 src/components/InviteScreen.vue
create mode 100644 src/lib/invites.spec.ts
create mode 100644 src/lib/invites.ts
create mode 100644 src/pages/i/index.astro
create mode 100644 vitest.config.ts
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 214ac0a..c4beb15 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -31,8 +31,9 @@ jobs:
- name: Typecheck
run: npm run typecheck
- - name: Backend tests
- run: npm run backend:test
+ # Frontend unit tests and backend tests both.
+ - name: Tests
+ run: npm test
# Catches the failure mode a typecheck cannot: a build that trips over
# the shared/ imports crossing the package boundary.
diff --git a/README.md b/README.md
index eada303..d8d2bdb 100644
--- a/README.md
+++ b/README.md
@@ -295,7 +295,9 @@ redeems it in the app, which flips their tier to `pro`.
BottleCount stores user data in the browser's IndexedDB through Dexie. Your custom ingredients, cocktails, settings, generated tickets and local app state stay on your device.
-On the free tier that is the whole story — there is no account and nothing is uploaded, because there is nowhere to upload it to. Signing in adds an account record (id, email, name, avatar URL, tier) in D1; party data is still local for every tier today, and moving it is the next piece of work ([ADR 0001](docs/adr/0001-cloudflare-tiers.md)).
+On the free tier that is the whole story — there is no account and nothing is uploaded, because there is nowhere to upload it to.
+
+Signing in adds an account record (id, email, name, avatar URL, tier) in D1. Turning on an **invite link** additionally publishes what an invitation card shows — the party's name, date, venue and cover — plus a row per guest who opens it, with the name they give and their answer. Your menu, shopping list, costs and budget are never uploaded on any tier; moving the rest of the party to your account is still to come ([ADR 0001](docs/adr/0001-cloudflare-tiers.md), [ADR 0002](docs/adr/0002-invite-links-and-the-funnel.md)).
Because browser storage is still local storage, export/import backup tools are an important part of the workflow for portability and recovery.
@@ -304,6 +306,7 @@ Because browser storage is still local storage, export/import backup tools are a
## Architecture decisions
- [ADR 0001 — Cloudflare, and three ways to run BottleCount](docs/adr/0001-cloudflare-tiers.md)
+- [ADR 0002 — Invite links, and what the funnel counts](docs/adr/0002-invite-links-and-the-funnel.md)
The backend has [its own README](backend/README.md) covering routes, local
setup, deployment and what the tests do and do not cover.
diff --git a/backend/README.md b/backend/README.md
index 44db704..8ebe9b3 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -8,19 +8,33 @@ See [ADR 0001](../docs/adr/0001-cloudflare-tiers.md) for why any of this exists.
## What it serves
-| Route | Auth | Purpose |
-| --------------------------- | -------- | ----------------------------------------------------------- |
-| `GET /` | — | Liveness, and which environment answered |
-| `GET /auth/google` | — | Google OAuth; sets the `session_token` cookie |
-| `POST /auth/logout` | — | Clears it (the cookie is httpOnly, so the page cannot) |
-| `POST /auth/dev` | — | Sign in without Google. **404 unless local or self-hosted** |
-| `GET /api/session` | optional | Who the caller is and what they may do |
-| `POST /api/licences/redeem` | session | Turns a licence code into `pro` |
+| Route | Auth | Purpose |
+| ----------------------------------------- | --------------- | ------------------------------------------------------------------- |
+| `GET /` | — | Liveness, and which environment answered |
+| `GET /auth/google` | — | Google OAuth; sets the `session_token` cookie |
+| `POST /auth/logout` | — | Clears it (the cookie is httpOnly, so the page cannot) |
+| `POST /auth/dev` | — | Sign in without Google. **404 unless local or self-hosted** |
+| `GET /api/session` | optional | Who the caller is and what they may do |
+| `POST /api/licences/redeem` | session | Turns a licence code into `pro` |
+| `POST /api/parties/publish` | session + `pro` | Publish or refresh a party's invite card |
+| `DELETE /api/parties/:localId/publish` | session + `pro` | Turn the link off; deletes its invites |
+| `GET /api/parties/:localId/invites` | session + `pro` | The host's RSVP funnel |
+| `PATCH /api/parties/:localId/invites/:id` | session + `pro` | Host overriding a guest's answer |
+| `POST /invite/:slug/open` | — | A guest opened the link. **Writes** — this is what "reached" counts |
+| `POST /invite/:slug/answer` | — | A guest's yes or no |
`/api/session` is the one `/api/*` route served without a session, because the
free tier _is_ a logged-out browser. The exemption is named explicitly in
`app.ts` rather than left to mount order.
+`/invite/*` is mounted **outside** `/api/*` entirely. Guests have no account —
+being able to RSVP without signing up is most of what an invite link is for — so
+the URL is the only credential those handlers have, and they are written knowing
+it: they return nothing a link holder should not see. They are also the only
+routes that write without an account behind them, so they sit behind a rate
+limit binding keyed on IP
+([ADR 0002](../docs/adr/0002-invite-links-and-the-funnel.md)).
+
## Running it locally
```bash
@@ -97,8 +111,14 @@ database.
## What the tests do and do not cover
They run on plain Vitest against in-memory repositories, so they cover routing,
-the `/api/*` guard, tier resolution and the redemption rules. They cannot catch
-a mistake in a SQL statement.
+the `/api/*` guard, tier resolution, the redemption rules, and the whole invite
+flow — depth down a referral chain, capacity refusing a confirmation but never a
+decline, one row per returning browser, owner isolation.
+
+They cannot catch a mistake in a SQL statement, and two of those rules are
+defended _by_ the SQL: the capacity check and the write are one statement, so two
+guests racing for the last place cannot both take it, whereas the fake does the
+check and the write separately. The fakes reproduce the rule, not the atomicity.
Covering that needs `@cloudflare/vitest-pool-workers`, which at the time of
writing peers on Vitest 4 while this project is on 5. When that clears, the
diff --git a/backend/migrations/0002_parties_and_invites.sql b/backend/migrations/0002_parties_and_invites.sql
new file mode 100644
index 0000000..9ad6436
--- /dev/null
+++ b/backend/migrations/0002_parties_and_invites.sql
@@ -0,0 +1,66 @@
+-- Published parties and the invite funnel.
+--
+-- This is the first party data the server holds, and it is deliberately only
+-- the part an invite link needs: what an invitation card shows, plus who
+-- followed it. The host's menu, shopping list, costs and locks stay in their
+-- browser — nobody opening a link needs them, and not storing them keeps the
+-- blast radius of a leaked slug down to "someone learns about a party".
+
+-- A party the host has chosen to publish. Unpublishing deletes the row, which
+-- cascades to its invites: turning the link off means the link stops working.
+CREATE TABLE parties (
+ id TEXT PRIMARY KEY,
+ owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ -- The party's id in the owner's IndexedDB. Unique per owner so republishing
+ -- updates in place instead of littering the table with copies, and so a host
+ -- who clears their browser cannot collide with another host's numbering.
+ local_id INTEGER NOT NULL,
+ -- What appears in the URL. Unguessable on its own: a readable slug plus
+ -- random suffix, because the guest-facing page has no other access control.
+ slug TEXT NOT NULL,
+ name TEXT NOT NULL,
+ date TEXT NOT NULL,
+ cover INTEGER NOT NULL DEFAULT 0,
+ venue_place TEXT NOT NULL DEFAULT '',
+ venue_city TEXT NOT NULL DEFAULT '',
+ venue_time TEXT NOT NULL DEFAULT '',
+ allow_forward INTEGER NOT NULL DEFAULT 1,
+ -- NULL when the host set no cap. When set, confirmations stop at it.
+ max_capacity INTEGER,
+ -- The host's own link. Guests who use it are depth 0.
+ root_token TEXT NOT NULL,
+ published_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX idx_parties_slug ON parties(slug);
+CREATE UNIQUE INDEX idx_parties_owner_local ON parties(owner_id, local_id);
+CREATE UNIQUE INDEX idx_parties_root_token ON parties(root_token);
+
+-- One row per person who opened the link, created on open rather than on
+-- answer. That is the whole point of the funnel: "reached" has to count people
+-- who never replied, and a row that only appears on answer cannot.
+CREATE TABLE invites (
+ id TEXT PRIMARY KEY,
+ party_id TEXT NOT NULL REFERENCES parties(id) ON DELETE CASCADE,
+ -- NULL until they answer — opening a link tells us nothing about who they are.
+ name TEXT,
+ status TEXT NOT NULL DEFAULT 'opened'
+ CHECK (status IN ('opened', 'confirmed', 'declined')),
+ -- 0 via the host's link, +1 per forward. The UI's "Direct invites" tier is
+ -- depth 0 and "Friends-of-friends" is everything above it.
+ depth INTEGER NOT NULL DEFAULT 0,
+ referrer_id TEXT REFERENCES invites(id) ON DELETE SET NULL,
+ -- This guest's own forward link, minted on open so a confirmation can hand it
+ -- straight back without a second write.
+ forward_token TEXT NOT NULL,
+ checked_in INTEGER NOT NULL DEFAULT 0,
+ checked_in_at TEXT,
+ opened_at TEXT NOT NULL,
+ answered_at TEXT
+);
+
+CREATE UNIQUE INDEX idx_invites_forward_token ON invites(forward_token);
+CREATE INDEX idx_invites_party ON invites(party_id);
+-- The confirmed-count query behind the capacity check runs on every open.
+CREATE INDEX idx_invites_party_status ON invites(party_id, status);
diff --git a/backend/src/app.ts b/backend/src/app.ts
index dba31b6..5a822e5 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -6,7 +6,9 @@ import { repositoriesFor } from './composition';
import type { Repositories } from './repositories/repositories';
import auth from './routes/auth';
import devAuth from './routes/devAuth';
+import invites from './routes/invites';
import licences from './routes/licences';
+import parties from './routes/parties';
import session from './routes/session';
export type Bindings = {
@@ -18,6 +20,10 @@ export type Bindings = {
ENVIRONMENT: string;
/** "true" on a self-hosted deployment — see shared/tiers.ts. */
SELF_HOSTED?: string;
+ /** Guards the unauthenticated invite endpoints. Absent locally. */
+ INVITE_RATE_LIMITER?: {
+ limit(o: { key: string }): Promise<{ success: boolean }>;
+ };
};
export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
@@ -84,6 +90,12 @@ export function createApp(overrides: AppOverrides = {}): App {
// Does its own optional JWT check — see routes/session.ts.
app.route('/api/session', session);
app.route('/api/licences', licences);
+ app.route('/api/parties', parties);
+
+ // Mounted outside /api/* on purpose: guests have no account, and being able
+ // to RSVP without signing up is most of what an invite link is for. The
+ // handlers are written knowing the URL is the only credential.
+ app.route('/invite', invites);
return app;
}
diff --git a/backend/src/appEnv.ts b/backend/src/appEnv.ts
index 98aae15..d1af4a4 100644
--- a/backend/src/appEnv.ts
+++ b/backend/src/appEnv.ts
@@ -3,4 +3,10 @@ import type { Repositories } from './repositories/repositories';
/** Everything the `*`-middleware puts on the context for routes to read. */
export interface AppVariables {
repositories: Repositories;
+ /**
+ * The authenticated user's id, set by a route group's own guard after it has
+ * resolved and checked the row — not by the JWT middleware, which only proves
+ * the cookie is signed.
+ */
+ userId?: string;
}
diff --git a/backend/src/lib/tokens.ts b/backend/src/lib/tokens.ts
new file mode 100644
index 0000000..cae540f
--- /dev/null
+++ b/backend/src/lib/tokens.ts
@@ -0,0 +1,40 @@
+/**
+ * Tokens and slugs for the invite link.
+ *
+ * Nothing behind an invite URL is authenticated — that is the whole point, a
+ * guest has no account — so the URL itself is the secret. These are sized to be
+ * unguessable rather than short.
+ */
+
+// Base32-ish, no I/O/0/1: these end up in URLs people read aloud and retype.
+const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
+
+function randomString(length: number): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
+ return Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
+}
+
+/**
+ * ~99 bits. Forward tokens identify a guest to anyone holding one, and a
+ * guessable one would let a stranger claim someone else's referral tree.
+ */
+export function newToken(): string {
+ return randomString(20);
+}
+
+/**
+ * A readable slug with a random tail. The readable half is courtesy — the tail
+ * is what stops someone enumerating parties, so it does not shrink when the
+ * name is long.
+ */
+export function newSlug(name: string): string {
+ const readable =
+ name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ .slice(0, 32) || 'party';
+ return `${readable}-${randomString(10)}`;
+}
diff --git a/backend/src/repositories/d1/index.ts b/backend/src/repositories/d1/index.ts
index fe46548..f33b147 100644
--- a/backend/src/repositories/d1/index.ts
+++ b/backend/src/repositories/d1/index.ts
@@ -1,10 +1,14 @@
import type { Repositories } from '../repositories';
+import { InviteRepositoryD1 } from './inviteRepositoryD1';
import { LicenceRepositoryD1 } from './licenceRepositoryD1';
+import { PartyRepositoryD1 } from './partyRepositoryD1';
import { UserRepositoryD1 } from './userRepositoryD1';
export function d1Repositories(db: D1Database): Repositories {
return {
users: new UserRepositoryD1(db),
licences: new LicenceRepositoryD1(db),
+ parties: new PartyRepositoryD1(db),
+ invites: new InviteRepositoryD1(db),
};
}
diff --git a/backend/src/repositories/d1/inviteRepositoryD1.ts b/backend/src/repositories/d1/inviteRepositoryD1.ts
new file mode 100644
index 0000000..de9bf68
--- /dev/null
+++ b/backend/src/repositories/d1/inviteRepositoryD1.ts
@@ -0,0 +1,244 @@
+import type { InviteAnswer, InviteStatus } from '../../../../shared/invites';
+import { INVITE_STATUSES } from '../../../../shared/invites';
+import { newToken } from '../../lib/tokens';
+import {
+ INVITE_ERRORS,
+ type Invite,
+ type InviteRepository,
+ type InviteWithReferrer,
+} from '../inviteRepository';
+import { err, ok, type Result } from '../result';
+
+interface InviteRow {
+ id: string;
+ party_id: string;
+ name: string | null;
+ status: string;
+ depth: number;
+ referrer_id: string | null;
+ forward_token: string;
+ checked_in: number;
+ checked_in_at: string | null;
+ opened_at: string;
+ answered_at: string | null;
+}
+
+interface InviteJoinRow extends InviteRow {
+ referrer_name: string | null;
+}
+
+function toStatus(value: string): InviteStatus {
+ // The column has a CHECK constraint but is still TEXT. Narrow rather than
+ // cast, and fall back to the state that claims the least.
+ return (INVITE_STATUSES as readonly string[]).includes(value)
+ ? (value as InviteStatus)
+ : 'opened';
+}
+
+function toInvite(row: InviteRow): Invite {
+ return {
+ id: row.id,
+ partyId: row.party_id,
+ name: row.name,
+ status: toStatus(row.status),
+ depth: row.depth,
+ referrerId: row.referrer_id,
+ forwardToken: row.forward_token,
+ checkedIn: row.checked_in === 1,
+ checkedInAt: row.checked_in_at,
+ openedAt: row.opened_at,
+ answeredAt: row.answered_at,
+ };
+}
+
+export class InviteRepositoryD1 implements InviteRepository {
+ constructor(private readonly db: D1Database) {}
+
+ async open({
+ partyId,
+ referrerToken,
+ rootToken,
+ existingInviteId,
+ }: {
+ partyId: string;
+ referrerToken: string | null;
+ rootToken: string;
+ existingInviteId: string | null;
+ }): Promise> {
+ // A browser that already has a row for this party is the same guest coming
+ // back — show them their answer rather than counting them twice. Scoped to
+ // the party so an id lifted from another party's link resolves to nothing.
+ if (existingInviteId) {
+ const existing = await this.db
+ .prepare('SELECT * FROM invites WHERE id = ? AND party_id = ?')
+ .bind(existingInviteId, partyId)
+ .first();
+ if (existing) return ok(toInvite(existing));
+ }
+
+ let depth = 0;
+ let referrerId: string | null = null;
+
+ // The host's own token is depth 0 and has no invite row behind it. Any
+ // other token has to resolve to an invite *of this party*; one that does
+ // not is treated as no referrer at all rather than rejected, because the
+ // common cause is a link from a party that has since been unpublished and
+ // the guest should still be able to RSVP.
+ if (referrerToken && referrerToken !== rootToken) {
+ const referrer = await this.db
+ .prepare(
+ 'SELECT id, depth FROM invites WHERE forward_token = ? AND party_id = ?',
+ )
+ .bind(referrerToken, partyId)
+ .first<{ id: string; depth: number }>();
+ if (referrer) {
+ referrerId = referrer.id;
+ depth = referrer.depth + 1;
+ }
+ }
+
+ const row = await this.db
+ .prepare(
+ `INSERT INTO invites (
+ id, party_id, name, status, depth, referrer_id, forward_token, opened_at
+ ) VALUES (?, ?, NULL, 'opened', ?, ?, ?, ?) RETURNING *`,
+ )
+ .bind(
+ crypto.randomUUID(),
+ partyId,
+ depth,
+ referrerId,
+ newToken(),
+ new Date().toISOString(),
+ )
+ .first();
+
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async findById(id: string): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM invites WHERE id = ?')
+ .bind(id)
+ .first();
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async answer({
+ inviteId,
+ partyId,
+ name,
+ answer,
+ maxCapacity,
+ }: {
+ inviteId: string;
+ partyId: string;
+ name: string;
+ answer: InviteAnswer;
+ maxCapacity: number | null;
+ }): Promise> {
+ const now = new Date().toISOString();
+
+ // Declining is always allowed — a full party is still a party you can say
+ // no to, and refusing the decline would leave the row stuck at `opened`.
+ if (answer === 'declined' || maxCapacity === null) {
+ const row = await this.db
+ .prepare(
+ `UPDATE invites SET name = ?, status = ?, answered_at = ?
+ WHERE id = ? AND party_id = ? RETURNING *`,
+ )
+ .bind(name, answer, now, inviteId, partyId)
+ .first();
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ // Capacity check and write in one statement. Counting first and updating
+ // after would let two guests racing for the last place both read "one left"
+ // and both confirm.
+ //
+ // The subquery excludes this invite, so a guest who is already confirmed and
+ // merely corrects their name does not have to fit into a party they are
+ // already counted in.
+ const row = await this.db
+ .prepare(
+ `UPDATE invites SET name = ?, status = 'confirmed', answered_at = ?
+ WHERE id = ? AND party_id = ?
+ AND (
+ SELECT COUNT(*) FROM invites others
+ WHERE others.party_id = ?
+ AND others.status = 'confirmed'
+ AND others.id <> ?
+ ) < ?
+ RETURNING *`,
+ )
+ .bind(name, now, inviteId, partyId, partyId, inviteId, maxCapacity)
+ .first();
+
+ if (row) return ok(toInvite(row));
+
+ // Nothing was written: either the invite is gone, or the party is full.
+ const stillThere = await this.db
+ .prepare('SELECT id FROM invites WHERE id = ? AND party_id = ?')
+ .bind(inviteId, partyId)
+ .first<{ id: string }>();
+
+ return err(stillThere ? INVITE_ERRORS.PARTY_FULL : INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async setStatus({
+ inviteId,
+ partyId,
+ status,
+ }: {
+ inviteId: string;
+ partyId: string;
+ status: InviteStatus;
+ }): Promise> {
+ // Sending someone back to `opened` clears the answer timestamp too, so the
+ // funnel does not show a guest who supposedly answered at a time but holds
+ // no answer.
+ const row = await this.db
+ .prepare(
+ `UPDATE invites
+ SET status = ?, answered_at = CASE WHEN ? = 'opened' THEN NULL ELSE ? END
+ WHERE id = ? AND party_id = ? RETURNING *`,
+ )
+ .bind(status, status, new Date().toISOString(), inviteId, partyId)
+ .first();
+
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async listForParty(partyId: string): Promise> {
+ // Oldest first: the host's client matches these onto rows it already has by
+ // id, and a stable order keeps newly opened invites appending at the end
+ // rather than reshuffling the list under the reader.
+ const { results } = await this.db
+ .prepare(
+ `SELECT i.*, r.name AS referrer_name
+ FROM invites i
+ LEFT JOIN invites r ON r.id = i.referrer_id
+ WHERE i.party_id = ?
+ ORDER BY i.opened_at ASC, i.id ASC`,
+ )
+ .bind(partyId)
+ .all();
+
+ return ok(
+ results.map((row) => ({
+ ...toInvite(row),
+ referrerName: row.referrer_name,
+ })),
+ );
+ }
+
+ async countConfirmed(partyId: string): Promise> {
+ const row = await this.db
+ .prepare(
+ "SELECT COUNT(*) AS n FROM invites WHERE party_id = ? AND status = 'confirmed'",
+ )
+ .bind(partyId)
+ .first<{ n: number }>();
+ return ok(row?.n ?? 0);
+ }
+}
diff --git a/backend/src/repositories/d1/partyRepositoryD1.ts b/backend/src/repositories/d1/partyRepositoryD1.ts
new file mode 100644
index 0000000..de30813
--- /dev/null
+++ b/backend/src/repositories/d1/partyRepositoryD1.ts
@@ -0,0 +1,140 @@
+import type { PublishPartyRequest } from '../../../../shared/invites';
+import { newSlug, newToken } from '../../lib/tokens';
+import {
+ PARTY_ERRORS,
+ type PartyRepository,
+ type PublishedParty,
+} from '../partyRepository';
+import { err, ok, type Result } from '../result';
+
+interface PartyRow {
+ id: string;
+ owner_id: string;
+ local_id: number;
+ slug: string;
+ name: string;
+ date: string;
+ cover: number;
+ venue_place: string;
+ venue_city: string;
+ venue_time: string;
+ allow_forward: number;
+ max_capacity: number | null;
+ root_token: string;
+ published_at: string;
+ updated_at: string;
+}
+
+function toParty(row: PartyRow): PublishedParty {
+ return {
+ id: row.id,
+ ownerId: row.owner_id,
+ localId: row.local_id,
+ slug: row.slug,
+ name: row.name,
+ date: row.date,
+ cover: row.cover,
+ venue: {
+ place: row.venue_place,
+ city: row.venue_city,
+ time: row.venue_time,
+ },
+ allowForward: row.allow_forward === 1,
+ maxCapacity: row.max_capacity,
+ rootToken: row.root_token,
+ publishedAt: row.published_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export class PartyRepositoryD1 implements PartyRepository {
+ constructor(private readonly db: D1Database) {}
+
+ async publish(
+ ownerId: string,
+ snapshot: PublishPartyRequest,
+ ): Promise> {
+ const now = new Date().toISOString();
+
+ // ON CONFLICT rather than a read-then-write: republishing is what happens
+ // every time the host opens the share sheet, so it has to be one round trip
+ // and it has to be safe against two devices doing it at once.
+ //
+ // `slug` and `root_token` are excluded from the update set on purpose. They
+ // are already out in the world on links the host has sent; rotating them on
+ // an edit would silently break every invitation.
+ const row = await this.db
+ .prepare(
+ `INSERT INTO parties (
+ id, owner_id, local_id, slug, name, date, cover,
+ venue_place, venue_city, venue_time,
+ allow_forward, max_capacity, root_token, published_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (owner_id, local_id) DO UPDATE SET
+ name = excluded.name,
+ date = excluded.date,
+ cover = excluded.cover,
+ venue_place = excluded.venue_place,
+ venue_city = excluded.venue_city,
+ venue_time = excluded.venue_time,
+ allow_forward = excluded.allow_forward,
+ max_capacity = excluded.max_capacity,
+ updated_at = excluded.updated_at
+ RETURNING *`,
+ )
+ .bind(
+ crypto.randomUUID(),
+ ownerId,
+ snapshot.localId,
+ newSlug(snapshot.name),
+ snapshot.name,
+ snapshot.date,
+ snapshot.cover,
+ snapshot.venue.place,
+ snapshot.venue.city,
+ snapshot.venue.time,
+ snapshot.allowForward ? 1 : 0,
+ snapshot.maxCapacity,
+ newToken(),
+ now,
+ now,
+ )
+ .first();
+
+ return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async findBySlug(slug: string): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM parties WHERE slug = ?')
+ .bind(slug)
+ .first();
+ return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async findByOwnerAndLocalId(
+ ownerId: string,
+ localId: number,
+ ): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM parties WHERE owner_id = ? AND local_id = ?')
+ .bind(ownerId, localId)
+ .first();
+ return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async unpublish(ownerId: string, id: string): Promise> {
+ // The owner check is in the WHERE clause, not a prior SELECT: a separate
+ // read would let another request change ownership in between, and it also
+ // means a party someone else owns is indistinguishable from one that does
+ // not exist.
+ const result = await this.db
+ .prepare('DELETE FROM parties WHERE id = ? AND owner_id = ?')
+ .bind(id, ownerId)
+ .run();
+
+ return result.meta.changes > 0
+ ? ok(undefined)
+ : err(PARTY_ERRORS.NOT_FOUND);
+ }
+}
diff --git a/backend/src/repositories/inviteRepository.ts b/backend/src/repositories/inviteRepository.ts
new file mode 100644
index 0000000..34717cf
--- /dev/null
+++ b/backend/src/repositories/inviteRepository.ts
@@ -0,0 +1,74 @@
+import type { InviteAnswer, InviteStatus } from '../../../shared/invites';
+import type { Result } from './result';
+
+export interface Invite {
+ id: string;
+ partyId: string;
+ name: string | null;
+ status: InviteStatus;
+ depth: number;
+ referrerId: string | null;
+ forwardToken: string;
+ checkedIn: boolean;
+ checkedInAt: string | null;
+ openedAt: string;
+ answeredAt: string | null;
+}
+
+/** An invite plus the referrer's display name, which the host's funnel shows. */
+export interface InviteWithReferrer extends Invite {
+ referrerName: string | null;
+}
+
+export const INVITE_ERRORS = {
+ NOT_FOUND: 'invite_not_found',
+ /** The cap is reached; the caller may still decline, just not confirm. */
+ PARTY_FULL: 'party_full',
+} as const;
+
+export interface InviteRepository {
+ /**
+ * Records that someone opened the link, at `referrerToken`'s depth + 1 (or 0
+ * for the host's own token). Returns the existing row when the caller already
+ * has one, so a reload or a second visit is the same guest, not a second one.
+ */
+ open(args: {
+ partyId: string;
+ referrerToken: string | null;
+ rootToken: string;
+ existingInviteId: string | null;
+ }): Promise>;
+
+ findById(id: string): Promise>;
+
+ /**
+ * Records an answer. Confirming is refused once the party is full, and the
+ * check has to be part of the write — two guests confirming the last place at
+ * once must not both succeed.
+ */
+ answer(args: {
+ inviteId: string;
+ partyId: string;
+ name: string;
+ answer: InviteAnswer;
+ maxCapacity: number | null;
+ }): Promise>;
+
+ /**
+ * The host overriding a guest's state — "I spoke to her, she's coming".
+ *
+ * Unlike {@link answer} this ignores capacity: the host is the authority on
+ * their own door, and a cap they set themselves should not stop them letting
+ * one more person in.
+ */
+ setStatus(args: {
+ inviteId: string;
+ partyId: string;
+ status: InviteStatus;
+ }): Promise>;
+
+ /** The host's funnel, oldest first so client-side ids stay stable. */
+ listForParty(partyId: string): Promise>;
+
+ countConfirmed(partyId: string): Promise>;
+}
diff --git a/backend/src/repositories/partyRepository.ts b/backend/src/repositories/partyRepository.ts
new file mode 100644
index 0000000..45d536a
--- /dev/null
+++ b/backend/src/repositories/partyRepository.ts
@@ -0,0 +1,42 @@
+import type { PublishPartyRequest } from '../../../shared/invites';
+import type { Result } from './result';
+
+export interface PublishedParty {
+ id: string;
+ ownerId: string;
+ localId: number;
+ slug: string;
+ name: string;
+ date: string;
+ cover: number;
+ venue: { place: string; city: string; time: string };
+ allowForward: boolean;
+ maxCapacity: number | null;
+ rootToken: string;
+ publishedAt: string;
+ updatedAt: string;
+}
+
+export const PARTY_ERRORS = {
+ NOT_FOUND: 'party_not_found',
+ NOT_OWNER: 'party_not_owner',
+} as const;
+
+export interface PartyRepository {
+ /**
+ * Publishes or republishes. Keyed on (owner, localId), so a host editing a
+ * party and sharing again updates the same row — and keeps the same slug, or
+ * every link they already sent would break.
+ */
+ publish(
+ ownerId: string,
+ snapshot: PublishPartyRequest,
+ ): Promise>;
+ findBySlug(slug: string): Promise>;
+ findByOwnerAndLocalId(
+ ownerId: string,
+ localId: number,
+ ): Promise>;
+ /** Unpublishing deletes the row; its invites cascade with it. */
+ unpublish(ownerId: string, id: string): Promise>;
+}
diff --git a/backend/src/repositories/repositories.ts b/backend/src/repositories/repositories.ts
index f19046d..8636a4e 100644
--- a/backend/src/repositories/repositories.ts
+++ b/backend/src/repositories/repositories.ts
@@ -1,8 +1,12 @@
+import type { InviteRepository } from './inviteRepository';
import type { LicenceRepository } from './licenceRepository';
+import type { PartyRepository } from './partyRepository';
import type { UserRepository } from './userRepository';
/** Everything a route can reach storage through. */
export interface Repositories {
users: UserRepository;
licences: LicenceRepository;
+ parties: PartyRepository;
+ invites: InviteRepository;
}
diff --git a/backend/src/routes/invites.ts b/backend/src/routes/invites.ts
new file mode 100644
index 0000000..a5cd4ca
--- /dev/null
+++ b/backend/src/routes/invites.ts
@@ -0,0 +1,179 @@
+import { Hono } from 'hono';
+import type {
+ InviteOpenDTO,
+ InviteOpenRequest,
+ InvitePartyDTO,
+} from '../../../shared/invites';
+import { isInviteAnswer } from '../../../shared/invites';
+import type { AppVariables } from '../appEnv';
+import { INVITE_ERRORS } from '../repositories/inviteRepository';
+import type { Invite } from '../repositories/inviteRepository';
+import type { PublishedParty } from '../repositories/partyRepository';
+
+type RateLimiter = { limit(o: { key: string }): Promise<{ success: boolean }> };
+
+type Bindings = {
+ // Optional: the local environment leaves it unbound so a fresh clone runs
+ // without a Cloudflare account. Absent means unlimited, which is correct for
+ // a machine only you can reach.
+ INVITE_RATE_LIMITER?: RateLimiter;
+};
+
+const invites = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
+
+/**
+ * Public, unauthenticated, and mounted outside the `/api/*` guard.
+ *
+ * A guest has no account by design — being able to RSVP without signing up is
+ * most of the value of an invite link. So the URL is the only credential, and
+ * these handlers must never return anything the link holder should not see:
+ * no other guests' names, no owner identity, no budget.
+ */
+
+function toPartyDTO(party: PublishedParty, full: boolean): InvitePartyDTO {
+ return {
+ slug: party.slug,
+ name: party.name,
+ date: party.date,
+ cover: party.cover,
+ venue: party.venue,
+ allowForward: party.allowForward,
+ full,
+ };
+}
+
+/**
+ * A guest's own forward link exists only once they have confirmed and only if
+ * the host allows forwarding. Handing it out at `opened` would let someone who
+ * never replied seed a referral tree.
+ */
+function forwardTokenFor(invite: Invite, party: PublishedParty): string | null {
+ if (!party.allowForward) return null;
+ return invite.status === 'confirmed' ? invite.forwardToken : null;
+}
+
+async function isFull(
+ repositories: AppVariables['repositories'],
+ party: PublishedParty,
+): Promise {
+ if (party.maxCapacity === null) return false;
+ const counted = await repositories.invites.countConfirmed(party.id);
+ return counted.ok && counted.value >= party.maxCapacity;
+}
+
+/** Guards the two write paths. Keyed on IP, since there is no account to key on. */
+async function rateLimited(
+ limiter: RateLimiter | undefined,
+ c: { req: { header(name: string): string | undefined } },
+): Promise {
+ if (!limiter) return false;
+ const key = c.req.header('cf-connecting-ip') ?? 'unknown';
+ const { success } = await limiter.limit({ key });
+ return !success;
+}
+
+/**
+ * `POST /invite/:slug/open` — someone opened the link.
+ *
+ * A POST rather than a GET because it writes: this is the row that makes
+ * "reached" a real number. The client sends back the `inviteId` it was given
+ * last time, so a reload, a second device-less visit or a guest returning to
+ * change their mind is the same person rather than a new one.
+ */
+invites.post('/:slug/open', async (c) => {
+ if (await rateLimited(c.env.INVITE_RATE_LIMITER, c)) {
+ return c.json({ error: 'rate_limited' }, 429);
+ }
+
+ const found = await c.var.repositories.parties.findBySlug(
+ c.req.param('slug'),
+ );
+ // An unpublished or never-published party is a 404 either way; there is
+ // nothing useful to tell a link holder apart from "this is not a party".
+ if (!found.ok) return c.json({ error: 'party_not_found' }, 404);
+ const party = found.value;
+
+ const body = await c.req
+ .json()
+ .catch(() => ({}) as InviteOpenRequest);
+
+ const opened = await c.var.repositories.invites.open({
+ partyId: party.id,
+ referrerToken: body.referrer ?? null,
+ rootToken: party.rootToken,
+ existingInviteId: body.inviteId ?? null,
+ });
+ if (!opened.ok) return c.json({ error: opened.error }, 500);
+
+ const invite = opened.value;
+ const dto: InviteOpenDTO = {
+ party: toPartyDTO(party, await isFull(c.var.repositories, party)),
+ inviteId: invite.id,
+ forwardToken: forwardTokenFor(invite, party),
+ status: invite.status,
+ name: invite.name,
+ depth: invite.depth,
+ };
+ return c.json(dto);
+});
+
+/**
+ * `POST /invite/:slug/answer` — yes or no.
+ *
+ * Answering again overwrites: someone who said maybe-then-no, or who mistyped
+ * their name, should not need a second row, and a second row would inflate the
+ * funnel's "reached" count with people who were only ever one guest.
+ */
+invites.post('/:slug/answer', async (c) => {
+ if (await rateLimited(c.env.INVITE_RATE_LIMITER, c)) {
+ return c.json({ error: 'rate_limited' }, 429);
+ }
+
+ const found = await c.var.repositories.parties.findBySlug(
+ c.req.param('slug'),
+ );
+ if (!found.ok) return c.json({ error: 'party_not_found' }, 404);
+ const party = found.value;
+
+ const body = await c.req.json().catch(() => null);
+ if (typeof body !== 'object' || body === null) {
+ return c.json({ error: 'invalid_answer' }, 400);
+ }
+ const { inviteId, name, answer } = body as Record;
+
+ if (typeof inviteId !== 'string' || !isInviteAnswer(answer)) {
+ return c.json({ error: 'invalid_answer' }, 400);
+ }
+ const trimmed = typeof name === 'string' ? name.trim().slice(0, 60) : '';
+ if (trimmed === '') return c.json({ error: 'name_required' }, 400);
+
+ const answered = await c.var.repositories.invites.answer({
+ inviteId,
+ partyId: party.id,
+ name: trimmed,
+ answer,
+ maxCapacity: party.maxCapacity,
+ });
+
+ if (!answered.ok) {
+ if (answered.error === INVITE_ERRORS.PARTY_FULL) {
+ return c.json({ error: answered.error }, 409);
+ }
+ // A stale inviteId (the host unpublished and republished, say) is a 404, so
+ // the client knows to open again rather than retrying an answer forever.
+ return c.json({ error: answered.error }, 404);
+ }
+
+ const invite = answered.value;
+ const dto: InviteOpenDTO = {
+ party: toPartyDTO(party, await isFull(c.var.repositories, party)),
+ inviteId: invite.id,
+ forwardToken: forwardTokenFor(invite, party),
+ status: invite.status,
+ name: invite.name,
+ depth: invite.depth,
+ };
+ return c.json(dto);
+});
+
+export default invites;
diff --git a/backend/src/routes/parties.ts b/backend/src/routes/parties.ts
new file mode 100644
index 0000000..39e3f55
--- /dev/null
+++ b/backend/src/routes/parties.ts
@@ -0,0 +1,208 @@
+import { Hono } from 'hono';
+import type { JwtVariables } from 'hono/jwt';
+import type {
+ HostInviteDTO,
+ PublishedPartyDTO,
+ PublishPartyRequest,
+} from '../../../shared/invites';
+import { INVITE_STATUSES } from '../../../shared/invites';
+import type { InviteStatus } from '../../../shared/invites';
+import { featuresFor, resolveTier } from '../../../shared/tiers';
+import type { AppVariables } from '../appEnv';
+import { PARTY_ERRORS } from '../repositories/partyRepository';
+import type { PublishedParty } from '../repositories/partyRepository';
+
+type Bindings = {
+ SELF_HOSTED?: string;
+};
+
+const parties = new Hono<{
+ Bindings: Bindings;
+ Variables: AppVariables & JwtVariables;
+}>();
+
+function toPublishedDTO(party: PublishedParty): PublishedPartyDTO {
+ return {
+ id: party.id,
+ slug: party.slug,
+ rootToken: party.rootToken,
+ publishedAt: party.publishedAt,
+ allowForward: party.allowForward,
+ };
+}
+
+/**
+ * Publishing is the paid feature, so the check is here and not only in the UI.
+ *
+ * It reads the tier from the user's row rather than the JWT: a session lives
+ * seven days, and a claim baked into one would keep granting `pro` for a week
+ * after a refund.
+ */
+parties.use('*', async (c, next) => {
+ const sub = c.get('jwtPayload')?.sub;
+ if (typeof sub !== 'string') return c.json({ error: 'unauthenticated' }, 401);
+
+ const found = await c.var.repositories.users.findById(sub);
+ if (!found.ok) return c.json({ error: 'unauthenticated' }, 401);
+
+ const tier = resolveTier({
+ storedTier: found.value.tier,
+ selfHosted: c.env.SELF_HOSTED === 'true',
+ });
+ if (!featuresFor(tier).inviteLink) {
+ return c.json({ error: 'upgrade_required', feature: 'inviteLink' }, 403);
+ }
+
+ c.set('userId', sub);
+ return next();
+});
+
+function parseSnapshot(body: unknown): PublishPartyRequest | null {
+ if (typeof body !== 'object' || body === null) return null;
+ const b = body as Partial;
+ if (typeof b.localId !== 'number' || !Number.isInteger(b.localId))
+ return null;
+ if (typeof b.name !== 'string' || b.name.trim() === '') return null;
+ if (typeof b.date !== 'string') return null;
+
+ const venue = b.venue ?? { place: '', city: '', time: '' };
+ return {
+ localId: b.localId,
+ // Bounded because these are rendered on a page anyone with the link can
+ // open; a host is not a threat, but a stolen session is.
+ name: b.name.trim().slice(0, 80),
+ date: b.date.slice(0, 10),
+ cover: Number.isInteger(b.cover) ? Math.max(0, Math.min(5, b.cover!)) : 0,
+ venue: {
+ place: String(venue.place ?? '').slice(0, 120),
+ city: String(venue.city ?? '').slice(0, 80),
+ time: String(venue.time ?? '').slice(0, 10),
+ },
+ allowForward: b.allowForward !== false,
+ maxCapacity:
+ typeof b.maxCapacity === 'number' && Number.isFinite(b.maxCapacity)
+ ? Math.max(1, Math.round(b.maxCapacity))
+ : null,
+ };
+}
+
+/**
+ * `POST /api/parties/publish` — make a party openable by link.
+ *
+ * Idempotent on (owner, localId): the host's share sheet calls it every time it
+ * opens, which is what keeps the guest-facing card in step with a renamed party
+ * or a moved venue. The slug survives, so links already sent keep working.
+ */
+parties.post('/publish', async (c) => {
+ const userId = c.get('userId') as string;
+ const snapshot = parseSnapshot(await c.req.json().catch(() => null));
+ if (!snapshot) return c.json({ error: 'invalid_party' }, 400);
+
+ const published = await c.var.repositories.parties.publish(userId, snapshot);
+ if (!published.ok) return c.json({ error: published.error }, 500);
+
+ return c.json(toPublishedDTO(published.value));
+});
+
+/** `DELETE /api/parties/:localId/publish` — turn the link off for good. */
+parties.delete('/:localId/publish', async (c) => {
+ const userId = c.get('userId') as string;
+ const localId = Number(c.req.param('localId'));
+ if (!Number.isInteger(localId))
+ return c.json({ error: 'invalid_party' }, 400);
+
+ const found = await c.var.repositories.parties.findByOwnerAndLocalId(
+ userId,
+ localId,
+ );
+ // Already gone is the state the caller wanted, so it is not an error.
+ if (!found.ok) return c.json({ ok: true });
+
+ const removed = await c.var.repositories.parties.unpublish(
+ userId,
+ found.value.id,
+ );
+ if (!removed.ok) return c.json({ error: removed.error }, 500);
+
+ return c.json({ ok: true });
+});
+
+/**
+ * `GET /api/parties/:localId/invites` — the funnel.
+ *
+ * Names are in here, which is why it is owner-scoped: the lookup is by
+ * (owner, localId), so there is no id a caller could substitute to read someone
+ * else's guest list.
+ */
+parties.get('/:localId/invites', async (c) => {
+ const userId = c.get('userId') as string;
+ const localId = Number(c.req.param('localId'));
+ if (!Number.isInteger(localId))
+ return c.json({ error: 'invalid_party' }, 400);
+
+ const found = await c.var.repositories.parties.findByOwnerAndLocalId(
+ userId,
+ localId,
+ );
+ if (!found.ok) {
+ const status = found.error === PARTY_ERRORS.NOT_FOUND ? 404 : 500;
+ return c.json({ error: found.error }, status);
+ }
+
+ const listed = await c.var.repositories.invites.listForParty(found.value.id);
+ if (!listed.ok) return c.json({ error: listed.error }, 500);
+
+ const invites: HostInviteDTO[] = listed.value.map((invite) => ({
+ id: invite.id,
+ name: invite.name,
+ status: invite.status,
+ depth: invite.depth,
+ referrer: invite.referrerName,
+ forwardToken: invite.forwardToken,
+ openedAt: invite.openedAt,
+ answeredAt: invite.answeredAt,
+ }));
+
+ return c.json({ party: toPublishedDTO(found.value), invites });
+});
+
+/**
+ * `PATCH /api/parties/:localId/invites/:inviteId` — the host overriding an answer.
+ *
+ * Hosts do talk to their guests off-platform ("she told me at work she's
+ * coming"), and without this the funnel would poll their change straight back
+ * out again a few seconds later. It is deliberately a different route from the
+ * guest's own answer: this one is owner-scoped, ignores capacity, and can send
+ * a row back to `opened`, none of which a guest may do.
+ */
+parties.patch('/:localId/invites/:inviteId', async (c) => {
+ const userId = c.get('userId') as string;
+ const localId = Number(c.req.param('localId'));
+ if (!Number.isInteger(localId))
+ return c.json({ error: 'invalid_party' }, 400);
+
+ const body = await c.req
+ .json<{ status?: string }>()
+ .catch(() => ({}) as { status?: string });
+ const status = body.status;
+ if (!status || !(INVITE_STATUSES as readonly string[]).includes(status)) {
+ return c.json({ error: 'invalid_status' }, 400);
+ }
+
+ const found = await c.var.repositories.parties.findByOwnerAndLocalId(
+ userId,
+ localId,
+ );
+ if (!found.ok) return c.json({ error: found.error }, 404);
+
+ const updated = await c.var.repositories.invites.setStatus({
+ inviteId: c.req.param('inviteId'),
+ partyId: found.value.id,
+ status: status as InviteStatus,
+ });
+ if (!updated.ok) return c.json({ error: updated.error }, 404);
+
+ return c.json({ ok: true, status: updated.value.status });
+});
+
+export default parties;
diff --git a/backend/src/tests/routes/funnel.spec.ts b/backend/src/tests/routes/funnel.spec.ts
new file mode 100644
index 0000000..c95b50f
--- /dev/null
+++ b/backend/src/tests/routes/funnel.spec.ts
@@ -0,0 +1,283 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import type {
+ HostInviteDTO,
+ InviteOpenDTO,
+ PublishedPartyDTO,
+} from '../../../../shared/invites';
+import type { Repositories } from '../../repositories/repositories';
+import { fakeInvites, fakeParties, resetFakeIds } from '../support/fakeInvites';
+import { aUser, fakeLicences, fakeUsers } from '../support/fakeRepositories';
+import { request, sessionCookie } from '../support/harness';
+
+let repositories: Repositories;
+let party: PublishedPartyDTO;
+
+beforeEach(async () => {
+ resetFakeIds();
+ repositories = {
+ users: fakeUsers([aUser({ tier: 'pro' })]),
+ licences: fakeLicences(),
+ parties: fakeParties(),
+ invites: fakeInvites(),
+ };
+ const res = await request('/api/parties/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: {
+ localId: 7,
+ name: 'Rooftop',
+ date: '2026-10-02',
+ cover: 0,
+ venue: { place: '', city: '', time: '21:00' },
+ allowForward: true,
+ maxCapacity: null,
+ },
+ });
+ party = (await res.json()) as PublishedPartyDTO;
+});
+
+/** Opens the link (optionally as a forward) and confirms, returning the guest. */
+async function guestConfirms(
+ name: string,
+ referrer?: string | null,
+): Promise {
+ const openRes = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: { referrer: referrer ?? null },
+ });
+ const opened = (await openRes.json()) as InviteOpenDTO;
+
+ const answerRes = await request(`/invite/${party.slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body: { inviteId: opened.inviteId, name, answer: 'confirmed' },
+ });
+ return (await answerRes.json()) as InviteOpenDTO;
+}
+
+async function funnel(): Promise {
+ const res = await request('/api/parties/7/invites', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { invites: HostInviteDTO[] };
+ return body.invites;
+}
+
+describe('depth', () => {
+ it("puts a guest from the host's own link at depth 0", async () => {
+ // Depth 0 is the "Direct invites" tier in the spread card.
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ expect(guest.depth).toBe(0);
+ });
+
+ it("treats no referrer at all as the host's link", async () => {
+ const guest = await guestConfirms('Giulia');
+ expect(guest.depth).toBe(0);
+ });
+
+ it('puts a friend of a guest at depth 1', async () => {
+ const giulia = await guestConfirms('Giulia', party.rootToken);
+ const marco = await guestConfirms('Marco', giulia.forwardToken);
+
+ expect(marco.depth).toBe(1);
+ });
+
+ it('keeps counting down the chain', async () => {
+ const giulia = await guestConfirms('Giulia', party.rootToken);
+ const marco = await guestConfirms('Marco', giulia.forwardToken);
+ const sara = await guestConfirms('Sara', marco.forwardToken);
+
+ expect(sara.depth).toBe(2);
+ });
+
+ it('falls back to depth 0 for a token that means nothing', async () => {
+ // Usually a link from a party that has since been unpublished. The guest
+ // should still be able to RSVP rather than hit an error.
+ const guest = await guestConfirms('Giulia', 'not-a-real-token');
+ expect(guest.depth).toBe(0);
+ });
+
+ it('records who referred whom, by name', async () => {
+ const giulia = await guestConfirms('Giulia', party.rootToken);
+ await guestConfirms('Marco', giulia.forwardToken);
+
+ const rows = await funnel();
+ const marco = rows.find((i) => i.name === 'Marco');
+ expect(marco?.referrer).toBe('Giulia');
+ expect(rows.find((i) => i.name === 'Giulia')?.referrer).toBeNull();
+ });
+});
+
+describe("the host's funnel", () => {
+ it('reports every state, including people who never answered', async () => {
+ await guestConfirms('Giulia', party.rootToken);
+
+ // Someone who opened and said no.
+ const declining = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ const declined = (await declining.json()) as InviteOpenDTO;
+ await request(`/invite/${party.slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body: { inviteId: declined.inviteId, name: 'Marco', answer: 'declined' },
+ });
+
+ // Someone who opened and walked away.
+ await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+
+ const rows = await funnel();
+ expect(rows).toHaveLength(3);
+ expect(rows.map((i) => i.status).sort()).toEqual([
+ 'confirmed',
+ 'declined',
+ 'opened',
+ ]);
+ // The one who never answered has no name to show — that is the point of
+ // "reached" being a separate number from "confirmed".
+ expect(rows.find((i) => i.status === 'opened')?.name).toBeNull();
+ });
+
+ it('returns invites oldest first', async () => {
+ await guestConfirms('First', party.rootToken);
+ await guestConfirms('Second', party.rootToken);
+
+ const rows = await funnel();
+ expect(rows.map((i) => i.name)).toEqual(['First', 'Second']);
+ });
+
+ it('404s a party the host has not published', async () => {
+ const res = await request('/api/parties/99/invites', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ expect(res.status).toBe(404);
+ });
+
+ it("does not show one host another host's guests", async () => {
+ // The lookup is by (owner, localId), so there is no id to substitute.
+ await guestConfirms('Giulia', party.rootToken);
+ repositories.users = fakeUsers([
+ aUser({ tier: 'pro' }),
+ aUser({ id: 'user-2', email: 'other@example.com', tier: 'pro' }),
+ ]);
+
+ const res = await request('/api/parties/7/invites', {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ expect(res.status).toBe(404);
+ });
+
+ it('refuses a free host', async () => {
+ repositories.users = fakeUsers([aUser({ tier: 'free' })]);
+ const res = await request('/api/parties/7/invites', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+
+ expect(res.status).toBe(403);
+ });
+});
+
+describe('the host overriding an answer', () => {
+ async function override(
+ inviteId: string,
+ status: string,
+ cookie = 'user-1',
+ ): Promise {
+ return request(`/api/parties/7/invites/${inviteId}`, {
+ repositories,
+ cookie: await sessionCookie(cookie),
+ method: 'PATCH',
+ body: { status },
+ });
+ }
+
+ it('marks someone who never answered as coming', async () => {
+ // Hosts hear from guests off-platform. Without this the funnel would poll
+ // the host's change straight back out again.
+ const openRes = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ const opened = (await openRes.json()) as InviteOpenDTO;
+
+ const res = await override(opened.inviteId, 'confirmed');
+
+ expect(res.status).toBe(200);
+ expect((await funnel())[0]?.status).toBe('confirmed');
+ });
+
+ it('can send a guest back to unanswered, clearing the answer time', async () => {
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ await override(guest.inviteId, 'opened');
+
+ const row = (await funnel())[0];
+ expect(row?.status).toBe('opened');
+ expect(row?.answeredAt).toBeNull();
+ });
+
+ it('ignores capacity — the host is the authority on their own door', async () => {
+ await request('/api/parties/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: {
+ localId: 7,
+ name: 'Rooftop',
+ date: '2026-10-02',
+ allowForward: true,
+ maxCapacity: 1,
+ },
+ });
+ await guestConfirms('Giulia', party.rootToken);
+
+ const openRes = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ const second = (await openRes.json()) as InviteOpenDTO;
+
+ // The guest cannot get in…
+ const guestTry = await request(`/invite/${party.slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body: { inviteId: second.inviteId, name: 'Marco', answer: 'confirmed' },
+ });
+ expect(guestTry.status).toBe(409);
+
+ // …but the host can let them.
+ expect((await override(second.inviteId, 'confirmed')).status).toBe(200);
+ });
+
+ it('rejects a status that is not a real one', async () => {
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ expect((await override(guest.inviteId, 'maybe')).status).toBe(400);
+ });
+
+ it("404s an invite belonging to someone else's party", async () => {
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ repositories.users = fakeUsers([
+ aUser({ tier: 'pro' }),
+ aUser({ id: 'user-2', email: 'other@example.com', tier: 'pro' }),
+ ]);
+
+ expect((await override(guest.inviteId, 'declined', 'user-2')).status).toBe(
+ 404,
+ );
+ });
+});
diff --git a/backend/src/tests/routes/invites.spec.ts b/backend/src/tests/routes/invites.spec.ts
new file mode 100644
index 0000000..bc186dd
--- /dev/null
+++ b/backend/src/tests/routes/invites.spec.ts
@@ -0,0 +1,422 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import type {
+ InviteOpenDTO,
+ PublishedPartyDTO,
+} from '../../../../shared/invites';
+import type { Repositories } from '../../repositories/repositories';
+import { fakeInvites, fakeParties, resetFakeIds } from '../support/fakeInvites';
+import { aUser, fakeLicences, fakeUsers } from '../support/fakeRepositories';
+import { request, sessionCookie } from '../support/harness';
+
+/** A pro host with one published party, and the repositories behind them. */
+async function aPublishedParty(
+ overrides: { maxCapacity?: number | null; allowForward?: boolean } = {},
+): Promise<{ repositories: Repositories; party: PublishedPartyDTO }> {
+ const repositories: Repositories = {
+ users: fakeUsers([aUser({ tier: 'pro' })]),
+ licences: fakeLicences(),
+ parties: fakeParties(),
+ invites: fakeInvites(),
+ };
+
+ const res = await request('/api/parties/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: {
+ localId: 7,
+ name: 'Rooftop',
+ date: '2026-10-02',
+ cover: 1,
+ venue: { place: 'The Roof', city: 'Milan', time: '21:00' },
+ allowForward: overrides.allowForward ?? true,
+ maxCapacity: overrides.maxCapacity ?? null,
+ },
+ });
+ expect(res.status).toBe(200);
+ return { repositories, party: (await res.json()) as PublishedPartyDTO };
+}
+
+async function open(
+ repositories: Repositories,
+ slug: string,
+ body: Record = {},
+): Promise {
+ const res = await request(`/invite/${slug}/open`, {
+ repositories,
+ method: 'POST',
+ body,
+ });
+ expect(res.status).toBe(200);
+ return (await res.json()) as InviteOpenDTO;
+}
+
+async function answer(
+ repositories: Repositories,
+ slug: string,
+ body: Record,
+): Promise {
+ return request(`/invite/${slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body,
+ });
+}
+
+beforeEach(() => resetFakeIds());
+
+describe('publishing a party', () => {
+ it('refuses a free host', async () => {
+ // The paywall is enforced here, not only by hiding the share sheet.
+ const repositories: Repositories = {
+ users: fakeUsers([aUser({ tier: 'free' })]),
+ licences: fakeLicences(),
+ parties: fakeParties(),
+ invites: fakeInvites(),
+ };
+
+ const res = await request('/api/parties/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 1, name: 'Party', date: '2026-10-02' },
+ });
+
+ expect(res.status).toBe(403);
+ expect(await res.json()).toMatchObject({ feature: 'inviteLink' });
+ });
+
+ it('allows a free host on a self-hosted deployment', async () => {
+ const repositories: Repositories = {
+ users: fakeUsers([aUser({ tier: 'free' })]),
+ licences: fakeLicences(),
+ parties: fakeParties(),
+ invites: fakeInvites(),
+ };
+
+ const res = await request('/api/parties/publish', {
+ repositories,
+ env: { SELF_HOSTED: 'true' },
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 1, name: 'Party', date: '2026-10-02' },
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('refuses an anonymous caller', async () => {
+ const res = await request('/api/parties/publish', {
+ repositories: {
+ users: fakeUsers(),
+ licences: fakeLicences(),
+ parties: fakeParties(),
+ invites: fakeInvites(),
+ },
+ method: 'POST',
+ body: { localId: 1, name: 'Party', date: '2026-10-02' },
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it('keeps the slug when the host republishes', async () => {
+ // Every share-sheet open republishes. A new slug would break every link
+ // already sent.
+ const { repositories, party } = await aPublishedParty();
+
+ const res = await request('/api/parties/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: {
+ localId: 7,
+ name: 'Rooftop (moved)',
+ date: '2026-10-09',
+ cover: 1,
+ venue: { place: 'The Roof', city: 'Milan', time: '22:00' },
+ allowForward: true,
+ maxCapacity: null,
+ },
+ });
+
+ const republished = (await res.json()) as PublishedPartyDTO;
+ expect(republished.slug).toBe(party.slug);
+ expect(republished.rootToken).toBe(party.rootToken);
+
+ // …and the guest-facing card reflects the edit.
+ const opened = await open(repositories, party.slug);
+ expect(opened.party.name).toBe('Rooftop (moved)');
+ expect(opened.party.venue.time).toBe('22:00');
+ });
+
+ it('rejects a snapshot with no name', async () => {
+ const { repositories } = await aPublishedParty();
+ const res = await request('/api/parties/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 8, name: ' ', date: '2026-10-02' },
+ });
+
+ expect(res.status).toBe(400);
+ });
+});
+
+describe('opening an invite link', () => {
+ it('records the visit before any answer', async () => {
+ // This is what makes "reached" a real number rather than a guess.
+ const { repositories, party } = await aPublishedParty();
+
+ const opened = await open(repositories, party.slug);
+
+ expect(opened.status).toBe('opened');
+ expect(opened.name).toBeNull();
+ expect(opened.depth).toBe(0);
+ expect(opened.party.name).toBe('Rooftop');
+ });
+
+ it('does not hand out a forward token before confirming', async () => {
+ // Otherwise someone who never replied could seed a referral tree.
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ expect(opened.forwardToken).toBeNull();
+ });
+
+ it('treats a returning browser as the same guest', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const first = await open(repositories, party.slug);
+ const second = await open(repositories, party.slug, {
+ inviteId: first.inviteId,
+ });
+
+ expect(second.inviteId).toBe(first.inviteId);
+ const listed = await repositories.invites.listForParty('party-1');
+ expect(listed.ok && listed.value).toHaveLength(1);
+ });
+
+ it('ignores an inviteId belonging to another party', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const first = await open(repositories, party.slug);
+
+ // Publish a second party and try to carry the first party's row into it.
+ await request('/api/parties/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 9, name: 'Other', date: '2026-11-01' },
+ });
+ const other = await repositories.parties.findByOwnerAndLocalId('user-1', 9);
+ expect(other.ok).toBe(true);
+ if (!other.ok) return;
+
+ const opened = await open(repositories, other.value.slug, {
+ inviteId: first.inviteId,
+ });
+
+ expect(opened.inviteId).not.toBe(first.inviteId);
+ });
+
+ it('404s an unknown slug', async () => {
+ const { repositories } = await aPublishedParty();
+ const res = await request('/invite/not-a-party/open', {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+
+ expect(res.status).toBe(404);
+ });
+
+ it('404s once the host unpublishes', async () => {
+ const { repositories, party } = await aPublishedParty();
+
+ const removed = await request('/api/parties/7/publish', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'DELETE',
+ });
+ expect(removed.status).toBe(200);
+
+ const res = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('answering', () => {
+ it('confirms and hands back a forward link', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(200);
+ const dto = (await res.json()) as InviteOpenDTO;
+ expect(dto.status).toBe('confirmed');
+ expect(dto.name).toBe('Giulia');
+ expect(dto.forwardToken).toBeTruthy();
+ });
+
+ it('withholds the forward link when the host disallows forwarding', async () => {
+ const { repositories, party } = await aPublishedParty({
+ allowForward: false,
+ });
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const dto = (await res.json()) as InviteOpenDTO;
+ expect(dto.forwardToken).toBeNull();
+ });
+
+ it('lets a guest change their mind without becoming a second guest', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'declined',
+ });
+
+ expect(res.status).toBe(200);
+ const listed = await repositories.invites.listForParty('party-1');
+ expect(listed.ok && listed.value).toHaveLength(1);
+ expect(listed.ok && listed.value[0]?.status).toBe('declined');
+ });
+
+ it('requires a name', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: ' ',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects an answer that is not one of the two', async () => {
+ // "opened" is a state, not something a guest can claim.
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'opened',
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('404s a stale invite id so the client knows to open again', async () => {
+ const { repositories, party } = await aPublishedParty();
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: 'invite-does-not-exist',
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('capacity', () => {
+ it('refuses a confirmation once the cap is reached', async () => {
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+
+ const first = await open(repositories, party.slug);
+ await answer(repositories, party.slug, {
+ inviteId: first.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const second = await open(repositories, party.slug);
+ const res = await answer(repositories, party.slug, {
+ inviteId: second.inviteId,
+ name: 'Marco',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(409);
+ expect(await res.json()).toMatchObject({ error: 'party_full' });
+ });
+
+ it('still lets someone decline a full party', async () => {
+ // Refusing the decline would strand the row at `opened` and overstate the
+ // "maybe" column with people who have already said no.
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+
+ const first = await open(repositories, party.slug);
+ await answer(repositories, party.slug, {
+ inviteId: first.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const second = await open(repositories, party.slug);
+ const res = await answer(repositories, party.slug, {
+ inviteId: second.inviteId,
+ name: 'Marco',
+ answer: 'declined',
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('lets an already-confirmed guest correct their name at the cap', async () => {
+ // They are already counted; re-confirming must not have to fit them in again.
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+ const opened = await open(repositories, party.slug);
+
+ await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giula',
+ answer: 'confirmed',
+ });
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as InviteOpenDTO).name).toBe('Giulia');
+ });
+
+ it('tells a late arrival the party is full before they answer', async () => {
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+ const first = await open(repositories, party.slug);
+ await answer(repositories, party.slug, {
+ inviteId: first.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const second = await open(repositories, party.slug);
+ expect(second.party.full).toBe(true);
+ });
+});
diff --git a/backend/src/tests/support/fakeInvites.ts b/backend/src/tests/support/fakeInvites.ts
new file mode 100644
index 0000000..e88ac1c
--- /dev/null
+++ b/backend/src/tests/support/fakeInvites.ts
@@ -0,0 +1,236 @@
+import type {
+ InviteAnswer,
+ InviteStatus,
+ PublishPartyRequest,
+} from '../../../../shared/invites';
+import type {
+ Invite,
+ InviteRepository,
+ InviteWithReferrer,
+} from '../../repositories/inviteRepository';
+import { INVITE_ERRORS } from '../../repositories/inviteRepository';
+import {
+ PARTY_ERRORS,
+ type PartyRepository,
+ type PublishedParty,
+} from '../../repositories/partyRepository';
+import { err, ok, type Result } from '../../repositories/result';
+
+/**
+ * In-memory parties and invites.
+ *
+ * These reproduce the *rules* — depth from the referrer, one row per returning
+ * browser, capacity refused on confirm but never on decline — because those are
+ * what the route tests are about. They do not reproduce the atomicity the real
+ * statements get from doing the check and the write together; a race is a
+ * property of the SQL, and only the Workers pool can test it.
+ */
+
+let counter = 0;
+const nextId = (prefix: string) => `${prefix}-${++counter}`;
+
+export function resetFakeIds(): void {
+ counter = 0;
+}
+
+export function fakeParties(seed: PublishedParty[] = []): PartyRepository & {
+ rows: Map;
+} {
+ const rows = new Map(seed.map((p) => [p.id, p]));
+
+ return {
+ rows,
+ async publish(
+ ownerId: string,
+ snapshot: PublishPartyRequest,
+ ): Promise> {
+ const now = new Date().toISOString();
+ for (const row of rows.values()) {
+ if (row.ownerId === ownerId && row.localId === snapshot.localId) {
+ // Republishing keeps the slug and root token — links already sent
+ // have to keep working.
+ const updated: PublishedParty = {
+ ...row,
+ name: snapshot.name,
+ date: snapshot.date,
+ cover: snapshot.cover,
+ venue: snapshot.venue,
+ allowForward: snapshot.allowForward,
+ maxCapacity: snapshot.maxCapacity,
+ updatedAt: now,
+ };
+ rows.set(row.id, updated);
+ return ok(updated);
+ }
+ }
+ const party: PublishedParty = {
+ id: nextId('party'),
+ ownerId,
+ localId: snapshot.localId,
+ slug: `${snapshot.name.toLowerCase().replace(/\W+/g, '-')}-${nextId('s')}`,
+ name: snapshot.name,
+ date: snapshot.date,
+ cover: snapshot.cover,
+ venue: snapshot.venue,
+ allowForward: snapshot.allowForward,
+ maxCapacity: snapshot.maxCapacity,
+ rootToken: nextId('root'),
+ publishedAt: now,
+ updatedAt: now,
+ };
+ rows.set(party.id, party);
+ return ok(party);
+ },
+ async findBySlug(slug: string): Promise> {
+ for (const row of rows.values()) {
+ if (row.slug === slug) return ok(row);
+ }
+ return err(PARTY_ERRORS.NOT_FOUND);
+ },
+ async findByOwnerAndLocalId(
+ ownerId: string,
+ localId: number,
+ ): Promise> {
+ for (const row of rows.values()) {
+ if (row.ownerId === ownerId && row.localId === localId) return ok(row);
+ }
+ return err(PARTY_ERRORS.NOT_FOUND);
+ },
+ async unpublish(ownerId: string, id: string): Promise> {
+ const row = rows.get(id);
+ if (!row || row.ownerId !== ownerId) return err(PARTY_ERRORS.NOT_FOUND);
+ rows.delete(id);
+ return ok(undefined);
+ },
+ };
+}
+
+export function fakeInvites(seed: Invite[] = []): InviteRepository & {
+ rows: Map;
+} {
+ const rows = new Map(seed.map((i) => [i.id, i]));
+
+ const confirmedCount = (partyId: string, excluding?: string) =>
+ [...rows.values()].filter(
+ (i) =>
+ i.partyId === partyId && i.status === 'confirmed' && i.id !== excluding,
+ ).length;
+
+ return {
+ rows,
+ async open({
+ partyId,
+ referrerToken,
+ rootToken,
+ existingInviteId,
+ }): Promise> {
+ if (existingInviteId) {
+ const existing = rows.get(existingInviteId);
+ if (existing && existing.partyId === partyId) return ok(existing);
+ }
+
+ let depth = 0;
+ let referrerId: string | null = null;
+ if (referrerToken && referrerToken !== rootToken) {
+ for (const row of rows.values()) {
+ if (row.forwardToken === referrerToken && row.partyId === partyId) {
+ referrerId = row.id;
+ depth = row.depth + 1;
+ break;
+ }
+ }
+ }
+
+ const invite: Invite = {
+ id: nextId('invite'),
+ partyId,
+ name: null,
+ status: 'opened',
+ depth,
+ referrerId,
+ forwardToken: nextId('fwd'),
+ checkedIn: false,
+ checkedInAt: null,
+ openedAt: new Date().toISOString(),
+ answeredAt: null,
+ };
+ rows.set(invite.id, invite);
+ return ok(invite);
+ },
+ async findById(id: string): Promise> {
+ const row = rows.get(id);
+ return row ? ok(row) : err(INVITE_ERRORS.NOT_FOUND);
+ },
+ async answer({
+ inviteId,
+ partyId,
+ name,
+ answer,
+ maxCapacity,
+ }: {
+ inviteId: string;
+ partyId: string;
+ name: string;
+ answer: InviteAnswer;
+ maxCapacity: number | null;
+ }): Promise> {
+ const row = rows.get(inviteId);
+ if (!row || row.partyId !== partyId) return err(INVITE_ERRORS.NOT_FOUND);
+
+ if (
+ answer === 'confirmed' &&
+ maxCapacity !== null &&
+ confirmedCount(partyId, inviteId) >= maxCapacity
+ ) {
+ return err(INVITE_ERRORS.PARTY_FULL);
+ }
+
+ const updated: Invite = {
+ ...row,
+ name,
+ status: answer,
+ answeredAt: new Date().toISOString(),
+ };
+ rows.set(inviteId, updated);
+ return ok(updated);
+ },
+ async setStatus({
+ inviteId,
+ partyId,
+ status,
+ }: {
+ inviteId: string;
+ partyId: string;
+ status: InviteStatus;
+ }): Promise> {
+ const row = rows.get(inviteId);
+ if (!row || row.partyId !== partyId) return err(INVITE_ERRORS.NOT_FOUND);
+ const updated: Invite = {
+ ...row,
+ status,
+ answeredAt: status === 'opened' ? null : new Date().toISOString(),
+ };
+ rows.set(inviteId, updated);
+ return ok(updated);
+ },
+
+ async listForParty(partyId: string): Promise> {
+ const list = [...rows.values()]
+ .filter((i) => i.partyId === partyId)
+ .sort(
+ (a, b) =>
+ a.openedAt.localeCompare(b.openedAt) || a.id.localeCompare(b.id),
+ )
+ .map((invite) => ({
+ ...invite,
+ referrerName: invite.referrerId
+ ? (rows.get(invite.referrerId)?.name ?? null)
+ : null,
+ }));
+ return ok(list);
+ },
+ async countConfirmed(partyId: string): Promise> {
+ return ok(confirmedCount(partyId));
+ },
+ };
+}
diff --git a/backend/src/tests/support/fakeRepositories.ts b/backend/src/tests/support/fakeRepositories.ts
index c533ca1..d198fea 100644
--- a/backend/src/tests/support/fakeRepositories.ts
+++ b/backend/src/tests/support/fakeRepositories.ts
@@ -1,4 +1,5 @@
import type { Tier } from '../../../../shared/tiers';
+import { fakeInvites, fakeParties } from './fakeInvites';
import type {
LicenceKey,
LicenceRepository,
@@ -85,8 +86,10 @@ export function fakeLicences(seed: LicenceKey[] = []): LicenceRepository {
export function fakeRepositories(
users = fakeUsers(),
licences = fakeLicences(),
+ parties = fakeParties(),
+ invites = fakeInvites(),
): Repositories {
- return { users, licences };
+ return { users, licences, parties, invites };
}
export function aUser(overrides: Partial = {}): User {
diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc
index 720c2e5..a03fa78 100644
--- a/backend/wrangler.jsonc
+++ b/backend/wrangler.jsonc
@@ -18,6 +18,24 @@
"database_id": "00000000-0000-0000-0000-000000000000",
},
],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
"vars": {
"FRONTEND_URL": "http://localhost:4321",
"ENVIRONMENT": "local",
@@ -37,6 +55,24 @@
"database_id": "",
},
],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
"vars": {
"FRONTEND_URL": "https://preview.bottlecount.pages.dev",
"ENVIRONMENT": "preview",
@@ -55,6 +91,24 @@
"database_id": "",
},
],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
"vars": {
"FRONTEND_URL": "https://bottlecount.pages.dev",
"ENVIRONMENT": "production",
@@ -78,6 +132,24 @@
"database_id": "",
},
],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
"vars": {
"FRONTEND_URL": "",
"ENVIRONMENT": "production",
diff --git a/docs/adr/0002-invite-links-and-the-funnel.md b/docs/adr/0002-invite-links-and-the-funnel.md
new file mode 100644
index 0000000..5d84cf1
--- /dev/null
+++ b/docs/adr/0002-invite-links-and-the-funnel.md
@@ -0,0 +1,110 @@
+# ADR 0002 — Invite links, and what the funnel counts
+
+Status: accepted
+Date: 2026-09-18
+Follows: [ADR 0001](0001-cloudflare-tiers.md)
+
+## Context
+
+ADR 0001 built the accounts and the paywall but left the three paid features
+locked and empty. This one fills in two of them: the invite link, and the RSVP
+funnel that counts what happens to it.
+
+The funnel already existed as a UI — four columns and a spread view — reading a
+list the host typed in themselves. Its columns were therefore fiction: "Reached"
+counted people the host had entered, and "Maybe" meant "the host has not heard
+back", which is not a thing a local array can know.
+
+## Decision
+
+### The server holds the invitation, not the party
+
+Publishing a party stores what an invitation card shows — name, date, venue,
+cover, forwarding, capacity — and nothing else. The menu, the shopping list, the
+costs and the locks stay in the host's browser.
+
+This is the smallest thing that makes a link work, and it bounds the damage from
+a leaked slug to "a stranger learns there is a party". It is also why `parties`
+in migration `0002` is not the `Party` type: it is the invitation, and the two
+should not be confused when cloud sync arrives.
+
+### A row is created on open, not on answer
+
+`POST /invite/:slug/open` writes. That is the whole reason "Reached" can be a
+real number: a row that only appears when somebody answers cannot count the
+people who looked and left, and those are exactly the people a host wants to
+chase.
+
+It also renames the states. `accepted`/`pending`/`declined` became
+`confirmed`/`opened`/`declined`, because `pending` used to mean "the host is
+waiting to hear" and now means "they opened the link and stopped". Parties saved
+before this are migrated on load (`store.ts`), since a funnel over the old words
+counts nothing.
+
+### Depth comes from the referrer, and the host is depth 0
+
+Every invite gets a `forward_token`. The host's link carries the party's
+`root_token` and produces depth 0; a guest's own link produces their depth + 1.
+An unrecognised token falls back to depth 0 rather than erroring — the usual
+cause is a link from a party that has since been unpublished, and that guest
+should still be able to RSVP.
+
+A forward token is only handed out once a guest **confirms**. Otherwise someone
+who never replied could seed a referral tree.
+
+### Capacity is checked inside the write
+
+`UPDATE … WHERE (SELECT COUNT(*) … ) < ?` rather than a count followed by an
+update, because two guests racing for the last place would both read "one left".
+Declining is never refused: a full party is still one you can say no to, and
+refusing would strand the row at `opened` and overstate the "maybe" column.
+
+### Identity is the URL, plus one id in `localStorage`
+
+Guests have no account — being able to RSVP without signing up is most of what an
+invite link is for — so the URL is the only credential, and the handlers return
+nothing a link holder should not see: no other guests' names, no owner, no
+budget. The browser keeps its `inviteId` so a reload is the same guest rather
+than a second one; a private window loses it and is counted again, which
+overstates "Reached" slightly and is much better than refusing the RSVP.
+
+The two public endpoints are the only routes on the Worker that write without an
+account behind them, so they sit behind a rate limit binding keyed on IP.
+
+### The host can override, and it has to reach the server
+
+Hosts hear from guests off-platform. Without `PATCH /api/parties/:id/invites/:id`
+the host's Accept button would be overwritten by the next poll twenty seconds
+later — a button that appears to work and then quietly undoes itself. The
+override ignores capacity, because the host is the authority on their own door.
+
+### One guest list, not two
+
+`mergeFunnel` folds the server's invites into `party.invites`, matching on
+`remoteId` and leaving rows without one alone. Those are the guests the host
+typed in by hand, which works on every tier and must survive a refresh that has
+never heard of them. Keeping one array means the guest list, the ticket flow, the
+door scanner and the KPI bar did not need to learn about a second source.
+
+## Consequences
+
+- **Publishing happens on every share-sheet open**, so a renamed party or moved
+ venue reaches guests without a separate "update" button. The slug and root
+ token are excluded from the update, or every link already sent would break.
+- **Unpublishing deletes the party and its invites.** Guests already merged into
+ the host's local list stay there — they are still coming — but they can no
+ longer change their answer.
+- **`/i/` needs a Pages Function.** Slugs are minted at runtime, so
+ `getStaticPaths` cannot know them; `functions/i/[[slug]].ts` rewrites the whole
+ space onto one built page, which reads the slug off the URL. Invite links
+ therefore do not work on a static-only host — which is moot, since they are a
+ paid feature and that host has no backend.
+- **Check-in state is still local.** The server has no idea the door scanner
+ exists, so `mergeFunnel` carries `used`/`usedAt` across refreshes rather than
+ letting the server blank them. Multi-device scanning (`doorScannerSync`) is
+ still declared and locked.
+- **The SQL is still untested.** The race conditions these statements are written
+ to survive — two confirmations for the last place, two devices republishing —
+ are properties of the statements, and the fakes cannot reproduce them. The flow
+ was verified by hand against a local D1; covering it properly still needs
+ `@cloudflare/vitest-pool-workers` (see `backend/vitest.config.ts`).
diff --git a/functions/i/[[slug]].ts b/functions/i/[[slug]].ts
new file mode 100644
index 0000000..2b62d18
--- /dev/null
+++ b/functions/i/[[slug]].ts
@@ -0,0 +1,34 @@
+/**
+ * Serves the invite page for every `/i/` URL.
+ *
+ * Pages Functions win over static assets on the same path, so this one has to
+ * hand back the asset itself: it rewrites the request onto `/i/`, which is the
+ * built invite page, and lets the component read the slug off the URL. Without
+ * it, `/i/rooftop-abc123` is a 404 — the build has no page at that path and
+ * cannot have one, because slugs are minted at runtime, long after the build.
+ *
+ * The browser's URL is untouched; only the asset lookup is redirected.
+ */
+interface Env {
+ ASSETS?: { fetch(request: Request): Promise };
+}
+
+interface Ctx {
+ request: Request;
+ env: Env;
+ next(): Promise;
+}
+
+export async function onRequest({
+ request,
+ env,
+ next,
+}: Ctx): Promise {
+ // A deployment without the ASSETS binding still works: fall through and let
+ // the platform serve whatever it would have.
+ if (!env.ASSETS) return next();
+
+ const url = new URL(request.url);
+ url.pathname = '/i/';
+ return env.ASSETS.fetch(new Request(url, request));
+}
diff --git a/functions/invite/[[catchall]].ts b/functions/invite/[[catchall]].ts
new file mode 100644
index 0000000..b3af6a4
--- /dev/null
+++ b/functions/invite/[[catchall]].ts
@@ -0,0 +1,9 @@
+import { proxyToBackend, type ProxyContext } from '../_backend';
+
+/**
+ * The guest-facing endpoints, which are outside `/api/*` because guests have no
+ * account. They still need a proxy of their own — without one, `/invite/...`
+ * falls through to the static build and every RSVP is a 404.
+ */
+export const onRequest = (ctx: ProxyContext): Promise =>
+ proxyToBackend(ctx);
diff --git a/package-lock.json b/package-lock.json
index 866354c..85f82c4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"prettier-plugin-astro": "^0.14.1",
"typescript": "^5.0.0",
"typescript-eslint": "^8.59.1",
+ "vitest": "^5.0.1",
"vue-eslint-parser": "^10.4.0"
},
"engines": {
@@ -1979,9 +1980,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -2541,6 +2542,17 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2550,6 +2562,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -2933,6 +2952,64 @@
"vue": "^3.0.0"
}
},
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
+ "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.1",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+ "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
+ "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@volar/kit": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
@@ -3353,6 +3430,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/astro": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/astro/-/astro-6.1.10.tgz",
@@ -3661,6 +3748,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -4346,9 +4443,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
- "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"license": "MIT"
},
"node_modules/esbuild": {
@@ -4845,6 +4942,16 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -6734,14 +6841,17 @@
}
},
"node_modules/obug": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
- "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
},
"node_modules/ofetch": {
"version": "1.5.1",
@@ -7007,9 +7117,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -7727,6 +7837,13 @@
"node": ">=20"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -7824,6 +7941,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -7938,6 +8069,16 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
+ "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/tinyclip": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz",
@@ -7948,9 +8089,9 @@
}
},
"node_modules/tinyexec": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
- "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -8765,6 +8906,99 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
+ "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.1",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "6.1.4",
+ "tinyexec": "1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.1",
+ "@vitest/browser-preview": "5.0.1",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.1",
+ "@vitest/coverage-v8": "5.0.1",
+ "@vitest/ui": "5.0.1",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+ "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
"node_modules/volar-service-css": {
"version": "0.0.71",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz",
@@ -9167,6 +9401,23 @@
"node": ">=4"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/package.json b/package.json
index 5c42007..dc1b9fe 100644
--- a/package.json
+++ b/package.json
@@ -14,9 +14,10 @@
"backend": "npm --prefix backend run",
"backend:dev": "npm --prefix backend run dev",
"backend:test": "npm --prefix backend run test",
- "test": "npm --prefix backend run test",
+ "test": "vitest run && npm --prefix backend run test",
"typecheck": "astro check && npm --prefix backend run typecheck",
- "install:all": "npm install && npm --prefix backend install"
+ "install:all": "npm install && npm --prefix backend install",
+ "test:frontend": "vitest run"
},
"engines": {
"node": ">=22.12.0"
@@ -45,6 +46,7 @@
"prettier-plugin-astro": "^0.14.1",
"typescript": "^5.0.0",
"typescript-eslint": "^8.59.1",
+ "vitest": "^5.0.1",
"vue-eslint-parser": "^10.4.0"
}
}
diff --git a/shared/invites.ts b/shared/invites.ts
new file mode 100644
index 0000000..325addb
--- /dev/null
+++ b/shared/invites.ts
@@ -0,0 +1,137 @@
+/**
+ * The invite-link contract, shared by the Worker and the frontend.
+ *
+ * Everything here crosses the network in both directions, so it lives beside
+ * `tiers.ts` for the same reason: a field the server renames and the client
+ * still reads is a bug that typechecks on both sides independently.
+ */
+
+// ── Status ──────────────────────────────────────────────────────────────────
+
+export const INVITE_STATUSES = ['opened', 'confirmed', 'declined'] as const;
+
+/**
+ * Where someone is in the funnel.
+ *
+ * `opened` is created the moment the link is opened, before any answer — that
+ * is what makes "reached" a real number rather than a guess, and what the
+ * "maybe" column counts. It is not a pending *invitation*: nobody was invited
+ * by name, they followed a link.
+ */
+export type InviteStatus = (typeof INVITE_STATUSES)[number];
+
+/** An answer a guest can give. Opening the link is not an answer. */
+export type InviteAnswer = Exclude;
+
+export function isInviteAnswer(value: unknown): value is InviteAnswer {
+ return value === 'confirmed' || value === 'declined';
+}
+
+// ── What a guest sees before answering ──────────────────────────────────────
+
+/**
+ * The public face of a party. Deliberately thin: anyone with the link can read
+ * this, so it carries what an invitation card would and nothing else — no
+ * budget, no shopping list, no guest names, no owner identity.
+ */
+export interface InvitePartyDTO {
+ slug: string;
+ name: string;
+ /** ISO date, `YYYY-MM-DD`. */
+ date: string;
+ cover: number;
+ venue: { place: string; city: string; time: string };
+ /** Whether a confirmed guest gets a forward link of their own. */
+ allowForward: boolean;
+ /** True when a capacity cap is set and confirmed guests have reached it. */
+ full: boolean;
+}
+
+/** What `POST /invite/:slug/open` answers with. */
+export interface InviteOpenDTO {
+ party: InvitePartyDTO;
+ /** This visitor's row. The client keeps it so a reload is not a second guest. */
+ inviteId: string;
+ /** Their own forward token — only present once they confirm and if allowed. */
+ forwardToken: string | null;
+ /** Their current answer, so returning to the link shows what they already said. */
+ status: InviteStatus;
+ name: string | null;
+ depth: number;
+}
+
+export interface InviteAnswerRequest {
+ inviteId: string;
+ name: string;
+ answer: InviteAnswer;
+}
+
+export interface InviteOpenRequest {
+ /** The forward token from `?r=`, when they arrived through another guest. */
+ referrer?: string | null;
+ /** A row this browser already owns for this party, from a previous visit. */
+ inviteId?: string | null;
+}
+
+// ── What the host sees ──────────────────────────────────────────────────────
+
+/** One row of the host's funnel. Names are visible here; this endpoint is theirs. */
+export interface HostInviteDTO {
+ id: string;
+ name: string | null;
+ status: InviteStatus;
+ /**
+ * 0 for someone who used the host's own link, +1 for each forward after that
+ * — so 1 is a friend of a guest. Matches the "Direct invites" and
+ * "Friends-of-friends" tiers the spread card already draws.
+ */
+ depth: number;
+ /** The referrer's display name, or null at depth 0. */
+ referrer: string | null;
+ forwardToken: string | null;
+ openedAt: string;
+ answeredAt: string | null;
+}
+
+/** What `POST /api/parties/publish` answers with, and what the host stores. */
+export interface PublishedPartyDTO {
+ id: string;
+ slug: string;
+ /** The host's own link. Guests who use it land at depth 0. */
+ rootToken: string;
+ publishedAt: string;
+ allowForward: boolean;
+}
+
+/** The snapshot a host pushes so guests have something to open. */
+export interface PublishPartyRequest {
+ /** The party's id in the host's browser. Republishing with it updates in place. */
+ localId: number;
+ name: string;
+ date: string;
+ cover: number;
+ venue: { place: string; city: string; time: string };
+ allowForward: boolean;
+ maxCapacity: number | null;
+}
+
+// ── Link building ───────────────────────────────────────────────────────────
+
+/**
+ * The one place an invite URL is spelled, so the host's share sheet, a guest's
+ * forward link and the page that resolves them cannot drift.
+ *
+ * `base` is the app's base path (`/` on Cloudflare, `/BottleCount/` on GitHub
+ * Pages) — leaving it out is how a build under a prefix hands out links that
+ * miss the prefix.
+ */
+export function inviteUrl(
+ origin: string,
+ base: string,
+ slug: string,
+ token?: string | null,
+): string {
+ const prefix = base.endsWith('/') ? base : `${base}/`;
+ const url = `${origin}${prefix}i/${slug}`;
+ return token ? `${url}?r=${encodeURIComponent(token)}` : url;
+}
diff --git a/src/components/HomeScreen.vue b/src/components/HomeScreen.vue
index b82e51b..6adef24 100644
--- a/src/components/HomeScreen.vue
+++ b/src/components/HomeScreen.vue
@@ -46,7 +46,7 @@ const partyCards = computed(() => {
})
: '—';
- const accepted = p.invites.filter((i) => i.status === 'accepted').length;
+ const accepted = p.invites.filter((i) => i.status === 'confirmed').length;
const r = store.calcForParty(p);
const avgProfit = (r.profit_min + r.profit_max) / 2;
const profitColor = avgProfit >= 0 ? 'var(--good)' : 'var(--bad)';
diff --git a/src/components/InviteScreen.vue b/src/components/InviteScreen.vue
new file mode 100644
index 0000000..df65199
--- /dev/null
+++ b/src/components/InviteScreen.vue
@@ -0,0 +1,534 @@
+
+
+
+
+
+
+
+
Opening your invite…
+
+
+
+
+
This invite has expired
+
+ The link is no longer active — the host may have closed the guest list.
+ Ask them for a fresh one.
+
+
+
+
diff --git a/src/components/modals/DoorScannerModal.vue b/src/components/modals/DoorScannerModal.vue
index 9c4e289..3cb96e4 100644
--- a/src/components/modals/DoorScannerModal.vue
+++ b/src/components/modals/DoorScannerModal.vue
@@ -80,7 +80,7 @@ async function onScanResult(result: { data: string }): Promise {
return;
}
- const accepted = party.invites.filter((i) => i.status === 'accepted');
+ const accepted = party.invites.filter((i) => i.status === 'confirmed');
const alreadyUsed = accepted.find(
(i) =>
@@ -160,7 +160,7 @@ onUnmounted(stopScanner);
// ── Derived stats ──────────────────────────────────────────────────────────
function accepted() {
return (store.activeParty()?.invites ?? []).filter(
- (i) => i.status === 'accepted',
+ (i) => i.status === 'confirmed',
);
}
diff --git a/src/components/modals/ShareModal.vue b/src/components/modals/ShareModal.vue
index caf0b2d..b8570ae 100644
--- a/src/components/modals/ShareModal.vue
+++ b/src/components/modals/ShareModal.vue
@@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue';
import { useStore, COVERS } from '../../lib/store';
import Modal from '../Modal.vue';
import Icon from '../Icon.vue';
+import { inviteUrl } from '../../../shared/invites';
const store = useStore();
@@ -16,28 +17,36 @@ const cover = computed(() => {
return COVERS[p.cover] ?? COVERS[0];
});
-// Built from the origin the app is actually served from, so a preview
-// deployment, a self-hosted domain and the hosted app each hand out a link that
-// points back at themselves. The `/i/` route that resolves these is still to
-// come — see docs/adr/0001-cloudflare-tiers.md.
+/**
+ * The host's own link, or '' until the party has been published.
+ *
+ * The slug is the server's, not one derived from the party name: renaming the
+ * party must not change a link already sent, and only the server knows which
+ * slug it handed out. `openShare` publishes on open, so this fills in a moment
+ * after the sheet appears — `publishing` covers the gap.
+ */
+const publication = computed(() => party.value?.publication ?? null);
+
const inviteLink = computed(() => {
- const p = party.value;
- if (!p) return '';
- const slug =
- p.name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-|-$/g, '') || 'party';
+ const pub = publication.value;
+ if (!pub) return '';
const origin =
typeof window === 'undefined'
? 'bottlecount.pages.dev'
: window.location.host;
- // BASE_URL, not a bare `/`: a build served under a path prefix would
- // otherwise hand out links that miss the prefix entirely.
- const base = import.meta.env.BASE_URL as string;
- return `${origin}${base}i/${slug}-${p.id}`;
+ return inviteUrl(
+ origin,
+ import.meta.env.BASE_URL as string,
+ pub.slug,
+ pub.rootToken,
+ );
});
+const publishing = computed(() => store.state.publishing);
+const publishFailed = computed(
+ () => !publishing.value && !publication.value && store.state.publishError,
+);
+
const venueWhere = computed(() => {
const p = party.value;
if (!p) return '';
@@ -79,9 +88,13 @@ const channels: Channel[] = [
].map((ch) => ({
...ch,
onClick: () => {
+ // Nothing to share until the party is published — sharing a blank link is
+ // worse than the button doing nothing for the second it takes.
+ if (!inviteLink.value) return;
if (ch.label === 'Copy link') {
- const link = `https://${inviteLink.value}`;
- navigator.clipboard?.writeText(link).catch(() => {});
+ navigator.clipboard
+ ?.writeText(`https://${inviteLink.value}`)
+ .catch(() => {});
}
sent.value = true;
},
@@ -224,7 +237,11 @@ watch(
"
>
- {{ inviteLink }}
+ Creating your link…
+
+ Couldn't reach the server — try again in a moment.
+
+ {{ inviteLink }}
+
+
+
+
+ Live · {{ lastSyncedLabel }}
+
+
diff --git a/src/components/modals/SendTicketModal.vue b/src/components/modals/SendTicketModal.vue
index 82fd4ac..c73290d 100644
--- a/src/components/modals/SendTicketModal.vue
+++ b/src/components/modals/SendTicketModal.vue
@@ -64,7 +64,7 @@ async function prepare(): Promise {
return;
}
try {
- const qr = await ticketQrDataUrl(ticketPayload(p, name));
+ const qr = await ticketQrDataUrl(p, ticketPayload(p, name));
ticketFile.value = await buildTicketFile({
partyName: p.name,
dateLabel: partyDateShort.value,
diff --git a/src/components/modals/TicketModal.vue b/src/components/modals/TicketModal.vue
index 00e72b9..31c266c 100644
--- a/src/components/modals/TicketModal.vue
+++ b/src/components/modals/TicketModal.vue
@@ -41,7 +41,7 @@ async function generateQR(name: string) {
qrDataUrl.value = null;
try {
- qrDataUrl.value = await ticketQrDataUrl(ticketPayload(p, name));
+ qrDataUrl.value = await ticketQrDataUrl(p, ticketPayload(p, name));
} catch {
qrDataUrl.value = null;
} finally {
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
index 57762b5..4a3c7ae 100644
--- a/src/lib/crypto.ts
+++ b/src/lib/crypto.ts
@@ -1,83 +1,133 @@
import { getKey, setKey } from './db';
-import type { TicketQRPayload } from './types';
+import type { TicketQRPayload } from '../../shared/tickets';
+import type { Party } from './types';
-// The HMAC key is persisted as a JWK (plain JSON), NOT as a CryptoKey object.
-// IndexedDB's structured-clone algorithm cannot serialise CryptoKey instances,
-// so storing one directly would throw a DataCloneError. Always call
-// crypto.subtle.exportKey("jwk", key) before writing to IndexedDB, and
-// crypto.subtle.importKey("jwk", ...) when reading it back.
-async function getOrCreateKey(): Promise {
- const stored = await getKey('hmac_key', null);
-
- if (stored) {
- return crypto.subtle.importKey(
- 'jwk',
- stored,
- { name: 'HMAC', hash: 'SHA-256' },
- true,
- ['sign', 'verify'],
- );
- }
+/**
+ * Signing and checking tickets.
+ *
+ * The key is per *party*, not per device. It used to be per device, generated
+ * into whichever browser first issued a ticket — which meant a co-organiser's
+ * phone could not verify anything the owner's phone had produced. It did not
+ * miscount guests; it rejected all of them.
+ *
+ * So a shared party carries its key on the server (`SharedPartyDTO.ticketKey`),
+ * every member is handed the same one, and the door verifies offline with it
+ * once it has been fetched — which matters, because doors are in basements.
+ * A local-only party still uses a device key, because there is no server to
+ * hold one and nobody else to agree with.
+ *
+ * Keys are persisted as JWKs, never as CryptoKey objects: IndexedDB's
+ * structured-clone step throws DataCloneError on a CryptoKey.
+ */
- const key = await crypto.subtle.generateKey(
+async function importKey(jwk: JsonWebKey): Promise {
+ return crypto.subtle.importKey(
+ 'jwk',
+ jwk,
{ name: 'HMAC', hash: 'SHA-256' },
true,
['sign', 'verify'],
);
+}
+
+/** The fallback key for a party that lives only in this browser. */
+async function deviceKey(): Promise {
+ const stored = await getKey('hmac_key', null);
+ if (stored) return importKey(stored);
- const jwk: JsonWebKey = await crypto.subtle.exportKey('jwk', key);
- await setKey('hmac_key', jwk);
+ const key = (await crypto.subtle.generateKey(
+ { name: 'HMAC', hash: 'SHA-256' },
+ true,
+ ['sign', 'verify'],
+ )) as CryptoKey;
+
+ await setKey('hmac_key', await crypto.subtle.exportKey('jwk', key));
return key;
}
/**
- * Called after a Google Sync pull — adopts the shared key from the sheet
- * so all devices sign/verify with the same secret.
- * Returns true if the key actually changed (caller should regenerate QR codes).
+ * The key a party's tickets are signed with.
+ *
+ * The party's own when it has one — every organiser holds the same — and this
+ * browser's otherwise.
*/
-export async function adoptRemoteKey(jwk: JsonWebKey): Promise {
- const local = await getKey('hmac_key', null);
- // Compare by the key material ("k" field in HMAC JWK)
- if (local?.k && local.k === jwk.k) return false;
- await setKey('hmac_key', jwk);
- return true;
+async function keyFor(party: Party): Promise {
+ const jwk = party.publication?.ticketKey;
+ return jwk ? importKey(jwk) : deviceKey();
}
-/**
- * Export the current local key as a JWK so it can be pushed to the sheet.
- */
-export async function exportLocalKeyJwk(): Promise {
- const key = await getOrCreateKey();
- return crypto.subtle.exportKey('jwk', key);
+/** What a ticket is signed over. Order is fixed; JSON key order is not. */
+function canonical(payload: TicketQRPayload): string {
+ return JSON.stringify([
+ payload.code,
+ payload.partyId,
+ payload.guestName,
+ payload.expiresAt,
+ ]);
}
-export async function signTicket(payload: TicketQRPayload): Promise {
- const key = await getOrCreateKey();
- const encoded = new TextEncoder().encode(JSON.stringify(payload));
- const sig = await crypto.subtle.sign('HMAC', key, encoded);
- const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)));
- const payB64 = btoa(JSON.stringify(payload));
- return `${payB64}.${sigB64}`;
+function toBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+}
+
+export async function signTicket(
+ party: Party,
+ payload: TicketQRPayload,
+): Promise {
+ const key = await keyFor(party);
+ const signature = await crypto.subtle.sign(
+ 'HMAC',
+ key,
+ new TextEncoder().encode(canonical(payload)),
+ );
+ const payloadB64 = btoa(JSON.stringify(payload));
+ return `${payloadB64}.${toBase64(new Uint8Array(signature))}`;
}
+export type TicketCheck =
+ | { ok: true; payload: TicketQRPayload }
+ | { ok: false; reason: 'malformed' | 'bad_signature' };
+
+/**
+ * Checks a scanned string's signature and nothing else.
+ *
+ * Whether the guest is actually coming, and whether they already walked in, are
+ * questions about the guest list rather than the signature — the door answers
+ * those separately, because the answers differ per party and per moment while
+ * this one never does.
+ */
export async function verifyTicket(
+ party: Party,
qrString: string,
-): Promise {
- const [payB64, sigB64] = qrString.split('.');
- if (!payB64 || !sigB64) return null;
+): Promise {
+ const [payloadB64, signatureB64] = qrString.split('.');
+ if (!payloadB64 || !signatureB64) return { ok: false, reason: 'malformed' };
try {
- const key = await getOrCreateKey();
- const payload = JSON.parse(atob(payB64)) as TicketQRPayload;
- const sig = Uint8Array.from(atob(sigB64), (c) => c.charCodeAt(0));
+ const payload = JSON.parse(atob(payloadB64)) as TicketQRPayload;
+ if (
+ typeof payload.code !== 'string' ||
+ typeof payload.guestName !== 'string'
+ ) {
+ return { ok: false, reason: 'malformed' };
+ }
+
+ const key = await keyFor(party);
+ const signature = Uint8Array.from(atob(signatureB64), (c) =>
+ c.charCodeAt(0),
+ );
const valid = await crypto.subtle.verify(
'HMAC',
key,
- sig,
- new TextEncoder().encode(JSON.stringify(payload)),
+ signature,
+ new TextEncoder().encode(canonical(payload)),
);
- return valid ? payload : null;
+ return valid
+ ? { ok: true, payload }
+ : { ok: false, reason: 'bad_signature' };
} catch {
- return null;
+ return { ok: false, reason: 'malformed' };
}
}
diff --git a/src/lib/invites.spec.ts b/src/lib/invites.spec.ts
index e8061d8..e65e991 100644
--- a/src/lib/invites.spec.ts
+++ b/src/lib/invites.spec.ts
@@ -11,6 +11,10 @@ function remote(overrides: Partial = {}): HostInviteDTO {
depth: 0,
referrer: null,
forwardToken: 'fwd-1',
+ ticketCode: 'AB23C',
+ source: 'link',
+ checkedIn: false,
+ checkedInAt: null,
openedAt: '2026-09-01T10:00:00.000Z',
answeredAt: '2026-09-01T10:01:00.000Z',
...overrides,
@@ -92,16 +96,35 @@ describe('mergeFunnel', () => {
expect(merged.find((i) => i.name === 'Marco')?.id).toBeGreaterThan(7);
});
- it('carries check-in state over, which the server does not know about', () => {
- // It comes from the door scanner, which is entirely client-side.
+ it('takes check-in from the server, not from this device', () => {
+ // The point of the whole door-sync feature. A phone that did not scan
+ // someone must still show them as arrived, and a phone that did must not
+ // out-vote the server once a check-in has been undone.
+ const merged = mergeFunnel(
+ [local({ id: 2, remoteId: 'remote-1' })],
+ [remote({ checkedIn: true, checkedInAt: '2026-10-02T23:14:00.000Z' })],
+ );
+
+ expect(merged[0]?.used).toBe(true);
+ expect(merged[0]?.usedAt).toBe('2026-10-02T23:14:00.000Z');
+ });
+
+ it('clears a local check-in the server no longer has', () => {
+ // An organiser undid it on the other phone — someone waved through by
+ // mistake. Keeping the local `true` would let them in a second time.
const existing = [
local({ id: 2, remoteId: 'remote-1', used: true, usedAt: '23:14' }),
];
- const merged = mergeFunnel(existing, [remote()]);
+ const merged = mergeFunnel(existing, [remote({ checkedIn: false })]);
- expect(merged[0]?.used).toBe(true);
- expect(merged[0]?.usedAt).toBe('23:14');
+ expect(merged[0]?.used).toBe(false);
+ expect(merged[0]?.usedAt).toBeUndefined();
+ });
+
+ it('carries the ticket code through, which the door reads', () => {
+ const merged = mergeFunnel([], [remote({ ticketCode: 'PQ4XZ' })]);
+ expect(merged[0]?.ticketCode).toBe('PQ4XZ');
});
it('drops a guest the server no longer lists', () => {
diff --git a/src/lib/invites.ts b/src/lib/invites.ts
index 0b23772..ad67d4b 100644
--- a/src/lib/invites.ts
+++ b/src/lib/invites.ts
@@ -74,6 +74,78 @@ export async function fetchFunnel(
// ── Guest-side API ──────────────────────────────────────────────────────────
+/**
+ * Tells the server a guest walked in.
+ *
+ * The server arbitrates, so this is what makes a second scan on a second phone
+ * fail. A 409 carries the time of the first scan, which is what the door needs
+ * to say rather than a bare refusal.
+ */
+export async function checkInRemote(
+ partyId: string,
+ inviteId: string,
+): Promise> {
+ try {
+ const res = await fetch(
+ `/api/parties/${encodeURIComponent(partyId)}/invites/${encodeURIComponent(inviteId)}/check-in`,
+ { method: 'POST', credentials: 'include' },
+ );
+ if (res.ok) {
+ const body = (await res.json()) as { checkedInAt: string | null };
+ return { ok: true, value: body };
+ }
+ const body = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ checkedInAt?: string | null;
+ };
+ return {
+ ok: false,
+ error: body.error ?? `http_${res.status}`,
+ value: { checkedInAt: body.checkedInAt ?? null },
+ };
+ } catch {
+ return { ok: false, error: 'network_error' };
+ }
+}
+
+/** Undoes a check-in, for the guest waved through by mistake. */
+export async function undoCheckInRemote(
+ partyId: string,
+ inviteId: string,
+): Promise> {
+ try {
+ const res = await fetch(
+ `/api/parties/${encodeURIComponent(partyId)}/invites/${encodeURIComponent(inviteId)}/check-in`,
+ { method: 'DELETE', credentials: 'include' },
+ );
+ return res.ok ? { ok: true } : { ok: false, error: `http_${res.status}` };
+ } catch {
+ return { ok: false, error: 'network_error' };
+ }
+}
+
+/** Adds a guest an organiser typed in, as a real invite the co-organiser sees. */
+export async function addRemoteGuest(
+ partyId: string,
+ name: string,
+): Promise> {
+ try {
+ const res = await fetch(
+ `/api/parties/${encodeURIComponent(partyId)}/invites`,
+ {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name }),
+ },
+ );
+ if (!res.ok) return { ok: false, error: `http_${res.status}` };
+ return { ok: true, value: (await res.json()) as HostInviteDTO };
+ } catch {
+ return { ok: false, error: 'network_error' };
+ }
+}
+
export function openInvite(
slug: string,
args: { referrer: string | null; inviteId: string | null },
@@ -105,9 +177,10 @@ export function answerInvite(
*
* - Rows are matched by `remoteId`, so a refresh updates a guest in place and
* keeps the client-side `id` their avatar colour and list key depend on.
- * - Local-only rows (no `remoteId`) are kept untouched. Those are the guests
- * the host typed in by hand, which works on every tier and must not be
- * deleted by a funnel refresh that has never heard of them.
+ * - Local-only rows (no `remoteId`) are kept untouched. On a *local-only* party
+ * those are all of them. On a shared party a guest typed in by an organiser
+ * is sent to the server and comes back with a `remoteId`, so a row without
+ * one there is only ever a save still in flight.
*
* Returns a new array; the caller decides when to commit it.
*/
@@ -125,11 +198,6 @@ export function mergeFunnel(
const merged = remote.map((row) => {
const previous = byRemoteId.get(row.id);
return {
- // Check-in state lives on the host's device (it comes from the door
- // scanner, which the server knows nothing about yet), so it is carried
- // over rather than overwritten with a default.
- used: previous?.used ?? false,
- ...(previous?.usedAt ? { usedAt: previous.usedAt } : {}),
id: previous?.id ?? ++nextId,
remoteId: row.id,
name: row.name ?? '',
@@ -137,6 +205,14 @@ export function mergeFunnel(
depth: row.depth,
referrer: row.referrer,
...(row.forwardToken ? { forwardToken: row.forwardToken } : {}),
+ ticketCode: row.ticketCode,
+ source: row.source,
+ // Check-in is the server's answer now, not this device's. That is the
+ // whole point: two phones on the door have to agree, and the one that
+ // agrees is the one that arbitrated. A local guess kept here would show
+ // a guest as not-yet-arrived on the phone that did not scan them.
+ used: row.checkedIn,
+ ...(row.checkedInAt ? { usedAt: row.checkedInAt } : {}),
openedAt: row.openedAt,
...(row.answeredAt ? { answeredAt: row.answeredAt } : {}),
} satisfies Invite;
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 2250dca..8a3dde1 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -8,7 +8,14 @@ import type {
} from './types';
import { db } from './db';
import { fetchSession, ANONYMOUS_SESSION } from './session';
-import { fetchFunnel, mergeFunnel, setRemoteInviteStatus } from './invites';
+import {
+ addRemoteGuest,
+ checkInRemote,
+ fetchFunnel,
+ mergeFunnel,
+ setRemoteInviteStatus,
+ undoCheckInRemote,
+} from './invites';
import {
applyDocument,
documentOf,
@@ -670,6 +677,13 @@ async function pull(): Promise {
const shared = result.value;
state.members = shared.members;
state.role = shared.role;
+ // A co-organiser's first pull is where they get the signing key, without
+ // which their scanner rejects every guest.
+ if (p.publication && shared.ticketKey) {
+ p.publication.ticketKey = shared.ticketKey;
+ p.publication.slug = shared.publication?.slug ?? null;
+ p.publication.rootToken = shared.publication?.rootToken ?? null;
+ }
if (shared.version !== sync.currentVersion) {
sync.adopt(shared.document, shared.version);
}
@@ -702,6 +716,7 @@ async function shareActiveParty(): Promise {
version: shared.version,
slug: shared.publication?.slug ?? null,
rootToken: shared.publication?.rootToken ?? null,
+ ticketKey: shared.ticketKey,
publishedAt: shared.updatedAt,
};
});
@@ -873,6 +888,7 @@ async function syncPartyList(): Promise {
version: shared.version,
slug: shared.publication?.slug ?? null,
rootToken: shared.publication?.rootToken ?? null,
+ ticketKey: shared.ticketKey,
publishedAt: shared.updatedAt,
},
};
@@ -948,6 +964,8 @@ async function syncFunnel(): Promise {
* what keeps a funnel refresh from deleting them.
*/
function addInvite(name: string): void {
+ const remoteId = activeParty()?.publication?.remoteId;
+
update((p) => {
const maxId =
p.invites.length > 0 ? Math.max(...p.invites.map((i) => i.id)) : 0;
@@ -957,9 +975,21 @@ function addInvite(name: string): void {
status: 'confirmed',
depth: 0,
referrer: null,
+ source: 'manual',
used: false,
});
});
+
+ // On a shared party the guest has to exist on the server too, or the
+ // co-organiser never sees them and the other phone on the door cannot check
+ // their ticket. The row appears locally first so the list responds at once;
+ // the next funnel sync replaces it with the server's, carrying the ticket
+ // code only the server can issue.
+ if (remoteId) {
+ void addRemoteGuest(remoteId, name).then((result) => {
+ if (result.ok) void syncFunnel();
+ });
+ }
}
/**
@@ -999,14 +1029,76 @@ function setInviteStatus(id: number, status: Invite['status']): void {
}
}
-function checkInGuest(id: number, time: string): void {
+/**
+ * Records that a guest walked in.
+ *
+ * On a shared party the server arbitrates, which is what makes "already
+ * scanned" true across every phone on the door. It is applied locally first so
+ * the scanner responds instantly — a door queue does not wait for a round trip
+ * — and reconciled by the answer.
+ *
+ * Returns the time of an *earlier* check-in when the server refuses, so the
+ * scanner can say when they came in rather than only that they did.
+ */
+async function checkInGuest(
+ id: number,
+ time: string,
+): Promise<{ ok: boolean; alreadyAt?: string | null }> {
+ const party = activeParty();
+ const invite = party?.invites.find((i) => i.id === id);
+ if (!party || !invite) return { ok: false };
+
+ const remoteId = party.publication?.remoteId;
+ const inviteRemoteId = invite.remoteId;
+
+ update((p) => {
+ const target = p.invites.find((i) => i.id === id);
+ if (target) {
+ target.used = true;
+ target.usedAt = time;
+ }
+ });
+
+ if (!remoteId || !inviteRemoteId) return { ok: true };
+
+ const result = await checkInRemote(remoteId, inviteRemoteId);
+ if (result.ok) return { ok: true };
+
+ if (result.error === 'already_checked_in') {
+ // Somebody else's phone got there first. Adopt their time — ours was a
+ // guess made a moment ago and theirs is what actually happened.
+ const alreadyAt = result.value?.checkedInAt ?? null;
+ update((p) => {
+ const target = p.invites.find((i) => i.id === id);
+ if (target && alreadyAt) target.usedAt = alreadyAt;
+ });
+ return { ok: false, alreadyAt };
+ }
+
+ // A network failure leaves the local check-in standing: the guest is through
+ // the door either way, and the next sync reconciles it. Refusing them over a
+ // dropped request would be the worse mistake.
+ return { ok: true };
+}
+
+/** Undoes a check-in on every device, for someone waved through by mistake. */
+async function undoCheckIn(id: number): Promise {
+ const party = activeParty();
+ const invite = party?.invites.find((i) => i.id === id);
+ if (!party || !invite) return;
+
update((p) => {
- const invite = p.invites.find((i) => i.id === id);
- if (invite) {
- invite.used = true;
- invite.usedAt = time;
+ const target = p.invites.find((i) => i.id === id);
+ if (target) {
+ target.used = false;
+ target.usedAt = undefined;
}
});
+
+ const remoteId = party.publication?.remoteId;
+ if (remoteId && invite.remoteId) {
+ await undoCheckInRemote(remoteId, invite.remoteId);
+ }
}
// ── Shopping ───────────────────────────────────────────────────────────────
@@ -1151,6 +1243,7 @@ export const store = {
addInvite,
setInviteStatus,
checkInGuest,
+ undoCheckIn,
// shopping
toggleChecked,
// modals
diff --git a/src/lib/ticket.ts b/src/lib/ticket.ts
index b23a80e..858c58f 100644
--- a/src/lib/ticket.ts
+++ b/src/lib/ticket.ts
@@ -1,10 +1,12 @@
import QRCode from 'qrcode';
import { signTicket } from './crypto';
-import type { Party, TicketQRPayload } from './types';
+import type { TicketQRPayload } from '../../shared/tickets';
+import { TICKET_CODE_ALPHABET, TICKET_CODE_LENGTH } from '../../shared/tickets';
+import type { Party } from './types';
// ── Ticket identity ──────────────────────────────────────────────────────────
-/** FNV-1a 32-bit hash — stable per-guest ticket id fallback. */
+/** FNV-1a 32-bit hash — the local-only fallback for a party with no server. */
function fnv1a(str: string): number {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
@@ -14,22 +16,57 @@ function fnv1a(str: string): number {
return h;
}
-/** Human-readable ticket code shown on the ticket, e.g. `BC-3-04217`. */
+/**
+ * A code derived from the guest's name, for a party that lives only in this
+ * browser.
+ *
+ * Shared parties get their codes from the server, where a unique index makes
+ * them genuinely unique. This is the free tier's substitute: deterministic, so
+ * reopening the app shows the same code on the same ticket, and drawn from the
+ * same alphabet so a guest cannot tell the difference. Two guests of the same
+ * party could in principle collide; with one device checking one door, the
+ * guest's name settles it.
+ */
+function derivedCode(party: Party, name: string): string {
+ let h = fnv1a(`${name}·${String(party.id)}`);
+ let code = '';
+ for (let i = 0; i < TICKET_CODE_LENGTH; i++) {
+ code += TICKET_CODE_ALPHABET[h % TICKET_CODE_ALPHABET.length];
+ h = Math.floor(h / TICKET_CODE_ALPHABET.length) + 7919 * (i + 1);
+ }
+ return code;
+}
+
+/**
+ * The five characters printed on a guest's ticket.
+ *
+ * The server's, whenever the party has one — that is the code the other phone
+ * on the door will look up, and the one door staff read back over a noisy room.
+ */
export function ticketCode(party: Party, name: string): string {
- const h = fnv1a(name + '·' + String(party.id));
- return `BC-${party.id}-${(h % 100000).toString().padStart(5, '0')}`;
+ const invite = party.invites.find((i) => i.name === name);
+ return invite?.ticketCode || derivedCode(party, name);
+}
+
+/**
+ * The party id a ticket is stamped with.
+ *
+ * The server's on a shared party, so a ticket issued on one device resolves on
+ * another. Falls back to the browser's local id, which is all a local-only
+ * party has.
+ */
+export function ticketPartyId(party: Party): string {
+ return party.publication?.remoteId ?? String(party.id);
}
/** Build the signed-QR payload for a guest's ticket. */
export function ticketPayload(party: Party, name: string): TicketQRPayload {
- const invite = party.invites.find((i) => i.name === name);
- const ticketId = invite?.id ?? fnv1a(name + '·' + String(party.id)) % 100000;
// Expiry: party date +1 day at 06:00.
const exp = new Date(party.date + 'T06:00:00');
exp.setDate(exp.getDate() + 1);
return {
- ticketId,
- partyId: party.id!,
+ code: ticketCode(party, name),
+ partyId: ticketPartyId(party),
guestName: name,
expiresAt: exp.toISOString(),
};
@@ -115,9 +152,10 @@ function overlayLogo(canvas: HTMLCanvasElement): Promise {
/** Render a signed ticket QR with the BottleCount logo in the centre. */
export async function ticketQrDataUrl(
+ party: Party,
payload: TicketQRPayload,
): Promise {
- const signed = await signTicket(payload);
+ const signed = await signTicket(party, payload);
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, signed, {
width: 320,
diff --git a/src/lib/types.ts b/src/lib/types.ts
index c8edab3..1b10d36 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1,11 +1,12 @@
import type { InviteStatus } from '../../shared/invites';
+import type { TicketQRPayload } from '../../shared/tickets';
/**
* Re-exported so components import their types from one place. The states
* themselves are the server's — see shared/invites.ts — because a guest's
* answer is what sets them.
*/
-export type { InviteStatus };
+export type { InviteStatus, TicketQRPayload };
// ── Catalog ────────────────────────────────────────────────────────────────
@@ -138,6 +139,10 @@ export interface Invite {
remoteId?: string;
/** This guest's own forward link token, once they have confirmed. */
forwardToken?: string;
+ /** The five characters on their ticket, when the party has a server. */
+ ticketCode?: string;
+ /** Whether an organiser typed them in rather than them RSVPing. */
+ source?: 'link' | 'manual';
openedAt?: string;
answeredAt?: string;
}
@@ -186,6 +191,11 @@ export interface PartyPublication {
slug: string | null;
/** The host's own link token. Guests who use it land at depth 0. */
rootToken: string | null;
+ /**
+ * The party's ticket-signing key. Every organiser holds the same one, which
+ * is what lets a second phone on the door verify a ticket the first issued.
+ */
+ ticketKey?: JsonWebKey | null;
publishedAt: string;
}
@@ -198,13 +208,6 @@ export interface Ticket {
expiresAt: string;
}
-export interface TicketQRPayload {
- ticketId: number;
- partyId: number;
- guestName: string;
- expiresAt: string;
-}
-
// ── IndexedDB userdata keys ────────────────────────────────────────────────
export type UserDataKey =
diff --git a/src/pages/docs.astro b/src/pages/docs.astro
index a637f45..6921cde 100644
--- a/src/pages/docs.astro
+++ b/src/pages/docs.astro
@@ -319,6 +319,47 @@ const base = import.meta.env.BASE_URL;
remove people. They also don't pay — the party is yours.
+
+
+
+
+
+
+
+
+
+
+
+
+
On the door
+
+
+ Open the scanner and point it at a guest's QR. It checks the
+ signature, the party and the expiry, then lets them in — or tells you
+ why not. It keeps working with no signal once the party has loaded.
+
+
+ Every ticket also has a five-character code, printed
+ on it and short enough to read across a noisy doorway. When a QR won't
+ scan — cracked screen, dead battery, a screenshot of a screenshot —
+ type the code and the guest's name instead. Both have to match: the
+ code on its own is short enough that someone could overhear it.
+
+
+ On the hosted and self-hosted plans you can put
+ more than one phone on the door. They share a single check-in
+ list, so a ticket that got someone in at the front can't get someone else
+ in at the back — the second phone tells you when the first one scanned it.
+ Waved someone through by mistake? Undo it and they can be scanned again.
+