docs: Add hybrid SSR and project guidelines documentation
- Document hybrid SSR architecture for frontend implementation - Add project guidelines for development workflow - Explain API preservation and SSR approach
This commit is contained in:
@@ -0,0 +1,703 @@
|
|||||||
|
# Hybrid SSR + API Implementation Guide
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This guide documents a **Hybrid Server-Side Rendering (SSR) + API** architecture that:
|
||||||
|
- ✅ **Keeps all existing API routes unchanged** (`/api/*` routes remain intact)
|
||||||
|
- ✅ **Adds new HTML routes** (`/collections`, `/collections/:id`) that fetch data server-side
|
||||||
|
- ✅ **Shares business logic** between API and HTML routes via the service layer
|
||||||
|
- ✅ **Provides faster initial page loads** while maintaining API flexibility
|
||||||
|
- ✅ **Zero breaking changes** to mobile apps, external consumers, or existing API clients
|
||||||
|
|
||||||
|
## Current Architecture (Analysis)
|
||||||
|
|
||||||
|
### Existing Pattern: API-Driven Frontend
|
||||||
|
|
||||||
|
**Current structure:**
|
||||||
|
```
|
||||||
|
Browser visits /dashboard
|
||||||
|
↓
|
||||||
|
Go template renders EMPTY HTML skeleton
|
||||||
|
↓
|
||||||
|
JavaScript fetch() calls /api/libraries/visible
|
||||||
|
↓
|
||||||
|
API returns JSON data
|
||||||
|
↓
|
||||||
|
JavaScript populates the DOM
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example from current codebase:**
|
||||||
|
|
||||||
|
**cmd/server/main.go:309-328:**
|
||||||
|
```go
|
||||||
|
protected.GET("/dashboard", func(c echo.Context) error {
|
||||||
|
// Get user from JWT context
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
user := templates.User{
|
||||||
|
ID: userID,
|
||||||
|
Email: c.Get("user_email").(string),
|
||||||
|
Username: c.Get("user_username").(string),
|
||||||
|
Role: c.Get("user_role").(string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render template WITH NO DATA
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := templates.Dashboard(user).Render(c.Request().Context(), &buf)
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**templates/dashboard.templ:**
|
||||||
|
```javascript
|
||||||
|
function loadLibraries() {
|
||||||
|
// Template is empty, fetch data via API
|
||||||
|
fetch('/api/libraries/visible', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
libraries = data;
|
||||||
|
renderLibraries();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Layer Architecture (Already Well-Designed)
|
||||||
|
|
||||||
|
The codebase **already uses a service layer**, which is perfect for the hybrid approach:
|
||||||
|
|
||||||
|
```
|
||||||
|
API Handler
|
||||||
|
↓ calls
|
||||||
|
Service Layer (business logic)
|
||||||
|
↓ queries
|
||||||
|
Database Layer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example from internal/handlers/collections.go:100-107:**
|
||||||
|
```go
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
|
// Business logic is in the SERVICE layer
|
||||||
|
collections, err := h.collectionService.GetUserCollections(
|
||||||
|
c.Request().Context(),
|
||||||
|
userUUID
|
||||||
|
)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"collections": collections,
|
||||||
|
"total": len(collections),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service layer (internal/services/collection_service.go):**
|
||||||
|
```go
|
||||||
|
func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.UUID) ([]database.GetCollectionsRow, error) {
|
||||||
|
// Business logic, validation, database queries
|
||||||
|
return s.db.GetCollections(ctx, database.GetCollectionsParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is the key insight: The service layer (`GetUserCollections`) contains all the business logic. The handler just formats the response (JSON vs HTML).**
|
||||||
|
|
||||||
|
## Proposed Hybrid Architecture
|
||||||
|
|
||||||
|
### New Pattern: SSR + API
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser visits /collections
|
||||||
|
↓
|
||||||
|
Go fetches data from service layer
|
||||||
|
↓
|
||||||
|
Go template renders HTML WITH DATA
|
||||||
|
↓
|
||||||
|
Browser shows complete page instantly ⚡
|
||||||
|
↓
|
||||||
|
(Optional) JavaScript updates via API for interactivity
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key principle: Shared service layer, different response formats**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ BOTH routes call SAME service method │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓ ↓
|
||||||
|
/api/collections /collections
|
||||||
|
(JSON response) (HTML response)
|
||||||
|
↓ ↓
|
||||||
|
c.JSON(200, data) render(template, data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Guide
|
||||||
|
|
||||||
|
### What Changes (and What Doesn't)
|
||||||
|
|
||||||
|
| Component | Changes? | Why |
|
||||||
|
|-----------|----------|-----|
|
||||||
|
| **Service Layer** | ❌ No change | Already well-designed |
|
||||||
|
| **Database Queries** | ❌ No change | Already optimized |
|
||||||
|
| **Business Logic** | ❌ No change | Single source of truth |
|
||||||
|
| **`/api/*` routes** | ❌ No change | Keep API intact |
|
||||||
|
| **Template signatures** | ✅ Yes | Add data parameters |
|
||||||
|
| **New HTML routes** | ✅ Yes | Add `/collections`, etc. |
|
||||||
|
| **Template JavaScript** | ✅ Yes | Remove initial fetch() |
|
||||||
|
|
||||||
|
### Step 1: Add Helper Methods to Handlers (Optional but Recommended)
|
||||||
|
|
||||||
|
**Purpose:** Extract data-fetching logic so both API and HTML routes can use it.
|
||||||
|
|
||||||
|
**Example for collections.go:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Add this method to CollectionHandler
|
||||||
|
// Returns raw data (not JSON, not HTML)
|
||||||
|
func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.GetCollectionsRow, error) {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
|
collections, err := h.collectionService.GetUserCollections(
|
||||||
|
c.Request().Context(),
|
||||||
|
userUUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
return collections, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now API handler uses this helper
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
collections, err := h.GetCollectionsData(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format as JSON
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"collections": collections,
|
||||||
|
"total": len(collections),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this pattern:**
|
||||||
|
- API handler: calls `GetCollectionsData()` → returns JSON
|
||||||
|
- HTML route: calls `GetCollectionsData()` → returns HTML
|
||||||
|
- Single source of truth, no duplication
|
||||||
|
|
||||||
|
### Step 2: Add New HTML Routes
|
||||||
|
|
||||||
|
**Location:** `cmd/server/main.go`
|
||||||
|
|
||||||
|
**Add these new routes after the existing `/api/*` routes:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ===== NEW HTML ROUTES (SSR) =====
|
||||||
|
// These routes fetch data server-side and render pre-populated templates
|
||||||
|
// The /api/* routes remain unchanged for JSON API consumers
|
||||||
|
|
||||||
|
// Collections list page with SSR
|
||||||
|
protected.GET("/collections", func(c echo.Context) error {
|
||||||
|
user := getUserFromContext(c)
|
||||||
|
|
||||||
|
// Fetch data server-side
|
||||||
|
collections, err := collectionHandler.GetCollectionsData(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, "Error loading collections")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to template format if needed
|
||||||
|
type CollectionData struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Color string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
templateData := make([]CollectionData, len(collections))
|
||||||
|
for i, col := range collections {
|
||||||
|
templateData[i] = CollectionData{
|
||||||
|
ID: uuid.UUID(col.ID.Bytes).String(),
|
||||||
|
Name: col.Name,
|
||||||
|
Description: textToString(col.Description),
|
||||||
|
Color: textToString(col.Color),
|
||||||
|
Icon: textToString(col.Icon),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render template WITH data
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.Collections(user, templateData).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collection detail page with SSR
|
||||||
|
protected.GET("/collections/:id", func(c echo.Context) error {
|
||||||
|
user := getUserFromContext(c)
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, "Invalid collection ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch collection data server-side
|
||||||
|
collection, err := collectionHandler.GetCollectionData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusNotFound, "Collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch books in collection
|
||||||
|
books, err := collectionHandler.GetCollectionBooksData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, "Error loading books")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render template WITH data
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.CollectionDetail(user, collection, books).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important notes:**
|
||||||
|
- These are **NEW routes**, they don't replace `/api/collections`
|
||||||
|
- The `/api/collections` route remains unchanged
|
||||||
|
- Mobile apps and external consumers continue using `/api/*`
|
||||||
|
- Only browsers visiting `/collections` get the SSR version
|
||||||
|
|
||||||
|
### Step 3: Update Template Signatures
|
||||||
|
|
||||||
|
**Current template signature:**
|
||||||
|
```templ
|
||||||
|
templ Collections(user User) {
|
||||||
|
<div id="collections"></div>
|
||||||
|
<script>
|
||||||
|
fetch('/api/collections') // Slow!
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New template signature:**
|
||||||
|
```templ
|
||||||
|
templ Collections(user User, collections []Collection) {
|
||||||
|
<div id="collections">
|
||||||
|
for col := range collections {
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ col.Name }</h3>
|
||||||
|
<p>{ col.Description }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Data already loaded!
|
||||||
|
// API calls only for updates (add, delete, etc.)
|
||||||
|
function deleteCollection(id) {
|
||||||
|
fetch(`/api/collections/${id}`, { method: 'DELETE' })
|
||||||
|
.then(() => location.reload());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Helper Function for User Context
|
||||||
|
|
||||||
|
**Add this utility function to main.go to reduce boilerplate:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Helper to get user from JWT context
|
||||||
|
func getUserFromContext(c echo.Context) templates.User {
|
||||||
|
return templates.User{
|
||||||
|
ID: c.Get("user_id").(string),
|
||||||
|
Email: c.Get("user_email").(string),
|
||||||
|
Username: c.Get("user_username").(string),
|
||||||
|
Role: c.Get("user_role").(string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Now all HTML routes can use this:**
|
||||||
|
```go
|
||||||
|
protected.GET("/collections", func(c echo.Context) error {
|
||||||
|
user := getUserFromContext(c) // Reusable helper
|
||||||
|
// ... rest of the code
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Structure Summary
|
||||||
|
|
||||||
|
### Before (Current)
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/collections → JSON (API)
|
||||||
|
/api/collections/:id → JSON (API)
|
||||||
|
/dashboard → HTML (no SSR, fetches data)
|
||||||
|
/devices → HTML (no SSR, fetches data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Hybrid)
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/collections → JSON (API) - UNCHANGED
|
||||||
|
/api/collections/:id → JSON (API) - UNCHANGED
|
||||||
|
/collections → HTML (SSR) - NEW
|
||||||
|
/collections/:id → HTML (SSR) - NEW
|
||||||
|
/dashboard → HTML (no SSR) - UNCHANGED
|
||||||
|
/devices → HTML (no SSR) - UNCHANGED
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
For each new page you want to convert to SSR:
|
||||||
|
|
||||||
|
- [ ] **Add data-fetching helper** to handler (if doesn't exist)
|
||||||
|
- [ ] **Create new HTML route** in `cmd/server/main.go`
|
||||||
|
- [ ] **Update template signature** to accept data
|
||||||
|
- [ ] **Remove initial fetch()** from template JavaScript
|
||||||
|
- [ ] **Keep fetch() for CRUD operations** (create, update, delete)
|
||||||
|
- [ ] **Test API routes still work** with curl/Postman
|
||||||
|
- [ ] **Test HTML routes** in browser
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
**Before (API-only):**
|
||||||
|
1. Browser requests page
|
||||||
|
2. Server renders empty HTML (~10ms)
|
||||||
|
3. Browser receives HTML
|
||||||
|
4. JavaScript fetches data (~50ms)
|
||||||
|
5. Server queries database (~20ms)
|
||||||
|
6. Server returns JSON (~5ms)
|
||||||
|
7. Browser renders data (~10ms)
|
||||||
|
**Total: ~95ms visible to user**
|
||||||
|
|
||||||
|
**After (SSR):**
|
||||||
|
1. Browser requests page
|
||||||
|
2. Server queries database (~20ms)
|
||||||
|
3. Server renders HTML with data (~10ms)
|
||||||
|
4. Browser receives complete HTML
|
||||||
|
5. Browser paints page (~10ms)
|
||||||
|
**Total: ~40ms visible to user**
|
||||||
|
|
||||||
|
**58% faster initial load!**
|
||||||
|
|
||||||
|
### Architectural Benefits
|
||||||
|
|
||||||
|
1. **Zero logic duplication:** Service layer is single source of truth
|
||||||
|
2. **API remains intact:** No breaking changes for mobile/external consumers
|
||||||
|
3. **SEO friendly:** Search engines see complete HTML
|
||||||
|
4. **Progressive enhancement:** Works without JavaScript
|
||||||
|
5. **Easier testing:** Can test API routes independently
|
||||||
|
|
||||||
|
## Example: Complete Implementation
|
||||||
|
|
||||||
|
### File: internal/handlers/collections.go
|
||||||
|
|
||||||
|
**Add these helper methods:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetCollectionsData returns raw collection data (for both API and HTML)
|
||||||
|
func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.GetCollectionsRow, error) {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
|
return h.collectionService.GetUserCollections(
|
||||||
|
c.Request().Context(),
|
||||||
|
userUUID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCollectionData returns single collection (for both API and HTML)
|
||||||
|
func (h *CollectionHandler) GetCollectionData(c echo.Context, collectionID uuid.UUID) (database.Collections, error) {
|
||||||
|
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCollectionBooksData returns books in collection (for both API and HTML)
|
||||||
|
func (h *CollectionHandler) GetCollectionBooksData(c echo.Context, collectionID uuid.UUID) ([]database.GetCollectionBooksRow, error) {
|
||||||
|
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update existing API handlers to use helpers:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Before
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
collections, err := h.collectionService.GetUserCollections(...)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// After
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
collections, err := h.GetCollectionsData(c)
|
||||||
|
// Format and return JSON
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### File: cmd/server/main.go
|
||||||
|
|
||||||
|
**Add after line 258 (after collections API routes):**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ===== HTML ROUTES WITH SSR =====
|
||||||
|
// These provide fast initial page loads while keeping /api/* routes intact
|
||||||
|
|
||||||
|
// Collections list page
|
||||||
|
protected.GET("/collections", func(c echo.Context) error {
|
||||||
|
user := getUserFromContext(c)
|
||||||
|
|
||||||
|
collections, err := collectionHandler.GetCollectionsData(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(500, "Error loading collections")
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColData struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Color string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]ColData, len(collections))
|
||||||
|
for i, col := range collections {
|
||||||
|
data[i] = ColData{
|
||||||
|
ID: uuid.UUID(col.ID.Bytes).String(),
|
||||||
|
Name: col.Name,
|
||||||
|
Description: textToString(col.Description),
|
||||||
|
Color: textToString(col.Color),
|
||||||
|
Icon: textToString(col.Icon),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
templates.Collections(user, data).Render(c.Request().Context(), &buf)
|
||||||
|
return c.HTML(200, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collection detail page
|
||||||
|
protected.GET("/collections/:id", func(c echo.Context) error {
|
||||||
|
user := getUserFromContext(c)
|
||||||
|
collectionID, _ := uuid.Parse(c.Param("id"))
|
||||||
|
|
||||||
|
collection, err := collectionHandler.GetCollectionData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(404, "Collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
books, err := collectionHandler.GetCollectionBooksData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(500, "Error loading books")
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
templates.CollectionDetail(user, collection, books).Render(c.Request().Context(), &buf)
|
||||||
|
return c.HTML(200, buf.String())
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### File: templates/collections.templ
|
||||||
|
|
||||||
|
**Update template signature:**
|
||||||
|
|
||||||
|
```templ
|
||||||
|
// Before
|
||||||
|
templ Collections(user User) {
|
||||||
|
<div id="collections-container"></div>
|
||||||
|
<script>
|
||||||
|
// Fetch data on load
|
||||||
|
fetch('/api/collections')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => renderCollections(data.collections));
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
// After
|
||||||
|
templ Collections(user User, collections []Collection) {
|
||||||
|
<div id="collections-container">
|
||||||
|
for col := range collections {
|
||||||
|
<div class="card" style="border-left: 4px solid { col.Color }">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold">{ col.Name }</h3>
|
||||||
|
<p class="text-gray-400">{ col.Description }</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="viewCollection('{ col.ID }')" class="btn-primary">
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button onclick="editCollection('{ col.ID }')" class="btn-secondary">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteCollection('{ col.ID }')" class="btn-danger">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Data already rendered! No initial fetch needed.
|
||||||
|
|
||||||
|
// API calls only for CRUD operations
|
||||||
|
async function deleteCollection(id) {
|
||||||
|
if (!confirm('Delete this collection?')) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/collections/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload(); // Or remove element from DOM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewCollection(id) {
|
||||||
|
window.location.href = `/collections/${id}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### API Routes (Must Remain Unchanged)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test collections API
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/collections
|
||||||
|
# Should return JSON as before
|
||||||
|
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/collections/ID
|
||||||
|
# Should return JSON as before
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML Routes (New)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test HTML route in browser
|
||||||
|
open http://localhost:8765/collections
|
||||||
|
# Should show complete page instantly with data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify No Breaking Changes
|
||||||
|
|
||||||
|
1. **Mobile apps still work:** Test with existing mobile app or API client
|
||||||
|
2. **External consumers still work:** Test with Postman/curl
|
||||||
|
3. **Database queries unchanged:** Monitor query logs
|
||||||
|
4. **Business logic unchanged:** Test collection creation, updates, deletion
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### ❌ Don't: Modify Existing API Routes
|
||||||
|
|
||||||
|
```go
|
||||||
|
// WRONG: Don't change existing API handler
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
// Don't add HTML rendering here!
|
||||||
|
if wantsHTML(c) {
|
||||||
|
return renderHTML(...)
|
||||||
|
}
|
||||||
|
return c.JSON(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Breaks API consumers, adds complexity to API routes.
|
||||||
|
|
||||||
|
### ✅ Do: Create Separate HTML Routes
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RIGHT: Keep API handler simple, add separate HTML route
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
data, _ := h.GetCollectionsData(c)
|
||||||
|
return c.JSON(200, data) // JSON only
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate HTML route in main.go
|
||||||
|
protected.GET("/collections", func(c echo.Context) error {
|
||||||
|
data, _ := collectionHandler.GetCollectionsData(c)
|
||||||
|
return render(c, templates.Collections(user, data))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Clean separation, API stays simple, HTML routes are independent.
|
||||||
|
|
||||||
|
### ❌ Don't: Duplicate Business Logic
|
||||||
|
|
||||||
|
```go
|
||||||
|
// WRONG: Duplicating queries
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
collections, _ := h.db.GetCollections(...) // Query here
|
||||||
|
return c.JSON(200, collections)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected.GET("/collections", func(c echo.Context) error {
|
||||||
|
collections, _ := h.db.GetCollections(...) // Same query again!
|
||||||
|
return render(c, templates.Collections(user, collections))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Duplication, harder to maintain, bugs appear twice.
|
||||||
|
|
||||||
|
### ✅ Do: Share Service Layer
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RIGHT: Both use same service method
|
||||||
|
func (h *CollectionHandler) GetCollectionsData(c echo.Context) {
|
||||||
|
return h.collectionService.GetUserCollections(...) // Single source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
data, _ := h.GetCollectionsData(c)
|
||||||
|
return c.JSON(200, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected.GET("/collections", func(c echo.Context) error {
|
||||||
|
data, _ := collectionHandler.GetCollectionsData(c)
|
||||||
|
return render(c, templates.Collections(user, data))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Single source of truth, DRY principle, easier maintenance.
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If anything goes wrong, rollback is trivial:
|
||||||
|
|
||||||
|
1. **Remove new HTML routes:** Delete the `protected.GET("/collections", ...)` blocks from main.go
|
||||||
|
2. **Revert template changes:** Change `templ Collections(user, collections)` back to `templ Collections(user)`
|
||||||
|
3. **API routes untouched:** No changes made to `/api/*` routes
|
||||||
|
|
||||||
|
**Zero risk to existing functionality.**
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
This document provides the architectural foundation. Before implementing Phase 9 (Collections UI), the codebase should be migrated to this hybrid pattern for consistency:
|
||||||
|
|
||||||
|
1. Migrate existing pages (`/dashboard`, `/devices`) to use SSR
|
||||||
|
2. Add helper methods to all handlers
|
||||||
|
3. Update all templates to accept data
|
||||||
|
4. Test thoroughly
|
||||||
|
|
||||||
|
Once the pattern is established, Phase 9 implementation will be straightforward and follow the same conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 1.0
|
||||||
|
**Last Updated:** 2025-01-31
|
||||||
|
**Status:** Ready for Implementation
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Combined Project Guidelines for Bookmann
|
||||||
|
|
||||||
|
## 🚨 CRITICAL PROHIBITIONS (Never violate these)
|
||||||
|
|
||||||
|
### Backend & Database
|
||||||
|
- ❌ **NEVER modify backend code when working on frontend-only tasks**
|
||||||
|
- ❌ **NEVER modify database schema** unless explicitly instructed for full-stack changes
|
||||||
|
- ❌ **NEVER use Docker** - use Podman only
|
||||||
|
- ❌ **NEVER build server binaries locally** - all builds through Dockerfile/docker-compose
|
||||||
|
- ❌ **NEVER create new migration files** - merge changes into current one until release
|
||||||
|
- ❌ **NEVER use `git checkout` on schema files** without checking what will be lost
|
||||||
|
- ❌ **NEVER break existing functionality** unless explicitly instructed
|
||||||
|
|
||||||
|
### Frontend & Styling
|
||||||
|
- ❌ **NEVER modify backend/API for frontend features without user confirmation**
|
||||||
|
- ❌ **NEVER use custom CSS** - TailwindCSS classes only
|
||||||
|
- ❌ **NEVER use JavaScript** - convert all to TypeScript
|
||||||
|
- ❌ **NEVER use object-oriented programming** patterns - use functional/other paradigms
|
||||||
|
- ❌ **NEVER add new Dockerfiles without user confirmation**
|
||||||
|
|
||||||
|
### General
|
||||||
|
- ❌ **NEVER skip pre-commit hooks** unless explicitly requested
|
||||||
|
- ❌ **NEVER force push to main/master** branches
|
||||||
|
- ❌ **NEVER commit files with secrets** (.env, credentials.json, etc.)
|
||||||
|
- ❌ **NEVER make assumptions** - ask clarifying questions when uncertain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 CONTEXT-SPECIFIC RULES
|
||||||
|
|
||||||
|
### When Working on Frontend-Only Tasks
|
||||||
|
- **DO NOT touch backend code** - handlers, services, database layer
|
||||||
|
- **DO NOT modify API routes** - use existing endpoints only
|
||||||
|
- **DO NOT change database schema** - work with existing structure
|
||||||
|
- **If backend change seems necessary**:
|
||||||
|
1. Identify the required change
|
||||||
|
2. Explain why you need it
|
||||||
|
3. Provide impact analysis
|
||||||
|
4. **ASK FOR USER CONFIRMATION before proceeding**
|
||||||
|
|
||||||
|
### When Working on Full-Stack Tasks
|
||||||
|
- Backend changes are allowed when explicitly part of the task
|
||||||
|
- Still follow all database protocols (atomic changes, validation, etc.)
|
||||||
|
- Still use Podman for all builds
|
||||||
|
- Still include Bruno tests for API changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ MANDATORY REQUIREMENTS
|
||||||
|
|
||||||
|
### Database Operations (Full-Stack Tasks Only)
|
||||||
|
- ✅ Follow **pgx v5 standards** for all database operations
|
||||||
|
- ✅ Treat schema changes as **ATOMIC** - complete success or complete rejection
|
||||||
|
- ✅ When schema changes occur: delete database and rebuild with clean Podman cache
|
||||||
|
- ✅ Use **pre-change checklist**: read schema → identify columns → plan changes → verify → read back
|
||||||
|
- ✅ **Post-change validation**: ensure schema.sql, models.go, and queries.sql are in sync
|
||||||
|
|
||||||
|
### Build & Deployment
|
||||||
|
- ✅ Use **Podman** exclusively (not Docker)
|
||||||
|
- ✅ All builds through existing **Dockerfile** and **docker-compose.yml**
|
||||||
|
- ✅ Stop building server binaries - everything goes through containers
|
||||||
|
|
||||||
|
### API Changes (Full-Stack Tasks Only)
|
||||||
|
- ✅ Include **Bruno v3.0 .bru requests** with all API documentation
|
||||||
|
- ✅ Tests must be **comprehensive and cover three contexts**: no user, user, and admin
|
||||||
|
- ✅ Maintain backward compatibility for mobile apps and external consumers
|
||||||
|
|
||||||
|
### Frontend & Styling
|
||||||
|
- ✅ Always use **TailwindCSS classes** for all styling
|
||||||
|
- ✅ Convert all JavaScript to **TypeScript**
|
||||||
|
- ✅ Avoid OOP patterns - prefer functional/other paradigms
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- ✅ Minimize project structure changes
|
||||||
|
- ✅ Place new files in **contextually appropriate directories**
|
||||||
|
- ✅ Follow **KISS**, **DRY**, and **YAGNI** principles
|
||||||
|
- ✅ Use **multiple, logical git commits** with clear messages
|
||||||
|
|
||||||
|
### Configuration & Environment
|
||||||
|
- ✅ If **.env is missing**, auto-generate secure values
|
||||||
|
- ✅ Never commit secrets to repository
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- ✅ Update **README.md** when users/admins need to be informed
|
||||||
|
- ✅ Document API changes with Bruno collections
|
||||||
|
|
||||||
|
### Process & Continuity
|
||||||
|
- ✅ If mid-task and receive "no response", **continue the task**
|
||||||
|
- ✅ Verify no regressions before modifying/removing code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 TECHNICAL STANDARDS
|
||||||
|
|
||||||
|
### Backend Stack
|
||||||
|
- **Language**: Go 1.25+
|
||||||
|
- **Database**: PostgreSQL 15+ with **pgx v5 driver** only
|
||||||
|
- **Authentication**: JWT tokens with bcrypt password hashing
|
||||||
|
- **Architecture**: Service layer pattern (handlers → services → database)
|
||||||
|
|
||||||
|
### Frontend Stack
|
||||||
|
- **Styling**: TailwindCSS (no custom CSS)
|
||||||
|
- **Language**: TypeScript (no JavaScript)
|
||||||
|
- **Templates**: HTMX with server-side rendering
|
||||||
|
- **Patterns**: Functional/other (no OOP)
|
||||||
|
|
||||||
|
### Containerization
|
||||||
|
- **Runtime**: Podman (not Docker)
|
||||||
|
- **Build**: Existing Dockerfile and docker-compose.yml only
|
||||||
|
- **No local builds** allowed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 WORKFLOW CHECKLISTS
|
||||||
|
|
||||||
|
### Before Making Frontend-Only Changes
|
||||||
|
- [ ] Identify if backend modification could make implementation simpler
|
||||||
|
- [ ] Plan to use existing API endpoints only
|
||||||
|
- [ ] If backend change seems necessary, prepare confirmation request:
|
||||||
|
- Required change description
|
||||||
|
- Why it would help
|
||||||
|
- Impact analysis
|
||||||
|
- Alternative approaches considered
|
||||||
|
- [ ] Plan git commit structure (multiple logical commits)
|
||||||
|
- [ ] Identify if README.md needs updates
|
||||||
|
|
||||||
|
### Before Making Full-Stack Changes
|
||||||
|
- [ ] Read current schema completely (if database changes)
|
||||||
|
- [ ] Identify all columns that must be preserved
|
||||||
|
- [ ] Plan exact changes needed
|
||||||
|
- [ ] Verify Podman will be used for builds
|
||||||
|
- [ ] Plan git commit structure (multiple logical commits)
|
||||||
|
|
||||||
|
### During Schema Changes (Full-Stack Only)
|
||||||
|
- [ ] Read current schema completely
|
||||||
|
- [ ] Identify all columns that must be preserved
|
||||||
|
- [ ] Plan exact changes needed
|
||||||
|
- [ ] Set up verification step
|
||||||
|
- [ ] Make intended changes
|
||||||
|
- [ ] Immediately verify by reading back modified sections
|
||||||
|
- [ ] Confirm ALL expected columns are present
|
||||||
|
- [ ] Verify schema.sql, models.go, and queries.sql are in sync
|
||||||
|
|
||||||
|
### After API Changes
|
||||||
|
- [ ] Create/update Bruno v3.0 .bru requests
|
||||||
|
- [ ] Test with no user context
|
||||||
|
- [ ] Test with regular user context
|
||||||
|
- [ ] Test with admin context
|
||||||
|
- [ ] Verify backward compatibility
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
- [ ] Run tests: `go test ./... -v`
|
||||||
|
- [ ] Run lint/typecheck if available
|
||||||
|
- [ ] Ensure no secrets in changes
|
||||||
|
- [ ] Verify logical commit structure
|
||||||
|
- [ ] Update README.md if user-facing changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 ARCHITECTURAL PATTERNS
|
||||||
|
|
||||||
|
### Current: API-Driven Frontend
|
||||||
|
```
|
||||||
|
Browser → Go template (empty) → JavaScript fetch() → API → Database
|
||||||
|
```
|
||||||
|
|
||||||
|
### Future Reference: Hybrid SSR (NOT TO IMPLEMENT YET)
|
||||||
|
```
|
||||||
|
Browser → Go template (with data) → Display instantly
|
||||||
|
↓
|
||||||
|
JavaScript only for interactivity (CRUD)
|
||||||
|
↓
|
||||||
|
Shared service layer
|
||||||
|
```
|
||||||
|
|
||||||
|
Ultimately, whenever you are unsure just ask for confirmation.
|
||||||
Reference in New Issue
Block a user