// Package db contains the queries the API serves. // // All reads are filtered against a per-app key. Role filtering happens // in-memory after the menu_items + role_grants are loaded — the joins // are small enough (typically <100 rows per app) that hand-filtering in // Go is simpler than a SQL CTE. package db import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) type DB struct { pool *pgxpool.Pool } func New(ctx context.Context, url string) (*DB, error) { pool, err := pgxpool.New(ctx, url) if err != nil { return nil, fmt.Errorf("pgxpool.New: %w", err) } if err := pool.Ping(ctx); err != nil { return nil, fmt.Errorf("ping: %w", err) } return &DB{pool: pool}, nil } func (d *DB) Close() { d.pool.Close() } type App struct { Key string DisplayName string BaseURL string } type Branding struct { LogoURL string ProductName string FooterHTML *string BrandColor *string } type MenuItem struct { ID string ParentID *string Zone string Key string TranslationKey string Href string Icon *string SortOrder int IsExternal bool Roles []string // empty = visible to anyone authenticated } func (d *DB) GetApp(ctx context.Context, appKey string) (*App, error) { row := d.pool.QueryRow(ctx, ` SELECT app_key, display_name, base_url FROM shell.apps WHERE app_key = $1 AND is_active = true `, appKey) var a App if err := row.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil { return nil, err } return &a, nil } func (d *DB) GetBranding(ctx context.Context, appKey string) (*Branding, error) { row := d.pool.QueryRow(ctx, ` SELECT logo_url, product_name, footer_html, brand_color FROM shell.branding WHERE app_key = $1 `, appKey) var b Branding if err := row.Scan(&b.LogoURL, &b.ProductName, &b.FooterHTML, &b.BrandColor); err != nil { return nil, err } return &b, nil } func (d *DB) ListMenuItems(ctx context.Context, appKey string) ([]MenuItem, error) { rows, err := d.pool.Query(ctx, ` SELECT mi.id::text, mi.parent_id::text, mi.zone::text, mi.key, mi.translation_key, mi.href, mi.icon, mi.sort_order, mi.is_external, COALESCE( (SELECT array_agg(role ORDER BY role) FROM shell.menu_role_grants WHERE menu_item_id = mi.id), ARRAY[]::text[] ) AS roles FROM shell.menu_items mi WHERE mi.app_key = $1 AND mi.is_active = true ORDER BY mi.zone, mi.sort_order, mi.key `, appKey) if err != nil { return nil, err } defer rows.Close() var out []MenuItem for rows.Next() { var m MenuItem var parent *string if err := rows.Scan( &m.ID, &parent, &m.Zone, &m.Key, &m.TranslationKey, &m.Href, &m.Icon, &m.SortOrder, &m.IsExternal, &m.Roles, ); err != nil { return nil, err } if parent != nil && *parent != "" { m.ParentID = parent } out = append(out, m) } return out, rows.Err() } // ListApps returns every active app — used by GET /api/v1/apps. func (d *DB) ListApps(ctx context.Context) ([]App, error) { rows, err := d.pool.Query(ctx, ` SELECT app_key, display_name, base_url FROM shell.apps WHERE is_active = true ORDER BY app_key `) if err != nil { return nil, err } defer rows.Close() var out []App for rows.Next() { var a App if err := rows.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() }