diff --git a/cmd/indexer/main.go b/cmd/indexer/main.go index 731bf2c..4110bfe 100644 --- a/cmd/indexer/main.go +++ b/cmd/indexer/main.go @@ -13,6 +13,7 @@ import ( "github.com/prabhupadasays/spsays-bff/internal/config" "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/revalidate" "github.com/typesense/typesense-go/v2/typesense" "github.com/typesense/typesense-go/v2/typesense/api" "go.mongodb.org/mongo-driver/v2/bson" @@ -191,6 +192,15 @@ func main() { } sugar.Info("=== Indexing complete ===") + + // Content changed → refresh the frontend sitemap cache (synchronous: this + // CLI exits right after, so a background goroutine wouldn't run). No-op + // when FRONTEND_URL / REVALIDATE_SECRET are unset. + if err := revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger).TriggerSync("sitemap"); err != nil { + sugar.Warnf("sitemap revalidation ping failed: %v", err) + } else if cfg.RevalidateSecret != "" { + sugar.Info("sitemap revalidation pinged") + } } // importBatch imports documents into Typesense in batches of batchSize. diff --git a/internal/config/config.go b/internal/config/config.go index ef9490e..f0b2887 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,10 @@ type Config struct { // Frontend FrontendURL string // for CORS + reset/verify links in emails + // RevalidateSecret authenticates BFF→frontend on-demand cache + // revalidation pings (POST /api/revalidate). Empty disables the pings. + RevalidateSecret string + // BFF public URL — the externally-reachable origin of this server. Used // to build the OAuth redirect_uri the provider sees (must match the // allow-list configured in the provider's console). In dev this is @@ -147,7 +151,8 @@ func Load() (*Config, error) { CookieDomain: getEnv("COOKIE_DOMAIN", ""), CookieSecure: getEnvBool("COOKIE_SECURE", false), - FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), + FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), + RevalidateSecret: getEnv("REVALIDATE_SECRET", ""), BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"), diff --git a/internal/server/server.go b/internal/server/server.go index 182bea4..63ff88a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ import ( "github.com/prabhupadasays/spsays-bff/pkg/oauth" "github.com/prabhupadasays/spsays-bff/pkg/realtime" "github.com/prabhupadasays/spsays-bff/pkg/response" + "github.com/prabhupadasays/spsays-bff/pkg/revalidate" "github.com/prabhupadasays/spsays-bff/pkg/shorturl" "go.uber.org/zap" ) @@ -163,6 +164,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { }, logger) authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL) + authSvc.SetRevalidator(revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger)) userSvc := service.NewUserService(userRepo, r2) // Social repositories + services share the same Postgres pool and R2 client. diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index fe37034..b51516b 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -21,6 +21,7 @@ import ( "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/mailer" + "github.com/prabhupadasays/spsays-bff/pkg/revalidate" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) @@ -45,12 +46,17 @@ type AuthService struct { mail mailer.Mailer logger *zap.Logger frontend string // FrontendURL — used to build reset / verify links + reval *revalidate.Client // optional — pings sitemap revalidation on new signups // TTLs — surfaced as fields so tests can shrink them. PasswordResetTTL time.Duration EmailVerifyTTL time.Duration } +// SetRevalidator wires the frontend cache-revalidation client. Optional — +// when unset, new-signup sitemap pings are simply skipped. +func (s *AuthService) SetRevalidator(r *revalidate.Client) { s.reval = r } + // NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if // the OAuth flows aren't wired (handler then returns 503). func NewAuthService( @@ -140,6 +146,9 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string) s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err)) } + // New public profile → refresh the sitemap so it's crawlable promptly. + s.reval.Trigger("sitemap") + return s.issueSession(u) } @@ -445,6 +454,8 @@ func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (* optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil { return nil, err } + // New public profile → refresh the sitemap so it's crawlable promptly. + s.reval.Trigger("sitemap") return s.issueSession(u) } diff --git a/pkg/revalidate/revalidate.go b/pkg/revalidate/revalidate.go new file mode 100644 index 0000000..c3ad2f6 --- /dev/null +++ b/pkg/revalidate/revalidate.go @@ -0,0 +1,78 @@ +// Package revalidate pings the frontend's on-demand cache revalidation +// endpoint so sitemap-relevant changes (new signups, content re-seeds) surface +// within seconds instead of waiting out the frontend's daily ISR window. +package revalidate + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "go.uber.org/zap" +) + +// Client posts to /api/revalidate. Safe to use when nil or when the +// frontend URL / secret are unset — Trigger becomes a no-op (so dev / misconfig +// never breaks the calling flow). +type Client struct { + base string + secret string + logger *zap.Logger +} + +func New(frontendURL, secret string, logger *zap.Logger) *Client { + return &Client{ + base: trimRightSlash(frontendURL), + secret: secret, + logger: logger, + } +} + +// Trigger fires a fire-and-forget POST to revalidate the given cache tag. +// Never blocks the caller and never errors out of band — use from request +// handlers (e.g. signup) on a long-lived server. +func (c *Client) Trigger(tag string) { + if c == nil || c.base == "" || c.secret == "" { + return + } + go func() { + if err := c.TriggerSync(tag); err != nil && c.logger != nil { + c.logger.Warn("revalidate ping failed", zap.String("tag", tag), zap.Error(err)) + } + }() +} + +// TriggerSync POSTs synchronously and returns any error. Use from short-lived +// CLIs (e.g. the indexer) that would exit before a background goroutine ran. +// No-op (nil) when the client / URL / secret is unset. +func (c *Client) TriggerSync(tag string) error { + if c == nil || c.base == "" || c.secret == "" { + return nil + } + endpoint := fmt.Sprintf("%s/api/revalidate?tag=%s&secret=%s", + c.base, url.QueryEscape(tag), url.QueryEscape(c.secret)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("revalidate returned %d", resp.StatusCode) + } + return nil +} + +func trimRightSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +}