DOCS-1: Init document work

This commit is contained in:
Blake Ridgway
2026-07-28 07:20:32 -05:00
parent 8f02a3fc8e
commit 0cbcc962f7
66 changed files with 12224 additions and 71 deletions

394
internal/store/store.go Normal file
View 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
}