feat(orion): add rest api for waitlist
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS waitlist_entries;
|
||||
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user