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/network"
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
"github.com/flowy-live/llink/internal/waitlist"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
@@ -75,9 +76,10 @@ func main() {
|
|||||||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||||
BucketName: gcsBucket,
|
BucketName: gcsBucket,
|
||||||
})
|
})
|
||||||
|
waitlistSvc := waitlist.NewService(db.Pool())
|
||||||
|
|
||||||
// Initialize handler
|
// 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
|
// Helper to wrap handlers with auth middleware
|
||||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
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/request-code", h.RequestSignInCode)
|
||||||
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
|
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
|
||||||
|
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Protected routes (auth required)
|
// Protected routes (auth required)
|
||||||
@@ -126,6 +129,11 @@ func main() {
|
|||||||
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
|
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
|
||||||
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
|
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
|
// Apply middleware
|
||||||
// nil allows all origins (required for electron app)
|
// nil allows all origins (required for electron app)
|
||||||
muxWithCors := middleware.CORS(nil)(mux)
|
muxWithCors := middleware.CORS(nil)(mux)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
"github.com/flowy-live/llink/internal/waitlist"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -23,15 +24,17 @@ type Handler struct {
|
|||||||
networkSvc network.Service
|
networkSvc network.Service
|
||||||
particleSvc particle.Service
|
particleSvc particle.Service
|
||||||
depotSvc depot.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{
|
return &Handler{
|
||||||
authSvc: authSvc,
|
authSvc: authSvc,
|
||||||
humanSvc: humanSvc,
|
humanSvc: humanSvc,
|
||||||
networkSvc: networkSvc,
|
networkSvc: networkSvc,
|
||||||
particleSvc: particleSvc,
|
particleSvc: particleSvc,
|
||||||
depotSvc: depotSvc,
|
depotSvc: depotSvc,
|
||||||
|
waitlistSvc: waitlistSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,6 +777,159 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
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
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type contextKey string
|
|||||||
const (
|
const (
|
||||||
emailContextKey contextKey = "email"
|
emailContextKey contextKey = "email"
|
||||||
humanIdContextKey contextKey = "humanId"
|
humanIdContextKey contextKey = "humanId"
|
||||||
|
isAdminContextKey contextKey = "isAdmin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WithEmail adds the email to the context
|
// WithEmail adds the email to the context
|
||||||
@@ -38,6 +39,17 @@ func HumanIdFromContext(ctx context.Context) (string, bool) {
|
|||||||
return humanId, ok
|
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
|
// 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 {
|
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
||||||
return func(next 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 := WithEmail(r.Context(), session.Email)
|
||||||
ctx = WithHumanId(ctx, session.HumanId)
|
ctx = WithHumanId(ctx, session.HumanId)
|
||||||
|
ctx = WithIsAdmin(ctx, authSvc.IsSystemAdmin(r.Context(), session.Email))
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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 := "[email protected]"
|
||||||
|
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 := "[email protected]"
|
||||||
|
|
||||||
|
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 := "[email protected]"
|
||||||
|
|
||||||
|
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, "[email protected]")
|
||||||
|
assert.ErrorIs(t, err, waitlist.EntryNotFoundError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitlistService_MarkInvited(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := waitlist.NewService(dbPool)
|
||||||
|
|
||||||
|
email := "[email protected]"
|
||||||
|
|
||||||
|
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, "[email protected]")
|
||||||
|
assert.ErrorIs(t, err, waitlist.EntryNotFoundError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitlistService_GetWaitlistFilters(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := waitlist.NewService(dbPool)
|
||||||
|
|
||||||
|
uninvitedEmail := "[email protected]"
|
||||||
|
invitedEmail := "[email protected]"
|
||||||
|
|
||||||
|
// 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, "[email protected]", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Should find with lowercase
|
||||||
|
entry, err := svc.GetWaitlistEntryByEmail(ctx, "[email protected]")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "[email protected]", entry.Email)
|
||||||
|
|
||||||
|
// Adding with different case should be a duplicate
|
||||||
|
err = svc.AddToWaitlist(ctx, "[email protected]", nil)
|
||||||
|
assert.ErrorIs(t, err, waitlist.AlreadyInWaitlistError)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
_, err = dbPool.Exec(ctx, `DELETE FROM waitlist_entries WHERE email = $1`, "[email protected]")
|
||||||
|
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