Add lifecycle_status = 'published' to the public-catalog queries (plans and add-ons listings) and reject checkout before any Stripe call unless the product behind the price clears the shared member gate (published + active + public). The currently-enrolled ladder rung stays renderable even if its product is later drafted or retired, fetched directly so members keep seeing what they are on. Introduce a single evaluateMemberGate definition shared by the catalog paths and the operator readiness panel so the surfaces cannot disagree about what is publishable for members.
67 lines
3.1 KiB
Go
67 lines
3.1 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// DefaultWebhookPartitionMonthsAhead is the default lookahead window for
|
|
// EnsureWebhookEventPartitions: the current month plus this many months
|
|
// ahead. One more than the baseline migration's initial 0..2 loop
|
|
// (migrations/00001_init.sql:1054), so a fresh install immediately gains
|
|
// headroom (design.md D3).
|
|
const DefaultWebhookPartitionMonthsAhead = 3
|
|
|
|
// EnsureWebhookEventPartitions creates the monthly RANGE partitions of
|
|
// core.webhook_events covering the UTC month of `now` through `now`'s month
|
|
// plus monthsAhead (inclusive) — e.g. monthsAhead=3 ensures four partitions:
|
|
// the current month and the next three.
|
|
//
|
|
// It mirrors the baseline migration's partition-creation DO block
|
|
// (migrations/00001_init.sql:1048-1064) exactly: the same
|
|
// core.webhook_events_YYYY_MM naming (zero-padded four-digit year, two-digit
|
|
// month) and the same month-start/next-month-start boundaries. That parity
|
|
// is load-bearing — partitions created here must never overlap the ones the
|
|
// migration creates, whichever runs first — so any change to the naming or
|
|
// boundary computation here MUST be mirrored back into that DO block, and
|
|
// vice versa.
|
|
//
|
|
// Idempotent via CREATE TABLE IF NOT EXISTS: running it twice, or racing the
|
|
// migration's own initial-partition creation, is safe. A concurrent-create
|
|
// race between two runners surfaces as an error to whichever loses; the
|
|
// caller logs it and the next run (boot pass or scheduled workflow) succeeds
|
|
// (design.md D3, Risks).
|
|
func EnsureWebhookEventPartitions(ctx context.Context, database *sql.DB, now time.Time, monthsAhead int) error {
|
|
monthStart := time.Date(now.UTC().Year(), now.UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
for i := 0; i <= monthsAhead; i++ {
|
|
start := monthStart.AddDate(0, i, 0)
|
|
end := start.AddDate(0, 1, 0)
|
|
|
|
// The partition name and the boundary literals are composed entirely
|
|
// from computed integers (never user input), so building the DDL
|
|
// string with fmt.Sprintf is safe — mirroring the migration's own
|
|
// EXECUTE FORMAT('... %s ... %L ... %L', part, m_start, m_end)
|
|
// approach exactly, which relies on the same property. Boundaries are
|
|
// embedded as literals rather than query parameters deliberately: pgx
|
|
// against PG18 rejects placeholders inside this DDL shape ("mismatched
|
|
// param and argument count") — CREATE TABLE ... PARTITION OF ...
|
|
// FOR VALUES FROM/TO isn't a statement pgx's extended protocol can
|
|
// describe placeholders for, the same family of pgx/PG18 DDL
|
|
// interaction ConnectPlain's doc comment (database.go) already works
|
|
// around for multi-statement migrations.
|
|
partition := fmt.Sprintf("core.webhook_events_%04d_%02d", start.Year(), int(start.Month()))
|
|
stmt := fmt.Sprintf(
|
|
`CREATE TABLE IF NOT EXISTS %s PARTITION OF core.webhook_events FOR VALUES FROM ('%s') TO ('%s')`,
|
|
partition, start.Format(time.RFC3339), end.Format(time.RFC3339),
|
|
)
|
|
if _, err := database.ExecContext(ctx, stmt); err != nil {
|
|
return fmt.Errorf("ensure webhook_events partition %s: %w", partition, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|