From acac407fd07f5efda36f36b64d8a0fe64490066f Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 29 Mar 2026 09:04:15 -0700 Subject: [PATCH] feat(orion): add rest api for waitlist --- go/cmd/orion/main.go | 10 +- go/internal/handler/handler.go | 158 ++++++++++++++++++- go/internal/middleware/auth.go | 13 ++ go/internal/waitlist/repository.go | 125 +++++++++++++++ go/internal/waitlist/service.go | 110 +++++++++++++ go/internal/waitlist/service_test.go | 209 +++++++++++++++++++++++++ go/migrations/000010_waitlist.down.sql | 1 + go/migrations/000010_waitlist.up.sql | 7 + 8 files changed, 631 insertions(+), 2 deletions(-) create mode 100644 go/internal/waitlist/repository.go create mode 100644 go/internal/waitlist/service.go create mode 100644 go/internal/waitlist/service_test.go create mode 100644 go/migrations/000010_waitlist.down.sql create mode 100644 go/migrations/000010_waitlist.up.sql diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index ca12f9f..ab35aa4 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -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) diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index c67a32a..4aca8bf 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -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 // ============================================================================ diff --git a/go/internal/middleware/auth.go b/go/internal/middleware/auth.go index 91e42dc..f061586 100644 --- a/go/internal/middleware/auth.go +++ b/go/internal/middleware/auth.go @@ -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)) }) } diff --git a/go/internal/waitlist/repository.go b/go/internal/waitlist/repository.go new file mode 100644 index 0000000..6f2b846 --- /dev/null +++ b/go/internal/waitlist/repository.go @@ -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 +} diff --git a/go/internal/waitlist/service.go b/go/internal/waitlist/service.go new file mode 100644 index 0000000..eebeb32 --- /dev/null +++ b/go/internal/waitlist/service.go @@ -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 +} diff --git a/go/internal/waitlist/service_test.go b/go/internal/waitlist/service_test.go new file mode 100644 index 0000000..3c76b5e --- /dev/null +++ b/go/internal/waitlist/service_test.go @@ -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 +} diff --git a/go/migrations/000010_waitlist.down.sql b/go/migrations/000010_waitlist.down.sql new file mode 100644 index 0000000..10a5574 --- /dev/null +++ b/go/migrations/000010_waitlist.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS waitlist_entries; diff --git a/go/migrations/000010_waitlist.up.sql b/go/migrations/000010_waitlist.up.sql new file mode 100644 index 0000000..35f2d79 --- /dev/null +++ b/go/migrations/000010_waitlist.up.sql @@ -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 +);