DOCS-1: Init document work
This commit is contained in:
394
internal/store/store.go
Normal file
394
internal/store/store.go
Normal file
@@ -0,0 +1,394 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user