395 lines
11 KiB
Go
395 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// planCatalog mirrors billing's PlanCatalog — key → display name.
|
|
var planCatalog = map[string]string{
|
|
"shared_starter": "Shared Starter",
|
|
"shared_pro": "Shared Pro",
|
|
"shared_business": "Shared Business",
|
|
"wp_starter": "WordPress Starter",
|
|
"wp_pro": "WordPress Pro",
|
|
"wp_business": "WordPress Business",
|
|
"vps_1": "VPS Tier 1",
|
|
"vps_2": "VPS Tier 2",
|
|
"vps_3": "VPS Tier 3",
|
|
"vps_4": "VPS Tier 4",
|
|
}
|
|
|
|
// PlanOptions returns plan catalog entries for admin UI dropdowns.
|
|
func PlanOptions() []PlanOption {
|
|
order := []string{
|
|
"shared_starter", "shared_pro", "shared_business",
|
|
"wp_starter", "wp_pro", "wp_business",
|
|
"vps_1", "vps_2", "vps_3", "vps_4",
|
|
}
|
|
out := make([]PlanOption, 0, len(order))
|
|
for _, k := range order {
|
|
out = append(out, PlanOption{Key: k, Name: planCatalog[k]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
type PlanOption struct {
|
|
Key string
|
|
Name string
|
|
}
|
|
|
|
// Customer holds the fields docs needs from billing's customers table.
|
|
type Customer struct {
|
|
ID int64
|
|
Email string
|
|
FirstName string
|
|
LastName string
|
|
}
|
|
|
|
// Subscription holds the active subscription for a customer (may be nil).
|
|
type Subscription struct {
|
|
PriceID string
|
|
PlanName string
|
|
Status string
|
|
}
|
|
|
|
// Page is a client or admin-managed doc page stored in docs.db.
|
|
type Page struct {
|
|
ID int64
|
|
Title string
|
|
Slug string
|
|
Description string
|
|
Section string
|
|
Content string // raw markdown
|
|
Visibility string // "public" | "plan:key" | "customer:id"
|
|
DisplayOrder int
|
|
CreatedAt string
|
|
UpdatedAt string
|
|
}
|
|
|
|
// Store wraps the billing DB (read-only) and docs DB (read-write).
|
|
type Store struct {
|
|
billing *sql.DB
|
|
docs *sql.DB
|
|
}
|
|
|
|
// New opens both databases and migrates the docs schema.
|
|
// If the billing database file does not exist the store starts without it
|
|
// (auth and admin features will be unavailable, but public docs still serve).
|
|
func New(billingPath, docsPath string) (*Store, error) {
|
|
var billing *sql.DB
|
|
|
|
if _, err := os.Stat(billingPath); err == nil {
|
|
billing, err = sql.Open("sqlite", billingPath+"?_journal_mode=WAL&mode=ro")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open billing db: %w", err)
|
|
}
|
|
if err := billing.Ping(); err != nil {
|
|
billing.Close()
|
|
billing = nil
|
|
slog.Warn("billing db ping failed, auth/admin disabled", "path", billingPath, "err", err)
|
|
}
|
|
} else {
|
|
slog.Warn("billing db not found, auth/admin disabled", "path", billingPath)
|
|
}
|
|
|
|
// Ensure the docs directory exists before opening.
|
|
if err := os.MkdirAll(filepath.Dir(docsPath), 0o755); err != nil {
|
|
if billing != nil {
|
|
billing.Close()
|
|
}
|
|
return nil, fmt.Errorf("create docs dir: %w", err)
|
|
}
|
|
|
|
docs, err := sql.Open("sqlite", docsPath+"?_journal_mode=WAL&_foreign_keys=on")
|
|
if err != nil {
|
|
if billing != nil {
|
|
billing.Close()
|
|
}
|
|
return nil, fmt.Errorf("open docs db: %w", err)
|
|
}
|
|
if err := docs.Ping(); err != nil {
|
|
if billing != nil {
|
|
billing.Close()
|
|
}
|
|
docs.Close()
|
|
return nil, fmt.Errorf("ping docs db: %w", err)
|
|
}
|
|
|
|
if err := migrate(docs); err != nil {
|
|
if billing != nil {
|
|
billing.Close()
|
|
}
|
|
docs.Close()
|
|
return nil, fmt.Errorf("migrate docs db: %w", err)
|
|
}
|
|
|
|
return &Store{billing: billing, docs: docs}, nil
|
|
}
|
|
|
|
// Close closes both database connections.
|
|
func (s *Store) Close() {
|
|
if s.billing != nil {
|
|
s.billing.Close()
|
|
}
|
|
s.docs.Close()
|
|
}
|
|
|
|
func migrate(db *sql.DB) error {
|
|
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
section TEXT NOT NULL DEFAULT 'client',
|
|
content TEXT NOT NULL DEFAULT '',
|
|
visibility TEXT NOT NULL DEFAULT 'public',
|
|
display_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
)`)
|
|
return err
|
|
}
|
|
|
|
// ── Auth (reads billing DB) ────────────────────────────────────────────────────
|
|
|
|
// GetCustomerBySession looks up a billing session token and returns the
|
|
// associated customer. Returns nil, nil if the token is missing, expired,
|
|
// or billing db is not available.
|
|
func (s *Store) GetCustomerBySession(token string) (*Customer, error) {
|
|
if s.billing == nil {
|
|
return nil, nil
|
|
}
|
|
var customerID int64
|
|
var expiresAt string
|
|
err := s.billing.QueryRow(
|
|
`SELECT customer_id, expires_at FROM sessions WHERE token = ?`, token,
|
|
).Scan(&customerID, &expiresAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query session: %w", err)
|
|
}
|
|
|
|
exp, err := time.Parse(time.RFC3339, expiresAt)
|
|
if err != nil || time.Now().UTC().After(exp) {
|
|
return nil, nil
|
|
}
|
|
|
|
var c Customer
|
|
err = s.billing.QueryRow(
|
|
`SELECT id, email, first_name, last_name FROM customers WHERE id = ?`, customerID,
|
|
).Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query customer: %w", err)
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// GetSubscription returns the most recent active/cancelling subscription for
|
|
// the given customer, or nil if they have none.
|
|
func (s *Store) GetSubscription(customerID int64) (*Subscription, error) {
|
|
if s.billing == nil {
|
|
return nil, nil
|
|
}
|
|
var sub Subscription
|
|
err := s.billing.QueryRow(`
|
|
SELECT stripe_price_id, plan_name, status
|
|
FROM subscriptions
|
|
WHERE customer_id = ?
|
|
AND status IN ('active','cancelling')
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
customerID,
|
|
).Scan(&sub.PriceID, &sub.PlanName, &sub.Status)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query subscription: %w", err)
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
// ListCustomers returns all billing customers (for admin visibility picker).
|
|
func (s *Store) ListCustomers() ([]Customer, error) {
|
|
rows, err := s.billing.Query(
|
|
`SELECT id, email, first_name, last_name FROM customers ORDER BY email`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list customers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Customer
|
|
for rows.Next() {
|
|
var c Customer
|
|
if err := rows.Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ── Visibility ────────────────────────────────────────────────────────────────
|
|
|
|
// CanSee reports whether a customer (with optional subscription) can view a
|
|
// page with the given visibility string.
|
|
func CanSee(c *Customer, sub *Subscription, visibility string) bool {
|
|
if visibility == "public" {
|
|
return true
|
|
}
|
|
if c == nil {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(visibility, "customer:") {
|
|
tail := strings.TrimPrefix(visibility, "customer:")
|
|
return fmt.Sprintf("%d", c.ID) == tail
|
|
}
|
|
if strings.HasPrefix(visibility, "plan:") && sub != nil {
|
|
key := strings.TrimPrefix(visibility, "plan:")
|
|
name, ok := planCatalog[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return sub.PlanName == name && (sub.Status == "active" || sub.Status == "cancelling")
|
|
}
|
|
return false
|
|
}
|
|
|
|
// VisibilityLabel returns a human-readable label for a visibility string.
|
|
func VisibilityLabel(visibility string) string {
|
|
switch {
|
|
case visibility == "public":
|
|
return "Public"
|
|
case strings.HasPrefix(visibility, "plan:"):
|
|
key := strings.TrimPrefix(visibility, "plan:")
|
|
if name, ok := planCatalog[key]; ok {
|
|
return "Plan: " + name
|
|
}
|
|
return "Plan: " + key
|
|
case strings.HasPrefix(visibility, "customer:"):
|
|
return "Customer #" + strings.TrimPrefix(visibility, "customer:")
|
|
}
|
|
return visibility
|
|
}
|
|
|
|
// ── Pages (reads/writes docs DB) ──────────────────────────────────────────────
|
|
|
|
func (s *Store) ListPages() ([]*Page, error) {
|
|
rows, err := s.docs.Query(`
|
|
SELECT id, title, slug, description, section, content,
|
|
visibility, display_order, created_at, updated_at
|
|
FROM pages
|
|
ORDER BY section, display_order, title`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list pages: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*Page
|
|
for rows.Next() {
|
|
var p Page
|
|
if err := rows.Scan(&p.ID, &p.Title, &p.Slug, &p.Description, &p.Section,
|
|
&p.Content, &p.Visibility, &p.DisplayOrder, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListVisiblePages returns pages the given customer can see, ordered for display.
|
|
func (s *Store) ListVisiblePages(c *Customer, sub *Subscription) ([]*Page, error) {
|
|
all, err := s.ListPages()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []*Page
|
|
for _, p := range all {
|
|
if CanSee(c, sub, p.Visibility) {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) GetPageBySlug(slug string) (*Page, error) {
|
|
var p Page
|
|
err := s.docs.QueryRow(`
|
|
SELECT id, title, slug, description, section, content,
|
|
visibility, display_order, created_at, updated_at
|
|
FROM pages WHERE slug = ?`, slug,
|
|
).Scan(&p.ID, &p.Title, &p.Slug, &p.Description, &p.Section,
|
|
&p.Content, &p.Visibility, &p.DisplayOrder, &p.CreatedAt, &p.UpdatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get page by slug: %w", err)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func (s *Store) GetPageByID(id int64) (*Page, error) {
|
|
var p Page
|
|
err := s.docs.QueryRow(`
|
|
SELECT id, title, slug, description, section, content,
|
|
visibility, display_order, created_at, updated_at
|
|
FROM pages WHERE id = ?`, id,
|
|
).Scan(&p.ID, &p.Title, &p.Slug, &p.Description, &p.Section,
|
|
&p.Content, &p.Visibility, &p.DisplayOrder, &p.CreatedAt, &p.UpdatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get page by id: %w", err)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func (s *Store) CreatePage(p *Page) (int64, error) {
|
|
res, err := s.docs.Exec(`
|
|
INSERT INTO pages (title, slug, description, section, content, visibility, display_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
p.Title, p.Slug, p.Description, p.Section, p.Content, p.Visibility, p.DisplayOrder,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create page: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
func (s *Store) UpdatePage(p *Page) error {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, err := s.docs.Exec(`
|
|
UPDATE pages
|
|
SET title = ?, slug = ?, description = ?, section = ?, content = ?,
|
|
visibility = ?, display_order = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
p.Title, p.Slug, p.Description, p.Section, p.Content,
|
|
p.Visibility, p.DisplayOrder, now, p.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update page: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) DeletePage(id int64) error {
|
|
_, err := s.docs.Exec(`DELETE FROM pages WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete page: %w", err)
|
|
}
|
|
return nil
|
|
}
|