mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
1215 lines
34 KiB
Markdown
1215 lines
34 KiB
Markdown
# Technical Specifications & Architecture
|
|
|
|
This document defines the technical stack, architecture patterns, code standards, and implementation details for the Terminal Coffee Shop project. **Read this before writing any code.**
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Development Workflow](#development-workflow)
|
|
2. [Technology Stack](#technology-stack)
|
|
3. [Database Schema](#database-schema)
|
|
4. [API Specifications](#api-specifications)
|
|
5. [Code Organization](#code-organization)
|
|
6. [Code Style Guide](#code-style-guide)
|
|
7. [Error Handling](#error-handling)
|
|
8. [Testing Standards](#testing-standards)
|
|
9. [Common Patterns](#common-patterns)
|
|
|
|
---
|
|
|
|
## Development Workflow
|
|
|
|
### Documentation-First Approach
|
|
|
|
**IMPORTANT**: Before implementing features using any library or technology, **always fetch and read the official documentation first**.
|
|
|
|
#### Process for Using New Libraries
|
|
|
|
1. **Identify the library/technology** you need to use (e.g., Chi router, GORM, Stripe SDK)
|
|
2. **Fetch the documentation** using WebFetch:
|
|
```
|
|
WebFetch the official documentation URL for the library
|
|
Read through relevant sections (getting started, API reference, examples)
|
|
```
|
|
3. **Understand the current best practices** from the docs (not from memory/assumptions)
|
|
4. **Implement** following the documented patterns
|
|
5. **Reference the docs** if you encounter issues
|
|
|
|
#### Why This Matters
|
|
|
|
- Libraries evolve - documentation ensures you use current patterns
|
|
- Prevents deprecated patterns or outdated approaches
|
|
- Helps discover features you might not know about
|
|
- Ensures compatibility with the version in go.mod
|
|
|
|
#### Example Workflow
|
|
|
|
```
|
|
Task: Implement Chi router middleware
|
|
|
|
Step 1: Check go.mod for version
|
|
Step 2: WebFetch https://go-chi.io/#/pages/middleware
|
|
Step 3: Read middleware documentation
|
|
Step 4: Implement following documented patterns
|
|
Step 5: Test implementation
|
|
```
|
|
|
|
#### Documentation Sources
|
|
|
|
- **Chi Router**: https://go-chi.io/
|
|
- **GORM**: https://gorm.io/docs/
|
|
- **Stripe Go**: https://stripe.com/docs/api?lang=go
|
|
- **Bubbletea**: https://github.com/charmbracelet/bubbletea
|
|
- **Lipgloss**: https://github.com/charmbracelet/lipgloss
|
|
|
|
---
|
|
|
|
## Technology Stack
|
|
|
|
### TUI (Terminal User Interface)
|
|
|
|
| Package | Version | Purpose | Documentation |
|
|
|---------|---------|---------|---------------|
|
|
| `github.com/charmbracelet/bubbletea` | v1.3.10+ | Main TUI framework (The Elm Architecture) | https://github.com/charmbracelet/bubbletea |
|
|
| `github.com/charmbracelet/lipgloss` | v1.1.0+ | Styling and layout | https://github.com/charmbracelet/lipgloss |
|
|
| `github.com/charmbracelet/bubbles` | v0.21.0+ | Pre-built UI components (viewport, spinner, etc.) | https://github.com/charmbracelet/bubbles |
|
|
| `github.com/charmbracelet/huh` | v0.8.0+ | Forms and input validation | https://github.com/charmbracelet/huh |
|
|
| `github.com/charmbracelet/wish` | v1.4.7+ | SSH server framework | https://github.com/charmbracelet/wish |
|
|
|
|
### Backend API
|
|
|
|
| Package | Version | Purpose | Documentation |
|
|
|---------|---------|---------|---------------|
|
|
| `github.com/go-chi/chi/v5` | v5.0.0+ | HTTP router | https://go-chi.io/ |
|
|
| `gorm.io/gorm` | v1.25.0+ | ORM for database operations | https://gorm.io/docs/ |
|
|
| `gorm.io/driver/postgres` | v1.5.0+ | PostgreSQL driver | https://gorm.io/docs/connecting_to_the_database.html |
|
|
| `github.com/golang-jwt/jwt/v5` | v5.0.0+ | JWT token handling | https://github.com/golang-jwt/jwt |
|
|
| `github.com/go-playground/validator/v10` | v10.0.0+ | Struct validation | https://github.com/go-playground/validator |
|
|
| `github.com/joho/godotenv` | v1.5.0+ | Environment variables | https://github.com/joho/godotenv |
|
|
|
|
### Payments & Services
|
|
|
|
| Package | Version | Purpose | Documentation |
|
|
|---------|---------|---------|---------------|
|
|
| `github.com/stripe/stripe-go/v78` | v78.12.0+ | Payment processing | https://stripe.com/docs/api?lang=go |
|
|
| `rsc.io/qr` | v0.2.0 | QR code generation | https://pkg.go.dev/rsc.io/qr |
|
|
|
|
### Utilities
|
|
|
|
| Package | Version | Purpose | Documentation |
|
|
|---------|---------|---------|---------------|
|
|
| `github.com/google/uuid` | v1.6.0+ | UUID generation | https://github.com/google/uuid |
|
|
| `golang.org/x/crypto` | latest | Cryptography utilities | https://pkg.go.dev/golang.org/x/crypto |
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
### Schema Design Principles
|
|
- Use UUIDs for primary keys (not auto-incrementing integers)
|
|
- Always include `created_at` and `updated_at` timestamps
|
|
- Use soft deletes (`deleted_at`) for important records
|
|
- Normalize data appropriately (3NF where possible)
|
|
- Add indexes on foreign keys and frequently queried columns
|
|
|
|
### Complete Schema (PostgreSQL)
|
|
|
|
```sql
|
|
-- Users table
|
|
CREATE TABLE users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
fingerprint VARCHAR(255) UNIQUE, -- SSH public key fingerprint
|
|
email VARCHAR(255) UNIQUE,
|
|
name VARCHAR(255),
|
|
stripe_customer_id VARCHAR(255), -- Stripe customer ID
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL
|
|
);
|
|
|
|
-- Products table
|
|
CREATE TABLE products (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
roast_type VARCHAR(100), -- "Dark Roast", "Medium Roast", etc.
|
|
ounces INTEGER, -- Size in ounces
|
|
bean_type VARCHAR(100), -- "Arabica", "Robusta", etc.
|
|
price INTEGER NOT NULL, -- Price in cents (e.g., 350 = $3.50)
|
|
color VARCHAR(7), -- Hex color for UI (e.g., "#8B4513")
|
|
image_url VARCHAR(500),
|
|
stock INTEGER DEFAULT 0,
|
|
active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL
|
|
);
|
|
|
|
-- Carts table
|
|
CREATE TABLE carts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Cart items table
|
|
CREATE TABLE cart_items (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
cart_id UUID REFERENCES carts(id) ON DELETE CASCADE,
|
|
product_id UUID REFERENCES products(id),
|
|
quantity INTEGER NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(cart_id, product_id) -- One entry per product per cart
|
|
);
|
|
|
|
-- Addresses table
|
|
CREATE TABLE addresses (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id),
|
|
name VARCHAR(255), -- Recipient name
|
|
street VARCHAR(255) NOT NULL,
|
|
street2 VARCHAR(255), -- Apt, suite, etc.
|
|
city VARCHAR(100) NOT NULL,
|
|
state VARCHAR(2) NOT NULL, -- US state code
|
|
zip VARCHAR(10) NOT NULL,
|
|
country VARCHAR(2) DEFAULT 'US',
|
|
is_default BOOLEAN DEFAULT false,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Payment methods table (stores Stripe tokens, not raw card data)
|
|
CREATE TABLE payment_methods (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id),
|
|
stripe_payment_method_id VARCHAR(255) NOT NULL, -- Stripe PM ID
|
|
brand VARCHAR(50), -- "Visa", "Mastercard", etc.
|
|
last4 VARCHAR(4), -- Last 4 digits
|
|
exp_month INTEGER,
|
|
exp_year INTEGER,
|
|
is_default BOOLEAN DEFAULT false,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Orders table
|
|
CREATE TABLE orders (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id),
|
|
address_id UUID REFERENCES addresses(id),
|
|
payment_method_id UUID REFERENCES payment_methods(id),
|
|
|
|
-- Amounts in cents
|
|
subtotal INTEGER NOT NULL,
|
|
shipping INTEGER NOT NULL DEFAULT 0,
|
|
tax INTEGER NOT NULL DEFAULT 0,
|
|
total INTEGER NOT NULL,
|
|
|
|
status VARCHAR(50) DEFAULT 'pending', -- pending, paid, shipped, delivered, cancelled
|
|
|
|
stripe_payment_intent_id VARCHAR(255), -- Stripe Payment Intent ID
|
|
tracking_number VARCHAR(255),
|
|
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Order items table (snapshot of products at time of order)
|
|
CREATE TABLE order_items (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
order_id UUID REFERENCES orders(id) ON DELETE CASCADE,
|
|
product_id UUID REFERENCES products(id),
|
|
|
|
-- Snapshot product details (in case product changes later)
|
|
product_name VARCHAR(255),
|
|
product_description TEXT,
|
|
price INTEGER NOT NULL, -- Price at time of order
|
|
quantity INTEGER NOT NULL,
|
|
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Indexes for performance
|
|
CREATE INDEX idx_users_email ON users(email);
|
|
CREATE INDEX idx_users_fingerprint ON users(fingerprint);
|
|
CREATE INDEX idx_carts_user_id ON carts(user_id);
|
|
CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id);
|
|
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
|
CREATE INDEX idx_payment_methods_user_id ON payment_methods(user_id);
|
|
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
|
CREATE INDEX idx_orders_status ON orders(status);
|
|
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
|
|
```
|
|
|
|
### GORM Model Examples
|
|
|
|
```go
|
|
// models/user.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
"gorm.io/gorm"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type User struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
|
Fingerprint string `gorm:"uniqueIndex;size:255"`
|
|
Email string `gorm:"uniqueIndex;size:255"`
|
|
Name string `gorm:"size:255"`
|
|
StripeCustomerID string `gorm:"size:255"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
|
}
|
|
|
|
// models/product.go
|
|
type Product struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
|
Name string `gorm:"size:255;not null" json:"name"`
|
|
Description string `gorm:"type:text" json:"description"`
|
|
RoastType string `gorm:"size:100" json:"roast_type"`
|
|
Ounces int `json:"ounces"`
|
|
BeanType string `gorm:"size:100" json:"bean_type"`
|
|
Price int `gorm:"not null" json:"price"` // cents
|
|
Color string `gorm:"size:7" json:"color"`
|
|
ImageURL string `gorm:"size:500" json:"image_url"`
|
|
Stock int `gorm:"default:0" json:"stock"`
|
|
Active bool `gorm:"default:true" json:"active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// models/cart.go
|
|
type Cart struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
|
UserID uuid.UUID `gorm:"type:uuid;not null"`
|
|
User User `gorm:"foreignKey:UserID"`
|
|
Items []CartItem `gorm:"foreignKey:CartID"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type CartItem struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
|
CartID uuid.UUID `gorm:"type:uuid;not null"`
|
|
ProductID uuid.UUID `gorm:"type:uuid;not null"`
|
|
Product Product `gorm:"foreignKey:ProductID"`
|
|
Quantity int `gorm:"not null;default:1"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API Specifications
|
|
|
|
### API Design Principles
|
|
|
|
1. **RESTful routes** - Use standard HTTP methods (GET, POST, PUT, DELETE)
|
|
2. **JSON responses** - All responses in JSON format
|
|
3. **Consistent error format** - See Error Handling section
|
|
4. **JWT authentication** - Bearer token in Authorization header
|
|
5. **Versioning** - All routes prefixed with `/api/v1/`
|
|
6. **Pagination** - Use `page` and `limit` query params
|
|
|
|
### API Routes
|
|
|
|
#### Authentication
|
|
```
|
|
POST /api/v1/auth/register - Create new user
|
|
POST /api/v1/auth/login - Get JWT token
|
|
POST /api/v1/auth/refresh - Refresh token
|
|
GET /api/v1/auth/me - Get current user (requires auth)
|
|
PUT /api/v1/auth/me - Update current user (requires auth)
|
|
```
|
|
|
|
#### Products
|
|
```
|
|
GET /api/v1/products - List all products (paginated)
|
|
GET /api/v1/products/:id - Get single product
|
|
POST /api/v1/products - Create product (admin only)
|
|
PUT /api/v1/products/:id - Update product (admin only)
|
|
DELETE /api/v1/products/:id - Delete product (admin only)
|
|
```
|
|
|
|
#### Cart
|
|
```
|
|
GET /api/v1/cart - Get current user's cart
|
|
POST /api/v1/cart/items - Add item to cart
|
|
PUT /api/v1/cart/items/:id - Update item quantity
|
|
DELETE /api/v1/cart/items/:id - Remove item from cart
|
|
DELETE /api/v1/cart - Clear entire cart
|
|
```
|
|
|
|
#### Payment Methods
|
|
```
|
|
GET /api/v1/payment-methods - List saved payment methods
|
|
POST /api/v1/payment-methods - Save new payment method
|
|
DELETE /api/v1/payment-methods/:id - Remove payment method
|
|
```
|
|
|
|
#### Addresses
|
|
```
|
|
GET /api/v1/addresses - List user's addresses
|
|
POST /api/v1/addresses - Create new address
|
|
PUT /api/v1/addresses/:id - Update address
|
|
DELETE /api/v1/addresses/:id - Delete address
|
|
```
|
|
|
|
#### Orders
|
|
```
|
|
GET /api/v1/orders - List user's orders
|
|
GET /api/v1/orders/:id - Get order details
|
|
POST /api/v1/orders - Create new order (checkout)
|
|
```
|
|
|
|
### Request/Response Examples
|
|
|
|
#### GET /api/v1/products
|
|
**Response (200 OK)**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"products": [
|
|
{
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"name": "Espresso",
|
|
"description": "Bold coffee...",
|
|
"roast_type": "Dark Roast",
|
|
"ounces": 2,
|
|
"bean_type": "Arabica",
|
|
"price": 350,
|
|
"color": "#8B4513",
|
|
"stock": 100,
|
|
"created_at": "2024-01-01T00:00:00Z"
|
|
}
|
|
],
|
|
"pagination": {
|
|
"page": 1,
|
|
"limit": 20,
|
|
"total": 6,
|
|
"total_pages": 1
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### POST /api/v1/cart/items
|
|
**Request**:
|
|
```json
|
|
{
|
|
"product_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"quantity": 2
|
|
}
|
|
```
|
|
|
|
**Response (201 Created)**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"cart": {
|
|
"id": "cart-uuid",
|
|
"items": [
|
|
{
|
|
"id": "item-uuid",
|
|
"product": { /* full product object */ },
|
|
"quantity": 2
|
|
}
|
|
],
|
|
"subtotal": 700
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### POST /api/v1/orders (Checkout)
|
|
**Request**:
|
|
```json
|
|
{
|
|
"address_id": "address-uuid",
|
|
"payment_method_id": "pm-uuid"
|
|
}
|
|
```
|
|
|
|
**Response (201 Created)**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"order": {
|
|
"id": "order-uuid",
|
|
"subtotal": 700,
|
|
"shipping": 500,
|
|
"tax": 0,
|
|
"total": 1200,
|
|
"status": "pending",
|
|
"items": [ /* order items */ ]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Code Organization
|
|
|
|
### Directory Structure
|
|
|
|
```
|
|
terminalShop/
|
|
├── main.go # Entry point (SSH server)
|
|
├── server.go # Current TUI entry point
|
|
├── model.go # Bubbletea TUI models and views
|
|
│
|
|
├── api/ # Backend API server
|
|
│ ├── main.go # API server entry point
|
|
│ ├── routes/
|
|
│ │ ├── auth.go # Auth routes
|
|
│ │ ├── products.go # Product routes
|
|
│ │ ├── cart.go # Cart routes
|
|
│ │ └── orders.go # Order routes
|
|
│ ├── middleware/
|
|
│ │ ├── auth.go # JWT auth middleware
|
|
│ │ ├── logger.go # Request logging
|
|
│ │ └── cors.go # CORS middleware
|
|
│ └── handlers/
|
|
│ ├── auth.go # Auth handler functions
|
|
│ ├── products.go # Product handlers
|
|
│ └── cart.go # Cart handlers
|
|
│
|
|
├── pkg/ # Shared packages
|
|
│ ├── models/ # Database models (GORM)
|
|
│ │ ├── user.go
|
|
│ │ ├── product.go
|
|
│ │ ├── cart.go
|
|
│ │ └── order.go
|
|
│ ├── database/ # Database connection
|
|
│ │ └── db.go
|
|
│ ├── apiclient/ # API client for TUI
|
|
│ │ └── client.go
|
|
│ ├── auth/ # Auth utilities
|
|
│ │ ├── jwt.go # JWT generation/validation
|
|
│ │ └── password.go # Password hashing
|
|
│ └── utils/ # Helper functions
|
|
│ ├── response.go # Standard response helpers
|
|
│ └── validation.go # Validation helpers
|
|
│
|
|
├── migrations/ # Database migrations
|
|
│ ├── 001_initial.sql
|
|
│ └── migrate.go
|
|
│
|
|
├── config/ # Configuration
|
|
│ └── config.go # Load env vars
|
|
│
|
|
├── tests/ # Tests
|
|
│ ├── api/
|
|
│ └── integration/
|
|
│
|
|
├── .env # Environment variables (not in git)
|
|
├── .env.example # Example env file
|
|
├── go.mod
|
|
├── go.sum
|
|
├── README.md
|
|
└── MODELS.md # This file
|
|
```
|
|
|
|
### Package Organization Rules
|
|
|
|
1. **One responsibility per package** - Each package should have a clear, single purpose
|
|
2. **No circular dependencies** - Organize imports in a clear hierarchy
|
|
3. **Shared code in pkg/** - Anything used by multiple packages goes in `pkg/`
|
|
4. **Keep handlers thin** - Business logic goes in separate service functions
|
|
5. **Models package is database-only** - No business logic in model structs
|
|
|
|
---
|
|
|
|
## Code Style Guide
|
|
|
|
### General Go Style
|
|
|
|
Follow the official [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) and [Effective Go](https://go.dev/doc/effective_go).
|
|
|
|
### Specific Rules for This Project
|
|
|
|
#### 1. Imports Organization
|
|
Always organize imports in three groups, separated by blank lines:
|
|
|
|
```go
|
|
import (
|
|
// Standard library
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
// External dependencies
|
|
"github.com/go-chi/chi/v5"
|
|
"gorm.io/gorm"
|
|
|
|
// Internal packages
|
|
"github.com/yourusername/terminalshop/pkg/models"
|
|
"github.com/yourusername/terminalshop/pkg/utils"
|
|
)
|
|
```
|
|
|
|
#### 2. Naming Conventions
|
|
|
|
- **Variables**: `camelCase` for local variables, `PascalCase` for exported
|
|
- **Constants**: `PascalCase` or `SCREAMING_SNAKE_CASE` for groups
|
|
- **Functions**: `PascalCase` for exported, `camelCase` for private
|
|
- **Interfaces**: Suffix with `-er` if single method (e.g., `Reader`, `Writer`)
|
|
- **Structs**: `PascalCase`, descriptive nouns
|
|
- **Files**: `snake_case.go`
|
|
|
|
```go
|
|
// Good
|
|
type UserService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (s *UserService) CreateUser(ctx context.Context, name string) (*models.User, error) {
|
|
// ...
|
|
}
|
|
|
|
// Bad
|
|
type userservice struct { } // should be PascalCase if exported
|
|
func (s *UserService) create_user() { } // should be camelCase if private
|
|
```
|
|
|
|
#### 3. Error Messages
|
|
|
|
- Start with lowercase
|
|
- No punctuation at the end
|
|
- Include context about what failed
|
|
|
|
```go
|
|
// Good
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
return nil, errors.New("user not found")
|
|
|
|
// Bad
|
|
return nil, fmt.Errorf("Error creating user: %v", err) // no caps, use %w
|
|
return nil, errors.New("User not found.") // no caps, no period
|
|
```
|
|
|
|
#### 4. Function Organization
|
|
|
|
Order functions in files logically:
|
|
1. Exported types/constants
|
|
2. Constructor functions (`New...`)
|
|
3. Public methods (alphabetically)
|
|
4. Private methods (alphabetically)
|
|
5. Helper functions
|
|
|
|
```go
|
|
// 1. Types
|
|
type UserHandler struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// 2. Constructor
|
|
func NewUserHandler(db *gorm.DB) *UserHandler {
|
|
return &UserHandler{db: db}
|
|
}
|
|
|
|
// 3. Public methods
|
|
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|
// ...
|
|
}
|
|
|
|
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
|
// ...
|
|
}
|
|
|
|
// 4. Private methods
|
|
func (h *UserHandler) validateUser(user *models.User) error {
|
|
// ...
|
|
}
|
|
|
|
// 5. Helper functions
|
|
func hashPassword(password string) (string, error) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
#### 5. Comment Style
|
|
|
|
- Use complete sentences for package and function comments
|
|
- Start comments with the name of the thing being described
|
|
- Use `//` for single-line, `/* */` for multi-line only when needed
|
|
|
|
```go
|
|
// UserHandler handles HTTP requests related to users.
|
|
type UserHandler struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// CreateUser creates a new user in the database and returns it.
|
|
// Returns an error if validation fails or database operation fails.
|
|
func (h *UserHandler) CreateUser(ctx context.Context, user *models.User) error {
|
|
// Validate user input
|
|
if err := h.validateUser(user); err != nil {
|
|
return fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Create user in database
|
|
if err := h.db.Create(user).Error; err != nil {
|
|
return fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
#### 6. Struct Tags
|
|
|
|
Always include JSON tags and use snake_case for JSON field names:
|
|
|
|
```go
|
|
type Product struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`
|
|
Name string `gorm:"size:255" json:"name"`
|
|
Price int `gorm:"not null" json:"price"`
|
|
RoastType string `gorm:"size:100" json:"roast_type"` // snake_case!
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
```
|
|
|
|
#### 7. Context Usage
|
|
|
|
Always pass `context.Context` as the first parameter:
|
|
|
|
```go
|
|
// Good
|
|
func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
|
return s.db.WithContext(ctx).First(&user, id).Error
|
|
}
|
|
|
|
// Bad
|
|
func (s *UserService) GetUser(id uuid.UUID) (*models.User, error) {
|
|
// No context!
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### Principles
|
|
|
|
1. **Always handle errors** - Never ignore errors with `_`
|
|
2. **Wrap errors** - Use `fmt.Errorf("context: %w", err)` to add context
|
|
3. **Don't panic** - Only panic in `main()` or `init()` for unrecoverable errors
|
|
4. **Return early** - Use guard clauses and early returns
|
|
|
|
### Standard Error Response Format (API)
|
|
|
|
All API errors should follow this format:
|
|
|
|
```json
|
|
{
|
|
"success": false,
|
|
"error": {
|
|
"code": "VALIDATION_ERROR",
|
|
"message": "Invalid input data",
|
|
"details": {
|
|
"email": "must be a valid email address",
|
|
"password": "must be at least 8 characters"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Error Response Helper
|
|
|
|
```go
|
|
// pkg/utils/response.go
|
|
package utils
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
)
|
|
|
|
type ErrorResponse struct {
|
|
Success bool `json:"success"`
|
|
Error Error `json:"error"`
|
|
}
|
|
|
|
type Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Details map[string]interface{} `json:"details,omitempty"`
|
|
}
|
|
|
|
func RespondError(w http.ResponseWriter, code int, errCode, message string, details map[string]interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(ErrorResponse{
|
|
Success: false,
|
|
Error: Error{
|
|
Code: errCode,
|
|
Message: message,
|
|
Details: details,
|
|
},
|
|
})
|
|
}
|
|
|
|
func RespondSuccess(w http.ResponseWriter, code int, data interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"data": data,
|
|
})
|
|
}
|
|
```
|
|
|
|
### Common Error Codes
|
|
|
|
```go
|
|
const (
|
|
ErrCodeValidation = "VALIDATION_ERROR"
|
|
ErrCodeUnauthorized = "UNAUTHORIZED"
|
|
ErrCodeForbidden = "FORBIDDEN"
|
|
ErrCodeNotFound = "NOT_FOUND"
|
|
ErrCodeConflict = "CONFLICT"
|
|
ErrCodeInternal = "INTERNAL_ERROR"
|
|
ErrCodeBadRequest = "BAD_REQUEST"
|
|
ErrCodePaymentFailed = "PAYMENT_FAILED"
|
|
)
|
|
```
|
|
|
|
### Error Handling Pattern (API Handlers)
|
|
|
|
```go
|
|
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
|
// Extract ID from URL
|
|
productID := chi.URLParam(r, "id")
|
|
if productID == "" {
|
|
utils.RespondError(w, http.StatusBadRequest, ErrCodeBadRequest, "product ID required", nil)
|
|
return
|
|
}
|
|
|
|
// Parse UUID
|
|
id, err := uuid.Parse(productID)
|
|
if err != nil {
|
|
utils.RespondError(w, http.StatusBadRequest, ErrCodeValidation, "invalid product ID", nil)
|
|
return
|
|
}
|
|
|
|
// Get product from database
|
|
product, err := h.productService.GetProduct(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
utils.RespondError(w, http.StatusNotFound, ErrCodeNotFound, "product not found", nil)
|
|
return
|
|
}
|
|
// Log the actual error internally
|
|
log.Printf("failed to get product: %v", err)
|
|
utils.RespondError(w, http.StatusInternalServerError, ErrCodeInternal, "internal server error", nil)
|
|
return
|
|
}
|
|
|
|
// Success response
|
|
utils.RespondSuccess(w, http.StatusOK, product)
|
|
}
|
|
```
|
|
|
|
### Error Handling in TUI
|
|
|
|
For the TUI, errors should be displayed to the user in a friendly way:
|
|
|
|
```go
|
|
// In model.go Update() function
|
|
switch msg := msg.(type) {
|
|
case error:
|
|
m.errorMessage = api.GetErrorMessage(msg)
|
|
m.showError = true
|
|
return m, nil
|
|
}
|
|
|
|
// In View() function
|
|
func (m model) View() string {
|
|
if m.showError {
|
|
errorBox := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FF0000")).
|
|
Bold(true).
|
|
Render("Error: " + m.errorMessage)
|
|
return errorBox + "\n" + m.normalView()
|
|
}
|
|
return m.normalView()
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Standards
|
|
|
|
### Testing Strategy
|
|
|
|
1. **Unit tests** for business logic and utilities
|
|
2. **Integration tests** for API endpoints with database
|
|
3. **No tests for TUI initially** (focus on API)
|
|
|
|
### Test File Organization
|
|
|
|
- Test files live next to the code they test
|
|
- Name: `<file>_test.go`
|
|
- Use table-driven tests for multiple cases
|
|
|
|
### Example Test Structure
|
|
|
|
```go
|
|
// api/handlers/product_test.go
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestProductHandler_GetProduct(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
productID string
|
|
setupMock func(*mockDB)
|
|
expectedStatus int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "success",
|
|
productID: "valid-uuid",
|
|
setupMock: func(m *mockDB) { /* setup */ },
|
|
expectedStatus: 200,
|
|
},
|
|
{
|
|
name: "not found",
|
|
productID: "nonexistent-uuid",
|
|
setupMock: func(m *mockDB) { /* setup */ },
|
|
expectedStatus: 404,
|
|
expectedError: "NOT_FOUND",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Test implementation
|
|
// Use require for setup assertions
|
|
// Use assert for test assertions
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### Testing Libraries
|
|
|
|
- `testing` - Standard library
|
|
- `github.com/stretchr/testify` - Assertions and mocking
|
|
- Test database: Use in-memory SQLite or Docker PostgreSQL
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Pattern 1: Service Layer
|
|
|
|
Separate business logic from HTTP handlers:
|
|
|
|
```go
|
|
// pkg/services/user_service.go
|
|
type UserService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewUserService(db *gorm.DB) *UserService {
|
|
return &UserService{db: db}
|
|
}
|
|
|
|
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*models.User, error) {
|
|
user := &models.User{
|
|
Name: name,
|
|
Email: email,
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// api/handlers/user_handler.go
|
|
type UserHandler struct {
|
|
userService *services.UserService
|
|
}
|
|
|
|
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|
// Parse request
|
|
var req CreateUserRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
utils.RespondError(w, 400, ErrCodeBadRequest, "invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
// Call service
|
|
user, err := h.userService.CreateUser(r.Context(), req.Name, req.Email)
|
|
if err != nil {
|
|
// Handle error
|
|
return
|
|
}
|
|
|
|
utils.RespondSuccess(w, 201, user)
|
|
}
|
|
```
|
|
|
|
### Pattern 2: Middleware for Auth
|
|
|
|
```go
|
|
// api/middleware/auth.go
|
|
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Extract token from Authorization header
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
utils.RespondError(w, 401, ErrCodeUnauthorized, "missing auth token", nil)
|
|
return
|
|
}
|
|
|
|
// Validate token
|
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
|
return []byte(jwtSecret), nil
|
|
})
|
|
|
|
if err != nil || !token.Valid {
|
|
utils.RespondError(w, 401, ErrCodeUnauthorized, "invalid token", nil)
|
|
return
|
|
}
|
|
|
|
// Extract user ID from claims
|
|
claims := token.Claims.(jwt.MapClaims)
|
|
userID := claims["user_id"].(string)
|
|
|
|
// Add to context
|
|
ctx := context.WithValue(r.Context(), "user_id", userID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// Usage in routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.AuthMiddleware(jwtSecret))
|
|
r.Get("/api/v1/cart", handlers.GetCart)
|
|
r.Post("/api/v1/cart/items", handlers.AddCartItem)
|
|
})
|
|
```
|
|
|
|
### Pattern 3: Repository Pattern (Optional)
|
|
|
|
For more complex queries, separate database access:
|
|
|
|
```go
|
|
// pkg/repositories/product_repository.go
|
|
type ProductRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (r *ProductRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Product, error) {
|
|
var product models.Product
|
|
err := r.db.WithContext(ctx).Where("id = ? AND active = ?", id, true).First(&product).Error
|
|
return &product, err
|
|
}
|
|
|
|
func (r *ProductRepository) List(ctx context.Context, page, limit int) ([]models.Product, int64, error) {
|
|
var products []models.Product
|
|
var total int64
|
|
|
|
offset := (page - 1) * limit
|
|
|
|
r.db.Model(&models.Product{}).Where("active = ?", true).Count(&total)
|
|
err := r.db.WithContext(ctx).
|
|
Where("active = ?", true).
|
|
Limit(limit).
|
|
Offset(offset).
|
|
Find(&products).Error
|
|
|
|
return products, total, err
|
|
}
|
|
```
|
|
|
|
### Pattern 4: Bubbletea View Switching
|
|
|
|
```go
|
|
// In model.go
|
|
type page int
|
|
|
|
const (
|
|
shopPage page = iota
|
|
cartPage
|
|
accountPage
|
|
paymentPage
|
|
)
|
|
|
|
type model struct {
|
|
currentPage page
|
|
// ... other fields
|
|
}
|
|
|
|
func (m model) SwitchToPage(p page) (model, tea.Cmd) {
|
|
m.currentPage = p
|
|
// Reset page-specific state
|
|
switch p {
|
|
case shopPage:
|
|
m.cursor = 0
|
|
case cartPage:
|
|
m.cartCursor = 0
|
|
// ...
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) View() string {
|
|
header := m.buildHeader()
|
|
footer := m.buildFooter()
|
|
|
|
var content string
|
|
switch m.currentPage {
|
|
case shopPage:
|
|
content = m.buildShopView()
|
|
case cartPage:
|
|
content = m.buildCartView()
|
|
case accountPage:
|
|
content = m.buildAccountView()
|
|
default:
|
|
content = "Unknown page"
|
|
}
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Configuration Management
|
|
|
|
### Environment Variables
|
|
|
|
Use a `.env` file for local development:
|
|
|
|
```bash
|
|
# .env (DO NOT COMMIT)
|
|
DATABASE_URL=postgresql://user:password@localhost:5432/terminalshop
|
|
JWT_SECRET=your-super-secret-jwt-key-change-this
|
|
STRIPE_SECRET_KEY=sk_test_...
|
|
STRIPE_PUBLIC_KEY=pk_test_...
|
|
API_PORT=8080
|
|
SSH_PORT=2222
|
|
ENVIRONMENT=development
|
|
```
|
|
|
|
### Config Package
|
|
|
|
```go
|
|
// config/config.go
|
|
package config
|
|
|
|
import (
|
|
"os"
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
type Config struct {
|
|
DatabaseURL string
|
|
JWTSecret string
|
|
StripeSecretKey string
|
|
StripePublicKey string
|
|
APIPort string
|
|
SSHPort string
|
|
Environment string
|
|
}
|
|
|
|
func Load() (*Config, error) {
|
|
// Load .env file in development
|
|
if os.Getenv("ENVIRONMENT") != "production" {
|
|
godotenv.Load()
|
|
}
|
|
|
|
return &Config{
|
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
|
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
|
APIPort: getEnvOrDefault("API_PORT", "8080"),
|
|
SSHPort: getEnvOrDefault("SSH_PORT", "2222"),
|
|
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
|
}, nil
|
|
}
|
|
|
|
func getEnvOrDefault(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Best Practices
|
|
|
|
1. **Never commit secrets** - Use `.env` and add to `.gitignore`
|
|
2. **Hash passwords** - Use `bcrypt` with cost >= 12
|
|
3. **Validate all inputs** - Never trust user input
|
|
4. **Use parameterized queries** - GORM does this automatically
|
|
5. **HTTPS in production** - Always use TLS
|
|
6. **Rate limiting** - Add to API endpoints
|
|
7. **CORS configuration** - Only allow trusted origins
|
|
|
|
---
|
|
|
|
## Reference Implementation
|
|
|
|
The `terminal-shop-source/` directory contains the complete terminal.shop codebase. When in doubt:
|
|
|
|
1. **Check similar functionality** in their code
|
|
2. **Copy patterns, not code verbatim** - adapt to our stack
|
|
3. **Understand before implementing** - don't blindly copy
|
|
|
|
Key files to reference:
|
|
- `packages/go/pkg/tui/*.go` - TUI views and components
|
|
- `packages/go/pkg/api/api.go` - API client integration
|
|
- `packages/core/src/` - Backend business logic (TypeScript, but concepts apply)
|
|
|
|
---
|
|
|
|
## Quick Reference Checklist
|
|
|
|
Before writing new code, ask:
|
|
- [ ] Does this follow the file organization structure?
|
|
- [ ] Are imports organized correctly?
|
|
- [ ] Are errors wrapped with context?
|
|
- [ ] Are all errors handled?
|
|
- [ ] Does this need a test?
|
|
- [ ] Are naming conventions followed?
|
|
- [ ] Is the API response in the standard format?
|
|
- [ ] Are database queries using context?
|
|
- [ ] Is sensitive data in environment variables?
|
|
- [ ] Does this match patterns from terminal.shop reference code?
|
|
|
|
---
|
|
|
|
**Remember**: Consistency is more important than perfection. Follow these patterns throughout the codebase, and reference this document frequently!
|