The hosts and venues feature is the only thing that differs structurally between our two plans. Pro is one account. Ultimate is five, so a landlord with two rooms or a small group with four sites can let each host run their own quiz night without passing one login around. The pricing page states it as "Up to 5 host accounts".
The implementation is one table and a count. What makes it work is which rows the count includes.
The owner is a member of their own venue
A venue has an owner column, so the ownership relation does not need a membership row at all. We insert one anyway, at signup, in both the password path and the OAuth callback:
await db.insert(venueMembersTable).values({
venue_id: venue.id,
user_id: signUpData.user.id,
invited_email: signUpData.user.email!,
role: 'owner',
status: 'active',
invited_by: signUpData.user.id,
})
invited_by pointing at the user themselves is a little odd to read, and so is an invited_email on a row nobody invited. The payoff is that every account with access to a venue is one row in one table, with no special case for the first one.
Which means the seat check can be a single count with no arithmetic:
const [countRow] = await db
.select({ count: count() })
.from(venueMembersTable)
.where(
and(
eq(venueMembersTable.venue_id, venue.id),
ne(venueMembersTable.status, 'revoked'),
)
)
const currentSeats = Number(countRow?.count ?? 0)
const limits = getPlanLimits((venue.plan_type ?? 'trial') as PlanType)
if (currentSeats >= limits.maxAccounts) { /* refuse */ }
No + 1 for the owner, no role != 'owner'. Five seats means five rows.
Three consequences of counting it that way
A Pro owner is permanently at their limit. maxAccounts is 1 on Trial and on Pro, and 5 on Ultimate. A Pro owner's own row is already there, so 1 >= 1 and every invite is refused. The settings panel says so before you try:
const activeCount = members.filter(m => m.status !== 'revoked').length;
const atLimit = activeCount >= maxAccounts;
rendered as "1 of 1 seat used", with the invite form not rendered at all when maxAccounts === 1. The server check for Pro is therefore unreachable through our own interface, and it still has to exist, because a server action is a POST endpoint that anybody can call directly whatever the page shows.
A pending invite costs a seat. The count excludes only revoked, so an invite that has been emailed and not accepted holds a seat exactly as firmly as a host who has been running quizzes for a year. That is the right default for a five-seat plan: the alternative is an owner inviting five people, all five accepting, and six accounts having access.
It does mean an abandoned invite holds a seat indefinitely, because nothing in the schema expires one. The owner's lever is the revoke button, which also nulls the token.
Revoking frees a seat immediately, but the row stays. Revoke sets status: 'revoked' and leaves everything else in place, so the seat is free on the next count while the history of who had access is not deleted. The cost of keeping the row is that a re-invite to the same address cannot be an insert, because of a unique index on (venue_id, invited_email):
if (existing?.status === 'revoked') {
// Reuse the existing row rather than inserting, which would violate
// the unique constraint uq_venue_members_venue_email.
await db.update(venueMembersTable).set({
user_id: null, role: 'host', status: 'pending',
invited_by: user.id, invite_token: token, accepted_at: null,
}).where(eq(venueMembersTable.id, existing.id))
}
Two code paths for one user-visible action, because the table remembers.
The accept path is where identity gets strict
Sending the invite is the easy half. Accepting it is where a seat model can quietly turn into a mess, because the person clicking the link already has an account of their own by the time they click it.
Two rules, both enforced on accept rather than on invite:
if (existingVenueOwner) {
return {
success: false,
error: 'You already own a venue. A host account cannot also be a venue owner.',
}
}
if (existingMembership) {
return {
success: false,
error: 'Your account is already linked to a different venue.',
}
}
One account belongs to exactly one venue, and cannot be both an owner and somebody else's host. Without the first rule, a user with their own trial venue could accept a seat and then have two venues resolving from one id, and every read in the dashboard would have to decide which one it meant. Our venue lookup is a two-step: owner first, then active membership, first row wins. That function is honest only because those two rules make "first row" unambiguous.
There is also an email check, which is the one that rejects the most invites in practice:
if (userRecord.email.toLowerCase() !== invite.invited_email.toLowerCase()) {
return {
success: false,
error: `This invite was sent to ${invite.invited_email}. Please log in with that account to accept it.`,
}
}
The link carries 32 random bytes as 64 hex characters, so holding it is proof of nothing except that it was emailed to somebody. Binding it to the address it was sent to means a forwarded link is useless to the person it was forwarded to, and the error message tells them exactly which account to use rather than making them guess.
On success the token is set to null in the same update that marks the row active, so the link is single use by construction rather than by a flag somebody has to remember to check.
The number that is not five
One more cap sits underneath all of this, and it is per account rather than per venue: three concurrent logged-in browser sessions each. So an Ultimate venue with all five seats filled can have fifteen signed-in browsers, which sounds generous until you picture the actual room. A till screen, a laptop in the office, somebody's phone, and the tablet behind the bar is four before anybody has gone home. The cap exists to make credential sharing inconvenient, not to ration devices, and that is why it is three rather than one.
If you want to see what the seats are actually for rather than how they are counted, the page we wrote for freelance quizmasters is the case the five-seat plan was built around: a host working a circuit, where the printed table codes stay with each venue and the second seat appears the moment somebody else hosts a night for you. The plan boundaries themselves are on the pricing page.
Top comments (0)