···11-CLAUDE.md11+# Arabica - Project Context for AI Agents
22+33+Coffee brew tracking application using AT Protocol for decentralized storage.
44+55+## Tech Stack
66+77+- **Language:** Go 1.21+
88+- **HTTP:** stdlib `net/http` with Go 1.22 routing
99+- **Storage:** AT Protocol PDS (user data), BoltDB (sessions/feed registry)
1010+- **Frontend:** Svelte SPA with client-side routing
1111+- **Testing:** Standard library testing + [shutter](https://github.com/ptdewey/shutter) for snapshot tests
1212+- **Logging:** zerolog
1313+1414+## Project Structure
1515+1616+```
1717+cmd/arabica-server/main.go # Application entry point
1818+internal/
1919+ atproto/ # AT Protocol integration
2020+ client.go # Authenticated PDS client (XRPC calls)
2121+ oauth.go # OAuth flow with PKCE/DPOP
2222+ store.go # database.Store implementation using PDS
2323+ cache.go # Per-session in-memory cache
2424+ records.go # Model <-> ATProto record conversion
2525+ resolver.go # AT-URI parsing and reference resolution
2626+ public_client.go # Unauthenticated public API access
2727+ nsid.go # Collection NSIDs and AT-URI builders
2828+ handlers/
2929+ handlers.go # HTTP handlers (API endpoints)
3030+ auth.go # OAuth login/logout/callback
3131+ api_snapshot_test.go # Snapshot tests for API responses
3232+ testutil.go # Test helpers and fixtures
3333+ __snapshots__/ # Snapshot files for regression testing
3434+ database/
3535+ store.go # Store interface definition
3636+ store_mock.go # Mock implementation for testing
3737+ boltstore/ # BoltDB implementation for sessions
3838+ feed/
3939+ service.go # Community feed aggregation
4040+ registry.go # User registration for feed
4141+ models/
4242+ models.go # Domain models and request types
4343+ middleware/
4444+ logging.go # Request logging middleware
4545+ routing/
4646+ routing.go # Router setup and middleware chain
4747+frontend/ # Svelte SPA source code
4848+ src/
4949+ routes/ # Page components
5050+ components/ # Reusable components
5151+ stores/ # Svelte stores (auth, cache)
5252+ lib/ # Utilities (router, API client)
5353+ public/ # Built SPA assets
5454+lexicons/ # AT Protocol lexicon definitions (JSON)
5555+static/ # Static assets (CSS, icons, service worker)
5656+ app/ # Built Svelte SPA
5757+```
5858+5959+## Key Concepts
6060+6161+### AT Protocol Integration
6262+6363+User data stored in their Personal Data Server (PDS), not locally. The app:
6464+6565+1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP)
6666+2. Gets access token scoped to user's DID
6767+3. Performs CRUD via XRPC calls to user's PDS
6868+6969+**Collections (NSIDs):**
7070+7171+- `social.arabica.alpha.bean` - Coffee beans
7272+- `social.arabica.alpha.roaster` - Roasters
7373+- `social.arabica.alpha.grinder` - Grinders
7474+- `social.arabica.alpha.brewer` - Brewing devices
7575+- `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer)
7676+7777+**Record keys:** TID format (timestamp-based identifiers)
7878+7979+**References:** Records reference each other via AT-URIs (`at://did/collection/rkey`)
8080+8181+### Store Interface
8282+8383+`internal/database/store.go` defines the `Store` interface. Two implementations:
8484+8585+- `AtprotoStore` - Production, stores in user's PDS
8686+- BoltDB stores only sessions and feed registry (not user data)
8787+8888+All Store methods take `context.Context` as first parameter.
8989+9090+### Request Flow
9191+9292+1. Request hits middleware (logging, auth check)
9393+2. Auth middleware extracts DID + session ID from cookies
9494+3. For SPA routes: Serve index.html (client-side routing)
9595+4. For API routes: Handler creates `AtprotoStore` scoped to user
9696+5. Store methods make XRPC calls to user's PDS
9797+6. Results returned as JSON
9898+9999+### Caching
100100+101101+`SessionCache` caches user data in memory (5-minute TTL):
102102+103103+- Avoids repeated PDS calls for same data
104104+- Invalidated on writes
105105+- Background cleanup removes expired entries
106106+107107+### Backfill Strategy
108108+109109+User records are backfilled from their PDS once per DID:
110110+111111+- **On startup**: Backfills registered users + known-dids file
112112+- **On first login**: Backfills the user's historical records
113113+- **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent redundant fetches
114114+- **Idempotent**: Safe to call multiple times (checks backfill status first)
115115+116116+This prevents excessive PDS requests while ensuring new users' historical data is indexed.
117117+118118+## Common Tasks
119119+120120+### Run Development Server
121121+122122+```bash
123123+# Run server (uses firehose mode by default)
124124+go run cmd/arabica-server/main.go
125125+126126+# Backfill known DIDs on startup
127127+go run cmd/arabica-server/main.go --known-dids known-dids.txt
128128+129129+# Using nix
130130+nix run
131131+```
132132+133133+### Run Tests
134134+135135+```bash
136136+go test ./...
137137+```
138138+139139+#### Snapshot Testing
140140+141141+Backend API responses are tested using snapshot tests with the [shutter](https://github.com/ptdewey/shutter) library. Snapshot tests capture the JSON response format and verify it remains consistent across changes.
142142+143143+**Location:** `internal/handlers/api_snapshot_test.go`
144144+145145+**Covered endpoints:**
146146+- Authentication: `/api/me`, `/client-metadata.json`
147147+- Data fetching: `/api/data`, `/api/feed-json`, `/api/profile-json/{actor}`
148148+- CRUD operations: Create/Update/Delete for beans, roasters, grinders, brewers, brews
149149+150150+**Running snapshot tests:**
151151+```bash
152152+cd internal/handlers && go test -v -run "Snapshot"
153153+```
154154+155155+**Working with snapshots:**
156156+```bash
157157+# Accept all new/changed snapshots
158158+shutter accept-all
159159+160160+# Reject all pending snapshots
161161+shutter reject-all
162162+163163+# Review snapshots interactively
164164+shutter review
165165+```
166166+167167+**Snapshot patterns used:**
168168+- `shutter.ScrubTimestamp()` - Removes timestamp values for deterministic tests
169169+- `shutter.IgnoreKey("created_at")` - Ignores specific JSON keys
170170+- `shutter.IgnoreKey("rkey")` - Ignores AT Protocol record keys (TIDs are time-based)
171171+172172+Snapshots are stored in `internal/handlers/__snapshots__/` and should be committed to version control.
173173+174174+### Build
175175+176176+```bash
177177+go build -o arabica cmd/arabica-server/main.go
178178+```
179179+180180+## Command-Line Flags
181181+182182+| Flag | Type | Default | Description |
183183+| --------------- | ------ | ------- | ----------------------------------------------------- |
184184+| `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) |
185185+| `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) |
186186+187187+**Known DIDs File Format:**
188188+- One DID per line (e.g., `did:plc:abc123xyz`)
189189+- Lines starting with `#` are comments
190190+- Empty lines are ignored
191191+- See `known-dids.txt.example` for reference
192192+193193+## Environment Variables
194194+195195+| Variable | Default | Description |
196196+| --------------------------- | --------------------------------- | ---------------------------------- |
197197+| `PORT` | 18910 | HTTP server port |
198198+| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy |
199199+| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
200200+| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
201201+| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
202202+| `SECURE_COOKIES` | false | Set true for HTTPS |
203203+| `LOG_LEVEL` | info | debug/info/warn/error |
204204+| `LOG_FORMAT` | console | console/json |
205205+206206+## Code Patterns
207207+208208+### Creating a Store
209209+210210+```go
211211+// In handlers, store is created per-request
212212+store, authenticated := h.getAtprotoStore(r)
213213+if !authenticated {
214214+ http.Error(w, "Authentication required", http.StatusUnauthorized)
215215+ return
216216+}
217217+218218+// Use store with request context
219219+brews, err := store.ListBrews(r.Context(), userID)
220220+```
221221+222222+### Record Conversion
223223+224224+```go
225225+// Model -> ATProto record
226226+record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI)
227227+228228+// ATProto record -> Model
229229+brew, err := RecordToBrew(record, atURI)
230230+```
231231+232232+### AT-URI Handling
233233+234234+```go
235235+// Build AT-URI
236236+uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc
237237+238238+// Parse AT-URI
239239+components, err := ResolveATURI(uri)
240240+// components.DID, components.Collection, components.RKey
241241+```
242242+243243+## Future Vision: Social Features
244244+245245+The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature.
246246+247247+### Planned Lexicons
248248+249249+```
250250+social.arabica.alpha.like - Like a brew (references brew AT-URI)
251251+social.arabica.alpha.comment - Comment on a brew
252252+social.arabica.alpha.follow - Follow another user
253253+social.arabica.alpha.share - Re-share a brew to your feed
254254+```
255255+256256+### Like Record (Planned)
257257+258258+```json
259259+{
260260+ "lexicon": 1,
261261+ "id": "social.arabica.alpha.like",
262262+ "defs": {
263263+ "main": {
264264+ "type": "record",
265265+ "key": "tid",
266266+ "record": {
267267+ "type": "object",
268268+ "required": ["subject", "createdAt"],
269269+ "properties": {
270270+ "subject": {
271271+ "type": "ref",
272272+ "ref": "com.atproto.repo.strongRef",
273273+ "description": "The brew being liked"
274274+ },
275275+ "createdAt": { "type": "string", "format": "datetime" }
276276+ }
277277+ }
278278+ }
279279+ }
280280+}
281281+```
282282+283283+### Comment Record (Planned)
284284+285285+```json
286286+{
287287+ "lexicon": 1,
288288+ "id": "social.arabica.alpha.comment",
289289+ "defs": {
290290+ "main": {
291291+ "type": "record",
292292+ "key": "tid",
293293+ "record": {
294294+ "type": "object",
295295+ "required": ["subject", "text", "createdAt"],
296296+ "properties": {
297297+ "subject": {
298298+ "type": "ref",
299299+ "ref": "com.atproto.repo.strongRef",
300300+ "description": "The brew being commented on"
301301+ },
302302+ "text": {
303303+ "type": "string",
304304+ "maxLength": 1000,
305305+ "maxGraphemes": 300
306306+ },
307307+ "createdAt": { "type": "string", "format": "datetime" }
308308+ }
309309+ }
310310+ }
311311+ }
312312+}
313313+```
314314+315315+### Implementation Approach
316316+317317+**Cross-user interactions:**
318318+319319+- Likes/comments stored in the actor's PDS (not the brew owner's)
320320+- Use `public_client.go` to read other users' brews
321321+- Aggregate likes/comments via relay/firehose or direct PDS queries
322322+323323+**Feed aggregation:**
324324+325325+- Current: Poll registered users' PDS for brews
326326+- Future: Subscribe to firehose for real-time updates
327327+- Index social interactions in local DB for fast queries
328328+329329+**UI patterns:**
330330+331331+- Like button on brew cards in feed
332332+- Comment thread below brew detail view
333333+- Share button to re-post with optional note
334334+- Notification system for interactions on your brews
335335+336336+### Key Design Decisions
337337+338338+1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` (URI + CID) to ensure the referenced brew hasn't changed
339339+2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's
340340+3. **Public by default** - Social interactions are public records, readable by anyone
341341+4. **Portable identity** - Users can switch PDS and keep their social graph
342342+343343+## Deployment Notes
344344+345345+### CSS Cache Busting
346346+347347+When making CSS/style changes, bump the version query parameter in `templates/layout.tmpl`:
348348+349349+```html
350350+<link rel="stylesheet" href="/static/css/output.css?v=0.1.3" />
351351+```
352352+353353+Cloudflare caches static assets, so incrementing the version ensures users get the updated styles.
354354+355355+## Known Issues / TODOs
356356+357357+Key areas:
358358+359359+- Context should flow through methods (some fixed, verify all paths)
360360+- Cache race conditions need copy-on-write pattern
361361+- Missing CID validation on record updates (AT Protocol best practice)
362362+- Rate limiting for PDS calls not implemented
+1-1
CLAUDE.md
···5252 public/ # Built SPA assets
5353lexicons/ # AT Protocol lexicon definitions (JSON)
5454templates/partials/ # Legacy HTMX partial templates (being phased out)
5555-web/static/ # Static assets (CSS, icons, service worker)
5555+static/ # Static assets (CSS, icons, service worker)
5656 app/ # Built Svelte SPA
5757```
5858
+8-8
MIGRATION.md
···5757│ ├── index.html
5858│ ├── vite.config.js
5959│ └── package.json
6060-└── web/static/app/ # Built Svelte output (served by Go)
6060+└── static/app/ # Built Svelte output (served by Go)
6161```
62626363## Development
···8787npm run build
8888```
89899090-This builds the Svelte app into `web/static/app/`
9090+This builds the Svelte app into `static/app/`
91919292Then run the Go server normally:
9393···9595go run cmd/arabica-server/main.go
9696```
97979898-The Go server will serve the built Svelte SPA from `web/static/app/`
9898+The Go server will serve the built Svelte SPA from `static/app/`
9999100100## Key Features Implemented
101101···159159160160```bash
161161# Old Alpine.js JavaScript
162162-web/static/js/alpine.min.js
163163-web/static/js/manage-page.js
164164-web/static/js/brew-form.js
165165-web/static/js/data-cache.js
166166-web/static/js/handle-autocomplete.js
162162+static/js/alpine.min.js
163163+static/js/manage-page.js
164164+static/js/brew-form.js
165165+static/js/data-cache.js
166166+static/js/handle-autocomplete.js
167167168168# Go templates (entire directory)
169169templates/
+2-2
docs/back-button-implementation.md
···17171818The implementation consists of:
19192020-1. **JavaScript Module** (`web/static/js/back-button.js`):
2020+1. **JavaScript Module** (`static/js/back-button.js`):
2121 - Detects if the user came from within the app (same-origin referrer)
2222 - Uses `history.back()` for internal navigation (preserves history stack)
2323 - Falls back to a specified URL for external/direct navigation
···80808181### New Files
82828383-1. **`web/static/js/back-button.js`**
8383+1. **`static/js/back-button.js`**
8484 - Core back button logic
8585 - Initialization and event handling
8686 - HTMX integration
···11----
22-title: odd number of arguments
33-test_name: TestDict_ErrorCases_Snapshot/odd_number_of_arguments
44-file_name: render_snapshot_test.go
55-version: 0.1.0
66----
77-"dict requires an even number of arguments"
···11-// Package bff provides Backend-For-Frontend functionality including
22-// template rendering and helper functions for the web UI.
33-package bff
44-55-import (
66- "encoding/json"
77- "fmt"
88- "net/url"
99- "strings"
1010-1111- "arabica/internal/models"
1212-)
1313-1414-// FormatTemp formats a temperature value with unit detection.
1515-// Returns "N/A" if temp is 0, otherwise determines C/F based on >100 threshold.
1616-func FormatTemp(temp float64) string {
1717- if temp == 0 {
1818- return "N/A"
1919- }
2020-2121- // REFACTOR: This probably isn't the best way to deal with units
2222- unit := 'C'
2323- if temp > 100 {
2424- unit = 'F'
2525- }
2626-2727- return fmt.Sprintf("%.1f°%c", temp, unit)
2828-}
2929-3030-// FormatTempValue formats a temperature for use in input fields (numeric value only).
3131-func FormatTempValue(temp float64) string {
3232- return fmt.Sprintf("%.1f", temp)
3333-}
3434-3535-// FormatTime formats seconds into a human-readable time string (e.g., "3m 30s").
3636-// Returns "N/A" if seconds is 0.
3737-func FormatTime(seconds int) string {
3838- if seconds == 0 {
3939- return "N/A"
4040- }
4141- if seconds < 60 {
4242- return fmt.Sprintf("%ds", seconds)
4343- }
4444- minutes := seconds / 60
4545- remaining := seconds % 60
4646- if remaining == 0 {
4747- return fmt.Sprintf("%dm", minutes)
4848- }
4949- return fmt.Sprintf("%dm %ds", minutes, remaining)
5050-}
5151-5252-// FormatRating formats a rating as "X/10".
5353-// Returns "N/A" if rating is 0.
5454-func FormatRating(rating int) string {
5555- if rating == 0 {
5656- return "N/A"
5757- }
5858- return fmt.Sprintf("%d/10", rating)
5959-}
6060-6161-// FormatID converts an int to string.
6262-func FormatID(id int) string {
6363- return fmt.Sprintf("%d", id)
6464-}
6565-6666-// FormatInt converts an int to string.
6767-func FormatInt(val int) string {
6868- return fmt.Sprintf("%d", val)
6969-}
7070-7171-// FormatRoasterID formats a nullable roaster ID.
7272-// Returns "null" if id is nil, otherwise the ID as a string.
7373-func FormatRoasterID(id *int) string {
7474- if id == nil {
7575- return "null"
7676- }
7777- return fmt.Sprintf("%d", *id)
7878-}
7979-8080-// PoursToJSON serializes a slice of pours to JSON for use in JavaScript.
8181-func PoursToJSON(pours []*models.Pour) string {
8282- if len(pours) == 0 {
8383- return "[]"
8484- }
8585-8686- type pourData struct {
8787- Water int `json:"water"`
8888- Time int `json:"time"`
8989- }
9090-9191- data := make([]pourData, len(pours))
9292- for i, p := range pours {
9393- data[i] = pourData{
9494- Water: p.WaterAmount,
9595- Time: p.TimeSeconds,
9696- }
9797- }
9898-9999- jsonBytes, err := json.Marshal(data)
100100- if err != nil {
101101- return "[]"
102102- }
103103-104104- return string(jsonBytes)
105105-}
106106-107107-// Ptr returns a pointer to the given value.
108108-func Ptr[T any](v T) *T {
109109- return &v
110110-}
111111-112112-// PtrEquals checks if a pointer equals a value.
113113-// Returns false if the pointer is nil.
114114-func PtrEquals[T comparable](p *T, val T) bool {
115115- if p == nil {
116116- return false
117117- }
118118- return *p == val
119119-}
120120-121121-// PtrValue returns the dereferenced value of a pointer, or zero value if nil.
122122-func PtrValue[T any](p *T) T {
123123- if p == nil {
124124- var zero T
125125- return zero
126126- }
127127- return *p
128128-}
129129-130130-// Iterate returns a slice of ints from 0 to n-1, useful for range loops in templates.
131131-func Iterate(n int) []int {
132132- result := make([]int, n)
133133- for i := range result {
134134- result[i] = i
135135- }
136136- return result
137137-}
138138-139139-// IterateRemaining returns a slice of ints for the remaining count, useful for star ratings.
140140-// For example, IterateRemaining(3, 5) returns [0, 1] for the 2 remaining empty stars.
141141-func IterateRemaining(current, total int) []int {
142142- remaining := total - current
143143- if remaining <= 0 {
144144- return nil
145145- }
146146- result := make([]int, remaining)
147147- for i := range result {
148148- result[i] = i
149149- }
150150- return result
151151-}
152152-153153-// HasTemp returns true if temperature is greater than zero
154154-func HasTemp(temp float64) bool {
155155- return temp > 0
156156-}
157157-158158-// HasValue returns true if the int value is greater than zero
159159-func HasValue(val int) bool {
160160- return val > 0
161161-}
162162-163163-// SafeAvatarURL validates and sanitizes avatar URLs to prevent XSS and other attacks.
164164-// Only allows HTTPS URLs from trusted domains (Bluesky CDN) or relative paths.
165165-// Returns a safe URL or empty string if invalid.
166166-func SafeAvatarURL(avatarURL string) string {
167167- if avatarURL == "" {
168168- return ""
169169- }
170170-171171- // Allow relative paths (e.g., /static/icon-placeholder.svg)
172172- if strings.HasPrefix(avatarURL, "/") {
173173- // Basic validation - must start with /static/
174174- if strings.HasPrefix(avatarURL, "/static/") {
175175- return avatarURL
176176- }
177177- return ""
178178- }
179179-180180- // Parse the URL
181181- parsedURL, err := url.Parse(avatarURL)
182182- if err != nil {
183183- return ""
184184- }
185185-186186- // Only allow HTTPS scheme
187187- if parsedURL.Scheme != "https" {
188188- return ""
189189- }
190190-191191- // Whitelist trusted domains for avatar images
192192- // Bluesky uses cdn.bsky.app for avatars
193193- trustedDomains := []string{
194194- "cdn.bsky.app",
195195- "av-cdn.bsky.app",
196196- }
197197-198198- hostLower := strings.ToLower(parsedURL.Host)
199199- for _, domain := range trustedDomains {
200200- if hostLower == domain || strings.HasSuffix(hostLower, "."+domain) {
201201- return avatarURL
202202- }
203203- }
204204-205205- // URL is not from a trusted domain
206206- return ""
207207-}
208208-209209-// SafeWebsiteURL validates and sanitizes website URLs for display.
210210-// Only allows HTTP/HTTPS URLs and performs basic validation.
211211-// Returns a safe URL or empty string if invalid.
212212-func SafeWebsiteURL(websiteURL string) string {
213213- if websiteURL == "" {
214214- return ""
215215- }
216216-217217- // Parse the URL
218218- parsedURL, err := url.Parse(websiteURL)
219219- if err != nil {
220220- return ""
221221- }
222222-223223- // Only allow HTTP and HTTPS schemes
224224- scheme := strings.ToLower(parsedURL.Scheme)
225225- if scheme != "http" && scheme != "https" {
226226- return ""
227227- }
228228-229229- // Basic hostname validation - must have at least one dot
230230- if !strings.Contains(parsedURL.Host, ".") {
231231- return ""
232232- }
233233-234234- return websiteURL
235235-}
236236-237237-// EscapeJS escapes a string for safe use in JavaScript string literals.
238238-// Handles newlines, quotes, backslashes, and other special characters.
239239-func EscapeJS(s string) string {
240240- // Replace special characters that would break JavaScript strings
241241- s = strings.ReplaceAll(s, "\\", "\\\\") // Must be first
242242- s = strings.ReplaceAll(s, "'", "\\'")
243243- s = strings.ReplaceAll(s, "\"", "\\\"")
244244- s = strings.ReplaceAll(s, "\n", "\\n")
245245- s = strings.ReplaceAll(s, "\r", "\\r")
246246- s = strings.ReplaceAll(s, "\t", "\\t")
247247- return s
248248-}
249249-250250-// Dict creates a map from alternating key-value arguments.
251251-// Useful for passing multiple parameters to sub-templates in Go templates.
252252-// Example: {{template "foo" dict "Key1" .Value1 "Key2" .Value2}}
253253-func Dict(values ...interface{}) (map[string]interface{}, error) {
254254- if len(values)%2 != 0 {
255255- return nil, fmt.Errorf("dict requires an even number of arguments")
256256- }
257257- dict := make(map[string]interface{}, len(values)/2)
258258- for i := 0; i < len(values); i += 2 {
259259- key, ok := values[i].(string)
260260- if !ok {
261261- return nil, fmt.Errorf("dict keys must be strings")
262262- }
263263- dict[key] = values[i+1]
264264- }
265265- return dict, nil
266266-}
···44 "context"
55 "encoding/json"
66 "net/http"
77- "sort"
87 "strconv"
98 "strings"
1091110 "arabica/internal/atproto"
1212- "arabica/internal/bff"
1311 "arabica/internal/database"
1412 "arabica/internal/feed"
1513 "arabica/internal/models"
···8280 return ""
8381}
84828585-// getUserProfile fetches the profile for an authenticated user.
8686-// Returns nil if unable to fetch profile (non-fatal error).
8787-func (h *Handler) getUserProfile(ctx context.Context, did string) *bff.UserProfile {
8888- if did == "" {
8989- return nil
9090- }
9191-9292- publicClient := atproto.NewPublicClient()
9393- profile, err := publicClient.GetProfile(ctx, did)
9494- if err != nil {
9595- log.Warn().Err(err).Str("did", did).Msg("Failed to fetch user profile for header")
9696- return nil
9797- }
9898-9999- userProfile := &bff.UserProfile{
100100- Handle: profile.Handle,
101101- }
102102- if profile.DisplayName != nil {
103103- userProfile.DisplayName = *profile.DisplayName
104104- }
105105- if profile.Avatar != nil {
106106- userProfile.Avatar = *profile.Avatar
107107- }
108108-109109- return userProfile
110110-}
111111-11283// getAtprotoStore creates a user-scoped atproto store from the request context.
11384// Returns the store and true if authenticated, or nil and false if not authenticated.
11485func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) {
···137108138109// SPA fallback handler - serves index.html for client-side routes
139110func (h *Handler) HandleSPAFallback(w http.ResponseWriter, r *http.Request) {
140140- http.ServeFile(w, r, "web/static/app/index.html")
111111+ http.ServeFile(w, r, "static/app/index.html")
141112}
142113143114// Home page
144115145145-// Community feed partial (loaded async via HTMX)
146146-func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) {
147147- var feedItems []*feed.FeedItem
148148-149149- // Check if user is authenticated
150150- _, err := atproto.GetAuthenticatedDID(r.Context())
151151- isAuthenticated := err == nil
152152-153153- if h.feedService != nil {
154154- if isAuthenticated {
155155- feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit)
156156- } else {
157157- // Unauthenticated users get a limited feed from the cache
158158- feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context())
159159- }
160160- }
161161-162162- if err := bff.RenderFeedPartial(w, feedItems, isAuthenticated); err != nil {
163163- http.Error(w, "Failed to render feed", http.StatusInternalServerError)
164164- log.Error().Err(err).Msg("Failed to render feed partial")
165165- }
166166-}
167167-168116// API endpoint for feed (JSON)
169117func (h *Handler) HandleFeedAPI(w http.ResponseWriter, r *http.Request) {
170118 var feedItems []*feed.FeedItem
···225173 isOwnProfile := isAuthenticated && currentUserDID == targetDID
226174227175 // Get profile info
228228- profile := h.getUserProfile(ctx, targetDID)
229229- if profile == nil {
176176+ publicClient := atproto.NewPublicClient()
177177+ profile, err := publicClient.GetProfile(ctx, targetDID)
178178+ if err != nil {
179179+ log.Warn().Err(err).Str("did", targetDID).Msg("Failed to fetch profile")
230180 http.Error(w, "Profile not found", http.StatusNotFound)
231181 return
232182 }
233183234184 // Fetch user's data using public client (works for any user)
235235- publicClient := atproto.NewPublicClient()
236185237186 // Fetch all collections in parallel
238187 g, ctx := errgroup.WithContext(ctx)
···360309}
361310362311// Brew list partial (loaded async via HTMX)
363363-func (h *Handler) HandleBrewListPartial(w http.ResponseWriter, r *http.Request) {
364364- // Require authentication
365365- store, authenticated := h.getAtprotoStore(r)
366366- if !authenticated {
367367- http.Error(w, "Authentication required", http.StatusUnauthorized)
368368- return
369369- }
370370-371371- brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto
372372- if err != nil {
373373- http.Error(w, "Failed to fetch brews", http.StatusInternalServerError)
374374- log.Error().Err(err).Msg("Failed to fetch brews")
375375- return
376376- }
377377-378378- if err := bff.RenderBrewListPartial(w, brews); err != nil {
379379- http.Error(w, "Failed to render content", http.StatusInternalServerError)
380380- log.Error().Err(err).Msg("Failed to render brew list partial")
381381- }
382382-}
383383-384384-// Manage page partial (loaded async via HTMX)
385385-func (h *Handler) HandleManagePartial(w http.ResponseWriter, r *http.Request) {
386386- // Require authentication
387387- store, authenticated := h.getAtprotoStore(r)
388388- if !authenticated {
389389- http.Error(w, "Authentication required", http.StatusUnauthorized)
390390- return
391391- }
392392-393393- ctx := r.Context()
394394-395395- // Fetch all collections in parallel using errgroup for proper error handling
396396- // and automatic context cancellation on first error
397397- g, ctx := errgroup.WithContext(ctx)
398398-399399- var beans []*models.Bean
400400- var roasters []*models.Roaster
401401- var grinders []*models.Grinder
402402- var brewers []*models.Brewer
403403-404404- g.Go(func() error {
405405- var err error
406406- beans, err = store.ListBeans(ctx)
407407- return err
408408- })
409409- g.Go(func() error {
410410- var err error
411411- roasters, err = store.ListRoasters(ctx)
412412- return err
413413- })
414414- g.Go(func() error {
415415- var err error
416416- grinders, err = store.ListGrinders(ctx)
417417- return err
418418- })
419419- g.Go(func() error {
420420- var err error
421421- brewers, err = store.ListBrewers(ctx)
422422- return err
423423- })
424424-425425- if err := g.Wait(); err != nil {
426426- http.Error(w, "Failed to fetch data", http.StatusInternalServerError)
427427- log.Error().Err(err).Msg("Failed to fetch manage page data")
428428- return
429429- }
430430-431431- // Link beans to their roasters
432432- atproto.LinkBeansToRoasters(beans, roasters)
433433-434434- if err := bff.RenderManagePartial(w, beans, roasters, grinders, brewers); err != nil {
435435- http.Error(w, "Failed to render content", http.StatusInternalServerError)
436436- log.Error().Err(err).Msg("Failed to render manage partial")
437437- }
438438-}
439439-440440-// List all brews
441441-442442-// Show new brew form
443443-444444-// Show brew view page
445445-446312// resolveBrewReferences resolves bean, grinder, and brewer references for a brew
447313func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew, ownerDID string, record map[string]interface{}) error {
448314 publicClient := atproto.NewPublicClient()
···1015881 }
10168821017883 // Fetch user profile
10181018- userProfile := h.getUserProfile(r.Context(), didStr)
10191019- if userProfile == nil {
884884+ publicClient := atproto.NewPublicClient()
885885+ profile, err := publicClient.GetProfile(r.Context(), didStr)
886886+ if err != nil {
887887+ log.Warn().Err(err).Str("did", didStr).Msg("Failed to fetch user profile")
1020888 http.Error(w, "Failed to fetch user profile", http.StatusInternalServerError)
1021889 return
1022890 }
1023891892892+ displayName := ""
893893+ if profile.DisplayName != nil {
894894+ displayName = *profile.DisplayName
895895+ }
896896+ avatar := ""
897897+ if profile.Avatar != nil {
898898+ avatar = *profile.Avatar
899899+ }
900900+1024901 response := map[string]interface{}{
1025902 "did": didStr,
10261026- "handle": userProfile.Handle,
10271027- "displayName": userProfile.DisplayName,
10281028- "avatar": userProfile.Avatar,
903903+ "handle": profile.Handle,
904904+ "displayName": displayName,
905905+ "avatar": avatar,
1029906 }
10309071031908 w.Header().Set("Content-Type", "application/json")
···14911368}
1492136914931370// HandleProfile displays a user's public profile with their brews and gear
14941494-14951495-// HandleProfilePartial returns profile data content (loaded async via HTMX)
14961496-func (h *Handler) HandleProfilePartial(w http.ResponseWriter, r *http.Request) {
14971497- actor := r.PathValue("actor")
14981498- if actor == "" {
14991499- http.Error(w, "Actor parameter is required", http.StatusBadRequest)
15001500- return
15011501- }
15021502-15031503- ctx := r.Context()
15041504- publicClient := atproto.NewPublicClient()
15051505-15061506- // Determine if actor is a DID or handle
15071507- var did string
15081508- var err error
15091509-15101510- if strings.HasPrefix(actor, "did:") {
15111511- did = actor
15121512- } else {
15131513- did, err = publicClient.ResolveHandle(ctx, actor)
15141514- if err != nil {
15151515- log.Warn().Err(err).Str("handle", actor).Msg("Failed to resolve handle")
15161516- http.Error(w, "User not found", http.StatusNotFound)
15171517- return
15181518- }
15191519- }
15201520-15211521- // Fetch all user data in parallel
15221522- g, gCtx := errgroup.WithContext(ctx)
15231523-15241524- var brews []*models.Brew
15251525- var beans []*models.Bean
15261526- var roasters []*models.Roaster
15271527- var grinders []*models.Grinder
15281528- var brewers []*models.Brewer
15291529-15301530- // Maps for resolving references
15311531- var beanMap map[string]*models.Bean
15321532- var beanRoasterRefMap map[string]string
15331533- var roasterMap map[string]*models.Roaster
15341534- var brewerMap map[string]*models.Brewer
15351535- var grinderMap map[string]*models.Grinder
15361536-15371537- // Fetch beans
15381538- g.Go(func() error {
15391539- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBean, 100)
15401540- if err != nil {
15411541- return err
15421542- }
15431543- beanMap = make(map[string]*models.Bean)
15441544- beanRoasterRefMap = make(map[string]string)
15451545- beans = make([]*models.Bean, 0, len(output.Records))
15461546- for _, record := range output.Records {
15471547- bean, err := atproto.RecordToBean(record.Value, record.URI)
15481548- if err != nil {
15491549- continue
15501550- }
15511551- beans = append(beans, bean)
15521552- beanMap[record.URI] = bean
15531553- if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" {
15541554- beanRoasterRefMap[record.URI] = roasterRef
15551555- }
15561556- }
15571557- return nil
15581558- })
15591559-15601560- // Fetch roasters
15611561- g.Go(func() error {
15621562- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDRoaster, 100)
15631563- if err != nil {
15641564- return err
15651565- }
15661566- roasterMap = make(map[string]*models.Roaster)
15671567- roasters = make([]*models.Roaster, 0, len(output.Records))
15681568- for _, record := range output.Records {
15691569- roaster, err := atproto.RecordToRoaster(record.Value, record.URI)
15701570- if err != nil {
15711571- continue
15721572- }
15731573- roasters = append(roasters, roaster)
15741574- roasterMap[record.URI] = roaster
15751575- }
15761576- return nil
15771577- })
15781578-15791579- // Fetch grinders
15801580- g.Go(func() error {
15811581- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDGrinder, 100)
15821582- if err != nil {
15831583- return err
15841584- }
15851585- grinderMap = make(map[string]*models.Grinder)
15861586- grinders = make([]*models.Grinder, 0, len(output.Records))
15871587- for _, record := range output.Records {
15881588- grinder, err := atproto.RecordToGrinder(record.Value, record.URI)
15891589- if err != nil {
15901590- continue
15911591- }
15921592- grinders = append(grinders, grinder)
15931593- grinderMap[record.URI] = grinder
15941594- }
15951595- return nil
15961596- })
15971597-15981598- // Fetch brewers
15991599- g.Go(func() error {
16001600- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrewer, 100)
16011601- if err != nil {
16021602- return err
16031603- }
16041604- brewerMap = make(map[string]*models.Brewer)
16051605- brewers = make([]*models.Brewer, 0, len(output.Records))
16061606- for _, record := range output.Records {
16071607- brewer, err := atproto.RecordToBrewer(record.Value, record.URI)
16081608- if err != nil {
16091609- continue
16101610- }
16111611- brewers = append(brewers, brewer)
16121612- brewerMap[record.URI] = brewer
16131613- }
16141614- return nil
16151615- })
16161616-16171617- // Fetch brews
16181618- g.Go(func() error {
16191619- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrew, 100)
16201620- if err != nil {
16211621- return err
16221622- }
16231623- brews = make([]*models.Brew, 0, len(output.Records))
16241624- for _, record := range output.Records {
16251625- brew, err := atproto.RecordToBrew(record.Value, record.URI)
16261626- if err != nil {
16271627- continue
16281628- }
16291629- // Store the raw record for reference resolution later
16301630- brew.BeanRKey = ""
16311631- if beanRef, ok := record.Value["beanRef"].(string); ok {
16321632- brew.BeanRKey = beanRef
16331633- }
16341634- if grinderRef, ok := record.Value["grinderRef"].(string); ok {
16351635- brew.GrinderRKey = grinderRef
16361636- }
16371637- if brewerRef, ok := record.Value["brewerRef"].(string); ok {
16381638- brew.BrewerRKey = brewerRef
16391639- }
16401640- brews = append(brews, brew)
16411641- }
16421642- return nil
16431643- })
16441644-16451645- if err := g.Wait(); err != nil {
16461646- log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data for profile partial")
16471647- http.Error(w, "Failed to load profile data", http.StatusInternalServerError)
16481648- return
16491649- }
16501650-16511651- // Resolve references for beans (roaster refs)
16521652- for _, bean := range beans {
16531653- if roasterRef, found := beanRoasterRefMap[atproto.BuildATURI(did, atproto.NSIDBean, bean.RKey)]; found {
16541654- if roaster, found := roasterMap[roasterRef]; found {
16551655- bean.Roaster = roaster
16561656- }
16571657- }
16581658- }
16591659-16601660- // Resolve references for brews
16611661- for _, brew := range brews {
16621662- // Resolve bean reference
16631663- if brew.BeanRKey != "" {
16641664- if bean, found := beanMap[brew.BeanRKey]; found {
16651665- brew.Bean = bean
16661666- }
16671667- }
16681668- // Resolve grinder reference
16691669- if brew.GrinderRKey != "" {
16701670- if grinder, found := grinderMap[brew.GrinderRKey]; found {
16711671- brew.GrinderObj = grinder
16721672- }
16731673- }
16741674- // Resolve brewer reference
16751675- if brew.BrewerRKey != "" {
16761676- if brewer, found := brewerMap[brew.BrewerRKey]; found {
16771677- brew.BrewerObj = brewer
16781678- }
16791679- }
16801680- }
16811681-16821682- // Sort brews in reverse chronological order (newest first)
16831683- sort.Slice(brews, func(i, j int) bool {
16841684- return brews[i].CreatedAt.After(brews[j].CreatedAt)
16851685- })
16861686-16871687- // Check if the viewing user is the profile owner
16881688- didStr, err := atproto.GetAuthenticatedDID(ctx)
16891689- isAuthenticated := err == nil && didStr != ""
16901690- isOwnProfile := isAuthenticated && didStr == did
16911691-16921692- // Render profile content partial (use actor as handle, which is already the handle if provided as such)
16931693- profileHandle := actor
16941694- if strings.HasPrefix(actor, "did:") {
16951695- // If actor was a DID, we need to resolve it to a handle
16961696- // We can get it from the first brew's author if available, or fetch profile
16971697- profile, err := publicClient.GetProfile(ctx, did)
16981698- if err == nil {
16991699- profileHandle = profile.Handle
17001700- } else {
17011701- profileHandle = did // Fallback to DID if we can't get handle
17021702- }
17031703- }
17041704-17051705- if err := bff.RenderProfilePartial(w, brews, beans, roasters, grinders, brewers, isOwnProfile, profileHandle); err != nil {
17061706- http.Error(w, "Failed to render content", http.StatusInternalServerError)
17071707- log.Error().Err(err).Msg("Failed to render profile partial")
17081708- }
17091709-}
17101710-17111711-// HandleNotFound renders the 404 page
17121712-func (h *Handler) HandleNotFound(w http.ResponseWriter, r *http.Request) {
17131713- // Check if current user is authenticated (for nav bar state)
17141714- didStr, err := atproto.GetAuthenticatedDID(r.Context())
17151715- isAuthenticated := err == nil && didStr != ""
17161716-17171717- var userProfile *bff.UserProfile
17181718- if isAuthenticated {
17191719- userProfile = h.getUserProfile(r.Context(), didStr)
17201720- }
17211721-17221722- if err := bff.Render404(w, isAuthenticated, didStr, userProfile); err != nil {
17231723- http.Error(w, "Page not found", http.StatusNotFound)
17241724- log.Error().Err(err).Msg("Failed to render 404 page")
17251725- }
17261726-}
-125
internal/handlers/handlers_test.go
···1616 "github.com/stretchr/testify/assert"
1717)
18181919-// TestHandleBrewListPartial_Success tests successful brew list retrieval
2020-func TestHandleBrewListPartial_Success(t *testing.T) {
2121- tc := NewTestContext()
2222- fixtures := tc.Fixtures
23192424- // Mock store to return test brews
2525- tc.MockStore.ListBrewsFunc = func(ctx context.Context, userID int) ([]*models.Brew, error) {
2626- return []*models.Brew{fixtures.Brew}, nil
2727- }
2828-2929- // Create handler with injected mock store dependency
3030- handler := tc.Handler
3131-3232- // We need to modify the handler to use our mock store
3333- // Since getAtprotoStore creates a new store, we'll need to test this differently
3434- // For now, let's test the authentication flow
3535-3636- req := NewAuthenticatedRequest("GET", "/api/brews/list", nil)
3737- rec := httptest.NewRecorder()
3838-3939- handler.HandleBrewListPartial(rec, req)
4040-4141- // The handler will try to create an atproto store which will fail without proper setup
4242- // This shows we need architectural changes to make handlers testable
4343- assert.Equal(t, http.StatusUnauthorized, rec.Code, "Expected unauthorized when OAuth is nil")
4444-}
4545-4646-// TestHandleBrewListPartial_Unauthenticated tests unauthenticated access
4747-func TestHandleBrewListPartial_Unauthenticated(t *testing.T) {
4848- tc := NewTestContext()
4949-5050- req := NewUnauthenticatedRequest("GET", "/api/brews/list")
5151- rec := httptest.NewRecorder()
5252-5353- tc.Handler.HandleBrewListPartial(rec, req)
5454-5555- assert.Equal(t, http.StatusUnauthorized, rec.Code)
5656- assert.Contains(t, rec.Body.String(), "Authentication required")
5757-}
58205921// TestHandleBrewDelete_Success tests successful brew deletion
6022func TestHandleBrewDelete_Success(t *testing.T) {
···193155 }
194156}
195157196196-// TestHandleBrewExport tests brew export functionality
197197-func TestHandleBrewExport(t *testing.T) {
198198- tc := NewTestContext()
199199- fixtures := tc.Fixtures
200200-201201- tc.MockStore.ListBrewsFunc = func(ctx context.Context, userID int) ([]*models.Brew, error) {
202202- return []*models.Brew{fixtures.Brew}, nil
203203- }
204204-205205- req := NewAuthenticatedRequest("GET", "/brews/export", nil)
206206- rec := httptest.NewRecorder()
207207-208208- tc.Handler.HandleBrewExport(rec, req)
209209-210210- // Will be unauthorized due to OAuth being nil
211211- assert.Equal(t, http.StatusUnauthorized, rec.Code)
212212-}
213158214159// TestHandleAPIListAll tests the API endpoint for listing all user data
215160func TestHandleAPIListAll(t *testing.T) {
···260205 assert.Contains(t, []int{http.StatusInternalServerError, http.StatusUnauthorized}, rec.Code)
261206}
262207263263-// TestHandleHome tests home page rendering
264264-func TestHandleHome(t *testing.T) {
265265- tests := []struct {
266266- name string
267267- authenticated bool
268268- wantStatus int
269269- }{
270270- {"authenticated user", true, http.StatusOK},
271271- {"unauthenticated user", false, http.StatusOK},
272272- }
273208274274- for _, tt := range tests {
275275- t.Run(tt.name, func(t *testing.T) {
276276- tc := NewTestContext()
277209278278- var req *http.Request
279279- if tt.authenticated {
280280- req = NewAuthenticatedRequest("GET", "/", nil)
281281- } else {
282282- req = NewUnauthenticatedRequest("GET", "/")
283283- }
284284- rec := httptest.NewRecorder()
285285-286286- tc.Handler.HandleHome(rec, req)
287287-288288- // Home page should render regardless of auth status
289289- // Will fail due to template rendering without proper setup
290290- // but should not panic
291291- assert.NotEqual(t, 0, rec.Code)
292292- })
293293- }
294294-}
295295-296296-// TestHandleManagePartial tests manage page data fetching
297297-func TestHandleManagePartial(t *testing.T) {
298298- tc := NewTestContext()
299299- fixtures := tc.Fixtures
300300-301301- // Mock all the data fetches
302302- tc.MockStore.ListBeansFunc = func(ctx context.Context) ([]*models.Bean, error) {
303303- return []*models.Bean{fixtures.Bean}, nil
304304- }
305305- tc.MockStore.ListRoastersFunc = func(ctx context.Context) ([]*models.Roaster, error) {
306306- return []*models.Roaster{fixtures.Roaster}, nil
307307- }
308308- tc.MockStore.ListGrindersFunc = func(ctx context.Context) ([]*models.Grinder, error) {
309309- return []*models.Grinder{fixtures.Grinder}, nil
310310- }
311311- tc.MockStore.ListBrewersFunc = func(ctx context.Context) ([]*models.Brewer, error) {
312312- return []*models.Brewer{fixtures.Brewer}, nil
313313- }
314314-315315- req := NewAuthenticatedRequest("GET", "/manage/content", nil)
316316- rec := httptest.NewRecorder()
317317-318318- tc.Handler.HandleManagePartial(rec, req)
319319-320320- // Will be unauthorized due to OAuth being nil
321321- assert.Equal(t, http.StatusUnauthorized, rec.Code)
322322-}
323323-324324-// TestHandleManagePartial_Unauthenticated tests unauthenticated access to manage
325325-func TestHandleManagePartial_Unauthenticated(t *testing.T) {
326326- tc := NewTestContext()
327327-328328- req := NewUnauthenticatedRequest("GET", "/manage/content")
329329- rec := httptest.NewRecorder()
330330-331331- tc.Handler.HandleManagePartial(rec, req)
332332-333333- assert.Equal(t, http.StatusUnauthorized, rec.Code)
334334-}
335210336211// TestParsePours tests pour parsing from form data
337212func TestParsePours(t *testing.T) {
+1-12
internal/routing/routing.go
···5454 // API endpoint for profile data (JSON for Svelte)
5555 mux.HandleFunc("GET /api/profile-json/{actor}", h.HandleProfileAPI)
56565757- // HTMX partials (legacy - being phased out)
5858- // These return HTML fragments and should only be accessed via HTMX
5959- // Still used by manage page and some dynamic content
6060- mux.Handle("GET /api/feed", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleFeedPartial)))
6161- mux.Handle("GET /api/brews", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleBrewListPartial)))
6262- mux.Handle("GET /api/manage", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleManagePartial)))
6363- mux.Handle("GET /api/profile/{actor}", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleProfilePartial)))
6464-6557 // Brew CRUD API routes (used by Svelte SPA)
6658 mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(h.HandleBrewCreate)))
6759 mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate)))
···8577 mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete)))
86788779 // Static files (must come after specific routes)
8888- fs := http.FileServer(http.Dir("web/static"))
8080+ fs := http.FileServer(http.Dir("static"))
8981 mux.Handle("GET /static/", http.StripPrefix("/static/", fs))
90829183 // SPA fallback - serve index.html for all unmatched routes (client-side routing)
9284 // This must be after all API routes and static files
9385 mux.HandleFunc("GET /{path...}", h.HandleSPAFallback)
9494-9595- // Catch-all 404 handler - now only used for non-GET requests
9696- mux.HandleFunc("/", h.HandleNotFound)
97869887 // Apply middleware in order (outermost first, innermost last)
9988 var handler http.Handler = mux