Files
base/plugins/waitlist/schema.go
zeekay a47ee006c6 feat(plugins/waitlist): viral waitlist plugin
Adds plugins/waitlist/ — a Base Go plugin that exposes a viral waitlist
under /v1/waitlist on any Base process. Replaces the standalone Next.js
+ Upstash Redis surface that lived in github.com/hanzoai/waitlist.

Surface
- POST /v1/waitlist/join     register, atomically credit referrer
- GET  /v1/waitlist/status   look up rank + share URL
- GET  /v1/waitlist/export   admin-only CSV (superuser or shared secret)

Storage
- Two collections auto-created on bootstrap: waitlists, waitlist_entries
- Indexes: UNIQUE(waitlist,email), UNIQUE(waitlist,refCode),
  (waitlist, referralCount DESC, createdAt ASC) for rank queries
- All mutations inside RunInTransaction — idempotent join, atomic
  referral counts, collision-safe refCode allocation

Anti-abuse
- crypto/rand 8-char refCode from 36-char alphabet (no 0/O/1/l/I/etc)
- Optional Cloudflare Turnstile verify (no-op when secret unset)
- In-process sliding-window IP rate limit (default 5/IP/hour, tested)
- Disposable email domain blocklist (overridable via Config)

Registration
- waitlist.MustRegister(app, waitlist.Config{Enabled: true, ...})
- TURNSTILE_SECRET_KEY and WAITLIST_ADMIN_SECRET resolved from env

Tests
- plugin_test.go: refCode uniqueness (5000 codes), email validation,
  disposable-domain check, sliding limiter (window expiry + disable),
  config defaults, prefix validation
2026-06-09 22:58:01 -07:00

122 lines
3.3 KiB
Go

// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package waitlist
import (
"database/sql"
"errors"
"fmt"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/types"
)
// ensureSchema idempotently creates the two collections that back the
// waitlist plugin. It is safe to call on every boot.
func (p *plugin) ensureSchema() error {
if _, err := p.ensureWaitlistsCollection(); err != nil {
return fmt.Errorf("waitlist: ensure waitlists collection: %w", err)
}
if _, err := p.ensureEntriesCollection(); err != nil {
return fmt.Errorf("waitlist: ensure entries collection: %w", err)
}
return nil
}
func (p *plugin) ensureWaitlistsCollection() (*core.Collection, error) {
name := p.config.waitlistsCollection()
if c, err := p.app.FindCollectionByNameOrId(name); err == nil {
return c, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
c := core.NewBaseCollection(name)
c.Fields.Add(
&core.TextField{
Name: "slug",
Required: true,
Min: 1,
Max: 100,
Pattern: `^[a-z0-9][a-z0-9-]*$`,
Presentable: true,
},
&core.TextField{
Name: "name",
Required: true,
Min: 1,
Max: 200,
},
&core.AutodateField{Name: "createdAt", OnCreate: true},
&core.AutodateField{Name: "updatedAt", OnCreate: true, OnUpdate: true},
)
c.Indexes = types.JSONArray[string]{
fmt.Sprintf("CREATE UNIQUE INDEX `idx_%s_slug` ON `%s` (`slug`)", name, name),
}
// No public CRUD — the plugin owns this collection. Superusers (admin)
// still have full access via the dashboard.
if err := p.app.Save(c); err != nil {
return nil, err
}
p.logger.Info("waitlist: created collection", "name", name)
return c, nil
}
func (p *plugin) ensureEntriesCollection() (*core.Collection, error) {
name := p.config.entriesCollection()
parent, err := p.ensureWaitlistsCollection()
if err != nil {
return nil, err
}
if c, err := p.app.FindCollectionByNameOrId(name); err == nil {
return c, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
c := core.NewBaseCollection(name)
c.Fields.Add(
&core.RelationField{
Name: "waitlist",
CollectionId: parent.Id,
Required: true,
CascadeDelete: true,
MaxSelect: 1,
},
&core.EmailField{
Name: "email",
Required: true,
},
&core.TextField{
Name: "refCode",
Required: true,
Min: 4,
Max: 32,
Pattern: `^[A-Za-z0-9]+$`,
Presentable: true,
},
&core.TextField{
Name: "referredBy",
Max: 32,
Pattern: `^[A-Za-z0-9]*$`,
},
&core.NumberField{
Name: "referralCount",
Required: false,
},
&core.AutodateField{Name: "createdAt", OnCreate: true},
)
c.Indexes = types.JSONArray[string]{
fmt.Sprintf("CREATE UNIQUE INDEX `idx_%s_waitlist_email` ON `%s` (`waitlist`, `email` COLLATE NOCASE)", name, name),
fmt.Sprintf("CREATE UNIQUE INDEX `idx_%s_waitlist_refCode` ON `%s` (`waitlist`, `refCode` COLLATE NOCASE)", name, name),
fmt.Sprintf("CREATE INDEX `idx_%s_rank` ON `%s` (`waitlist`, `referralCount` DESC, `createdAt` ASC)", name, name),
}
if err := p.app.Save(c); err != nil {
return nil, err
}
p.logger.Info("waitlist: created collection", "name", name)
return c, nil
}