feat(orion): add rest api for waitlist

This commit is contained in:
talksik
2026-03-29 09:04:15 -07:00
parent c471c28bb0
commit acac407fd0
8 changed files with 631 additions and 2 deletions
+9 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
"github.com/flowy-live/llink/internal/waitlist"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
@@ -75,9 +76,10 @@ func main() {
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
BucketName: gcsBucket,
})
waitlistSvc := waitlist.NewService(db.Pool())
// Initialize handler
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc)
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc)
// Helper to wrap handlers with auth middleware
withAuth := func(hf http.HandlerFunc) http.Handler {
@@ -97,6 +99,7 @@ func main() {
})
mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode)
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
// ==========================================================================
// Protected routes (auth required)
@@ -126,6 +129,11 @@ func main() {
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
// Waitlist (admin-only)
mux.Handle("GET /waitlist", withAuth(h.GetWaitlist))
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
// Apply middleware
// nil allows all origins (required for electron app)
muxWithCors := middleware.CORS(nil)(mux)
+157 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
"github.com/flowy-live/llink/internal/waitlist"
)
type Handler struct {
@@ -23,15 +24,17 @@ type Handler struct {
networkSvc network.Service
particleSvc particle.Service
depotSvc depot.Service
waitlistSvc waitlist.Service
}
func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service) *Handler {
func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service) *Handler {
return &Handler{
authSvc: authSvc,
humanSvc: humanSvc,
networkSvc: networkSvc,
particleSvc: particleSvc,
depotSvc: depotSvc,
waitlistSvc: waitlistSvc,
}
}
@@ -774,6 +777,159 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// ============================================================================
// Waitlist DTOs
// ============================================================================
type AddToWaitlistRequest struct {
Email string `json:"email"`
Metadata map[string]string `json:"metadata"`
}
type WaitlistEntryResponse struct {
Id int `json:"id"`
Email string `json:"email"`
Metadata map[string]string `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
InvitedAt *time.Time `json:"invited_at,omitempty"`
}
type InviteWaitlistEntrantRequest struct {
Email string `json:"email"`
}
// ============================================================================
// Waitlist Handlers
// ============================================================================
// AddToWaitlist adds an email to the waitlist (public, no auth)
func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) {
var req AddToWaitlistRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
err := h.waitlistSvc.AddToWaitlist(r.Context(), req.Email, req.Metadata)
if err != nil {
if errors.Is(err, waitlist.AlreadyInWaitlistError) {
http.Error(w, "already in the waitlist", http.StatusConflict)
return
}
slog.Error("failed to add to waitlist", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetWaitlist returns all waitlist entries (admin-only)
func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
filterParam := r.URL.Query().Get("filter")
filter := waitlist.GetWaitlistFilterAll
switch filterParam {
case "invited":
filter = waitlist.GetWaitlistFilterInvitedOnly
case "uninvited":
filter = waitlist.GetWaitlistFilterUninvitedOnly
}
entries, err := h.waitlistSvc.GetWaitlist(r.Context(), filter)
if err != nil {
slog.Error("failed to get waitlist", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := make([]WaitlistEntryResponse, 0, len(entries))
for _, e := range entries {
resp = append(resp, waitlistEntryToDTO(e))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// GetWaitlistEntry returns a single waitlist entry by email (admin-only)
func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
email := r.PathValue("email")
if email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
entry, err := h.waitlistSvc.GetWaitlistEntryByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, waitlist.EntryNotFoundError) {
http.Error(w, "entry not found", http.StatusNotFound)
return
}
slog.Error("failed to get waitlist entry", "error", err, "email", email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(waitlistEntryToDTO(entry))
}
// InviteWaitlistEntrant marks a waitlist entry as invited (admin-only)
func (h *Handler) InviteWaitlistEntrant(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var req InviteWaitlistEntrantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
if err := h.waitlistSvc.MarkWaitlistEntryInvited(r.Context(), req.Email); err != nil {
if errors.Is(err, waitlist.EntryNotFoundError) {
http.Error(w, "entry not found", http.StatusNotFound)
return
}
slog.Error("failed to invite waitlist entrant", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func waitlistEntryToDTO(e *waitlist.WaitlistEntry) WaitlistEntryResponse {
return WaitlistEntryResponse{
Id: e.Id,
Email: e.Email,
Metadata: e.Metadata,
CreatedAt: e.CreatedAt,
InvitedAt: e.InvitedAt,
}
}
// ============================================================================
// Helper Functions
// ============================================================================
+13
View File
@@ -14,6 +14,7 @@ type contextKey string
const (
emailContextKey contextKey = "email"
humanIdContextKey contextKey = "humanId"
isAdminContextKey contextKey = "isAdmin"
)
// WithEmail adds the email to the context
@@ -38,6 +39,17 @@ func HumanIdFromContext(ctx context.Context) (string, bool) {
return humanId, ok
}
// WithIsAdmin adds the admin flag to the context
func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context {
return context.WithValue(ctx, isAdminContextKey, isAdmin)
}
// IsAdminFromContext extracts the admin flag from the context
func IsAdminFromContext(ctx context.Context) bool {
isAdmin, ok := ctx.Value(isAdminContextKey).(bool)
return ok && isAdmin
}
// Auth returns a middleware that validates the session token and adds the email to the context
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
@@ -61,6 +73,7 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
ctx := WithEmail(r.Context(), session.Email)
ctx = WithHumanId(ctx, session.HumanId)
ctx = WithIsAdmin(ctx, authSvc.IsSystemAdmin(r.Context(), session.Email))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+125
View File
@@ -0,0 +1,125 @@
package waitlist
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
var errNotFound = errors.New("not found")
var errAlreadyExists = errors.New("already exists")
type repository interface {
create(ctx context.Context, email string, metadata map[string]string) (*WaitlistEntry, error)
getAll(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, error)
getByEmail(ctx context.Context, email string) (*WaitlistEntry, error)
markInvited(ctx context.Context, email string) error
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
func (r *repositoryImpl) create(ctx context.Context, email string, metadata map[string]string) (*WaitlistEntry, error) {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return nil, err
}
var entry WaitlistEntry
var metadataBytes []byte
err = r.pool.QueryRow(ctx,
`INSERT INTO waitlist_entries (email, metadata)
VALUES ($1, $2)
RETURNING id, email, metadata, created_at, invited_at`,
email, metadataJSON,
).Scan(&entry.Id, &entry.Email, &metadataBytes, &entry.CreatedAt, &entry.InvitedAt)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, errAlreadyExists
}
return nil, err
}
if err := json.Unmarshal(metadataBytes, &entry.Metadata); err != nil {
return nil, err
}
return &entry, nil
}
func (r *repositoryImpl) getAll(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, error) {
query := `SELECT id, email, metadata, created_at, invited_at FROM waitlist_entries`
switch filter {
case GetWaitlistFilterInvitedOnly:
query += ` WHERE invited_at IS NOT NULL`
case GetWaitlistFilterUninvitedOnly:
query += ` WHERE invited_at IS NULL`
}
query += ` ORDER BY created_at DESC`
rows, err := r.pool.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []*WaitlistEntry
for rows.Next() {
var entry WaitlistEntry
var metadataBytes []byte
if err := rows.Scan(&entry.Id, &entry.Email, &metadataBytes, &entry.CreatedAt, &entry.InvitedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(metadataBytes, &entry.Metadata); err != nil {
return nil, err
}
entries = append(entries, &entry)
}
return entries, rows.Err()
}
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*WaitlistEntry, error) {
var entry WaitlistEntry
var metadataBytes []byte
err := r.pool.QueryRow(ctx,
`SELECT id, email, metadata, created_at, invited_at FROM waitlist_entries WHERE email = $1`,
email,
).Scan(&entry.Id, &entry.Email, &metadataBytes, &entry.CreatedAt, &entry.InvitedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
}
return nil, err
}
if err := json.Unmarshal(metadataBytes, &entry.Metadata); err != nil {
return nil, err
}
return &entry, nil
}
func (r *repositoryImpl) markInvited(ctx context.Context, email string) error {
result, err := r.pool.Exec(ctx,
`UPDATE waitlist_entries SET invited_at = NOW() WHERE email = $1`,
email,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package waitlist
import (
"context"
"errors"
"time"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
type WaitlistEntry struct {
Id int `db:"id"`
Email string `db:"email"`
Metadata map[string]string `db:"metadata"`
CreatedAt time.Time `db:"created_at"`
InvitedAt *time.Time `db:"invited_at"`
}
var (
AlreadyInWaitlistError = errors.New("already in the waitlist")
EntryNotFoundError = errors.New("not found")
)
type GetWaitlistFilter string
const (
GetWaitlistFilterAll GetWaitlistFilter = "all"
GetWaitlistFilterInvitedOnly GetWaitlistFilter = "invited-only"
GetWaitlistFilterUninvitedOnly GetWaitlistFilter = "uninvited-only"
)
type Service interface {
// returns AlreadyInWaitlistError if already in the waitlist
// any other error is a failure
AddToWaitlist(ctx context.Context, email string, metadata map[string]string) error
GetWaitlist(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, error)
// returns error if not found
GetWaitlistEntryByEmail(ctx context.Context, email string) (*WaitlistEntry, error)
MarkWaitlistEntryInvited(ctx context.Context, email string) error
}
type serviceImpl struct {
repo repository
}
func NewService(pool *pgxpool.Pool) Service {
return &serviceImpl{
repo: newRepository(pool),
}
}
func (s *serviceImpl) AddToWaitlist(ctx context.Context, email string, metadata map[string]string) error {
normalizedEmail, err := utils.NormalizeEmail(email)
if err != nil {
return errors.New("invalid email")
}
if metadata == nil {
metadata = map[string]string{}
}
_, err = s.repo.create(ctx, normalizedEmail, metadata)
if err != nil {
if errors.Is(err, errAlreadyExists) {
return AlreadyInWaitlistError
}
return err
}
return nil
}
func (s *serviceImpl) GetWaitlist(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, error) {
return s.repo.getAll(ctx, filter)
}
func (s *serviceImpl) GetWaitlistEntryByEmail(ctx context.Context, email string) (*WaitlistEntry, error) {
normalizedEmail, err := utils.NormalizeEmail(email)
if err != nil {
return nil, errors.New("invalid email")
}
entry, err := s.repo.getByEmail(ctx, normalizedEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, EntryNotFoundError
}
return nil, err
}
return entry, nil
}
func (s *serviceImpl) MarkWaitlistEntryInvited(ctx context.Context, email string) error {
normalizedEmail, err := utils.NormalizeEmail(email)
if err != nil {
return errors.New("invalid email")
}
err = s.repo.markInvited(ctx, normalizedEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return EntryNotFoundError
}
return err
}
return nil
}
+209
View File
@@ -0,0 +1,209 @@
package waitlist_test
import (
"context"
"os"
"testing"
"github.com/flowy-live/llink/internal/testhelper"
"github.com/flowy-live/llink/internal/waitlist"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
)
var dbPool *pgxpool.Pool
func TestMain(m *testing.M) {
dbPool = testhelper.SetupTestDB()
defer testhelper.TeardownTestDB()
ret := m.Run()
os.Exit(ret)
}
func TestWaitlistService_AddAndGet(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
email := "newuser@example.com"
metadata := map[string]string{"source": "landing-page", "plan": "pro"}
// Add to waitlist
err := svc.AddToWaitlist(ctx, email, metadata)
assert.NoError(t, err)
// Get entry by email
entry, err := svc.GetWaitlistEntryByEmail(ctx, email)
assert.NoError(t, err)
assert.Equal(t, email, entry.Email)
assert.Equal(t, "landing-page", entry.Metadata["source"])
assert.Equal(t, "pro", entry.Metadata["plan"])
assert.NotZero(t, entry.CreatedAt)
assert.Nil(t, entry.InvitedAt)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM waitlist_entries WHERE email = $1`, email)
assert.NoError(t, err)
}
func TestWaitlistService_AddDuplicate(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
email := "duplicate@example.com"
err := svc.AddToWaitlist(ctx, email, nil)
assert.NoError(t, err)
// Adding the same email again should return AlreadyInWaitlistError
err = svc.AddToWaitlist(ctx, email, nil)
assert.ErrorIs(t, err, waitlist.AlreadyInWaitlistError)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM waitlist_entries WHERE email = $1`, email)
assert.NoError(t, err)
}
func TestWaitlistService_AddNilMetadata(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
email := "nilmeta@example.com"
err := svc.AddToWaitlist(ctx, email, nil)
assert.NoError(t, err)
entry, err := svc.GetWaitlistEntryByEmail(ctx, email)
assert.NoError(t, err)
assert.NotNil(t, entry.Metadata)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM waitlist_entries WHERE email = $1`, email)
assert.NoError(t, err)
}
func TestWaitlistService_GetEntryNotFound(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
_, err := svc.GetWaitlistEntryByEmail(ctx, "nonexistent@example.com")
assert.ErrorIs(t, err, waitlist.EntryNotFoundError)
}
func TestWaitlistService_MarkInvited(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
email := "invite-me@example.com"
err := svc.AddToWaitlist(ctx, email, nil)
assert.NoError(t, err)
// Mark as invited
err = svc.MarkWaitlistEntryInvited(ctx, email)
assert.NoError(t, err)
// Verify invited_at is set
entry, err := svc.GetWaitlistEntryByEmail(ctx, email)
assert.NoError(t, err)
assert.NotNil(t, entry.InvitedAt)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM waitlist_entries WHERE email = $1`, email)
assert.NoError(t, err)
}
func TestWaitlistService_MarkInvitedNotFound(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
err := svc.MarkWaitlistEntryInvited(ctx, "ghost@example.com")
assert.ErrorIs(t, err, waitlist.EntryNotFoundError)
}
func TestWaitlistService_GetWaitlistFilters(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
uninvitedEmail := "uninvited-filter@example.com"
invitedEmail := "invited-filter@example.com"
// Add two entries
err := svc.AddToWaitlist(ctx, uninvitedEmail, nil)
assert.NoError(t, err)
err = svc.AddToWaitlist(ctx, invitedEmail, nil)
assert.NoError(t, err)
// Mark one as invited
err = svc.MarkWaitlistEntryInvited(ctx, invitedEmail)
assert.NoError(t, err)
// Filter: all
entries, err := svc.GetWaitlist(ctx, waitlist.GetWaitlistFilterAll)
assert.NoError(t, err)
emails := extractEmails(entries)
assert.Contains(t, emails, uninvitedEmail)
assert.Contains(t, emails, invitedEmail)
// Filter: invited only
entries, err = svc.GetWaitlist(ctx, waitlist.GetWaitlistFilterInvitedOnly)
assert.NoError(t, err)
emails = extractEmails(entries)
assert.Contains(t, emails, invitedEmail)
assert.NotContains(t, emails, uninvitedEmail)
// Filter: uninvited only
entries, err = svc.GetWaitlist(ctx, waitlist.GetWaitlistFilterUninvitedOnly)
assert.NoError(t, err)
emails = extractEmails(entries)
assert.Contains(t, emails, uninvitedEmail)
assert.NotContains(t, emails, invitedEmail)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM waitlist_entries WHERE email IN ($1, $2)`, uninvitedEmail, invitedEmail)
assert.NoError(t, err)
}
func TestWaitlistService_EmailNormalization(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
// Add with uppercase email
err := svc.AddToWaitlist(ctx, "UPPER@EXAMPLE.COM", nil)
assert.NoError(t, err)
// Should find with lowercase
entry, err := svc.GetWaitlistEntryByEmail(ctx, "upper@example.com")
assert.NoError(t, err)
assert.Equal(t, "upper@example.com", entry.Email)
// Adding with different case should be a duplicate
err = svc.AddToWaitlist(ctx, "Upper@Example.Com", nil)
assert.ErrorIs(t, err, waitlist.AlreadyInWaitlistError)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM waitlist_entries WHERE email = $1`, "upper@example.com")
assert.NoError(t, err)
}
func TestWaitlistService_InvalidEmail(t *testing.T) {
ctx := context.Background()
svc := waitlist.NewService(dbPool)
err := svc.AddToWaitlist(ctx, "not-an-email", nil)
assert.Error(t, err)
_, err = svc.GetWaitlistEntryByEmail(ctx, "not-an-email")
assert.Error(t, err)
err = svc.MarkWaitlistEntryInvited(ctx, "not-an-email")
assert.Error(t, err)
}
func extractEmails(entries []*waitlist.WaitlistEntry) []string {
emails := make([]string, len(entries))
for i, e := range entries {
emails[i] = e.Email
}
return emails
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS waitlist_entries;
+7
View File
@@ -0,0 +1,7 @@
CREATE TABLE waitlist_entries (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
invited_at TIMESTAMPTZ
);