Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: split up handlers.go

+2855 -2806
+2 -39
.cells/cells.jsonl
··· 1 - {"id":"01KGA7S43ENCHDZQ9DXPGEA6W1","title":"Consolidate three modal systems into one","description":"CRITICAL: Consolidate three different modal implementations:\n- entity_modals.templ (Alpine.js x-show)\n- dialog_modals.templ (native HTML5 \u003cdialog\u003e)\n- profile.templ (inline Alpine modals)\n\nRecommendation: Migrate all to native \u003cdialog\u003e for modern standard and better a11y.\nRemove Alpine.js modal code and inline modal implementations.\n\nAcceptance criteria:\n- All modals use consistent implementation\n- Better accessibility\n- Reduced maintenance burden","status":"completed","priority":"high","assignee":"patrick","change_id":"zvoovqqnlutxuzrwzpslzvxvsokyvyux","labels":["frontend","refactor","modals"],"created_at":"2026-01-31T14:37:13.710608293Z","updated_at":"2026-01-31T15:07:11.640016975Z","completed_at":"2026-01-31T15:07:11.609500036Z"} 2 - {"id":"01KGA7SZW8TPBWJZ40DHFC9RSF","title":"Fix modal fade-out animations","description":"Modals don't fade out on save/cancel as expected. They should smoothly transition out.\n\nAcceptance criteria:\n- Modals fade out smoothly when closed\n- Consistent animation timing\n- Works for both save and cancel actions","status":"completed","priority":"normal","labels":["frontend","modals","ux"],"created_at":"2026-01-31T14:37:42.152625516Z","updated_at":"2026-01-31T22:01:05.514388522Z","completed_at":"2026-01-31T22:01:05.499540746Z"} 3 - {"id":"01KGA7SZY65S5V97C7B3PKA172","title":"Fix feed view details button","description":"Feed item 'view details' button should be removed. The 'new brew' text in 'added a new brew' should link to view page instead (underline this text).\n\nAcceptance criteria:\n- Remove 'view details' button\n- Make 'new brew' text clickable and underlined\n- Links to correct brew view page","status":"completed","priority":"normal","labels":["frontend","feed","ux"],"created_at":"2026-01-31T14:37:42.214656646Z","updated_at":"2026-01-31T16:28:52.517267974Z","completed_at":"2026-01-31T16:28:52.506611951Z"} 4 - {"id":"01KGA7T004T9HZR1Q5MHSC48G6","title":"Fix manage page notes truncation","description":"Manage brews page truncates notes unnecessarily, while variables wrap to many more lines.\n\nAcceptance criteria:\n- Notes should not be truncated (or have higher character limit)\n- Consistent text wrapping behavior\n- Better readability","status":"completed","priority":"low","labels":["frontend","manage","ux"],"created_at":"2026-01-31T14:37:42.276141862Z","updated_at":"2026-01-31T21:36:44.490824871Z","completed_at":"2026-01-31T21:36:44.478912822Z"} 5 1 {"id":"01KGA7T026JJ8H9EK24K17QRA4","title":"Improve loading UX with progress bars or transitions","description":"Loading on HTMX could be snappier with loading bars or transitionary animations.\n\nOptions:\n- Loading bar waiting until everything is loaded\n- Transitionary animations between skeleton and full loads\n- Reconsider if skeleton loading is needed with SSR (probably yes due to PDS data fetches)\n\nAcceptance criteria:\n- Smoother loading experience\n- No jarring transitions\n- Consistent loading indicators","status":"open","priority":"normal","labels":["frontend","ux","htmx"],"created_at":"2026-01-31T14:37:42.342432216Z","updated_at":"2026-01-31T14:37:42.342432216Z"} 6 2 {"id":"01KGA7T04HWD93MJQDKZ62XQNB","title":"Fix skeleton headers to match actual table headers","description":"Headers in loading skeletons need to exactly match final table headers.\n\nCurrent issue:\n- Profile refresh shows brew headers for all tabs\n- Inconsistent column counts (brew_list: 6 cols, profile: 5 cols, manage: 5 cols)\n\nAcceptance criteria:\n- Skeleton headers match actual content headers\n- Tab-specific skeletons or dynamic skeletons based on active tab\n- Consistent column structure","status":"open","priority":"normal","labels":["frontend","ux","skeleton"],"created_at":"2026-01-31T14:37:42.417653543Z","updated_at":"2026-01-31T14:37:42.417653543Z"} 7 - {"id":"01KGA7T06FC5V4Z92570G0BY5G","title":"Style mail link and back button on terms page","description":"Add proper styling to mail link and 'back to home' button on terms page.\n\nAcceptance criteria:\n- Consistent button/link styling\n- Matches site design system\n- Good visual hierarchy","status":"completed","priority":"low","assignee":"patrick","change_id":"znoumknmuoxkwwrrzrlvtonksrokkvnu","labels":["frontend","styling"],"created_at":"2026-01-31T14:37:42.479212418Z","updated_at":"2026-02-01T00:25:41.74512756Z","completed_at":"2026-02-01T00:25:41.714091178Z"} 8 3 {"id":"01KGA7T08BAWPB977KSHZNS8NE","title":"Revision pass on about and terms text","description":"Review and revise text content in about and terms pages for clarity and accuracy.\n\nAcceptance criteria:\n- Clear, concise messaging\n- No typos or grammar issues\n- Accurate information","status":"claimed","priority":"low","assignee":"patrick","labels":["content","documentation"],"created_at":"2026-01-31T14:37:42.539757583Z","updated_at":"2026-01-31T15:30:48.329882372Z"} 9 4 {"id":"01KGA7T0AE0R3F8PRHX761W03E","title":"Implement profile picture caching in database","description":"Cache profile pictures to database to avoid reloading them frequently. This may already be partially implemented - needs investigation.\n\nAcceptance criteria:\n- Profile pictures cached in database\n- Reduced PDS API calls for avatar fetching\n- Cache invalidation strategy\n- Performance improvement","status":"open","priority":"high","labels":["backend","performance","caching"],"created_at":"2026-01-31T14:37:42.606749189Z","updated_at":"2026-01-31T14:37:42.606749189Z"} 10 - {"id":"01KGA7T0CRPVYSNS9FHE13NMQC","title":"Fix double page transition on back navigation","description":"Hitting back from /brews/new to home page shows welcome box, then transitions in - looks bad.\n\nIssue: Transition seems to trigger twice\nRoot cause: Transition code may be brittle\n\nAcceptance criteria:\n- Single smooth transition on back navigation\n- No double-rendering of welcome box\n- Consider refactoring transition code to be less brittle","status":"completed","priority":"normal","labels":["frontend","navigation","transitions"],"created_at":"2026-01-31T14:37:42.680660721Z","updated_at":"2026-01-31T21:37:20.31354036Z","completed_at":"2026-01-31T21:37:20.300721855Z"} 11 - {"id":"01KGA7TZ6365GY1YG55BGYVKMH","title":"Fix non-functional back button on view page","description":"Back button on brew view page does nothing and produces no logs.\n\nAcceptance criteria:\n- Back button navigates to previous page\n- Proper navigation behavior\n- Debug logging if needed","status":"completed","priority":"normal","labels":["frontend","navigation","bug"],"created_at":"2026-01-31T14:38:14.211441899Z","updated_at":"2026-01-31T20:23:51.90297311Z","completed_at":"2026-01-31T20:23:51.889306189Z"} 12 - {"id":"01KGA7TZ94GDGY53NFXW1K7K6Y","title":"Fix modal record creation not updating list","description":"Adding a record from a modal in brews page doesn't update the list until page refresh.\n\nOlder version on main branch had correct behavior: adds to list and auto-selects.\n\nAcceptance criteria:\n- New records appear in list immediately after creation\n- Auto-select newly created record\n- No page refresh needed","status":"completed","priority":"high","change_id":"kkktpzkmkwuwwrqoptsyoyuuwvqwwwrz","workspace_path":"/home/patrick/projects/arabica/.worktrees/cell-01KGA7TZ94GD","labels":["frontend","htmx","modals","bug"],"created_at":"2026-01-31T14:38:14.308945383Z","updated_at":"2026-01-31T15:55:09.476557868Z","completed_at":"2026-01-31T15:55:09.462025879Z"} 13 - {"id":"01KGA7TZBJ5KYR9HVS57SY5CBD","title":"Reduce table flash on page load","description":"Tables flash briefly on load. Could load more smoothly, perhaps by delaying page until skeleton is rendered.\n\nProfile page does this well with profile banner and stats.\n\nAcceptance criteria:\n- Smoother table loading\n- No visible flash/jump\n- Better perceived performance","status":"completed","priority":"low","change_id":"opplvnrzuswososkrrsrurokmlltszwn","labels":["frontend","ux","performance"],"created_at":"2026-01-31T14:38:14.386960128Z","updated_at":"2026-01-31T16:27:23.272054602Z","completed_at":"2026-01-31T16:27:23.246146545Z"} 14 - {"id":"01KGA7TZE5PE47X9JTQ85ZZJM4","title":"Refactor web code into organized subdirectory","description":"Move all frontend code into web subdirectory within internal.\n\nCurrent structure needs organization:\n- Move components and pages into separate dirs\n- Target: internal/web/components, internal/web/pages\n- bff directory should also move to internal/web/bff\n\nAcceptance criteria:\n- Clean directory structure\n- All imports updated\n- Tests still pass\n- No broken references","status":"completed","priority":"normal","labels":["refactor","structure"],"created_at":"2026-01-31T14:38:14.469128797Z","updated_at":"2026-01-31T21:41:04.12120685Z","completed_at":"2026-01-31T21:41:04.109407281Z"} 15 5 {"id":"01KGA7TZG6AP8FJJTW5GAA60X5","title":"Evaluate merging manage and profile pages","description":"Consider if manage, profile, and brew list should be separate pages.\n\nOptions:\n- Merge manage and profile\n- Merge brew list and manage\n- Keep separate\n\nNeeds design decision before implementation.\n\nAcceptance criteria:\n- Evaluate user flow and UX\n- Document decision rationale\n- Implementation plan if merge is chosen","status":"open","priority":"low","labels":["design","ux","frontend"],"created_at":"2026-01-31T14:38:14.534537889Z","updated_at":"2026-01-31T14:38:14.534537889Z"} 16 - {"id":"01KGA7TZJDMR7DWT1M0MAKP6NR","title":"Standardize edit/delete buttons on profile tables","description":"Profile tables are inconsistent - some have edit/delete buttons, others don't.\n\nAlso consider adding buttons below each table for that record type.\n\nAcceptance criteria:\n- All tables have consistent button placement\n- Either all have edit/delete or none do\n- Add buttons below tables for adding new records\n- Consistent UX across all tables","status":"completed","priority":"normal","labels":["frontend","ux","consistency"],"created_at":"2026-01-31T14:38:14.605304682Z","updated_at":"2026-01-31T22:23:51.571545748Z","completed_at":"2026-01-31T22:23:51.559620864Z"} 17 - {"id":"01KGA7TZMPFGZH6R6ZMASAD2GE","title":"Design nested modal system for entity creation","description":"Enable creating related entities from within modals (e.g., create roaster from within bean modal).\n\nDesign idea:\n- Transition that moves first modal left\n- Opens second modal to the right\n- Smooth nested flow\n\nAcceptance criteria:\n- Design nested modal UX\n- Smooth transitions\n- Good visual hierarchy\n- Can return to parent modal\n- Data flows correctly between modals","status":"blocked","priority":"low","blocked_by":["01KGA7S43ENCHDZQ9DXPGEA6W1"],"labels":["design","ux","modals","future"],"created_at":"2026-01-31T14:38:14.678025134Z","updated_at":"2026-01-31T14:38:59.768553798Z"} 18 - {"id":"01KGA7TZPQPY2JC3HFPVPKHNBQ","title":"Refactor forms to use form components","description":"Form components in forms.templ are defined but rarely used (only in brew_form.templ).\nModals and other forms use inline HTML instead.\n\nComponents available:\n- TextInput\n- NumberInput\n- TextArea\n- Select\n- FormField\n\nAcceptance criteria:\n- All forms use form components\n- Remove inline form HTML\n- Consistent form styling\n- Better maintainability","status":"completed","priority":"normal","assignee":"patrick","change_id":"rlzoowttmqxrqwlmpkottlurxzsxslyu","labels":["refactor","frontend","forms"],"created_at":"2026-01-31T14:38:14.743183429Z","updated_at":"2026-01-31T14:58:58.965241694Z","completed_at":"2026-01-31T14:58:58.924457633Z"} 19 - {"id":"01KGA7TZRHNG2AVHBT91NJY4KD","title":"Replace inline card classes with Card component","description":"card.templ defines Card component but many places use inline card classes instead.\n\nAcceptance criteria:\n- All card-style elements use Card component\n- Remove inline card class usage\n- Consistent card styling\n- Component-based approach","status":"completed","priority":"low","assignee":"patrick","change_id":"yokzvvstynposzwsznxpqptwstqrxzqu","labels":["refactor","frontend","components"],"created_at":"2026-01-31T14:38:14.801428078Z","updated_at":"2026-02-01T00:25:45.227470232Z","completed_at":"2026-02-01T00:25:45.186028348Z"} 6 + {"id":"01KGA7TZMPFGZH6R6ZMASAD2GE","title":"Design nested modal system for entity creation","description":"Enable creating related entities from within modals (e.g., create roaster from within bean modal).\n\nDesign idea:\n- Transition that moves first modal left\n- Opens second modal to the right\n- Smooth nested flow\n\nAcceptance criteria:\n- Design nested modal UX\n- Smooth transitions\n- Good visual hierarchy\n- Can return to parent modal\n- Data flows correctly between modals","status":"open","priority":"low","labels":["design","ux","modals","future"],"created_at":"2026-01-31T14:38:14.678025134Z","updated_at":"2026-02-15T16:06:00.000000000Z"} 20 7 {"id":"01KGA7TZTT05WWENTHR41X4RQH","title":"Replace inline buttons with button components","description":"buttons.templ defines PrimaryButton/SecondaryButton but they're rarely used.\nMost places use inline button classes.\n\nAcceptance criteria:\n- All buttons use button components\n- Remove inline button class usage\n- Consistent button styling\n- Component-based approach","status":"open","priority":"low","labels":["refactor","frontend","components"],"created_at":"2026-01-31T14:38:14.874329448Z","updated_at":"2026-01-31T14:38:14.874329448Z"} 21 - {"id":"01KGA7VXDTVY5W11QJ1VPZ5M3Y","title":"Implement settings menu","description":"Create settings menu with the following features:\n\n1. Private mode - Don't show in community feed (records still public via PDS API)\n2. Dev mode - Show DID, copy DID in profiles (remove 'logged in as \u003cdid\u003e' from home)\n3. Toggle for table view vs future post-style view\n\nAcceptance criteria:\n- Settings page/modal created\n- All three settings implemented\n- Settings persisted per user\n- UI updated based on settings","status":"blocked","priority":"normal","blocked_by":["01KGA7VXFQABCAQV83A6C49WEY"],"labels":["feature","frontend","settings"],"created_at":"2026-01-31T14:38:45.178941765Z","updated_at":"2026-01-31T14:39:00.016324424Z"} 8 + {"id":"01KGA7VXDTVY5W11QJ1VPZ5M3Y","title":"Implement settings menu","description":"Create settings menu with the following features:\n\n1. Private mode - Don't show in community feed (records still public via PDS API)\n2. Dev mode - Show DID, copy DID in profiles (remove 'logged in as <did>' from home)\n3. Toggle for table view vs future post-style view\n\nAcceptance criteria:\n- Settings page/modal created\n- All three settings implemented\n- Settings persisted per user\n- UI updated based on settings","status":"blocked","priority":"normal","blocked_by":["01KGA7VXFQABCAQV83A6C49WEY"],"labels":["feature","frontend","settings"],"created_at":"2026-01-31T14:38:45.178941765Z","updated_at":"2026-01-31T14:39:00.016324424Z"} 22 9 {"id":"01KGA7VXFQABCAQV83A6C49WEY","title":"Design post-style record view (mobile-friendly)","description":"LARGE FEATURE: Complete record styling refactor from table-style to mobile-friendly post-style.\n\nSimilar to Bluesky posts format.\nShould include setting to use legacy table view.\n\nThis is a major redesign - to be done later down the line.\n\nAcceptance criteria:\n- Design post-style layout\n- Mobile-friendly and responsive\n- Legacy table view option\n- Smooth migration path\n- Better UX on mobile devices","status":"open","priority":"low","labels":["feature","design","frontend","mobile","future"],"created_at":"2026-01-31T14:38:45.23927703Z","updated_at":"2026-01-31T14:38:45.23927703Z"} 23 10 {"id":"01KGA7VXHR31MSHEKH91C41TYM","title":"Add loading progress bars to page navigation","description":"Consider adding loading bars to page loads (above header perhaps).\n\nSeparate nicer/prettier loading bar would also be nice on brews page.\n\nAcceptance criteria:\n- Loading bar visible during page navigation\n- Positioned appropriately (above header)\n- Smooth animations\n- Better perceived performance","status":"open","priority":"low","labels":["feature","frontend","ux"],"created_at":"2026-01-31T14:38:45.304772299Z","updated_at":"2026-01-31T14:38:45.304772299Z"} 24 11 {"id":"01KGA7VXM0F3A1R10BC3V29A0Y","title":"Verify context flows through all methods","description":"From CLAUDE.md Known Issues: Context should flow through methods (some fixed, verify all paths).\n\nAudit codebase to ensure context.Context is properly passed through all method chains.\n\nAcceptance criteria:\n- All methods accept and use context\n- No context.Background() in handlers\n- Context cancellation works correctly\n- Request timeouts respected","status":"open","priority":"high","labels":["backend","refactor","correctness"],"created_at":"2026-01-31T14:38:45.376033105Z","updated_at":"2026-01-31T14:38:45.376033105Z"} ··· 26 13 {"id":"01KGA7VXR4EVGR470P4M9JH80C","title":"Add CID validation on record updates","description":"From CLAUDE.md Known Issues: Missing CID validation on record updates (AT Protocol best practice).\n\nImplement CID (Content Identifier) validation when updating records to ensure record hasn't changed.\n\nAcceptance criteria:\n- CID validation on all record updates\n- Proper error handling for CID mismatches\n- Follows AT Protocol best practices\n- Tests covering CID validation","status":"open","priority":"normal","labels":["backend","atproto","validation"],"created_at":"2026-01-31T14:38:45.508526626Z","updated_at":"2026-01-31T14:38:45.508526626Z"} 27 14 {"id":"01KGA7VXT4S4VGYZ2FDE1RR2XX","title":"Implement rate limiting for PDS calls","description":"From CLAUDE.md Known Issues: Rate limiting for PDS calls not implemented.\n\nAdd rate limiting to prevent excessive API calls to PDS servers.\n\nAcceptance criteria:\n- Rate limiting implemented for PDS API calls\n- Configurable limits\n- Proper error handling when rate limited\n- Backoff strategy\n- Monitoring/logging of rate limit hits","status":"open","priority":"high","labels":["backend","performance","atproto"],"created_at":"2026-01-31T14:38:45.572884867Z","updated_at":"2026-01-31T14:38:45.572884867Z"} 28 15 {"id":"01KGA7VXVZQBMZX6RVG3B49TYW","title":"Consider migration from BoltDB to SQLite","description":"Far future consideration: Maybe swap from BoltDB to SQLite using non-cgo library.\n\nThis is exploratory - need to evaluate:\n- Benefits of SQLite vs BoltDB\n- Migration effort\n- Performance impact\n- Query capabilities\n\nAcceptance criteria:\n- Evaluate pros/cons\n- Document decision\n- If proceeding: migration plan and implementation","status":"claimed","priority":"low","assignee":"patrick","labels":["backend","database","future","evaluation"],"created_at":"2026-01-31T14:38:45.631852717Z","updated_at":"2026-01-31T15:31:07.948160371Z"} 29 - {"id":"01KGACBM7JBN0G9R5PYXEPCFXS","title":"Fix manage page table refresh after modal operations","description":"The manage page tables don't refresh when entities are created, updated, or deleted via modals. The entity managers and entity-helpers already try to trigger refreshes but the HTMX element only responds to initial page load.","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-01-31T15:57:14.354187839Z","updated_at":"2026-01-31T16:08:49.974241439Z","completed_at":"2026-01-31T15:58:50.307512595Z","notes":[{"timestamp":"2026-01-31T15:57:40.913521034Z","author":"patrick","message":"## Problem\nThe entity managers (in manage-page.js) and entity-helpers.js already try to trigger refreshes by calling:\n htmx.trigger(manageLoader, 'load')\n\nHowever, the HTMX element in manage.templ (line 64) has:\n hx-trigger=\"load\"\n\nThis only responds to the initial page load event, not subsequent programmatic 'load' triggers.\n\n## Requirements\n1. Modify the HTMX trigger in manage.templ to respond to a custom refresh event\n2. Update manage-page.js entity manager onSuccess callbacks to dispatch the custom event\n3. Update entity-helpers.js refreshEntityDropdown to dispatch the custom event\n4. Ensure both creates and deletes trigger the refresh\n5. Test all CRUD operations for all entity types (beans, roasters, grinders, brewers)\n\n## Implementation Approach\n1. Add custom event listener to hx-trigger (e.g., 'load, refreshManage from:body')\n2. Dispatch 'refreshManage' event instead of/in addition to triggering 'load'\n3. Verify the event bubbles up to trigger the HTMX reload\n\n## Files to Modify\n- internal/web/pages/manage.templ (HTMX trigger)\n- web/static/js/manage-page.js (entity manager onSuccess callbacks)\n- web/static/js/entity-helpers.js (refreshEntityDropdown function)\n\n## Success Criteria\n- Creating an entity via modal immediately shows it in the table\n- Updating an entity via modal immediately shows changes in the table\n- Deleting an entity immediately removes it from the table\n- Works for all four entity types (beans, roasters, grinders, brewers)"},{"timestamp":"2026-01-31T15:58:47.084907175Z","author":"patrick","message":"## Implementation Complete\n\nModified three files to fix table refresh:\n\n1. **internal/web/pages/manage.templ**\n - Updated hx-trigger to: 'load, refreshManage from:body'\n - Now responds to both initial page load AND custom refresh events\n\n2. **web/static/js/manage-page.js**\n - Updated all 4 entity manager onSuccess callbacks\n - Now dispatches 'refreshManage' CustomEvent on document.body\n\n3. **web/static/js/entity-helpers.js**\n - Updated refreshEntityDropdown function\n - Dispatches 'refreshManage' event when on manage page\n\nThe custom event approach is cleaner than triggering 'load' programmatically and ensures proper event bubbling for HTMX to catch."},{"timestamp":"2026-01-31T16:00:19.33430635Z","author":"patrick","message":"## Bug Fix: Delete not refreshing table\n\nFound that the delete() method in entity-manager.js wasn't calling the onSuccess callback, while save() was.\n\n**Modified:** web/static/js/entity-manager.js (lines 154-162)\n- Added onSuccess callback invocation after cache invalidation in delete method\n- Now both save() and delete() trigger the refreshManage event\n- Delete operations will now properly refresh the manage page tables"},{"timestamp":"2026-01-31T16:02:50.833338786Z","author":"patrick","message":"## Additional fix for delete refresh\n\nThe onSuccess callback approach wasn't sufficient for deletes. Added explicit event dispatching:\n\n**Modified files:**\n1. web/static/js/entity-manager.js\n - delete() method now returns boolean (true if successful, false if cancelled/failed)\n \n2. web/static/js/manage-page.js \n - All delete methods now check return value and dispatch refreshManage only on success\n - Prevents refresh if user cancels the confirmation dialog\n\n**Flow:**\n1. User clicks Delete → Alpine.js calls deleteBean/deleteRoaster/etc\n2. Entity manager shows confirmation dialog\n3. If cancelled: return false, no refresh\n4. If confirmed: DELETE request → return true\n5. manage-page.js receives true → dispatches refreshManage event\n6. HTMX hears event → reloads table\n\nThis is more reliable than relying solely on the onSuccess callback."},{"timestamp":"2026-01-31T16:08:49.961660711Z","author":"patrick","message":"## SOLUTION: Converted delete to use HTMX instead of Alpine.js\n\nThe root issue was mixing patterns - Edit used HTMX but Delete used Alpine.js + JavaScript fetch. This created inconsistency and timing issues.\n\n**Changed:** internal/web/components/manage_partial.templ\n- All delete buttons now use HTMX attributes instead of @click\n- hx-delete=\"/api/beans/{rkey}\" - sends DELETE request\n- hx-confirm=\"Are you sure...\" - built-in confirmation dialog\n- hx-target=\"closest tr\" - targets the parent table row\n- hx-swap=\"outerHTML swap:0.5s\" - removes row with fade animation\n\n**How it works:**\n1. User clicks Delete\n2. HTMX shows confirmation dialog\n3. If confirmed, sends DELETE to /api/beans/{rkey}\n4. Server deletes and returns 200 OK (empty body)\n5. HTMX swaps the table row's outerHTML with empty response\n6. Row instantly vanishes with 0.5s fade effect\n\n**Benefits:**\n- Immediate visual feedback (no full table reload needed)\n- Consistent with Edit button pattern\n- Simpler than JavaScript approach\n- Row-level granularity (only removes what was deleted)\n- No Alpine.js/HTMX coordination issues\n\n**Note:** The delete methods in manage-page.js are now unused and could be cleaned up in a future refactor."}]} 30 - {"id":"01KGAEB648HERFCPSKW446H8SE","title":"Add closed/open status for coffee bean bags","description":"Add a boolean 'closed' field to the bean lexicon. Update UI to:\n1. Filter closed bags from brew dropdown\n2. Show open bags table at top of profile page\n3. Show closed bags table below roasters on profile page\n4. Show all bags in manage page with newest first, adding an 'open' column\n\nChanges needed:\n- Update lexicons/social.arabica.alpha.bean.json\n- Update internal/atproto/records.go for Bean model\n- Update internal/models/models.go\n- Update brew form dropdown to filter closed beans\n- Update profile page layout with separate tables\n- Update manage page table with open/closed column","status":"completed","priority":"high","assignee":"patrick","created_at":"2026-01-31T16:31:57.0649568Z","updated_at":"2026-01-31T16:55:24.596373389Z","completed_at":"2026-01-31T16:43:26.855808963Z","notes":[{"timestamp":"2026-01-31T16:43:26.748989495Z","author":"patrick","message":"Successfully implemented closed bag feature:\n- Added 'closed' boolean field to bean lexicon\n- Updated Bean model and request structs\n- Updated record conversion functions\n- Filtered closed bags from brew form dropdown\n- Split beans table on profile page into open bags (top) and closed bags (below roasters)\n- Added Status column to manage page beans table with Open/Closed badges\n- Added checkbox to bean form modals for marking bags as closed\n- Updated form handlers to parse closed field\nAll changes tested and verified to compile successfully."},{"timestamp":"2026-01-31T16:50:21.14203731Z","author":"patrick","message":"Fixed bean editing/closing issue:\n- Removed method='dialog' from all entity form dialogs (bean, roaster, grinder, brewer)\n- Added HTMX event handler to close dialog after successful submission\n- Added 'closed: false' to beanManager defaultFormData in manage-page.js\n- Updated editBean() to accept and pass closed parameter\n- Forms now properly submit via HTMX before closing\n- The 'closed' checkbox value is now correctly saved to PDS"},{"timestamp":"2026-01-31T16:55:24.581637936Z","author":"patrick","message":"Additional debugging and fixes:\n- Added explicit hx-trigger='submit' to all dialog forms to ensure HTMX intercepts form submission\n- Changed Alpine.js @ syntax to hx-on:: syntax for HTMX event handlers for better compatibility\n- Added explicit value='true' to closed checkbox for clearer form data\n- Updated handlers to check for 'true' instead of 'on' for closed field\n- Added debug logging to show what values are being received in handlers\n\nPlease test again and check server logs for debug output showing the closed_value and closed_parsed fields."}]} 31 - {"id":"01KGAW0BCP3ZN415GBW40JFYST","title":"Use consistent default profile picture across profile and feed pages","description":"The default avatar fallback on the profile page uses bg-brown-300 (lighter brown) for large size, while the feed uses bg-brown-600 (darker brown) for medium size. This creates visual inconsistency. Update the Avatar component to use the same background color (bg-brown-300) for all sizes to maintain consistency across the app.","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-01-31T20:30:42.070239386Z","updated_at":"2026-01-31T20:32:38.61578807Z","completed_at":"2026-01-31T20:32:38.60500932Z"} 32 - {"id":"01KGAW9PRR6X6WHKCZVKY2KQBH","title":"Add hover effect to profile avatars","description":"Add visual hover effects to profile avatars throughout the app (feed, header, profile pages). The effect should provide visual feedback when users hover over clickable profile pictures. Consider adding a subtle scale transform, glow effect, or ring color change to indicate interactivity.","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-01-31T20:35:48.632605618Z","updated_at":"2026-01-31T21:13:34.89255148Z","completed_at":"2026-01-31T20:37:03.856961732Z","notes":[{"timestamp":"2026-01-31T20:37:00.955611391Z","author":"patrick","message":"## Hover Effects Added\n\nUpdated the Avatar component in internal/web/components/shared.templ to add interactive hover effects:\n\n**For image avatars (all sizes):**\n- transition-all duration-200 - Smooth transitions for all properties\n- hover:ring-amber-400 - Ring color changes to amber on hover\n- hover:ring-4 - Ring grows thicker on hover (from ring-2 to ring-4)\n- hover:scale-110 (sm/md) - Avatar scales to 110% on hover\n- hover:scale-105 (lg) - Large avatars scale to 105% (subtler)\n\n**For fallback avatars (initials):**\n- Same transition and ring effects for sm/md sizes\n- hover:bg-brown-400 for large size (background darkens slightly)\n- hover:scale-110 (sm/md) and hover:scale-105 (lg)\n\n**Also fixed:**\n- Updated fallback avatar ring color from ring-brown-600 to ring-brown-500 for consistency with image avatars\n\n**Effect:**\n- Avatars in the feed, header, and profile pages now have smooth, visually appealing hover effects\n- The amber ring glow provides clear visual feedback that the avatar is clickable\n- Scale transform adds a subtle \"lift\" effect that feels responsive\n- 200ms duration provides smooth transitions without lag"},{"timestamp":"2026-01-31T21:13:34.87780839Z","author":"patrick","message":"## Final Implementation\n\nUpdated avatar hover effects based on feedback:\n\n**Changes:**\n1. Changed hover ring color from amber-400 (yellow) to brown-400 (more brown tone)\n2. Removed all scale transforms - avatars stay the same size on hover\n3. Only the ring/border changes color and thickness\n\n**Hover Effects (all sizes):**\n- transition-all duration-200 - Smooth 200ms transitions\n- hover:ring-brown-400 (sm/md) - Ring color changes to lighter brown\n- hover:border-brown-400 (lg) - Border color changes to lighter brown \n- hover:ring-4 / hover:border-4 - Ring/border grows from 2px to 4px thickness\n- hover:bg-brown-400 (lg fallback only) - Background darkens slightly\n\n**Result:**\n- Subtle, elegant hover effect that doesn't distort the avatar\n- Brown color palette stays consistent with the app theme\n- Clear visual feedback that avatars are clickable\n- Works across feed, header, and profile pages"}]} 33 - {"id":"01KGAYXQVTNKFSNEBYZHV0XVPG","title":"Remove unused Go functions in atproto package","description":"Several exported functions in the atproto package are completely unused:\n\n1. ResolveBeanRef() - internal/atproto/resolver.go:78\n - Wrapper for resolveRef() that's never called\n - ResolveBeanRefWithRoaster() is used instead\n\n2. GetSessionDataFromContext() - internal/atproto/oauth.go:203\n - Leftover from refactoring, never called\n\n3. RequireAuth() - internal/atproto/oauth.go:212\n - Alternative auth middleware that's never registered\n\n4. Commented-out fetchAllData() - internal/handlers/handlers.go:1530-1564\n - Replaced by inline errgroup usage\n\nAdditionally verify if these are truly unused:\n- DeleteAuthRequestInfo() in boltstore/session_store.go\n- DeleteAllSessionsForDID() in boltstore/session_store.go\n\nAcceptance criteria:\n- Remove all confirmed dead code\n- Verify no external usage exists\n- Run tests to confirm nothing breaks","status":"completed","priority":"high","created_at":"2026-01-31T21:21:42.26644177Z","updated_at":"2026-01-31T21:28:01.229416423Z","completed_at":"2026-01-31T21:28:01.218546904Z","notes":[{"timestamp":"2026-01-31T21:27:57.208998517Z","author":"patrick","message":"Successfully removed unused Go functions:\n\nREMOVED:\n1. ResolveBeanRef() from internal/atproto/resolver.go:78 - Confirmed unused, only ResolveBeanRefWithRoaster() is used\n2. GetSessionDataFromContext() from internal/atproto/oauth.go:203 - Confirmed unused\n3. RequireAuth() from internal/atproto/oauth.go:212 - Confirmed unused middleware\n4. DeleteAllSessionsForDID() from internal/database/boltstore/session_store.go:168 - Confirmed unused\n\nRETAINED:\n- DeleteAuthRequestInfo() in boltstore/session_store.go - Required by oauth.ClientAuthStore interface, cannot be removed\n\nNOT FOUND:\n- Commented-out fetchAllData() in handlers.go - Already removed in previous cleanup\n\nVERIFICATION:\n- Searched codebase to confirm no usage of removed functions\n- go build completes without errors\n- All tests pass (go test ./...)"}]} 34 - {"id":"01KGAYXQY6N1RPV042XF9907N6","title":"Refactor entity CRUD handlers to reduce duplication","description":"The handlers.go file contains significant code duplication across entity CRUD operations:\n\n**Create handlers (4 nearly identical):**\n- HandleBeanCreate (lines 897-960)\n- HandleRoasterCreate (lines 963-1011)\n- HandleGrinderCreate (lines 1223-1272)\n- HandleBrewerCreate (lines 1359-1407)\n\n**Update handlers (4 nearly identical):**\n- HandleBeanUpdate (lines 1039-1114)\n- HandleRoasterUpdate (lines 1139-1198)\n- HandleGrinderUpdate (lines 1274-1334)\n- HandleBrewerUpdate (lines 1409-1468)\n\nEach follows the exact same pattern:\n1. Check authentication\n2. Parse Content-Type (JSON vs form)\n3. Decode request body\n4. Validate request\n5. Call store method\n6. Encode JSON response\n\n**Proposed refactoring:**\nCreate a generic handler factory or helper that accepts:\n- Request type struct\n- Entity type/NSID\n- Form field mapping\n- Store method reference\n\nThis could reduce 500+ lines of repetitive code.\n\nAcceptance criteria:\n- Extract common logic into reusable functions\n- Reduce total lines by at least 300\n- Maintain all existing functionality\n- All tests pass","status":"completed","priority":"normal","created_at":"2026-01-31T21:21:42.342615886Z","updated_at":"2026-01-31T21:28:10.769242471Z","completed_at":"2026-01-31T21:28:10.756429903Z","notes":[{"timestamp":"2026-01-31T21:28:08.516192437Z","author":"patrick","message":"Refactoring completed successfully.\n\nChanges made:\n1. Added 3 new helper functions:\n - isJSONRequest() - Checks if request Content-Type is JSON\n - decodeRequest() - Generic decoder for JSON or form data\n - writeJSON() - Standardized JSON response encoding\n\n2. Refactored 8 entity CRUD handlers to use helpers:\n - HandleBeanCreate, HandleBeanUpdate\n - HandleRoasterCreate, HandleRoasterUpdate\n - HandleGrinderCreate, HandleGrinderUpdate\n - HandleBrewerCreate, HandleBrewerUpdate\n\nResults:\n- Reduced file from 2239 to 2185 lines (54 lines removed)\n- Eliminated duplication of Content-Type parsing logic (8 occurrences)\n- Eliminated duplication of form parsing logic (8 occurrences)\n- Eliminated duplication of JSON encoding logic (8 occurrences)\n- All handlers now follow consistent patterns\n- All tests pass (15 test cases, 100% pass rate)\n- Code successfully compiles\n\nPattern established:\nEach handler now follows a clean, consistent flow:\n1. Authenticate via getAtprotoStore()\n2. Decode via decodeRequest() helper\n3. Validate request\n4. Execute store operation\n5. Encode response via writeJSON() helper\n\nThe refactoring makes it much easier to add new entity types in the future by following the established pattern."}]} 35 - {"id":"01KGAYXR0JP80F5KCQG26VHF5B","title":"Remove deprecated modal component files","description":"Several modal component files are marked as deprecated and should be removed:\n\n**Files to remove:**\n1. internal/web/components/modal.templ\n - Contains Alpine.js x-show based Modal components\n - Comment: 'This file is kept only for backwards compatibility and will be removed in a future cleanup'\n\n2. internal/web/components/modal_templ.go (generated)\n\n3. internal/web/components/entity_modals.templ\n - Contains deprecated Alpine.js modals for Bean, Grinder, Brewer, Roaster\n - Superseded by dialog_modals.templ using native HTML5 \u003cdialog\u003e\n\n4. internal/web/components/entity_modals_templ.go (generated)\n\n**Also update:**\n- internal/web/pages/components_test.go has test 'Modal with static title' that tests deprecated component\n\n**Current replacement:**\n- dialog_modals.templ uses native HTML5 \u003cdialog\u003e elements\n\nAcceptance criteria:\n- Remove all deprecated modal files\n- Update or remove any tests using deprecated components\n- Verify no imports reference removed files\n- App still functions correctly","status":"completed","priority":"normal","created_at":"2026-01-31T21:21:42.418646862Z","updated_at":"2026-01-31T21:26:34.970210414Z","completed_at":"2026-01-31T21:26:34.955317746Z","notes":[{"timestamp":"2026-01-31T21:26:34.938735417Z","author":"patrick","message":"All deprecated modal component files have been successfully removed:\n\nFiles removed:\n- internal/web/components/modal.templ\n- internal/web/components/modal_templ.go\n- internal/web/components/entity_modals.templ\n- internal/web/components/entity_modals_templ.go\n\nTest updates:\n- Removed TestModalComponents from internal/web/pages/components_test.go\n\nVerification:\n- No code references to removed components remain\n- All web page tests pass\n- The app uses dialog_modals.templ for all modal functionality"}]} 36 - {"id":"01KGAYXR359TWZPS2AJF9ZB4S5","title":"Extract profile data fetching into reusable function","description":"Two handlers contain nearly identical ~100-line blocks for fetching user profile data:\n\n**Duplicated in:**\n1. HandleProfile (lines 1628-1733)\n2. HandleProfilePartial (lines 1875-1980)\n\nBoth do:\n- Create errgroup context\n- Fetch beans, roasters, grinders, brewers, brews in parallel\n- Parse records and build lookup maps\n- Resolve references between entities\n\n**Proposed refactoring:**\nExtract into a single helper function:\n\nfunc (h *Handler) fetchUserData(ctx context.Context, did string, publicClient *atproto.PublicClient) (*UserDataBundle, error)\n\nThis function would return a struct containing:\n- Beans (with roasters resolved)\n- Roasters\n- Grinders\n- Brewers\n- Brews (with all refs resolved)\n\nAcceptance criteria:\n- Create fetchUserData helper function\n- Refactor both handlers to use it\n- Reduce duplicated code by ~80-100 lines\n- All profile functionality works as before","status":"completed","priority":"low","created_at":"2026-01-31T21:21:42.501437644Z","updated_at":"2026-01-31T21:32:30.483213969Z","completed_at":"2026-01-31T21:32:30.468389045Z"} 37 - {"id":"01KGB1KDNNZZ7ZDA5NCK0T4E1G","title":"Create shared entity table components for profile and manage pages","description":"## Summary\nRefactor the profile and manage page tables to use shared components, reducing duplication and improving maintainability.\n\n## Current State\n- `manage_partial.templ` has tables for beans, roasters, grinders, brewers with Edit/Delete actions\n- `profile_partial.templ` has similar tables but without actions\n- Beans on profile are split into \"Open Bags\" and \"Closed Bags\" sections\n- Significant HTML/structure duplication between the two files\n\n## Proposed Solution\nCreate shared table components with configurable props:\n\n### Shared Components to Create\n1. **BeansTable** - shared beans table component\n2. **RoastersTable** - shared roasters table component \n3. **GrindersTable** - shared grinders table component\n4. **BrewersTable** - shared brewers table component\n\n### Props Pattern\nEach component should accept props that control behavior:\n\n```go\ntype EntityTableProps struct {\n Items []*models.Entity\n ShowActions bool // Whether to render Edit/Delete column\n // Other entity-specific options as needed\n}\n```\n\n### Beans Table Special Handling\nThe beans table has unique requirements:\n- Manage page: single table with Status column, shows all beans\n- Profile page: split into Open/Closed sections, no Status column\n\nOptions to handle this:\n1. **Single component with `ShowStatus` prop** - manage uses it, profile calls it twice with filtered lists\n2. **`GroupByStatus` prop** - component internally groups and renders sections\n3. Keep beans separate if complexity doesn't warrant sharing\n\nRecommend option 1 - simpler, profile already filters beans via `filterOpenBeans`/`filterClosedBeans`.\n\n### Actions Implementation\nWhen `ShowActions: true`:\n- Render action column header\n- Render Edit button with `hx-get=\"/api/modals/{entity}/{rkey}\"`\n- Render Delete button with `hx-delete=\"/api/{entities}/{rkey}\"`\n\n### Files to Modify\n- `internal/web/components/manage_partial.templ` - use shared components\n- `internal/web/components/profile_partial.templ` - use shared components\n- `internal/web/components/entity_tables.templ` (new) - shared table components\n\n### Acceptance Criteria\n- [ ] Shared table components created in `entity_tables.templ`\n- [ ] `manage_partial.templ` refactored to use shared components\n- [ ] `profile_partial.templ` refactored to use shared components\n- [ ] Actions can be enabled/disabled via props\n- [ ] No visual regressions on manage page\n- [ ] No visual regressions on profile page\n- [ ] `templ generate` runs successfully\n- [ ] Tests pass (if any)","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","refactoring"],"created_at":"2026-01-31T22:08:29.877786981Z","updated_at":"2026-01-31T22:16:42.669635082Z","completed_at":"2026-01-31T22:16:42.657711384Z"} 38 - {"id":"01KGB2WBMP539SFSKZCKJAYH0C","title":"Prevent line wrap between emoji and text in table headers","description":"Add whitespace-nowrap to table header cells to prevent the emoji and column name from wrapping onto separate lines.","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend"],"created_at":"2026-01-31T22:30:51.286845962Z","updated_at":"2026-01-31T22:31:23.380048647Z","completed_at":"2026-01-31T22:31:23.369060496Z"} 39 - {"id":"01KGBF5Q2KJ8PZNSMJPFFF31C8","title":"Add AT Protocol explanation page","description":"Create a page explaining what the AT Protocol is that the footer link redirects to instead of atproto.com.\n\nAcceptance criteria:\n- New /atproto page explaining the AT Protocol\n- Footer AT Protocol link redirects to /atproto instead of external site\n- Page matches site design system\n- Explains what AT Protocol is and how Arabica uses it\n- Links to atproto.com for those who want more info","status":"cancelled","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-06T01:44:49.962642705Z","notes":[{"timestamp":"2026-02-01T02:10:19.279321594Z","author":"patrick","message":"Initial implementation complete:\n- Created /atproto page with AT Protocol explanation\n- Updated footer link to point to /atproto instead of external site\n- Added route and handler\n- Page covers: PDS, DIDs, Lexicons, AT-URIs, how Arabica uses ATProto\n- Includes links to atproto.com for more info\nReady for review and final polish"}]} 40 - {"id":"01KGBM1YCGZRNTVCJDZFV3100R","title":"Move web/static to static directory","description":"Move web/static directory to top-level static directory and update all references across the codebase.\n\nFiles to update:\n- flake.nix (tailwindcss paths)\n- .gitignore (output.css path)\n- justfile (tailwindcss paths)\n- default.nix (tailwindcss paths)\n- .gitattributes (vendored JS paths)\n- internal/routing/routing.go (file server path)\n- deploy/Dockerfile (COPY command)\n- CLAUDE.md (documentation)\n\nAcceptance criteria:\n- web/static moved to static/\n- All references updated\n- Server still serves static files correctly\n- Tailwind CSS build still works","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T03:31:00.112467261Z","updated_at":"2026-02-01T03:32:59.754877212Z","completed_at":"2026-02-01T03:32:59.739794374Z"} 41 - {"id":"01KGDXVE566WVDG8TC9DWPSYXF","title":"Implement likes for social interactions","description":"Add a like lexicon and implement like functionality across the app.\n\n## Lexicon: social.arabica.alpha.like\n\nThe like record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support liking any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and comments once implemented)\n- Include createdAt timestamp\n- Use TID as record key\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.like.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Like model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateLike, DeleteLike, GetLikesForSubject, GetUserLikes\n6. Implement in AtprotoStore\n7. Update firehose indexing to track likes\n\n## Frontend Implementation\n\n1. Add like button component\n2. Show like counts on brews/beans/etc in feed\n3. Update like counts in response to firehose events (real-time updates)\n4. Toggle like state based on current user's likes\n\n## Acceptance Criteria\n\n- Users can like any arabica.social record\n- Likes are stored in the user's PDS (actor-owned data)\n- Like counts display on records in the feed\n- Real-time like count updates via firehose\n- Optimistic UI updates for likes","status":"cancelled","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-06T01:45:10.223739659Z"} 42 16 {"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"} 43 17 {"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"} 44 - {"id":"01KGGG8EHG9Y6VQ8PZM9W74584","title":"Refactor lexicon record types to use concrete RecordType","description":"Replace magic strings for lexicon record types with a concrete RecordType type and defined constants.\n\n## Current State\nMagic strings like \"brew\", \"bean\", \"roaster\", \"grinder\", \"brewer\" are used throughout the codebase:\n- `internal/feed/service.go` - FeedItem.RecordType field uses string type\n- `internal/firehose/index.go` - Sets RecordType to string literals\n- `internal/web/pages/feed.templ` - Switch cases on string values\n- `internal/handlers/handlers.go` - writeJSON calls with string type names\n\n## Proposed Changes\n1. Define a `RecordType` type in `internal/atproto/nsid.go` (or a new file)\n2. Create constants: `RecordTypeBrew`, `RecordTypeBean`, `RecordTypeRoaster`, `RecordTypeGrinder`, `RecordTypeBrewer`, `RecordTypeLike`\n3. Update `FeedItem.RecordType` in `feed/service.go` to use the new type\n4. Update all string literal usages to use the constants\n5. Consider adding helper methods (e.g., `RecordType.String()`, `RecordType.DisplayName()`)\n\n## Acceptance Criteria\n- No magic strings for record types remain in Go code\n- Template switch statements updated to use constants\n- Type safety enforced at compile time\n- All tests pass","status":"completed","priority":"normal","labels":["refactoring","tech-debt"],"created_at":"2026-02-03T01:00:51.120049807Z","updated_at":"2026-02-03T01:06:55.448414863Z","completed_at":"2026-02-03T01:06:55.435337779Z"} 45 - {"id":"01KGGHX6R13BR2S5K4WZM79F6K","title":"Design profile brews with social features","description":"## Problem\n\nThe profile page currently displays brews in a table format, but we need to add likes and shares which don't fit ergonomically into table columns. Additionally, the table design doesn't work well on mobile.\n\n## Options to Evaluate\n\n### Option A: Add columns to existing table\n- Add Like count and Share button columns\n- Pros: Minimal change, consistent with current design\n- Cons: Tables already cramped, poor mobile experience, doesn't match feed UX\n\n### Option B: Convert brews to card-based layout (like feed)\n- Reuse/adapt FeedBrewContent component for profile brews\n- Keep tables for equipment/beans (less social, more data-dense)\n- Pros: Better mobile experience, natural fit for social buttons, consistent with feed\n- Cons: More work, different UX between profile and brew list\n\n### Option C: Hybrid responsive approach\n- Cards on mobile, table on desktop\n- Pros: Best of both worlds\n- Cons: More complexity, two layouts to maintain\n\n## Acceptance Criteria\n\n1. Research complete: Document the chosen approach with rationale\n2. Design spec: Mockup or clear description of the new layout\n3. If cards: Define how profile brew cards differ from feed cards (actions, ownership indicators)\n4. Consider: Should beans/equipment also get social features eventually?\n\n## Implementation Subtasks (create as separate cells)\n\nAfter design is approved:\n- [ ] Create ProfileBrewCard component (if card approach)\n- [ ] Update profile brews tab to use new layout \n- [ ] Add like count display to brew entries\n- [ ] Add share button to brew entries\n- [ ] Mobile responsiveness testing\n- [ ] Update any related styles in app.css\n\n## Notes\n\n- Social buttons already exist in components/social_buttons.templ\n- Feed cards provide a good reference: pages/feed.templ\n- Current brew table: components/brew_list_table.templ\n- Profile page: pages/profile.templ","status":"completed","priority":"high","assignee":"patrick","labels":["frontend","design","ux"],"created_at":"2026-02-03T01:29:39.841071728Z","updated_at":"2026-02-03T01:40:12.593195795Z","completed_at":"2026-02-03T01:40:12.57909568Z","notes":[{"timestamp":"2026-02-03T01:39:17.359358581Z","author":"patrick","message":"## Final Design Approved\n\n### Card Structure\n- Reuse feed cards as-is with author header (username/avatar)\n- Content area clickable → navigates to /brews/{id}\n- New action bar below content (outside record div)\n\n### Action Bar Order\n[💬 Comments] [♡ Like] [↗ Share] [⋯ More]\n\n- Comments: placeholder/disabled for future\n- Like/Share: moved from top-right to action bar\n- More menu (shows for ALL users):\n - Edit (owner only)\n - Delete (owner only) \n - Report (all users - placeholder endpoint + TODO)"}]} 46 - {"id":"01KGGJF66PJV9SSKCTGJP4ETWP","title":"Create ActionBar component for feed/profile cards","description":"## Task\n\nCreate a new ActionBar component for use below feed card content.\n\n## Component: ActionBar\n\n**Location:** `internal/web/components/action_bar.templ`\n\n**Props:**\n- SubjectURI string - AT-URI of the record\n- SubjectCID string - CID for strong reference\n- IsLiked bool - current user liked state\n- LikeCount int - number of likes\n- IsOwner bool - whether current user owns this record\n- ViewURL string - URL for the detail view (card click)\n- EditURL string - URL for edit action (owner only)\n- DeleteURL string - URL/handler for delete (owner only)\n- IsAuthenticated bool - whether user is logged in\n\n**Layout:**\n`[💬 Comments] [♡ Like] [↗ Share] [⋯ More]`\n\n- Comments: disabled placeholder icon + '0' count\n- Like: reuse existing like button logic (HTMX toggle)\n- Share: reuse existing share button logic\n- More: Alpine.js dropdown menu with Edit/Delete/Report\n\n**Styling:**\n- Horizontal flex row with gap\n- Icons with counts where applicable\n- More menu as dropdown positioned bottom-right\n\n## Acceptance Criteria\n- [ ] ActionBar component created with all props\n- [ ] Comments placeholder (disabled, shows 0)\n- [ ] Like button functional (HTMX toggle)\n- [ ] Share button functional (Web Share API)\n- [ ] More dropdown with Edit/Delete (owner) and Report (all)\n- [ ] templ generate runs successfully","status":"completed","priority":"high","assignee":"patrick","labels":["frontend","component"],"created_at":"2026-02-03T01:39:29.110143411Z","updated_at":"2026-02-03T01:43:15.859354984Z","completed_at":"2026-02-03T01:43:15.845204627Z"} 47 - {"id":"01KGGJFDK0S6VBVY51HJWDS6CF","title":"Update feed cards to use ActionBar","description":"## Task\n\nUpdate the feed page to use the new ActionBar component instead of inline social buttons.\n\n## Changes to feed.templ\n\n1. Remove social buttons from author row (top-right)\n2. Make content area clickable (link to /brews/{rkey} or appropriate detail URL)\n3. Add ActionBar below the content div\n4. Pass appropriate props (SubjectURI, SubjectCID, like state, ownership, URLs)\n\n## Card Click Behavior\n\n- Wrap .feed-content-box in an anchor tag\n- Link to brew detail: /brews/{rkey}\n- For other record types (beans, equipment): link to appropriate manage section or skip\n\n## Acceptance Criteria\n- [ ] Social buttons removed from author row\n- [ ] Content area clickable for brews\n- [ ] ActionBar rendered below content\n- [ ] Like/Share still functional via ActionBar\n- [ ] More menu shows Edit/Delete for owner, Report for all\n- [ ] templ generate runs successfully\n- [ ] Visual regression check on feed page","status":"completed","priority":"high","assignee":"patrick","labels":["frontend"],"created_at":"2026-02-03T01:39:36.672372228Z","updated_at":"2026-02-03T01:44:45.86254562Z","completed_at":"2026-02-03T01:44:45.851397847Z"} 48 - {"id":"01KGGJFSZ4YDNF6ZE8F9WRT79F","title":"Replace profile brew table with cards","description":"## Task\n\nReplace the table-based brew list on the profile page with card-based layout matching the feed.\n\n## Changes\n\n### profile.templ / profile_partial.templ\n1. Replace BrewListTablePartial with card-based layout\n2. Reuse FeedItem/FeedBrewContent components or create ProfileBrewCard\n3. Include ActionBar with owner actions enabled\n4. Maintain grid/list layout for multiple brews\n\n### Layout Considerations\n- Cards should stack vertically (like feed)\n- Consider max-width for readability\n- Mobile: single column\n- Desktop: could be single column or 2-column grid\n\n### Props to Pass\n- Full brew data\n- Bean/roaster/grinder/brewer references\n- IsOwner: true (it's the user's profile)\n- Like/share state for each brew\n\n## Acceptance Criteria\n- [ ] Brew table replaced with cards on profile\n- [ ] Cards show all brew info (bean, method, variables, notes, rating)\n- [ ] ActionBar with Edit/Delete functional for owner\n- [ ] Cards link to brew detail view\n- [ ] Mobile responsive\n- [ ] templ generate runs successfully","status":"completed","priority":"high","assignee":"patrick","labels":["frontend"],"created_at":"2026-02-03T01:39:49.348915829Z","updated_at":"2026-02-03T01:49:13.11140332Z","completed_at":"2026-02-03T01:49:13.098282125Z"} 49 - {"id":"01KGGJG2FDK2NN8H059PTHNME0","title":"Add placeholder report endpoint","description":"## Task\n\nAdd a placeholder report endpoint for the More menu Report action.\n\n## Endpoint\n\n**Route:** POST /api/report\n**Handler:** HandleReport\n\n## Request Body\n```json\n{\n \"subject_uri\": \"at://did:plc:xxx/social.arabica.alpha.brew/rkey\",\n \"subject_cid\": \"bafyrei...\",\n \"reason\": \"spam|inappropriate|other\"\n}\n```\n\n## Response\n- 200 OK with `{\"status\": \"received\"}`\n- Log the report for now\n- TODO comment indicating future implementation needs\n\n## Implementation Notes\n- Add route to routing.go\n- Create minimal handler that logs and returns success\n- No actual moderation system yet - just capture the intent\n\n## Acceptance Criteria\n- [ ] POST /api/report endpoint exists\n- [ ] Accepts subject_uri, subject_cid, reason\n- [ ] Logs report details\n- [ ] Returns success response\n- [ ] TODO comment for future moderation system","status":"completed","priority":"normal","assignee":"patrick","labels":["backend","api"],"created_at":"2026-02-03T01:39:58.061904095Z","updated_at":"2026-02-03T01:43:12.681518812Z","completed_at":"2026-02-03T01:43:12.667221097Z"} 50 - {"id":"01KGGK6V4WMC3X67E72JTE27A7","title":"Fix 'failed to convert record to feed item' warning for likes","description":"The firehose index logs a warning 'failed to convert record to feed item' when processing like records. This is expected behavior since likes are not displayed as standalone feed items - they're indexed for like counts but shouldn't appear in the feed.\n\nThe warning appears in internal/firehose/index.go:433 when recordToFeedItem returns an error for NSIDLike records (line 582-584).\n\nFix: Handle the like case silently without logging a warning, since this is expected behavior. Either:\n1. Skip like records before calling recordToFeedItem\n2. Return a special sentinel error from recordToFeedItem that indicates 'skip without warning'\n3. Check the collection type at the call site and skip likes there\n\nThis applies to both authenticated and unauthenticated users.","status":"completed","priority":"normal","labels":["bug"],"created_at":"2026-02-03T01:52:24.220349056Z","updated_at":"2026-02-03T01:54:54.698106017Z","completed_at":"2026-02-03T01:54:54.686934901Z"} 51 - {"id":"01KGGNPG25M2AJ619P65FPMEH4","title":"Add OpenGraph metadata to brew pages","description":"Add dynamic OpenGraph metadata support for brew pages to enable rich social sharing previews.\n\n## Background\n\nCurrently, the site uses static OpenGraph tags in layout.templ:\n- og:title: \"Arabica - Coffee Brew Tracker\" (static)\n- og:description: \"Track your coffee brewing journey...\" (static) \n- og:type: \"website\" (static)\n- og:image: NOT PRESENT\n\nWhen users share brew links on social media, all previews look identical and provide no context about the specific brew.\n\n## Requirements\n\n### 1. Extend LayoutData struct\n\nAdd optional OpenGraph fields to `internal/web/components/layout.templ`:\n\n```go\ntype LayoutData struct {\n Title string\n IsAuthenticated bool\n UserDID string\n UserProfile *bff.UserProfile\n CSPNonce string\n \n // OpenGraph metadata (optional, falls back to defaults)\n OGTitle string\n OGDescription string\n OGImage string\n OGType string // \"website\", \"article\"\n OGUrl string // Canonical URL\n}\n```\n\n### 2. Update layout.templ head section\n\nModify meta tag rendering to use dynamic values with fallbacks:\n- If OGTitle set, use it; otherwise use site default\n- If OGDescription set, use it; otherwise use site default\n- If OGImage set, render og:image tag (currently missing entirely)\n- If OGUrl set, render og:url tag\n- Support og:type (default \"website\", brew pages use \"article\")\n\n### 3. Update buildLayoutData helper\n\nExtend `handlers.go` buildLayoutData() to accept optional OG parameters, or create a new helper `buildLayoutDataWithOG()`.\n\n### 4. Update HandleBrewView handler\n\nConstruct descriptive OG metadata from brew data:\n- **og:title**: \"{Bean Name} from {Origin} - Arabica\" or similar\n- **og:description**: \"{Rating}/10 - {Tasting Notes preview}\" or brew summary\n- **og:type**: \"article\"\n- **og:url**: The share URL (already computed as `shareURL`)\n- **og:image**: Static default for now (e.g., /static/og-brew-default.png)\n\nAvailable data for description construction:\n- brew.Rating, brew.TastingNotes\n- brew.Bean.Name, brew.Bean.Origin, brew.Bean.RoastLevel\n- brew.Bean.Roaster.Name\n- brew.CoffeeAmount, brew.WaterAmount, brew.Temperature\n\n### 5. Add Twitter Card support\n\nAdd Twitter-specific meta tags:\n- twitter:card = \"summary\" (or \"summary_large_image\" if we have images)\n- twitter:title, twitter:description (same as OG values)\n\n### 6. Static fallback image\n\nCreate or source a default OG image for brews:\n- Location: /static/og-brew-default.png or similar\n- Size: 1200x630px (recommended OG image size)\n- Design: Coffee-themed, includes Arabica branding\n\n## Acceptance Criteria\n\n- [ ] LayoutData extended with optional OG fields\n- [ ] layout.templ renders dynamic OG tags with fallbacks\n- [ ] Brew view pages include descriptive OG metadata\n- [ ] Twitter Card tags included\n- [ ] Static default OG image created and served\n- [ ] Existing pages (home, about, etc.) continue working with defaults\n- [ ] templ generate runs successfully\n- [ ] Manual testing: share brew URL to social platform preview tool\n\n## Implementation Notes\n\n- Follow existing patterns in handlers.go for data flow\n- Keep backwards compatible - pages not setting OG fields should use defaults\n- Consider creating a helper function to build OG description from brew data\n- The shareURL is already constructed in HandleBrewView (lines 695-701)\n\n## Future Enhancements (out of scope)\n\n- Dynamic image generation showing brew stats\n- Profile page OG metadata\n- Feed page OG metadata with recent brew preview","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","social"],"created_at":"2026-02-03T02:35:54.309356038Z","updated_at":"2026-02-03T02:53:43.170537883Z","completed_at":"2026-02-03T02:53:43.15817599Z","notes":[{"timestamp":"2026-02-03T02:53:36.100130152Z","author":"patrick","message":"Implementation complete:\n- Extended LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)\n- Added helper methods ogTitle(), ogDescription(), ogType() with fallbacks\n- Updated layout.templ to render dynamic OG and Twitter Card meta tags\n- Added populateBrewOGMetadata() helper in handlers.go\n- Updated HandleBrewView to populate OG metadata from brew data\n- Added PublicURL to handler Config for absolute URLs\n- Added comprehensive tests for OG metadata generation\n- All tests pass"}]} 52 - {"id":"01KGM9QB86N5RX50042DT1YN1N","title":"Consolidate Tailwind CSS usage","description":"Reduce Tailwind class duplication by leveraging existing CSS abstractions and adding missing utility classes.\n\n## Changes Required\n\n### 1. Add new utility classes to app.css\n- `.page-container` variants (sm, md, lg, xl) for max-width containers\n- `.avatar-sm`, `.avatar-md`, `.avatar-lg` size classes\n- `.section-box` for bg-brown-50 rounded sections\n\n### 2. Update templ components to use existing abstractions\n- WelcomeCard (shared.templ) - use `.card` instead of inline gradient\n- ProfileHeader, ProfileStat, ProfileTabs (profile.templ) - use `.card`\n- Header dropdown (header.templ) - use `.action-menu`\n- Login/CTA buttons (shared.templ) - use `.btn-primary`\n- Avatar component (shared.templ) - use new CSS classes instead of templ.KV\n\n### 3. Replace inline page containers\n- Replace `max-w-4xl mx-auto` etc. with `.page-container-*` classes across all pages\n\n## Acceptance Criteria\n- No visual changes to the site\n- Reduced class verbosity in templ files\n- Avatar component simplified\n- Consistent use of existing abstractions","status":"completed","priority":"normal","assignee":"patrick","labels":["css","refactor"],"created_at":"2026-02-04T12:23:36.966151046Z","updated_at":"2026-02-04T12:27:59.86186814Z","completed_at":"2026-02-04T12:27:59.850499942Z"} 53 - {"id":"01KHABKRB6RHR2AVE8PR28R66D","title":"Account request page (/join)","description":"Create a /join page where prospective users can request an arabica.systems PDS account.\n\n## Requirements\n- Simple form collecting: email address, preferred handle (optional), brief reason/message (optional)\n- On submit, sends an email notification to the admin (arabica.systems address TBD) with the request details\n- Stores the request in BoltDB for record-keeping\n- Shows a confirmation page after submission (\"Thanks, we'll review your request and email you an invite code\")\n- Rate limiting / basic spam prevention (e.g. honeypot field)\n- Matches the existing arabica.social design system (templ components, Tailwind, brown color palette)\n\n## Technical Details\n- Add SMTP support to the application (Go stdlib net/smtp or similar)\n- New env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, ADMIN_EMAIL\n- New BoltDB bucket for account requests\n- New handler + templ page + route\n- No authentication required (public page)\n\n## Acceptance Criteria\n- Form renders correctly and matches site design\n- Email is sent to admin on submission\n- Request is persisted in BoltDB\n- Confirmation shown to user\n- Basic input validation (valid email format)","status":"in_progress","priority":"normal","assignee":"patrick","labels":["frontend","backend"],"created_at":"2026-02-13T01:59:53.958852961Z","updated_at":"2026-02-13T02:05:46.199584438Z","notes":[{"timestamp":"2026-02-13T02:03:54.190326645Z","author":"patrick","message":"SMTP approach decided: use Go stdlib net/smtp. Support both implicit TLS (port 465 via tls.Dial + smtp.NewClient) and STARTTLS (port 587/2525). VPS may block ports 25/587, so port 465 and 2525 must work. No external email SaaS dependency needed."}]} 54 - {"id":"01KHABM3J6XPXFTCPW6JX378W7","title":"Account creation page (/join/create)","description":"Create a /join/create page where users with an invite code can create their arabica.systems PDS account.\n\n## Requirements\n- Form collecting: invite code, desired handle (with @arabica.systems suffix shown), email, password (with confirmation)\n- Calls describeServer on arabica.systems PDS first to get available domains and requirements\n- Calls com.atproto.server.createAccount on the PDS (arabica.systems) via XRPC\n- Shows success page with next steps (e.g. log in to arabica.social, set up your profile)\n- Proper error handling for: invalid invite code, handle taken, invalid password, etc.\n- Password strength indicator or requirements display\n- Matches existing arabica.social design system\n\n## Technical Details\n- The PDS is at arabica.systems - XRPC calls go to https://arabica.systems/xrpc/...\n- createAccount params: handle, email, password, inviteCode\n- describeServer tells us available handle domains and whether invites are required\n- New env var: PDS_SERVICE_URL (e.g. https://arabica.systems)\n- New handler + templ page + route\n- No arabica.social authentication required (public page)\n- Do NOT store the user's password - it goes directly to the PDS\n\n## Security Considerations \n- Password goes directly to PDS via server-side XRPC call (not client-side JS)\n- HTTPS only for the XRPC call\n- No password logging\n\n## Acceptance Criteria\n- Form renders with all required fields\n- Valid invite code + details creates account on PDS\n- Appropriate error messages for each failure mode (InvalidInviteCode, HandleNotAvailable, etc.)\n- Success page with clear next steps\n- Matches site design system","status":"open","priority":"normal","labels":["frontend","backend"],"created_at":"2026-02-13T02:00:05.446933524Z","updated_at":"2026-02-13T02:43:36.680372523Z"}
-24
CLAUDE.md
··· 1 - # Current Cell Task 2 - 3 - **Cell ID:** 01KGA7T06FC5 4 - **Title:** Style mail link and back button on terms page 5 - 6 - ## Task Description 7 - 8 - Add proper styling to mail link and 'back to home' button on terms page. 9 - 10 - Acceptance criteria: 11 - 12 - - Consistent button/link styling 13 - - Matches site design system 14 - - Good visual hierarchy 15 - 16 - ## Completion Instructions 17 - 18 - When you have completed this task: 19 - 20 - 1. Ensure all work is committed and ready to merge 21 - 2. Exit the session - you'll be prompted to complete, keep, or reopen the cell 22 - 23 - --- 24 - 25 1 # Arabica - Project Context for AI Agents 26 2 27 3 Coffee brew tracking application using AT Protocol for decentralized storage.
+672
internal/handlers/brew.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "strconv" 9 + "strings" 10 + 11 + "arabica/internal/atproto" 12 + "arabica/internal/models" 13 + "arabica/internal/web/bff" 14 + "arabica/internal/web/components" 15 + "arabica/internal/web/pages" 16 + 17 + "github.com/rs/zerolog/log" 18 + ) 19 + 20 + // populateBrewOGMetadata sets OpenGraph metadata on layoutData for a brew page. 21 + // This enriches social media previews when brew links are shared. 22 + func (h *Handler) populateBrewOGMetadata(layoutData *components.LayoutData, brew *models.Brew, shareURL string) { 23 + if brew == nil { 24 + return 25 + } 26 + 27 + // Build OG title from bean info 28 + var ogTitle string 29 + if brew.Bean != nil { 30 + if brew.Bean.Origin != "" { 31 + ogTitle = fmt.Sprintf("%s from %s", brew.Bean.Name, brew.Bean.Origin) 32 + } else { 33 + ogTitle = brew.Bean.Name 34 + } 35 + } else { 36 + ogTitle = "Coffee Brew" 37 + } 38 + 39 + // Build OG description with rating and tasting notes 40 + var descParts []string 41 + if brew.Rating > 0 { 42 + descParts = append(descParts, fmt.Sprintf("Rated %d/10", brew.Rating)) 43 + } 44 + if brew.TastingNotes != "" { 45 + // Truncate tasting notes if too long 46 + notes := brew.TastingNotes 47 + if len(notes) > 100 { 48 + notes = notes[:97] + "..." 49 + } 50 + descParts = append(descParts, notes) 51 + } 52 + if brew.Bean != nil && brew.Bean.Roaster != nil { 53 + descParts = append(descParts, fmt.Sprintf("Roasted by %s", brew.Bean.Roaster.Name)) 54 + } 55 + 56 + var ogDescription string 57 + if len(descParts) > 0 { 58 + ogDescription = strings.Join(descParts, " · ") 59 + } else { 60 + ogDescription = "A coffee brew tracked on Arabica" 61 + } 62 + 63 + // Build absolute URL if public URL is configured 64 + var ogURL string 65 + if h.config.PublicURL != "" && shareURL != "" { 66 + ogURL = h.config.PublicURL + shareURL 67 + } 68 + 69 + layoutData.OGTitle = ogTitle 70 + layoutData.OGDescription = ogDescription 71 + layoutData.OGType = "article" 72 + layoutData.OGUrl = ogURL 73 + } 74 + 75 + // Brew list partial (loaded async via HTMX) 76 + func (h *Handler) HandleBrewListPartial(w http.ResponseWriter, r *http.Request) { 77 + // Require authentication 78 + store, authenticated := h.getAtprotoStore(r) 79 + if !authenticated { 80 + http.Error(w, "Authentication required", http.StatusUnauthorized) 81 + return 82 + } 83 + 84 + brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 85 + if err != nil { 86 + http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 87 + log.Error().Err(err).Msg("Failed to fetch brews") 88 + return 89 + } 90 + 91 + if err := components.BrewListTablePartial(components.BrewListTableProps{ 92 + Brews: brews, 93 + IsOwnProfile: true, 94 + }).Render(r.Context(), w); err != nil { 95 + http.Error(w, "Failed to render content", http.StatusInternalServerError) 96 + log.Error().Err(err).Msg("Failed to render brew list partial") 97 + } 98 + } 99 + 100 + // List all brews 101 + func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { 102 + // Require authentication 103 + _, authenticated := h.getAtprotoStore(r) 104 + if !authenticated { 105 + http.Redirect(w, r, "/login", http.StatusFound) 106 + return 107 + } 108 + 109 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 110 + userProfile := h.getUserProfile(r.Context(), didStr) 111 + 112 + // Create layout data 113 + layoutData := h.buildLayoutData(r, "Your Brews", authenticated, didStr, userProfile) 114 + 115 + // Create brew list props 116 + brewListProps := pages.BrewListProps{} 117 + 118 + // Render using templ component 119 + if err := pages.BrewList(layoutData, brewListProps).Render(r.Context(), w); err != nil { 120 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 121 + log.Error().Err(err).Msg("Failed to render brew list page") 122 + } 123 + } 124 + 125 + // Show new brew form 126 + func (h *Handler) HandleBrewNew(w http.ResponseWriter, r *http.Request) { 127 + // Require authentication 128 + _, authenticated := h.getAtprotoStore(r) 129 + if !authenticated { 130 + http.Redirect(w, r, "/login", http.StatusFound) 131 + return 132 + } 133 + 134 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 135 + userProfile := h.getUserProfile(r.Context(), didStr) 136 + 137 + // Don't fetch data from PDS - client will populate dropdowns from cache 138 + // This makes the page load much faster 139 + layoutData := h.buildLayoutData(r, "New Brew", authenticated, didStr, userProfile) 140 + 141 + brewFormProps := pages.BrewFormProps{ 142 + Brew: nil, 143 + } 144 + 145 + if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil { 146 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 147 + log.Error().Err(err).Msg("Failed to render brew form") 148 + } 149 + } 150 + 151 + // Show brew view page 152 + func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) { 153 + rkey := validateRKey(w, r.PathValue("id")) 154 + if rkey == "" { 155 + return 156 + } 157 + 158 + // Check if owner (DID or handle) is specified in query params 159 + owner := r.URL.Query().Get("owner") 160 + 161 + // Check authentication 162 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 163 + isAuthenticated := err == nil && didStr != "" 164 + 165 + var userProfile *bff.UserProfile 166 + if isAuthenticated { 167 + userProfile = h.getUserProfile(r.Context(), didStr) 168 + } 169 + 170 + var brew *models.Brew 171 + var brewOwnerDID string 172 + var isOwner bool 173 + var subjectURI, subjectCID string 174 + 175 + if owner != "" { 176 + // Viewing someone else's brew - use public client 177 + publicClient := atproto.NewPublicClient() 178 + 179 + // Resolve owner to DID if it's a handle 180 + if strings.HasPrefix(owner, "did:") { 181 + brewOwnerDID = owner 182 + } else { 183 + resolved, err := publicClient.ResolveHandle(r.Context(), owner) 184 + if err != nil { 185 + log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for brew view") 186 + http.Error(w, "User not found", http.StatusNotFound) 187 + return 188 + } 189 + brewOwnerDID = resolved 190 + } 191 + 192 + // Fetch the brew record from the owner's PDS 193 + record, err := publicClient.GetRecord(r.Context(), brewOwnerDID, atproto.NSIDBrew, rkey) 194 + if err != nil { 195 + log.Error().Err(err).Str("did", brewOwnerDID).Str("rkey", rkey).Msg("Failed to get brew record") 196 + http.Error(w, "Brew not found", http.StatusNotFound) 197 + return 198 + } 199 + 200 + // Store URI and CID for like button 201 + subjectURI = record.URI 202 + subjectCID = record.CID 203 + 204 + // Convert record to brew 205 + brew, err = atproto.RecordToBrew(record.Value, record.URI) 206 + if err != nil { 207 + log.Error().Err(err).Msg("Failed to convert brew record") 208 + http.Error(w, "Failed to load brew", http.StatusInternalServerError) 209 + return 210 + } 211 + 212 + // Resolve references (bean, grinder, brewer) 213 + if err := h.resolveBrewReferences(r.Context(), brew, brewOwnerDID, record.Value); err != nil { 214 + log.Warn().Err(err).Msg("Failed to resolve some brew references") 215 + // Don't fail the request, just log the warning 216 + } 217 + 218 + // Check if viewing user is the owner 219 + isOwner = isAuthenticated && didStr == brewOwnerDID 220 + } else { 221 + // Viewing own brew - require authentication 222 + store, authenticated := h.getAtprotoStore(r) 223 + if !authenticated { 224 + http.Redirect(w, r, "/login", http.StatusFound) 225 + return 226 + } 227 + 228 + // Use type assertion to access GetBrewRecordByRKey 229 + atprotoStore, ok := store.(*atproto.AtprotoStore) 230 + if !ok { 231 + http.Error(w, "Internal error", http.StatusInternalServerError) 232 + log.Error().Msg("Failed to cast store to AtprotoStore") 233 + return 234 + } 235 + 236 + brewRecord, err := atprotoStore.GetBrewRecordByRKey(r.Context(), rkey) 237 + if err != nil { 238 + http.Error(w, "Brew not found", http.StatusNotFound) 239 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view") 240 + return 241 + } 242 + 243 + brew = brewRecord.Brew 244 + subjectURI = brewRecord.URI 245 + subjectCID = brewRecord.CID 246 + isOwner = true 247 + } 248 + 249 + // Construct share URL (needed for both OG metadata and props) 250 + var shareURL string 251 + if owner != "" { 252 + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, owner) 253 + } else if userProfile != nil && userProfile.Handle != "" { 254 + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, userProfile.Handle) 255 + } 256 + 257 + // Create layout data with OpenGraph metadata 258 + layoutData := h.buildLayoutData(r, "Brew Details", isAuthenticated, didStr, userProfile) 259 + h.populateBrewOGMetadata(layoutData, brew, shareURL) 260 + 261 + // Get like data 262 + var isLiked bool 263 + var likeCount int 264 + if h.feedIndex != nil && subjectURI != "" { 265 + likeCount = h.feedIndex.GetLikeCount(subjectURI) 266 + if isAuthenticated { 267 + isLiked = h.feedIndex.HasUserLiked(didStr, subjectURI) 268 + } 269 + } 270 + 271 + // Create brew view props 272 + brewViewProps := pages.BrewViewProps{ 273 + Brew: brew, 274 + IsOwnProfile: isOwner, 275 + IsAuthenticated: isAuthenticated, 276 + SubjectURI: subjectURI, 277 + SubjectCID: subjectCID, 278 + IsLiked: isLiked, 279 + LikeCount: likeCount, 280 + ShareURL: shareURL, 281 + } 282 + 283 + // Render using templ component 284 + if err := pages.BrewView(layoutData, brewViewProps).Render(r.Context(), w); err != nil { 285 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 286 + log.Error().Err(err).Msg("Failed to render brew view") 287 + } 288 + } 289 + 290 + // resolveBrewReferences resolves bean, grinder, and brewer references for a brew 291 + func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew, ownerDID string, record map[string]interface{}) error { 292 + publicClient := atproto.NewPublicClient() 293 + 294 + // Resolve bean reference 295 + if beanRef, ok := record["beanRef"].(string); ok && beanRef != "" { 296 + beanRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBean, atproto.ExtractRKeyFromURI(beanRef)) 297 + if err == nil { 298 + if bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI); err == nil { 299 + brew.Bean = bean 300 + 301 + // Resolve roaster reference for the bean 302 + if roasterRef, ok := beanRecord.Value["roasterRef"].(string); ok && roasterRef != "" { 303 + roasterRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDRoaster, atproto.ExtractRKeyFromURI(roasterRef)) 304 + if err == nil { 305 + if roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI); err == nil { 306 + brew.Bean.Roaster = roaster 307 + } 308 + } 309 + } 310 + } 311 + } 312 + } 313 + 314 + // Resolve grinder reference 315 + if grinderRef, ok := record["grinderRef"].(string); ok && grinderRef != "" { 316 + grinderRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDGrinder, atproto.ExtractRKeyFromURI(grinderRef)) 317 + if err == nil { 318 + if grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI); err == nil { 319 + brew.GrinderObj = grinder 320 + } 321 + } 322 + } 323 + 324 + // Resolve brewer reference 325 + if brewerRef, ok := record["brewerRef"].(string); ok && brewerRef != "" { 326 + brewerRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBrewer, atproto.ExtractRKeyFromURI(brewerRef)) 327 + if err == nil { 328 + if brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI); err == nil { 329 + brew.BrewerObj = brewer 330 + } 331 + } 332 + } 333 + 334 + return nil 335 + } 336 + 337 + // Show edit brew form 338 + func (h *Handler) HandleBrewEdit(w http.ResponseWriter, r *http.Request) { 339 + rkey := validateRKey(w, r.PathValue("id")) 340 + if rkey == "" { 341 + return 342 + } 343 + 344 + // Require authentication 345 + store, authenticated := h.getAtprotoStore(r) 346 + if !authenticated { 347 + http.Redirect(w, r, "/login", http.StatusFound) 348 + return 349 + } 350 + 351 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 352 + userProfile := h.getUserProfile(r.Context(), didStr) 353 + 354 + brew, err := store.GetBrewByRKey(r.Context(), rkey) 355 + if err != nil { 356 + http.Error(w, "Brew not found", http.StatusNotFound) 357 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for edit") 358 + return 359 + } 360 + 361 + // Don't fetch dropdown data from PDS - client will populate from cache 362 + // This makes the page load much faster 363 + layoutData := h.buildLayoutData(r, "Edit Brew", authenticated, didStr, userProfile) 364 + 365 + brewFormProps := pages.BrewFormProps{ 366 + Brew: brew, 367 + PoursJSON: bff.PoursToJSON(brew.Pours), 368 + } 369 + 370 + if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil { 371 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 372 + log.Error().Err(err).Msg("Failed to render brew edit form") 373 + } 374 + } 375 + 376 + // maxPours is the maximum number of pours allowed in a single brew 377 + const maxPours = 100 378 + 379 + // parsePours extracts pour data from form values with bounds checking 380 + func parsePours(r *http.Request) []models.CreatePourData { 381 + var pours []models.CreatePourData 382 + 383 + for i := 0; i < maxPours; i++ { 384 + waterKey := "pour_water_" + strconv.Itoa(i) 385 + timeKey := "pour_time_" + strconv.Itoa(i) 386 + 387 + waterStr := r.FormValue(waterKey) 388 + timeStr := r.FormValue(timeKey) 389 + 390 + if waterStr == "" && timeStr == "" { 391 + break 392 + } 393 + 394 + water, _ := strconv.Atoi(waterStr) 395 + pourTime, _ := strconv.Atoi(timeStr) 396 + 397 + if water > 0 && pourTime >= 0 { 398 + pours = append(pours, models.CreatePourData{ 399 + WaterAmount: water, 400 + TimeSeconds: pourTime, 401 + }) 402 + } 403 + } 404 + 405 + return pours 406 + } 407 + 408 + // ValidationError represents a validation error with field name and message 409 + type ValidationError struct { 410 + Field string 411 + Message string 412 + } 413 + 414 + // validateBrewRequest validates brew form input and returns any validation errors 415 + func validateBrewRequest(r *http.Request) (temperature float64, waterAmount, coffeeAmount, timeSeconds, rating int, pours []models.CreatePourData, errs []ValidationError) { 416 + // Parse and validate temperature 417 + if tempStr := r.FormValue("temperature"); tempStr != "" { 418 + var err error 419 + temperature, err = strconv.ParseFloat(tempStr, 64) 420 + if err != nil { 421 + errs = append(errs, ValidationError{Field: "temperature", Message: "invalid temperature format"}) 422 + } else if temperature < 0 || temperature > 212 { 423 + errs = append(errs, ValidationError{Field: "temperature", Message: "temperature must be between 0 and 212"}) 424 + } 425 + } 426 + 427 + // Parse and validate water amount 428 + if waterStr := r.FormValue("water_amount"); waterStr != "" { 429 + var err error 430 + waterAmount, err = strconv.Atoi(waterStr) 431 + if err != nil { 432 + errs = append(errs, ValidationError{Field: "water_amount", Message: "invalid water amount"}) 433 + } else if waterAmount < 0 || waterAmount > 10000 { 434 + errs = append(errs, ValidationError{Field: "water_amount", Message: "water amount must be between 0 and 10000ml"}) 435 + } 436 + } 437 + 438 + // Parse and validate coffee amount 439 + if coffeeStr := r.FormValue("coffee_amount"); coffeeStr != "" { 440 + var err error 441 + coffeeAmount, err = strconv.Atoi(coffeeStr) 442 + if err != nil { 443 + errs = append(errs, ValidationError{Field: "coffee_amount", Message: "invalid coffee amount"}) 444 + } else if coffeeAmount < 0 || coffeeAmount > 1000 { 445 + errs = append(errs, ValidationError{Field: "coffee_amount", Message: "coffee amount must be between 0 and 1000g"}) 446 + } 447 + } 448 + 449 + // Parse and validate time 450 + if timeStr := r.FormValue("time_seconds"); timeStr != "" { 451 + var err error 452 + timeSeconds, err = strconv.Atoi(timeStr) 453 + if err != nil { 454 + errs = append(errs, ValidationError{Field: "time_seconds", Message: "invalid time"}) 455 + } else if timeSeconds < 0 || timeSeconds > 3600 { 456 + errs = append(errs, ValidationError{Field: "time_seconds", Message: "brew time must be between 0 and 3600 seconds"}) 457 + } 458 + } 459 + 460 + // Parse and validate rating 461 + if ratingStr := r.FormValue("rating"); ratingStr != "" { 462 + var err error 463 + rating, err = strconv.Atoi(ratingStr) 464 + if err != nil { 465 + errs = append(errs, ValidationError{Field: "rating", Message: "invalid rating"}) 466 + } else if rating < 0 || rating > 10 { 467 + errs = append(errs, ValidationError{Field: "rating", Message: "rating must be between 0 and 10"}) 468 + } 469 + } 470 + 471 + // Parse pours 472 + pours = parsePours(r) 473 + 474 + return 475 + } 476 + 477 + // Create new brew 478 + func (h *Handler) HandleBrewCreate(w http.ResponseWriter, r *http.Request) { 479 + // Require authentication first 480 + store, authenticated := h.getAtprotoStore(r) 481 + if !authenticated { 482 + http.Redirect(w, r, "/login", http.StatusFound) 483 + return 484 + } 485 + 486 + if err := r.ParseForm(); err != nil { 487 + http.Error(w, "Invalid form data", http.StatusBadRequest) 488 + return 489 + } 490 + 491 + // Validate input 492 + temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r) 493 + if len(validationErrs) > 0 { 494 + // Return first validation error 495 + http.Error(w, validationErrs[0].Message, http.StatusBadRequest) 496 + return 497 + } 498 + 499 + // Validate required fields 500 + beanRKey := r.FormValue("bean_rkey") 501 + if beanRKey == "" { 502 + http.Error(w, "Bean selection is required", http.StatusBadRequest) 503 + return 504 + } 505 + if !atproto.ValidateRKey(beanRKey) { 506 + http.Error(w, "Invalid bean selection", http.StatusBadRequest) 507 + return 508 + } 509 + 510 + // Validate optional rkeys 511 + grinderRKey := r.FormValue("grinder_rkey") 512 + if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" { 513 + http.Error(w, errMsg, http.StatusBadRequest) 514 + return 515 + } 516 + brewerRKey := r.FormValue("brewer_rkey") 517 + if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" { 518 + http.Error(w, errMsg, http.StatusBadRequest) 519 + return 520 + } 521 + 522 + req := &models.CreateBrewRequest{ 523 + BeanRKey: beanRKey, 524 + Method: r.FormValue("method"), 525 + Temperature: temperature, 526 + WaterAmount: waterAmount, 527 + CoffeeAmount: coffeeAmount, 528 + TimeSeconds: timeSeconds, 529 + GrindSize: r.FormValue("grind_size"), 530 + GrinderRKey: grinderRKey, 531 + BrewerRKey: brewerRKey, 532 + TastingNotes: r.FormValue("tasting_notes"), 533 + Rating: rating, 534 + Pours: pours, 535 + } 536 + 537 + _, err := store.CreateBrew(r.Context(), req, 1) // User ID not used with atproto 538 + if err != nil { 539 + http.Error(w, "Failed to create brew", http.StatusInternalServerError) 540 + log.Error().Err(err).Msg("Failed to create brew") 541 + return 542 + } 543 + 544 + // Redirect to brew list 545 + w.Header().Set("HX-Redirect", "/brews") 546 + w.WriteHeader(http.StatusOK) 547 + } 548 + 549 + // Update existing brew 550 + func (h *Handler) HandleBrewUpdate(w http.ResponseWriter, r *http.Request) { 551 + rkey := validateRKey(w, r.PathValue("id")) 552 + if rkey == "" { 553 + return 554 + } 555 + 556 + // Require authentication 557 + store, authenticated := h.getAtprotoStore(r) 558 + if !authenticated { 559 + http.Redirect(w, r, "/login", http.StatusFound) 560 + return 561 + } 562 + 563 + if err := r.ParseForm(); err != nil { 564 + http.Error(w, "Invalid form data", http.StatusBadRequest) 565 + return 566 + } 567 + 568 + // Validate input 569 + temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r) 570 + if len(validationErrs) > 0 { 571 + http.Error(w, validationErrs[0].Message, http.StatusBadRequest) 572 + return 573 + } 574 + 575 + // Validate required fields 576 + beanRKey := r.FormValue("bean_rkey") 577 + if beanRKey == "" { 578 + http.Error(w, "Bean selection is required", http.StatusBadRequest) 579 + return 580 + } 581 + if !atproto.ValidateRKey(beanRKey) { 582 + http.Error(w, "Invalid bean selection", http.StatusBadRequest) 583 + return 584 + } 585 + 586 + // Validate optional rkeys 587 + grinderRKey := r.FormValue("grinder_rkey") 588 + if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" { 589 + http.Error(w, errMsg, http.StatusBadRequest) 590 + return 591 + } 592 + brewerRKey := r.FormValue("brewer_rkey") 593 + if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" { 594 + http.Error(w, errMsg, http.StatusBadRequest) 595 + return 596 + } 597 + 598 + req := &models.CreateBrewRequest{ 599 + BeanRKey: beanRKey, 600 + Method: r.FormValue("method"), 601 + Temperature: temperature, 602 + WaterAmount: waterAmount, 603 + CoffeeAmount: coffeeAmount, 604 + TimeSeconds: timeSeconds, 605 + GrindSize: r.FormValue("grind_size"), 606 + GrinderRKey: grinderRKey, 607 + BrewerRKey: brewerRKey, 608 + TastingNotes: r.FormValue("tasting_notes"), 609 + Rating: rating, 610 + Pours: pours, 611 + } 612 + 613 + err := store.UpdateBrewByRKey(r.Context(), rkey, req) 614 + if err != nil { 615 + http.Error(w, "Failed to update brew", http.StatusInternalServerError) 616 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brew") 617 + return 618 + } 619 + 620 + // Redirect to brew list 621 + w.Header().Set("HX-Redirect", "/brews") 622 + w.WriteHeader(http.StatusOK) 623 + } 624 + 625 + // Delete brew 626 + func (h *Handler) HandleBrewDelete(w http.ResponseWriter, r *http.Request) { 627 + rkey := validateRKey(w, r.PathValue("id")) 628 + if rkey == "" { 629 + return 630 + } 631 + 632 + // Require authentication 633 + store, authenticated := h.getAtprotoStore(r) 634 + if !authenticated { 635 + http.Error(w, "Authentication required", http.StatusUnauthorized) 636 + return 637 + } 638 + 639 + if err := store.DeleteBrewByRKey(r.Context(), rkey); err != nil { 640 + http.Error(w, "Failed to delete brew", http.StatusInternalServerError) 641 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brew") 642 + return 643 + } 644 + 645 + w.WriteHeader(http.StatusOK) 646 + } 647 + 648 + // Export brews as JSON 649 + func (h *Handler) HandleBrewExport(w http.ResponseWriter, r *http.Request) { 650 + // Require authentication 651 + store, authenticated := h.getAtprotoStore(r) 652 + if !authenticated { 653 + http.Error(w, "Authentication required", http.StatusUnauthorized) 654 + return 655 + } 656 + 657 + brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 658 + if err != nil { 659 + http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 660 + log.Error().Err(err).Msg("Failed to list brews for export") 661 + return 662 + } 663 + 664 + w.Header().Set("Content-Type", "application/json") 665 + w.Header().Set("Content-Disposition", "attachment; filename=arabica-brews.json") 666 + 667 + encoder := json.NewEncoder(w) 668 + encoder.SetIndent("", " ") 669 + if err := encoder.Encode(brews); err != nil { 670 + log.Error().Err(err).Msg("Failed to encode brews for export") 671 + } 672 + }
+657
internal/handlers/entities.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "arabica/internal/atproto" 8 + "arabica/internal/models" 9 + "arabica/internal/web/components" 10 + "arabica/internal/web/pages" 11 + 12 + "github.com/rs/zerolog/log" 13 + "golang.org/x/sync/errgroup" 14 + ) 15 + 16 + // Manage page partial (loaded async via HTMX) 17 + func (h *Handler) HandleManagePartial(w http.ResponseWriter, r *http.Request) { 18 + // Require authentication 19 + store, authenticated := h.getAtprotoStore(r) 20 + if !authenticated { 21 + http.Error(w, "Authentication required", http.StatusUnauthorized) 22 + return 23 + } 24 + 25 + ctx := r.Context() 26 + 27 + // Fetch all collections in parallel using errgroup for proper error handling 28 + // and automatic context cancellation on first error 29 + g, ctx := errgroup.WithContext(ctx) 30 + 31 + var beans []*models.Bean 32 + var roasters []*models.Roaster 33 + var grinders []*models.Grinder 34 + var brewers []*models.Brewer 35 + 36 + g.Go(func() error { 37 + var err error 38 + beans, err = store.ListBeans(ctx) 39 + return err 40 + }) 41 + g.Go(func() error { 42 + var err error 43 + roasters, err = store.ListRoasters(ctx) 44 + return err 45 + }) 46 + g.Go(func() error { 47 + var err error 48 + grinders, err = store.ListGrinders(ctx) 49 + return err 50 + }) 51 + g.Go(func() error { 52 + var err error 53 + brewers, err = store.ListBrewers(ctx) 54 + return err 55 + }) 56 + 57 + if err := g.Wait(); err != nil { 58 + http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 59 + log.Error().Err(err).Msg("Failed to fetch manage page data") 60 + return 61 + } 62 + 63 + // Link beans to their roasters 64 + atproto.LinkBeansToRoasters(beans, roasters) 65 + 66 + // Render manage partial 67 + if err := components.ManagePartial(components.ManagePartialProps{ 68 + Beans: beans, 69 + Roasters: roasters, 70 + Grinders: grinders, 71 + Brewers: brewers, 72 + }).Render(r.Context(), w); err != nil { 73 + http.Error(w, "Failed to render content", http.StatusInternalServerError) 74 + log.Error().Err(err).Msg("Failed to render manage partial") 75 + } 76 + } 77 + 78 + // API endpoint to list all user data (beans, roasters, grinders, brewers, brews) 79 + // Used by client-side cache for faster page loads 80 + func (h *Handler) HandleAPIListAll(w http.ResponseWriter, r *http.Request) { 81 + store, authenticated := h.getAtprotoStore(r) 82 + if !authenticated { 83 + http.Error(w, "Authentication required", http.StatusUnauthorized) 84 + return 85 + } 86 + 87 + // Get user DID for cache validation 88 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 89 + 90 + ctx := r.Context() 91 + 92 + // Fetch all collections in parallel using errgroup 93 + g, ctx := errgroup.WithContext(ctx) 94 + 95 + var beans []*models.Bean 96 + var roasters []*models.Roaster 97 + var grinders []*models.Grinder 98 + var brewers []*models.Brewer 99 + var brews []*models.Brew 100 + 101 + g.Go(func() error { 102 + var err error 103 + beans, err = store.ListBeans(ctx) 104 + return err 105 + }) 106 + g.Go(func() error { 107 + var err error 108 + roasters, err = store.ListRoasters(ctx) 109 + return err 110 + }) 111 + g.Go(func() error { 112 + var err error 113 + grinders, err = store.ListGrinders(ctx) 114 + return err 115 + }) 116 + g.Go(func() error { 117 + var err error 118 + brewers, err = store.ListBrewers(ctx) 119 + return err 120 + }) 121 + g.Go(func() error { 122 + var err error 123 + brews, err = store.ListBrews(ctx, 1) // User ID not used with atproto 124 + return err 125 + }) 126 + 127 + if err := g.Wait(); err != nil { 128 + http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 129 + log.Error().Err(err).Msg("Failed to fetch all data for API") 130 + return 131 + } 132 + 133 + // Link beans to roasters 134 + atproto.LinkBeansToRoasters(beans, roasters) 135 + 136 + response := map[string]interface{}{ 137 + "did": userDID, 138 + "beans": beans, 139 + "roasters": roasters, 140 + "grinders": grinders, 141 + "brewers": brewers, 142 + "brews": brews, 143 + } 144 + 145 + w.Header().Set("Content-Type", "application/json") 146 + if err := json.NewEncoder(w).Encode(response); err != nil { 147 + log.Error().Err(err).Msg("Failed to encode API response") 148 + } 149 + } 150 + 151 + // API endpoint to create bean 152 + func (h *Handler) HandleBeanCreate(w http.ResponseWriter, r *http.Request) { 153 + // Require authentication 154 + store, authenticated := h.getAtprotoStore(r) 155 + if !authenticated { 156 + http.Error(w, "Authentication required", http.StatusUnauthorized) 157 + return 158 + } 159 + 160 + var req models.CreateBeanRequest 161 + 162 + // Decode request (JSON or form) 163 + if err := decodeRequest(r, &req, func() error { 164 + req = models.CreateBeanRequest{ 165 + Name: r.FormValue("name"), 166 + Origin: r.FormValue("origin"), 167 + RoastLevel: r.FormValue("roast_level"), 168 + Process: r.FormValue("process"), 169 + Description: r.FormValue("description"), 170 + RoasterRKey: r.FormValue("roaster_rkey"), 171 + Closed: r.FormValue("closed") == "true", 172 + } 173 + log.Debug(). 174 + Str("name", req.Name). 175 + Str("closed_value", r.FormValue("closed")). 176 + Bool("closed_parsed", req.Closed). 177 + Msg("Parsed bean create form") 178 + return nil 179 + }); err != nil { 180 + http.Error(w, "Invalid request body", http.StatusBadRequest) 181 + return 182 + } 183 + 184 + // Validate request 185 + if err := req.Validate(); err != nil { 186 + http.Error(w, err.Error(), http.StatusBadRequest) 187 + return 188 + } 189 + 190 + // Validate optional roaster rkey 191 + if errMsg := validateOptionalRKey(req.RoasterRKey, "Roaster selection"); errMsg != "" { 192 + http.Error(w, errMsg, http.StatusBadRequest) 193 + return 194 + } 195 + 196 + bean, err := store.CreateBean(r.Context(), &req) 197 + if err != nil { 198 + http.Error(w, "Failed to create bean", http.StatusInternalServerError) 199 + log.Error().Err(err).Msg("Failed to create bean") 200 + return 201 + } 202 + 203 + writeJSON(w, bean, "bean") 204 + } 205 + 206 + // API endpoint to create roaster 207 + func (h *Handler) HandleRoasterCreate(w http.ResponseWriter, r *http.Request) { 208 + // Require authentication 209 + store, authenticated := h.getAtprotoStore(r) 210 + if !authenticated { 211 + http.Error(w, "Authentication required", http.StatusUnauthorized) 212 + return 213 + } 214 + 215 + var req models.CreateRoasterRequest 216 + 217 + // Decode request (JSON or form) 218 + if err := decodeRequest(r, &req, func() error { 219 + req = models.CreateRoasterRequest{ 220 + Name: r.FormValue("name"), 221 + Location: r.FormValue("location"), 222 + Website: r.FormValue("website"), 223 + } 224 + return nil 225 + }); err != nil { 226 + http.Error(w, "Invalid request body", http.StatusBadRequest) 227 + return 228 + } 229 + 230 + // Validate request 231 + if err := req.Validate(); err != nil { 232 + http.Error(w, err.Error(), http.StatusBadRequest) 233 + return 234 + } 235 + 236 + roaster, err := store.CreateRoaster(r.Context(), &req) 237 + if err != nil { 238 + http.Error(w, "Failed to create roaster", http.StatusInternalServerError) 239 + log.Error().Err(err).Msg("Failed to create roaster") 240 + return 241 + } 242 + 243 + writeJSON(w, roaster, "roaster") 244 + } 245 + 246 + // Manage page 247 + func (h *Handler) HandleManage(w http.ResponseWriter, r *http.Request) { 248 + // Require authentication 249 + _, authenticated := h.getAtprotoStore(r) 250 + if !authenticated { 251 + http.Redirect(w, r, "/login", http.StatusFound) 252 + return 253 + } 254 + 255 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 256 + userProfile := h.getUserProfile(r.Context(), didStr) 257 + 258 + // Create layout data 259 + layoutData := h.buildLayoutData(r, "Manage", authenticated, didStr, userProfile) 260 + 261 + // Create manage props 262 + manageProps := pages.ManageProps{} 263 + 264 + // Render using templ component 265 + if err := pages.Manage(layoutData, manageProps).Render(r.Context(), w); err != nil { 266 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 267 + log.Error().Err(err).Msg("Failed to render manage page") 268 + } 269 + } 270 + 271 + // Bean update/delete handlers 272 + func (h *Handler) HandleBeanUpdate(w http.ResponseWriter, r *http.Request) { 273 + rkey := validateRKey(w, r.PathValue("id")) 274 + if rkey == "" { 275 + return 276 + } 277 + 278 + // Require authentication 279 + store, authenticated := h.getAtprotoStore(r) 280 + if !authenticated { 281 + http.Error(w, "Authentication required", http.StatusUnauthorized) 282 + return 283 + } 284 + 285 + var req models.UpdateBeanRequest 286 + 287 + // Decode request (JSON or form) 288 + if err := decodeRequest(r, &req, func() error { 289 + req = models.UpdateBeanRequest{ 290 + Name: r.FormValue("name"), 291 + Origin: r.FormValue("origin"), 292 + RoastLevel: r.FormValue("roast_level"), 293 + Process: r.FormValue("process"), 294 + Description: r.FormValue("description"), 295 + RoasterRKey: r.FormValue("roaster_rkey"), 296 + Closed: r.FormValue("closed") == "true", 297 + } 298 + log.Debug(). 299 + Str("rkey", rkey). 300 + Str("name", req.Name). 301 + Str("closed_value", r.FormValue("closed")). 302 + Bool("closed_parsed", req.Closed). 303 + Msg("Parsed bean update form") 304 + return nil 305 + }); err != nil { 306 + http.Error(w, "Invalid request body", http.StatusBadRequest) 307 + return 308 + } 309 + 310 + // Validate request 311 + if err := req.Validate(); err != nil { 312 + http.Error(w, err.Error(), http.StatusBadRequest) 313 + return 314 + } 315 + 316 + // Validate optional roaster rkey 317 + if errMsg := validateOptionalRKey(req.RoasterRKey, "Roaster selection"); errMsg != "" { 318 + http.Error(w, errMsg, http.StatusBadRequest) 319 + return 320 + } 321 + 322 + if err := store.UpdateBeanByRKey(r.Context(), rkey, &req); err != nil { 323 + http.Error(w, "Failed to update bean", http.StatusInternalServerError) 324 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update bean") 325 + return 326 + } 327 + 328 + bean, err := store.GetBeanByRKey(r.Context(), rkey) 329 + if err != nil { 330 + http.Error(w, "Failed to fetch updated bean", http.StatusInternalServerError) 331 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get bean after update") 332 + return 333 + } 334 + 335 + writeJSON(w, bean, "bean") 336 + } 337 + 338 + func (h *Handler) HandleBeanDelete(w http.ResponseWriter, r *http.Request) { 339 + rkey := validateRKey(w, r.PathValue("id")) 340 + if rkey == "" { 341 + return 342 + } 343 + 344 + // Require authentication 345 + store, authenticated := h.getAtprotoStore(r) 346 + if !authenticated { 347 + http.Error(w, "Authentication required", http.StatusUnauthorized) 348 + return 349 + } 350 + 351 + if err := store.DeleteBeanByRKey(r.Context(), rkey); err != nil { 352 + http.Error(w, "Failed to delete bean", http.StatusInternalServerError) 353 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete bean") 354 + return 355 + } 356 + 357 + w.WriteHeader(http.StatusOK) 358 + } 359 + 360 + // Roaster update/delete handlers 361 + func (h *Handler) HandleRoasterUpdate(w http.ResponseWriter, r *http.Request) { 362 + rkey := validateRKey(w, r.PathValue("id")) 363 + if rkey == "" { 364 + return 365 + } 366 + 367 + // Require authentication 368 + store, authenticated := h.getAtprotoStore(r) 369 + if !authenticated { 370 + http.Error(w, "Authentication required", http.StatusUnauthorized) 371 + return 372 + } 373 + 374 + var req models.UpdateRoasterRequest 375 + 376 + // Decode request (JSON or form) 377 + if err := decodeRequest(r, &req, func() error { 378 + req = models.UpdateRoasterRequest{ 379 + Name: r.FormValue("name"), 380 + Location: r.FormValue("location"), 381 + Website: r.FormValue("website"), 382 + } 383 + return nil 384 + }); err != nil { 385 + http.Error(w, "Invalid request body", http.StatusBadRequest) 386 + return 387 + } 388 + 389 + // Validate request 390 + if err := req.Validate(); err != nil { 391 + http.Error(w, err.Error(), http.StatusBadRequest) 392 + return 393 + } 394 + 395 + if err := store.UpdateRoasterByRKey(r.Context(), rkey, &req); err != nil { 396 + http.Error(w, "Failed to update roaster", http.StatusInternalServerError) 397 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update roaster") 398 + return 399 + } 400 + 401 + roaster, err := store.GetRoasterByRKey(r.Context(), rkey) 402 + if err != nil { 403 + http.Error(w, "Failed to fetch updated roaster", http.StatusInternalServerError) 404 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get roaster after update") 405 + return 406 + } 407 + 408 + writeJSON(w, roaster, "roaster") 409 + } 410 + 411 + func (h *Handler) HandleRoasterDelete(w http.ResponseWriter, r *http.Request) { 412 + rkey := validateRKey(w, r.PathValue("id")) 413 + if rkey == "" { 414 + return 415 + } 416 + 417 + // Require authentication 418 + store, authenticated := h.getAtprotoStore(r) 419 + if !authenticated { 420 + http.Error(w, "Authentication required", http.StatusUnauthorized) 421 + return 422 + } 423 + 424 + if err := store.DeleteRoasterByRKey(r.Context(), rkey); err != nil { 425 + http.Error(w, "Failed to delete roaster", http.StatusInternalServerError) 426 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete roaster") 427 + return 428 + } 429 + 430 + w.WriteHeader(http.StatusOK) 431 + } 432 + 433 + // Grinder CRUD handlers 434 + func (h *Handler) HandleGrinderCreate(w http.ResponseWriter, r *http.Request) { 435 + // Require authentication 436 + store, authenticated := h.getAtprotoStore(r) 437 + if !authenticated { 438 + http.Error(w, "Authentication required", http.StatusUnauthorized) 439 + return 440 + } 441 + 442 + var req models.CreateGrinderRequest 443 + 444 + // Decode request (JSON or form) 445 + if err := decodeRequest(r, &req, func() error { 446 + req = models.CreateGrinderRequest{ 447 + Name: r.FormValue("name"), 448 + GrinderType: r.FormValue("grinder_type"), 449 + BurrType: r.FormValue("burr_type"), 450 + Notes: r.FormValue("notes"), 451 + } 452 + return nil 453 + }); err != nil { 454 + http.Error(w, "Invalid request body", http.StatusBadRequest) 455 + return 456 + } 457 + 458 + // Validate request 459 + if err := req.Validate(); err != nil { 460 + http.Error(w, err.Error(), http.StatusBadRequest) 461 + return 462 + } 463 + 464 + grinder, err := store.CreateGrinder(r.Context(), &req) 465 + if err != nil { 466 + http.Error(w, "Failed to create grinder", http.StatusInternalServerError) 467 + log.Error().Err(err).Msg("Failed to create grinder") 468 + return 469 + } 470 + 471 + writeJSON(w, grinder, "grinder") 472 + } 473 + 474 + func (h *Handler) HandleGrinderUpdate(w http.ResponseWriter, r *http.Request) { 475 + rkey := validateRKey(w, r.PathValue("id")) 476 + if rkey == "" { 477 + return 478 + } 479 + 480 + // Require authentication 481 + store, authenticated := h.getAtprotoStore(r) 482 + if !authenticated { 483 + http.Error(w, "Authentication required", http.StatusUnauthorized) 484 + return 485 + } 486 + 487 + var req models.UpdateGrinderRequest 488 + 489 + // Decode request (JSON or form) 490 + if err := decodeRequest(r, &req, func() error { 491 + req = models.UpdateGrinderRequest{ 492 + Name: r.FormValue("name"), 493 + GrinderType: r.FormValue("grinder_type"), 494 + BurrType: r.FormValue("burr_type"), 495 + Notes: r.FormValue("notes"), 496 + } 497 + return nil 498 + }); err != nil { 499 + http.Error(w, "Invalid request body", http.StatusBadRequest) 500 + return 501 + } 502 + 503 + // Validate request 504 + if err := req.Validate(); err != nil { 505 + http.Error(w, err.Error(), http.StatusBadRequest) 506 + return 507 + } 508 + 509 + if err := store.UpdateGrinderByRKey(r.Context(), rkey, &req); err != nil { 510 + http.Error(w, "Failed to update grinder", http.StatusInternalServerError) 511 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update grinder") 512 + return 513 + } 514 + 515 + grinder, err := store.GetGrinderByRKey(r.Context(), rkey) 516 + if err != nil { 517 + http.Error(w, "Failed to fetch updated grinder", http.StatusInternalServerError) 518 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get grinder after update") 519 + return 520 + } 521 + 522 + writeJSON(w, grinder, "grinder") 523 + } 524 + 525 + func (h *Handler) HandleGrinderDelete(w http.ResponseWriter, r *http.Request) { 526 + rkey := validateRKey(w, r.PathValue("id")) 527 + if rkey == "" { 528 + return 529 + } 530 + 531 + // Require authentication 532 + store, authenticated := h.getAtprotoStore(r) 533 + if !authenticated { 534 + http.Error(w, "Authentication required", http.StatusUnauthorized) 535 + return 536 + } 537 + 538 + if err := store.DeleteGrinderByRKey(r.Context(), rkey); err != nil { 539 + http.Error(w, "Failed to delete grinder", http.StatusInternalServerError) 540 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete grinder") 541 + return 542 + } 543 + 544 + w.WriteHeader(http.StatusOK) 545 + } 546 + 547 + // Brewer CRUD handlers 548 + func (h *Handler) HandleBrewerCreate(w http.ResponseWriter, r *http.Request) { 549 + // Require authentication 550 + store, authenticated := h.getAtprotoStore(r) 551 + if !authenticated { 552 + http.Error(w, "Authentication required", http.StatusUnauthorized) 553 + return 554 + } 555 + 556 + var req models.CreateBrewerRequest 557 + 558 + // Decode request (JSON or form) 559 + if err := decodeRequest(r, &req, func() error { 560 + req = models.CreateBrewerRequest{ 561 + Name: r.FormValue("name"), 562 + BrewerType: r.FormValue("brewer_type"), 563 + Description: r.FormValue("description"), 564 + } 565 + return nil 566 + }); err != nil { 567 + http.Error(w, "Invalid request body", http.StatusBadRequest) 568 + return 569 + } 570 + 571 + // Validate request 572 + if err := req.Validate(); err != nil { 573 + http.Error(w, err.Error(), http.StatusBadRequest) 574 + return 575 + } 576 + 577 + brewer, err := store.CreateBrewer(r.Context(), &req) 578 + if err != nil { 579 + http.Error(w, "Failed to create brewer", http.StatusInternalServerError) 580 + log.Error().Err(err).Msg("Failed to create brewer") 581 + return 582 + } 583 + 584 + writeJSON(w, brewer, "brewer") 585 + } 586 + 587 + func (h *Handler) HandleBrewerUpdate(w http.ResponseWriter, r *http.Request) { 588 + rkey := validateRKey(w, r.PathValue("id")) 589 + if rkey == "" { 590 + return 591 + } 592 + 593 + // Require authentication 594 + store, authenticated := h.getAtprotoStore(r) 595 + if !authenticated { 596 + http.Error(w, "Authentication required", http.StatusUnauthorized) 597 + return 598 + } 599 + 600 + var req models.UpdateBrewerRequest 601 + 602 + // Decode request (JSON or form) 603 + if err := decodeRequest(r, &req, func() error { 604 + req = models.UpdateBrewerRequest{ 605 + Name: r.FormValue("name"), 606 + BrewerType: r.FormValue("brewer_type"), 607 + Description: r.FormValue("description"), 608 + } 609 + return nil 610 + }); err != nil { 611 + http.Error(w, "Invalid request body", http.StatusBadRequest) 612 + return 613 + } 614 + 615 + // Validate request 616 + if err := req.Validate(); err != nil { 617 + http.Error(w, err.Error(), http.StatusBadRequest) 618 + return 619 + } 620 + 621 + if err := store.UpdateBrewerByRKey(r.Context(), rkey, &req); err != nil { 622 + http.Error(w, "Failed to update brewer", http.StatusInternalServerError) 623 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brewer") 624 + return 625 + } 626 + 627 + brewer, err := store.GetBrewerByRKey(r.Context(), rkey) 628 + if err != nil { 629 + http.Error(w, "Failed to fetch updated brewer", http.StatusInternalServerError) 630 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brewer after update") 631 + return 632 + } 633 + 634 + writeJSON(w, brewer, "brewer") 635 + } 636 + 637 + func (h *Handler) HandleBrewerDelete(w http.ResponseWriter, r *http.Request) { 638 + rkey := validateRKey(w, r.PathValue("id")) 639 + if rkey == "" { 640 + return 641 + } 642 + 643 + // Require authentication 644 + store, authenticated := h.getAtprotoStore(r) 645 + if !authenticated { 646 + http.Error(w, "Authentication required", http.StatusUnauthorized) 647 + return 648 + } 649 + 650 + if err := store.DeleteBrewerByRKey(r.Context(), rkey); err != nil { 651 + http.Error(w, "Failed to delete brewer", http.StatusInternalServerError) 652 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brewer") 653 + return 654 + } 655 + 656 + w.WriteHeader(http.StatusOK) 657 + }
+201
internal/handlers/feed.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + 7 + "arabica/internal/atproto" 8 + "arabica/internal/feed" 9 + "arabica/internal/models" 10 + "arabica/internal/moderation" 11 + "arabica/internal/web/bff" 12 + "arabica/internal/web/components" 13 + "arabica/internal/web/pages" 14 + 15 + "github.com/rs/zerolog/log" 16 + ) 17 + 18 + // buildModerationContext creates moderation context for feed rendering 19 + // Returns empty context if moderation is not configured or user is not a moderator 20 + func (h *Handler) buildModerationContext(ctx context.Context, viewerDID string, items []*feed.FeedItem) pages.FeedModerationContext { 21 + modCtx := pages.FeedModerationContext{ 22 + HiddenURIs: make(map[string]bool), 23 + } 24 + 25 + // Check if moderation is configured and user is a moderator 26 + if h.moderationService == nil || viewerDID == "" { 27 + return modCtx 28 + } 29 + 30 + if !h.moderationService.IsModerator(viewerDID) { 31 + return modCtx 32 + } 33 + 34 + modCtx.IsModerator = true 35 + modCtx.CanHideRecord = h.moderationService.HasPermission(viewerDID, moderation.PermissionHideRecord) 36 + modCtx.CanBlockUser = h.moderationService.HasPermission(viewerDID, moderation.PermissionBlacklistUser) 37 + 38 + // Build map of hidden URIs for efficient lookup 39 + if h.moderationStore != nil { 40 + for _, item := range items { 41 + if item.SubjectURI != "" { 42 + if h.moderationStore.IsRecordHidden(ctx, item.SubjectURI) { 43 + modCtx.HiddenURIs[item.SubjectURI] = true 44 + } 45 + } 46 + } 47 + } 48 + 49 + return modCtx 50 + } 51 + 52 + // Home page 53 + func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 54 + // Check if user is authenticated 55 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 56 + isAuthenticated := err == nil && didStr != "" 57 + 58 + // Fetch user profile for authenticated users 59 + var userProfile *bff.UserProfile 60 + if isAuthenticated { 61 + userProfile = h.getUserProfile(r.Context(), didStr) 62 + } 63 + 64 + // Create layout data 65 + layoutData := h.buildLayoutData(r, "Home", isAuthenticated, didStr, userProfile) 66 + 67 + // Create home props 68 + homeProps := pages.HomeProps{ 69 + IsAuthenticated: isAuthenticated, 70 + UserDID: didStr, 71 + } 72 + 73 + // Render using templ component 74 + if err := pages.Home(layoutData, homeProps).Render(r.Context(), w); err != nil { 75 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 76 + log.Error().Err(err).Msg("Failed to render home page") 77 + } 78 + } 79 + 80 + // Community feed partial (loaded async via HTMX) 81 + func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { 82 + var feedItems []*feed.FeedItem 83 + 84 + // Check if user is authenticated 85 + viewerDID, err := atproto.GetAuthenticatedDID(r.Context()) 86 + isAuthenticated := err == nil 87 + 88 + if h.feedService != nil { 89 + if isAuthenticated { 90 + feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit) 91 + } else { 92 + // Unauthenticated users get a limited feed from the cache 93 + feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context()) 94 + } 95 + } 96 + 97 + // Populate IsLikedByViewer and IsOwner for each feed item if user is authenticated 98 + if isAuthenticated { 99 + for _, item := range feedItems { 100 + // Check if viewer owns this record 101 + if item.Author != nil { 102 + item.IsOwner = item.Author.DID == viewerDID 103 + } 104 + // Check if viewer liked this record 105 + if h.feedIndex != nil && item.SubjectURI != "" { 106 + item.IsLikedByViewer = h.feedIndex.HasUserLiked(viewerDID, item.SubjectURI) 107 + } 108 + } 109 + } 110 + 111 + // Build moderation context for moderators 112 + modCtx := h.buildModerationContext(r.Context(), viewerDID, feedItems) 113 + 114 + if err := pages.FeedPartialWithModeration(feedItems, isAuthenticated, modCtx).Render(r.Context(), w); err != nil { 115 + http.Error(w, "Failed to render feed", http.StatusInternalServerError) 116 + log.Error().Err(err).Msg("Failed to render feed partial") 117 + } 118 + } 119 + 120 + // HandleLikeToggle handles creating or deleting a like on a record 121 + func (h *Handler) HandleLikeToggle(w http.ResponseWriter, r *http.Request) { 122 + // Require authentication 123 + store, authenticated := h.getAtprotoStore(r) 124 + if !authenticated { 125 + http.Error(w, "Authentication required", http.StatusUnauthorized) 126 + return 127 + } 128 + 129 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 130 + 131 + if err := r.ParseForm(); err != nil { 132 + http.Error(w, "Invalid form data", http.StatusBadRequest) 133 + return 134 + } 135 + 136 + subjectURI := r.FormValue("subject_uri") 137 + subjectCID := r.FormValue("subject_cid") 138 + 139 + if subjectURI == "" || subjectCID == "" { 140 + http.Error(w, "subject_uri and subject_cid are required", http.StatusBadRequest) 141 + return 142 + } 143 + 144 + // Check if user already liked this record 145 + existingLike, err := store.GetUserLikeForSubject(r.Context(), subjectURI) 146 + if err != nil { 147 + http.Error(w, "Failed to check like status", http.StatusInternalServerError) 148 + log.Error().Err(err).Msg("Failed to check existing like") 149 + return 150 + } 151 + 152 + var isLiked bool 153 + var likeCount int 154 + 155 + if existingLike != nil { 156 + // Unlike: delete the existing like 157 + if err := store.DeleteLikeByRKey(r.Context(), existingLike.RKey); err != nil { 158 + http.Error(w, "Failed to unlike", http.StatusInternalServerError) 159 + log.Error().Err(err).Msg("Failed to delete like") 160 + return 161 + } 162 + isLiked = false 163 + 164 + // Update firehose index 165 + if h.feedIndex != nil { 166 + _ = h.feedIndex.DeleteLike(didStr, subjectURI) 167 + likeCount = h.feedIndex.GetLikeCount(subjectURI) 168 + } 169 + } else { 170 + // Like: create a new like 171 + req := &models.CreateLikeRequest{ 172 + SubjectURI: subjectURI, 173 + SubjectCID: subjectCID, 174 + } 175 + like, err := store.CreateLike(r.Context(), req) 176 + if err != nil { 177 + http.Error(w, "Failed to like", http.StatusInternalServerError) 178 + log.Error().Err(err).Msg("Failed to create like") 179 + return 180 + } 181 + isLiked = true 182 + 183 + // Update firehose index 184 + if h.feedIndex != nil { 185 + _ = h.feedIndex.UpsertLike(didStr, like.RKey, subjectURI) 186 + likeCount = h.feedIndex.GetLikeCount(subjectURI) 187 + } 188 + } 189 + 190 + // Return the updated like button component 191 + if err := components.LikeButton(components.LikeButtonProps{ 192 + SubjectURI: subjectURI, 193 + SubjectCID: subjectCID, 194 + IsLiked: isLiked, 195 + LikeCount: likeCount, 196 + IsAuthenticated: true, 197 + }).Render(r.Context(), w); err != nil { 198 + http.Error(w, "Failed to render", http.StatusInternalServerError) 199 + log.Error().Err(err).Msg("Failed to render like button") 200 + } 201 + }
-2743
internal/handlers/handlers.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 - "errors" 7 - "fmt" 8 6 "net/http" 9 - "sort" 10 - "strconv" 11 7 "strings" 12 - "time" 13 8 14 9 "arabica/internal/atproto" 15 10 "arabica/internal/database" ··· 18 13 "arabica/internal/feed" 19 14 "arabica/internal/firehose" 20 15 "arabica/internal/middleware" 21 - "arabica/internal/models" 22 16 "arabica/internal/moderation" 23 17 "arabica/internal/web/bff" 24 18 "arabica/internal/web/components" 25 - "arabica/internal/web/pages" 26 19 27 - comatproto "github.com/bluesky-social/indigo/api/atproto" 28 - "github.com/bluesky-social/indigo/xrpc" 29 20 "github.com/rs/zerolog/log" 30 - "golang.org/x/sync/errgroup" 31 21 ) 32 22 33 23 // Config holds handler configuration options ··· 190 180 return userProfile 191 181 } 192 182 193 - // buildModerationContext creates moderation context for feed rendering 194 - // Returns empty context if moderation is not configured or user is not a moderator 195 - func (h *Handler) buildModerationContext(ctx context.Context, viewerDID string, items []*feed.FeedItem) pages.FeedModerationContext { 196 - modCtx := pages.FeedModerationContext{ 197 - HiddenURIs: make(map[string]bool), 198 - } 199 - 200 - // Check if moderation is configured and user is a moderator 201 - if h.moderationService == nil || viewerDID == "" { 202 - return modCtx 203 - } 204 - 205 - if !h.moderationService.IsModerator(viewerDID) { 206 - return modCtx 207 - } 208 - 209 - modCtx.IsModerator = true 210 - modCtx.CanHideRecord = h.moderationService.HasPermission(viewerDID, moderation.PermissionHideRecord) 211 - modCtx.CanBlockUser = h.moderationService.HasPermission(viewerDID, moderation.PermissionBlacklistUser) 212 - 213 - // Build map of hidden URIs for efficient lookup 214 - if h.moderationStore != nil { 215 - for _, item := range items { 216 - if item.SubjectURI != "" { 217 - if h.moderationStore.IsRecordHidden(ctx, item.SubjectURI) { 218 - modCtx.HiddenURIs[item.SubjectURI] = true 219 - } 220 - } 221 - } 222 - } 223 - 224 - return modCtx 225 - } 226 - 227 183 // getAtprotoStore creates a user-scoped atproto store from the request context. 228 184 // Returns the store and true if authenticated, or nil and false if not authenticated. 229 185 func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) { ··· 267 223 IsModerator: isModerator, 268 224 } 269 225 } 270 - 271 - // populateBrewOGMetadata sets OpenGraph metadata on layoutData for a brew page. 272 - // This enriches social media previews when brew links are shared. 273 - func (h *Handler) populateBrewOGMetadata(layoutData *components.LayoutData, brew *models.Brew, shareURL string) { 274 - if brew == nil { 275 - return 276 - } 277 - 278 - // Build OG title from bean info 279 - var ogTitle string 280 - if brew.Bean != nil { 281 - if brew.Bean.Origin != "" { 282 - ogTitle = fmt.Sprintf("%s from %s", brew.Bean.Name, brew.Bean.Origin) 283 - } else { 284 - ogTitle = brew.Bean.Name 285 - } 286 - } else { 287 - ogTitle = "Coffee Brew" 288 - } 289 - 290 - // Build OG description with rating and tasting notes 291 - var descParts []string 292 - if brew.Rating > 0 { 293 - descParts = append(descParts, fmt.Sprintf("Rated %d/10", brew.Rating)) 294 - } 295 - if brew.TastingNotes != "" { 296 - // Truncate tasting notes if too long 297 - notes := brew.TastingNotes 298 - if len(notes) > 100 { 299 - notes = notes[:97] + "..." 300 - } 301 - descParts = append(descParts, notes) 302 - } 303 - if brew.Bean != nil && brew.Bean.Roaster != nil { 304 - descParts = append(descParts, fmt.Sprintf("Roasted by %s", brew.Bean.Roaster.Name)) 305 - } 306 - 307 - var ogDescription string 308 - if len(descParts) > 0 { 309 - ogDescription = strings.Join(descParts, " · ") 310 - } else { 311 - ogDescription = "A coffee brew tracked on Arabica" 312 - } 313 - 314 - // Build absolute URL if public URL is configured 315 - var ogURL string 316 - if h.config.PublicURL != "" && shareURL != "" { 317 - ogURL = h.config.PublicURL + shareURL 318 - } 319 - 320 - layoutData.OGTitle = ogTitle 321 - layoutData.OGDescription = ogDescription 322 - layoutData.OGType = "article" 323 - layoutData.OGUrl = ogURL 324 - } 325 - 326 - // ProfileDataBundle holds all user data fetched from their PDS for profile display 327 - type ProfileDataBundle struct { 328 - Beans []*models.Bean 329 - Roasters []*models.Roaster 330 - Grinders []*models.Grinder 331 - Brewers []*models.Brewer 332 - Brews []*models.Brew 333 - } 334 - 335 - // fetchUserProfileData fetches all user data from their PDS in parallel. 336 - // This includes beans, roasters, grinders, brewers, and brews with all references resolved. 337 - // Brews are sorted in reverse chronological order (newest first). 338 - func (h *Handler) fetchUserProfileData(ctx context.Context, did string, publicClient *atproto.PublicClient) (*ProfileDataBundle, error) { 339 - // Fetch all user data in parallel 340 - g, gCtx := errgroup.WithContext(ctx) 341 - 342 - var brews []*models.Brew 343 - var beans []*models.Bean 344 - var roasters []*models.Roaster 345 - var grinders []*models.Grinder 346 - var brewers []*models.Brewer 347 - 348 - // Maps for resolving references 349 - var beanMap map[string]*models.Bean 350 - var beanRoasterRefMap map[string]string 351 - var roasterMap map[string]*models.Roaster 352 - var brewerMap map[string]*models.Brewer 353 - var grinderMap map[string]*models.Grinder 354 - 355 - // Fetch beans 356 - g.Go(func() error { 357 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBean, 100) 358 - if err != nil { 359 - return err 360 - } 361 - beanMap = make(map[string]*models.Bean) 362 - beanRoasterRefMap = make(map[string]string) 363 - beans = make([]*models.Bean, 0, len(output.Records)) 364 - for _, record := range output.Records { 365 - bean, err := atproto.RecordToBean(record.Value, record.URI) 366 - if err != nil { 367 - continue 368 - } 369 - beans = append(beans, bean) 370 - beanMap[record.URI] = bean 371 - if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" { 372 - beanRoasterRefMap[record.URI] = roasterRef 373 - } 374 - } 375 - return nil 376 - }) 377 - 378 - // Fetch roasters 379 - g.Go(func() error { 380 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDRoaster, 100) 381 - if err != nil { 382 - return err 383 - } 384 - roasterMap = make(map[string]*models.Roaster) 385 - roasters = make([]*models.Roaster, 0, len(output.Records)) 386 - for _, record := range output.Records { 387 - roaster, err := atproto.RecordToRoaster(record.Value, record.URI) 388 - if err != nil { 389 - continue 390 - } 391 - roasters = append(roasters, roaster) 392 - roasterMap[record.URI] = roaster 393 - } 394 - return nil 395 - }) 396 - 397 - // Fetch grinders 398 - g.Go(func() error { 399 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDGrinder, 100) 400 - if err != nil { 401 - return err 402 - } 403 - grinderMap = make(map[string]*models.Grinder) 404 - grinders = make([]*models.Grinder, 0, len(output.Records)) 405 - for _, record := range output.Records { 406 - grinder, err := atproto.RecordToGrinder(record.Value, record.URI) 407 - if err != nil { 408 - continue 409 - } 410 - grinders = append(grinders, grinder) 411 - grinderMap[record.URI] = grinder 412 - } 413 - return nil 414 - }) 415 - 416 - // Fetch brewers 417 - g.Go(func() error { 418 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrewer, 100) 419 - if err != nil { 420 - return err 421 - } 422 - brewerMap = make(map[string]*models.Brewer) 423 - brewers = make([]*models.Brewer, 0, len(output.Records)) 424 - for _, record := range output.Records { 425 - brewer, err := atproto.RecordToBrewer(record.Value, record.URI) 426 - if err != nil { 427 - continue 428 - } 429 - brewers = append(brewers, brewer) 430 - brewerMap[record.URI] = brewer 431 - } 432 - return nil 433 - }) 434 - 435 - // Fetch brews 436 - g.Go(func() error { 437 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrew, 100) 438 - if err != nil { 439 - return err 440 - } 441 - brews = make([]*models.Brew, 0, len(output.Records)) 442 - for _, record := range output.Records { 443 - brew, err := atproto.RecordToBrew(record.Value, record.URI) 444 - if err != nil { 445 - continue 446 - } 447 - // Store the raw record for reference resolution later 448 - brew.BeanRKey = "" 449 - if beanRef, ok := record.Value["beanRef"].(string); ok { 450 - brew.BeanRKey = beanRef 451 - } 452 - if grinderRef, ok := record.Value["grinderRef"].(string); ok { 453 - brew.GrinderRKey = grinderRef 454 - } 455 - if brewerRef, ok := record.Value["brewerRef"].(string); ok { 456 - brew.BrewerRKey = brewerRef 457 - } 458 - brews = append(brews, brew) 459 - } 460 - return nil 461 - }) 462 - 463 - if err := g.Wait(); err != nil { 464 - return nil, err 465 - } 466 - 467 - // Resolve references for beans (roaster refs) 468 - for _, bean := range beans { 469 - if roasterRef, found := beanRoasterRefMap[atproto.BuildATURI(did, atproto.NSIDBean, bean.RKey)]; found { 470 - if roaster, found := roasterMap[roasterRef]; found { 471 - bean.Roaster = roaster 472 - } 473 - } 474 - } 475 - 476 - // Resolve references for brews 477 - for _, brew := range brews { 478 - // Resolve bean reference 479 - if brew.BeanRKey != "" { 480 - if bean, found := beanMap[brew.BeanRKey]; found { 481 - brew.Bean = bean 482 - } 483 - } 484 - // Resolve grinder reference 485 - if brew.GrinderRKey != "" { 486 - if grinder, found := grinderMap[brew.GrinderRKey]; found { 487 - brew.GrinderObj = grinder 488 - } 489 - } 490 - // Resolve brewer reference 491 - if brew.BrewerRKey != "" { 492 - if brewer, found := brewerMap[brew.BrewerRKey]; found { 493 - brew.BrewerObj = brewer 494 - } 495 - } 496 - } 497 - 498 - // Sort brews in reverse chronological order (newest first) 499 - sort.Slice(brews, func(i, j int) bool { 500 - return brews[i].CreatedAt.After(brews[j].CreatedAt) 501 - }) 502 - 503 - return &ProfileDataBundle{ 504 - Beans: beans, 505 - Roasters: roasters, 506 - Grinders: grinders, 507 - Brewers: brewers, 508 - Brews: brews, 509 - }, nil 510 - } 511 - 512 - // Home page 513 - func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 514 - // Check if user is authenticated 515 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 516 - isAuthenticated := err == nil && didStr != "" 517 - 518 - // Fetch user profile for authenticated users 519 - var userProfile *bff.UserProfile 520 - if isAuthenticated { 521 - userProfile = h.getUserProfile(r.Context(), didStr) 522 - } 523 - 524 - // Create layout data 525 - layoutData := h.buildLayoutData(r, "Home", isAuthenticated, didStr, userProfile) 526 - 527 - // Create home props 528 - homeProps := pages.HomeProps{ 529 - IsAuthenticated: isAuthenticated, 530 - UserDID: didStr, 531 - } 532 - 533 - // Render using templ component 534 - if err := pages.Home(layoutData, homeProps).Render(r.Context(), w); err != nil { 535 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 536 - log.Error().Err(err).Msg("Failed to render home page") 537 - } 538 - } 539 - 540 - // Community feed partial (loaded async via HTMX) 541 - func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { 542 - var feedItems []*feed.FeedItem 543 - 544 - // Check if user is authenticated 545 - viewerDID, err := atproto.GetAuthenticatedDID(r.Context()) 546 - isAuthenticated := err == nil 547 - 548 - if h.feedService != nil { 549 - if isAuthenticated { 550 - feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit) 551 - } else { 552 - // Unauthenticated users get a limited feed from the cache 553 - feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context()) 554 - } 555 - } 556 - 557 - // Populate IsLikedByViewer and IsOwner for each feed item if user is authenticated 558 - if isAuthenticated { 559 - for _, item := range feedItems { 560 - // Check if viewer owns this record 561 - if item.Author != nil { 562 - item.IsOwner = item.Author.DID == viewerDID 563 - } 564 - // Check if viewer liked this record 565 - if h.feedIndex != nil && item.SubjectURI != "" { 566 - item.IsLikedByViewer = h.feedIndex.HasUserLiked(viewerDID, item.SubjectURI) 567 - } 568 - } 569 - } 570 - 571 - // Build moderation context for moderators 572 - modCtx := h.buildModerationContext(r.Context(), viewerDID, feedItems) 573 - 574 - if err := pages.FeedPartialWithModeration(feedItems, isAuthenticated, modCtx).Render(r.Context(), w); err != nil { 575 - http.Error(w, "Failed to render feed", http.StatusInternalServerError) 576 - log.Error().Err(err).Msg("Failed to render feed partial") 577 - } 578 - } 579 - 580 - // Brew list partial (loaded async via HTMX) 581 - func (h *Handler) HandleBrewListPartial(w http.ResponseWriter, r *http.Request) { 582 - // Require authentication 583 - store, authenticated := h.getAtprotoStore(r) 584 - if !authenticated { 585 - http.Error(w, "Authentication required", http.StatusUnauthorized) 586 - return 587 - } 588 - 589 - brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 590 - if err != nil { 591 - http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 592 - log.Error().Err(err).Msg("Failed to fetch brews") 593 - return 594 - } 595 - 596 - if err := components.BrewListTablePartial(components.BrewListTableProps{ 597 - Brews: brews, 598 - IsOwnProfile: true, 599 - }).Render(r.Context(), w); err != nil { 600 - http.Error(w, "Failed to render content", http.StatusInternalServerError) 601 - log.Error().Err(err).Msg("Failed to render brew list partial") 602 - } 603 - } 604 - 605 - // Manage page partial (loaded async via HTMX) 606 - func (h *Handler) HandleManagePartial(w http.ResponseWriter, r *http.Request) { 607 - // Require authentication 608 - store, authenticated := h.getAtprotoStore(r) 609 - if !authenticated { 610 - http.Error(w, "Authentication required", http.StatusUnauthorized) 611 - return 612 - } 613 - 614 - ctx := r.Context() 615 - 616 - // Fetch all collections in parallel using errgroup for proper error handling 617 - // and automatic context cancellation on first error 618 - g, ctx := errgroup.WithContext(ctx) 619 - 620 - var beans []*models.Bean 621 - var roasters []*models.Roaster 622 - var grinders []*models.Grinder 623 - var brewers []*models.Brewer 624 - 625 - g.Go(func() error { 626 - var err error 627 - beans, err = store.ListBeans(ctx) 628 - return err 629 - }) 630 - g.Go(func() error { 631 - var err error 632 - roasters, err = store.ListRoasters(ctx) 633 - return err 634 - }) 635 - g.Go(func() error { 636 - var err error 637 - grinders, err = store.ListGrinders(ctx) 638 - return err 639 - }) 640 - g.Go(func() error { 641 - var err error 642 - brewers, err = store.ListBrewers(ctx) 643 - return err 644 - }) 645 - 646 - if err := g.Wait(); err != nil { 647 - http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 648 - log.Error().Err(err).Msg("Failed to fetch manage page data") 649 - return 650 - } 651 - 652 - // Link beans to their roasters 653 - atproto.LinkBeansToRoasters(beans, roasters) 654 - 655 - // Render manage partial 656 - if err := components.ManagePartial(components.ManagePartialProps{ 657 - Beans: beans, 658 - Roasters: roasters, 659 - Grinders: grinders, 660 - Brewers: brewers, 661 - }).Render(r.Context(), w); err != nil { 662 - http.Error(w, "Failed to render content", http.StatusInternalServerError) 663 - log.Error().Err(err).Msg("Failed to render manage partial") 664 - } 665 - } 666 - 667 - // List all brews 668 - func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { 669 - // Require authentication 670 - _, authenticated := h.getAtprotoStore(r) 671 - if !authenticated { 672 - http.Redirect(w, r, "/login", http.StatusFound) 673 - return 674 - } 675 - 676 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 677 - userProfile := h.getUserProfile(r.Context(), didStr) 678 - 679 - // Create layout data 680 - layoutData := h.buildLayoutData(r, "Your Brews", authenticated, didStr, userProfile) 681 - 682 - // Create brew list props 683 - brewListProps := pages.BrewListProps{} 684 - 685 - // Render using templ component 686 - if err := pages.BrewList(layoutData, brewListProps).Render(r.Context(), w); err != nil { 687 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 688 - log.Error().Err(err).Msg("Failed to render brew list page") 689 - } 690 - } 691 - 692 - // Show new brew form 693 - func (h *Handler) HandleBrewNew(w http.ResponseWriter, r *http.Request) { 694 - // Require authentication 695 - _, authenticated := h.getAtprotoStore(r) 696 - if !authenticated { 697 - http.Redirect(w, r, "/login", http.StatusFound) 698 - return 699 - } 700 - 701 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 702 - userProfile := h.getUserProfile(r.Context(), didStr) 703 - 704 - // Don't fetch data from PDS - client will populate dropdowns from cache 705 - // This makes the page load much faster 706 - layoutData := h.buildLayoutData(r, "New Brew", authenticated, didStr, userProfile) 707 - 708 - brewFormProps := pages.BrewFormProps{ 709 - Brew: nil, 710 - } 711 - 712 - if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil { 713 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 714 - log.Error().Err(err).Msg("Failed to render brew form") 715 - } 716 - } 717 - 718 - // Show brew view page 719 - func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) { 720 - rkey := validateRKey(w, r.PathValue("id")) 721 - if rkey == "" { 722 - return 723 - } 724 - 725 - // Check if owner (DID or handle) is specified in query params 726 - owner := r.URL.Query().Get("owner") 727 - 728 - // Check authentication 729 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 730 - isAuthenticated := err == nil && didStr != "" 731 - 732 - var userProfile *bff.UserProfile 733 - if isAuthenticated { 734 - userProfile = h.getUserProfile(r.Context(), didStr) 735 - } 736 - 737 - var brew *models.Brew 738 - var brewOwnerDID string 739 - var isOwner bool 740 - var subjectURI, subjectCID string 741 - 742 - if owner != "" { 743 - // Viewing someone else's brew - use public client 744 - publicClient := atproto.NewPublicClient() 745 - 746 - // Resolve owner to DID if it's a handle 747 - if strings.HasPrefix(owner, "did:") { 748 - brewOwnerDID = owner 749 - } else { 750 - resolved, err := publicClient.ResolveHandle(r.Context(), owner) 751 - if err != nil { 752 - log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for brew view") 753 - http.Error(w, "User not found", http.StatusNotFound) 754 - return 755 - } 756 - brewOwnerDID = resolved 757 - } 758 - 759 - // Fetch the brew record from the owner's PDS 760 - record, err := publicClient.GetRecord(r.Context(), brewOwnerDID, atproto.NSIDBrew, rkey) 761 - if err != nil { 762 - log.Error().Err(err).Str("did", brewOwnerDID).Str("rkey", rkey).Msg("Failed to get brew record") 763 - http.Error(w, "Brew not found", http.StatusNotFound) 764 - return 765 - } 766 - 767 - // Store URI and CID for like button 768 - subjectURI = record.URI 769 - subjectCID = record.CID 770 - 771 - // Convert record to brew 772 - brew, err = atproto.RecordToBrew(record.Value, record.URI) 773 - if err != nil { 774 - log.Error().Err(err).Msg("Failed to convert brew record") 775 - http.Error(w, "Failed to load brew", http.StatusInternalServerError) 776 - return 777 - } 778 - 779 - // Resolve references (bean, grinder, brewer) 780 - if err := h.resolveBrewReferences(r.Context(), brew, brewOwnerDID, record.Value); err != nil { 781 - log.Warn().Err(err).Msg("Failed to resolve some brew references") 782 - // Don't fail the request, just log the warning 783 - } 784 - 785 - // Check if viewing user is the owner 786 - isOwner = isAuthenticated && didStr == brewOwnerDID 787 - } else { 788 - // Viewing own brew - require authentication 789 - store, authenticated := h.getAtprotoStore(r) 790 - if !authenticated { 791 - http.Redirect(w, r, "/login", http.StatusFound) 792 - return 793 - } 794 - 795 - // Use type assertion to access GetBrewRecordByRKey 796 - atprotoStore, ok := store.(*atproto.AtprotoStore) 797 - if !ok { 798 - http.Error(w, "Internal error", http.StatusInternalServerError) 799 - log.Error().Msg("Failed to cast store to AtprotoStore") 800 - return 801 - } 802 - 803 - brewRecord, err := atprotoStore.GetBrewRecordByRKey(r.Context(), rkey) 804 - if err != nil { 805 - http.Error(w, "Brew not found", http.StatusNotFound) 806 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view") 807 - return 808 - } 809 - 810 - brew = brewRecord.Brew 811 - subjectURI = brewRecord.URI 812 - subjectCID = brewRecord.CID 813 - isOwner = true 814 - } 815 - 816 - // Construct share URL (needed for both OG metadata and props) 817 - var shareURL string 818 - if owner != "" { 819 - shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, owner) 820 - } else if userProfile != nil && userProfile.Handle != "" { 821 - shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, userProfile.Handle) 822 - } 823 - 824 - // Create layout data with OpenGraph metadata 825 - layoutData := h.buildLayoutData(r, "Brew Details", isAuthenticated, didStr, userProfile) 826 - h.populateBrewOGMetadata(layoutData, brew, shareURL) 827 - 828 - // Get like data 829 - var isLiked bool 830 - var likeCount int 831 - if h.feedIndex != nil && subjectURI != "" { 832 - likeCount = h.feedIndex.GetLikeCount(subjectURI) 833 - if isAuthenticated { 834 - isLiked = h.feedIndex.HasUserLiked(didStr, subjectURI) 835 - } 836 - } 837 - 838 - // Create brew view props 839 - brewViewProps := pages.BrewViewProps{ 840 - Brew: brew, 841 - IsOwnProfile: isOwner, 842 - IsAuthenticated: isAuthenticated, 843 - SubjectURI: subjectURI, 844 - SubjectCID: subjectCID, 845 - IsLiked: isLiked, 846 - LikeCount: likeCount, 847 - ShareURL: shareURL, 848 - } 849 - 850 - // Render using templ component 851 - if err := pages.BrewView(layoutData, brewViewProps).Render(r.Context(), w); err != nil { 852 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 853 - log.Error().Err(err).Msg("Failed to render brew view") 854 - } 855 - } 856 - 857 - // resolveBrewReferences resolves bean, grinder, and brewer references for a brew 858 - func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew, ownerDID string, record map[string]interface{}) error { 859 - publicClient := atproto.NewPublicClient() 860 - 861 - // Resolve bean reference 862 - if beanRef, ok := record["beanRef"].(string); ok && beanRef != "" { 863 - beanRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBean, atproto.ExtractRKeyFromURI(beanRef)) 864 - if err == nil { 865 - if bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI); err == nil { 866 - brew.Bean = bean 867 - 868 - // Resolve roaster reference for the bean 869 - if roasterRef, ok := beanRecord.Value["roasterRef"].(string); ok && roasterRef != "" { 870 - roasterRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDRoaster, atproto.ExtractRKeyFromURI(roasterRef)) 871 - if err == nil { 872 - if roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI); err == nil { 873 - brew.Bean.Roaster = roaster 874 - } 875 - } 876 - } 877 - } 878 - } 879 - } 880 - 881 - // Resolve grinder reference 882 - if grinderRef, ok := record["grinderRef"].(string); ok && grinderRef != "" { 883 - grinderRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDGrinder, atproto.ExtractRKeyFromURI(grinderRef)) 884 - if err == nil { 885 - if grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI); err == nil { 886 - brew.GrinderObj = grinder 887 - } 888 - } 889 - } 890 - 891 - // Resolve brewer reference 892 - if brewerRef, ok := record["brewerRef"].(string); ok && brewerRef != "" { 893 - brewerRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBrewer, atproto.ExtractRKeyFromURI(brewerRef)) 894 - if err == nil { 895 - if brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI); err == nil { 896 - brew.BrewerObj = brewer 897 - } 898 - } 899 - } 900 - 901 - return nil 902 - } 903 - 904 - // Show edit brew form 905 - func (h *Handler) HandleBrewEdit(w http.ResponseWriter, r *http.Request) { 906 - rkey := validateRKey(w, r.PathValue("id")) 907 - if rkey == "" { 908 - return 909 - } 910 - 911 - // Require authentication 912 - store, authenticated := h.getAtprotoStore(r) 913 - if !authenticated { 914 - http.Redirect(w, r, "/login", http.StatusFound) 915 - return 916 - } 917 - 918 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 919 - userProfile := h.getUserProfile(r.Context(), didStr) 920 - 921 - brew, err := store.GetBrewByRKey(r.Context(), rkey) 922 - if err != nil { 923 - http.Error(w, "Brew not found", http.StatusNotFound) 924 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for edit") 925 - return 926 - } 927 - 928 - // Don't fetch dropdown data from PDS - client will populate from cache 929 - // This makes the page load much faster 930 - layoutData := h.buildLayoutData(r, "Edit Brew", authenticated, didStr, userProfile) 931 - 932 - brewFormProps := pages.BrewFormProps{ 933 - Brew: brew, 934 - PoursJSON: bff.PoursToJSON(brew.Pours), 935 - } 936 - 937 - if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil { 938 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 939 - log.Error().Err(err).Msg("Failed to render brew edit form") 940 - } 941 - } 942 - 943 - // maxPours is the maximum number of pours allowed in a single brew 944 - const maxPours = 100 945 - 946 - // parsePours extracts pour data from form values with bounds checking 947 - func parsePours(r *http.Request) []models.CreatePourData { 948 - var pours []models.CreatePourData 949 - 950 - for i := 0; i < maxPours; i++ { 951 - waterKey := "pour_water_" + strconv.Itoa(i) 952 - timeKey := "pour_time_" + strconv.Itoa(i) 953 - 954 - waterStr := r.FormValue(waterKey) 955 - timeStr := r.FormValue(timeKey) 956 - 957 - if waterStr == "" && timeStr == "" { 958 - break 959 - } 960 - 961 - water, _ := strconv.Atoi(waterStr) 962 - pourTime, _ := strconv.Atoi(timeStr) 963 - 964 - if water > 0 && pourTime >= 0 { 965 - pours = append(pours, models.CreatePourData{ 966 - WaterAmount: water, 967 - TimeSeconds: pourTime, 968 - }) 969 - } 970 - } 971 - 972 - return pours 973 - } 974 - 975 - // ValidationError represents a validation error with field name and message 976 - type ValidationError struct { 977 - Field string 978 - Message string 979 - } 980 - 981 - // validateBrewRequest validates brew form input and returns any validation errors 982 - func validateBrewRequest(r *http.Request) (temperature float64, waterAmount, coffeeAmount, timeSeconds, rating int, pours []models.CreatePourData, errs []ValidationError) { 983 - // Parse and validate temperature 984 - if tempStr := r.FormValue("temperature"); tempStr != "" { 985 - var err error 986 - temperature, err = strconv.ParseFloat(tempStr, 64) 987 - if err != nil { 988 - errs = append(errs, ValidationError{Field: "temperature", Message: "invalid temperature format"}) 989 - } else if temperature < 0 || temperature > 212 { 990 - errs = append(errs, ValidationError{Field: "temperature", Message: "temperature must be between 0 and 212"}) 991 - } 992 - } 993 - 994 - // Parse and validate water amount 995 - if waterStr := r.FormValue("water_amount"); waterStr != "" { 996 - var err error 997 - waterAmount, err = strconv.Atoi(waterStr) 998 - if err != nil { 999 - errs = append(errs, ValidationError{Field: "water_amount", Message: "invalid water amount"}) 1000 - } else if waterAmount < 0 || waterAmount > 10000 { 1001 - errs = append(errs, ValidationError{Field: "water_amount", Message: "water amount must be between 0 and 10000ml"}) 1002 - } 1003 - } 1004 - 1005 - // Parse and validate coffee amount 1006 - if coffeeStr := r.FormValue("coffee_amount"); coffeeStr != "" { 1007 - var err error 1008 - coffeeAmount, err = strconv.Atoi(coffeeStr) 1009 - if err != nil { 1010 - errs = append(errs, ValidationError{Field: "coffee_amount", Message: "invalid coffee amount"}) 1011 - } else if coffeeAmount < 0 || coffeeAmount > 1000 { 1012 - errs = append(errs, ValidationError{Field: "coffee_amount", Message: "coffee amount must be between 0 and 1000g"}) 1013 - } 1014 - } 1015 - 1016 - // Parse and validate time 1017 - if timeStr := r.FormValue("time_seconds"); timeStr != "" { 1018 - var err error 1019 - timeSeconds, err = strconv.Atoi(timeStr) 1020 - if err != nil { 1021 - errs = append(errs, ValidationError{Field: "time_seconds", Message: "invalid time"}) 1022 - } else if timeSeconds < 0 || timeSeconds > 3600 { 1023 - errs = append(errs, ValidationError{Field: "time_seconds", Message: "brew time must be between 0 and 3600 seconds"}) 1024 - } 1025 - } 1026 - 1027 - // Parse and validate rating 1028 - if ratingStr := r.FormValue("rating"); ratingStr != "" { 1029 - var err error 1030 - rating, err = strconv.Atoi(ratingStr) 1031 - if err != nil { 1032 - errs = append(errs, ValidationError{Field: "rating", Message: "invalid rating"}) 1033 - } else if rating < 0 || rating > 10 { 1034 - errs = append(errs, ValidationError{Field: "rating", Message: "rating must be between 0 and 10"}) 1035 - } 1036 - } 1037 - 1038 - // Parse pours 1039 - pours = parsePours(r) 1040 - 1041 - return 1042 - } 1043 - 1044 - // Create new brew 1045 - func (h *Handler) HandleBrewCreate(w http.ResponseWriter, r *http.Request) { 1046 - // Require authentication first 1047 - store, authenticated := h.getAtprotoStore(r) 1048 - if !authenticated { 1049 - http.Redirect(w, r, "/login", http.StatusFound) 1050 - return 1051 - } 1052 - 1053 - if err := r.ParseForm(); err != nil { 1054 - http.Error(w, "Invalid form data", http.StatusBadRequest) 1055 - return 1056 - } 1057 - 1058 - // Validate input 1059 - temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r) 1060 - if len(validationErrs) > 0 { 1061 - // Return first validation error 1062 - http.Error(w, validationErrs[0].Message, http.StatusBadRequest) 1063 - return 1064 - } 1065 - 1066 - // Validate required fields 1067 - beanRKey := r.FormValue("bean_rkey") 1068 - if beanRKey == "" { 1069 - http.Error(w, "Bean selection is required", http.StatusBadRequest) 1070 - return 1071 - } 1072 - if !atproto.ValidateRKey(beanRKey) { 1073 - http.Error(w, "Invalid bean selection", http.StatusBadRequest) 1074 - return 1075 - } 1076 - 1077 - // Validate optional rkeys 1078 - grinderRKey := r.FormValue("grinder_rkey") 1079 - if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" { 1080 - http.Error(w, errMsg, http.StatusBadRequest) 1081 - return 1082 - } 1083 - brewerRKey := r.FormValue("brewer_rkey") 1084 - if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" { 1085 - http.Error(w, errMsg, http.StatusBadRequest) 1086 - return 1087 - } 1088 - 1089 - req := &models.CreateBrewRequest{ 1090 - BeanRKey: beanRKey, 1091 - Method: r.FormValue("method"), 1092 - Temperature: temperature, 1093 - WaterAmount: waterAmount, 1094 - CoffeeAmount: coffeeAmount, 1095 - TimeSeconds: timeSeconds, 1096 - GrindSize: r.FormValue("grind_size"), 1097 - GrinderRKey: grinderRKey, 1098 - BrewerRKey: brewerRKey, 1099 - TastingNotes: r.FormValue("tasting_notes"), 1100 - Rating: rating, 1101 - Pours: pours, 1102 - } 1103 - 1104 - _, err := store.CreateBrew(r.Context(), req, 1) // User ID not used with atproto 1105 - if err != nil { 1106 - http.Error(w, "Failed to create brew", http.StatusInternalServerError) 1107 - log.Error().Err(err).Msg("Failed to create brew") 1108 - return 1109 - } 1110 - 1111 - // Redirect to brew list 1112 - w.Header().Set("HX-Redirect", "/brews") 1113 - w.WriteHeader(http.StatusOK) 1114 - } 1115 - 1116 - // Update existing brew 1117 - func (h *Handler) HandleBrewUpdate(w http.ResponseWriter, r *http.Request) { 1118 - rkey := validateRKey(w, r.PathValue("id")) 1119 - if rkey == "" { 1120 - return 1121 - } 1122 - 1123 - // Require authentication 1124 - store, authenticated := h.getAtprotoStore(r) 1125 - if !authenticated { 1126 - http.Redirect(w, r, "/login", http.StatusFound) 1127 - return 1128 - } 1129 - 1130 - if err := r.ParseForm(); err != nil { 1131 - http.Error(w, "Invalid form data", http.StatusBadRequest) 1132 - return 1133 - } 1134 - 1135 - // Validate input 1136 - temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r) 1137 - if len(validationErrs) > 0 { 1138 - http.Error(w, validationErrs[0].Message, http.StatusBadRequest) 1139 - return 1140 - } 1141 - 1142 - // Validate required fields 1143 - beanRKey := r.FormValue("bean_rkey") 1144 - if beanRKey == "" { 1145 - http.Error(w, "Bean selection is required", http.StatusBadRequest) 1146 - return 1147 - } 1148 - if !atproto.ValidateRKey(beanRKey) { 1149 - http.Error(w, "Invalid bean selection", http.StatusBadRequest) 1150 - return 1151 - } 1152 - 1153 - // Validate optional rkeys 1154 - grinderRKey := r.FormValue("grinder_rkey") 1155 - if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" { 1156 - http.Error(w, errMsg, http.StatusBadRequest) 1157 - return 1158 - } 1159 - brewerRKey := r.FormValue("brewer_rkey") 1160 - if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" { 1161 - http.Error(w, errMsg, http.StatusBadRequest) 1162 - return 1163 - } 1164 - 1165 - req := &models.CreateBrewRequest{ 1166 - BeanRKey: beanRKey, 1167 - Method: r.FormValue("method"), 1168 - Temperature: temperature, 1169 - WaterAmount: waterAmount, 1170 - CoffeeAmount: coffeeAmount, 1171 - TimeSeconds: timeSeconds, 1172 - GrindSize: r.FormValue("grind_size"), 1173 - GrinderRKey: grinderRKey, 1174 - BrewerRKey: brewerRKey, 1175 - TastingNotes: r.FormValue("tasting_notes"), 1176 - Rating: rating, 1177 - Pours: pours, 1178 - } 1179 - 1180 - err := store.UpdateBrewByRKey(r.Context(), rkey, req) 1181 - if err != nil { 1182 - http.Error(w, "Failed to update brew", http.StatusInternalServerError) 1183 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brew") 1184 - return 1185 - } 1186 - 1187 - // Redirect to brew list 1188 - w.Header().Set("HX-Redirect", "/brews") 1189 - w.WriteHeader(http.StatusOK) 1190 - } 1191 - 1192 - // Delete brew 1193 - func (h *Handler) HandleBrewDelete(w http.ResponseWriter, r *http.Request) { 1194 - rkey := validateRKey(w, r.PathValue("id")) 1195 - if rkey == "" { 1196 - return 1197 - } 1198 - 1199 - // Require authentication 1200 - store, authenticated := h.getAtprotoStore(r) 1201 - if !authenticated { 1202 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1203 - return 1204 - } 1205 - 1206 - if err := store.DeleteBrewByRKey(r.Context(), rkey); err != nil { 1207 - http.Error(w, "Failed to delete brew", http.StatusInternalServerError) 1208 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brew") 1209 - return 1210 - } 1211 - 1212 - w.WriteHeader(http.StatusOK) 1213 - } 1214 - 1215 - // Export brews as JSON 1216 - func (h *Handler) HandleBrewExport(w http.ResponseWriter, r *http.Request) { 1217 - // Require authentication 1218 - store, authenticated := h.getAtprotoStore(r) 1219 - if !authenticated { 1220 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1221 - return 1222 - } 1223 - 1224 - brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 1225 - if err != nil { 1226 - http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 1227 - log.Error().Err(err).Msg("Failed to list brews for export") 1228 - return 1229 - } 1230 - 1231 - w.Header().Set("Content-Type", "application/json") 1232 - w.Header().Set("Content-Disposition", "attachment; filename=arabica-brews.json") 1233 - 1234 - encoder := json.NewEncoder(w) 1235 - encoder.SetIndent("", " ") 1236 - if err := encoder.Encode(brews); err != nil { 1237 - log.Error().Err(err).Msg("Failed to encode brews for export") 1238 - } 1239 - } 1240 - 1241 - // HandleLikeToggle handles creating or deleting a like on a record 1242 - func (h *Handler) HandleLikeToggle(w http.ResponseWriter, r *http.Request) { 1243 - // Require authentication 1244 - store, authenticated := h.getAtprotoStore(r) 1245 - if !authenticated { 1246 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1247 - return 1248 - } 1249 - 1250 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 1251 - 1252 - if err := r.ParseForm(); err != nil { 1253 - http.Error(w, "Invalid form data", http.StatusBadRequest) 1254 - return 1255 - } 1256 - 1257 - subjectURI := r.FormValue("subject_uri") 1258 - subjectCID := r.FormValue("subject_cid") 1259 - 1260 - if subjectURI == "" || subjectCID == "" { 1261 - http.Error(w, "subject_uri and subject_cid are required", http.StatusBadRequest) 1262 - return 1263 - } 1264 - 1265 - // Check if user already liked this record 1266 - existingLike, err := store.GetUserLikeForSubject(r.Context(), subjectURI) 1267 - if err != nil { 1268 - http.Error(w, "Failed to check like status", http.StatusInternalServerError) 1269 - log.Error().Err(err).Msg("Failed to check existing like") 1270 - return 1271 - } 1272 - 1273 - var isLiked bool 1274 - var likeCount int 1275 - 1276 - if existingLike != nil { 1277 - // Unlike: delete the existing like 1278 - if err := store.DeleteLikeByRKey(r.Context(), existingLike.RKey); err != nil { 1279 - http.Error(w, "Failed to unlike", http.StatusInternalServerError) 1280 - log.Error().Err(err).Msg("Failed to delete like") 1281 - return 1282 - } 1283 - isLiked = false 1284 - 1285 - // Update firehose index 1286 - if h.feedIndex != nil { 1287 - _ = h.feedIndex.DeleteLike(didStr, subjectURI) 1288 - likeCount = h.feedIndex.GetLikeCount(subjectURI) 1289 - } 1290 - } else { 1291 - // Like: create a new like 1292 - req := &models.CreateLikeRequest{ 1293 - SubjectURI: subjectURI, 1294 - SubjectCID: subjectCID, 1295 - } 1296 - like, err := store.CreateLike(r.Context(), req) 1297 - if err != nil { 1298 - http.Error(w, "Failed to like", http.StatusInternalServerError) 1299 - log.Error().Err(err).Msg("Failed to create like") 1300 - return 1301 - } 1302 - isLiked = true 1303 - 1304 - // Update firehose index 1305 - if h.feedIndex != nil { 1306 - _ = h.feedIndex.UpsertLike(didStr, like.RKey, subjectURI) 1307 - likeCount = h.feedIndex.GetLikeCount(subjectURI) 1308 - } 1309 - } 1310 - 1311 - // Return the updated like button component 1312 - if err := components.LikeButton(components.LikeButtonProps{ 1313 - SubjectURI: subjectURI, 1314 - SubjectCID: subjectCID, 1315 - IsLiked: isLiked, 1316 - LikeCount: likeCount, 1317 - IsAuthenticated: true, 1318 - }).Render(r.Context(), w); err != nil { 1319 - http.Error(w, "Failed to render", http.StatusInternalServerError) 1320 - log.Error().Err(err).Msg("Failed to render like button") 1321 - } 1322 - } 1323 - 1324 - // API endpoint to list all user data (beans, roasters, grinders, brewers, brews) 1325 - // Used by client-side cache for faster page loads 1326 - func (h *Handler) HandleAPIListAll(w http.ResponseWriter, r *http.Request) { 1327 - store, authenticated := h.getAtprotoStore(r) 1328 - if !authenticated { 1329 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1330 - return 1331 - } 1332 - 1333 - // Get user DID for cache validation 1334 - userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 1335 - 1336 - ctx := r.Context() 1337 - 1338 - // Fetch all collections in parallel using errgroup 1339 - g, ctx := errgroup.WithContext(ctx) 1340 - 1341 - var beans []*models.Bean 1342 - var roasters []*models.Roaster 1343 - var grinders []*models.Grinder 1344 - var brewers []*models.Brewer 1345 - var brews []*models.Brew 1346 - 1347 - g.Go(func() error { 1348 - var err error 1349 - beans, err = store.ListBeans(ctx) 1350 - return err 1351 - }) 1352 - g.Go(func() error { 1353 - var err error 1354 - roasters, err = store.ListRoasters(ctx) 1355 - return err 1356 - }) 1357 - g.Go(func() error { 1358 - var err error 1359 - grinders, err = store.ListGrinders(ctx) 1360 - return err 1361 - }) 1362 - g.Go(func() error { 1363 - var err error 1364 - brewers, err = store.ListBrewers(ctx) 1365 - return err 1366 - }) 1367 - g.Go(func() error { 1368 - var err error 1369 - brews, err = store.ListBrews(ctx, 1) // User ID not used with atproto 1370 - return err 1371 - }) 1372 - 1373 - if err := g.Wait(); err != nil { 1374 - http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 1375 - log.Error().Err(err).Msg("Failed to fetch all data for API") 1376 - return 1377 - } 1378 - 1379 - // Link beans to roasters 1380 - atproto.LinkBeansToRoasters(beans, roasters) 1381 - 1382 - response := map[string]interface{}{ 1383 - "did": userDID, 1384 - "beans": beans, 1385 - "roasters": roasters, 1386 - "grinders": grinders, 1387 - "brewers": brewers, 1388 - "brews": brews, 1389 - } 1390 - 1391 - w.Header().Set("Content-Type", "application/json") 1392 - if err := json.NewEncoder(w).Encode(response); err != nil { 1393 - log.Error().Err(err).Msg("Failed to encode API response") 1394 - } 1395 - } 1396 - 1397 - // API endpoint to create bean 1398 - func (h *Handler) HandleBeanCreate(w http.ResponseWriter, r *http.Request) { 1399 - // Require authentication 1400 - store, authenticated := h.getAtprotoStore(r) 1401 - if !authenticated { 1402 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1403 - return 1404 - } 1405 - 1406 - var req models.CreateBeanRequest 1407 - 1408 - // Decode request (JSON or form) 1409 - if err := decodeRequest(r, &req, func() error { 1410 - req = models.CreateBeanRequest{ 1411 - Name: r.FormValue("name"), 1412 - Origin: r.FormValue("origin"), 1413 - RoastLevel: r.FormValue("roast_level"), 1414 - Process: r.FormValue("process"), 1415 - Description: r.FormValue("description"), 1416 - RoasterRKey: r.FormValue("roaster_rkey"), 1417 - Closed: r.FormValue("closed") == "true", 1418 - } 1419 - log.Debug(). 1420 - Str("name", req.Name). 1421 - Str("closed_value", r.FormValue("closed")). 1422 - Bool("closed_parsed", req.Closed). 1423 - Msg("Parsed bean create form") 1424 - return nil 1425 - }); err != nil { 1426 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1427 - return 1428 - } 1429 - 1430 - // Validate request 1431 - if err := req.Validate(); err != nil { 1432 - http.Error(w, err.Error(), http.StatusBadRequest) 1433 - return 1434 - } 1435 - 1436 - // Validate optional roaster rkey 1437 - if errMsg := validateOptionalRKey(req.RoasterRKey, "Roaster selection"); errMsg != "" { 1438 - http.Error(w, errMsg, http.StatusBadRequest) 1439 - return 1440 - } 1441 - 1442 - bean, err := store.CreateBean(r.Context(), &req) 1443 - if err != nil { 1444 - http.Error(w, "Failed to create bean", http.StatusInternalServerError) 1445 - log.Error().Err(err).Msg("Failed to create bean") 1446 - return 1447 - } 1448 - 1449 - writeJSON(w, bean, "bean") 1450 - } 1451 - 1452 - // API endpoint to create roaster 1453 - func (h *Handler) HandleRoasterCreate(w http.ResponseWriter, r *http.Request) { 1454 - // Require authentication 1455 - store, authenticated := h.getAtprotoStore(r) 1456 - if !authenticated { 1457 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1458 - return 1459 - } 1460 - 1461 - var req models.CreateRoasterRequest 1462 - 1463 - // Decode request (JSON or form) 1464 - if err := decodeRequest(r, &req, func() error { 1465 - req = models.CreateRoasterRequest{ 1466 - Name: r.FormValue("name"), 1467 - Location: r.FormValue("location"), 1468 - Website: r.FormValue("website"), 1469 - } 1470 - return nil 1471 - }); err != nil { 1472 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1473 - return 1474 - } 1475 - 1476 - // Validate request 1477 - if err := req.Validate(); err != nil { 1478 - http.Error(w, err.Error(), http.StatusBadRequest) 1479 - return 1480 - } 1481 - 1482 - roaster, err := store.CreateRoaster(r.Context(), &req) 1483 - if err != nil { 1484 - http.Error(w, "Failed to create roaster", http.StatusInternalServerError) 1485 - log.Error().Err(err).Msg("Failed to create roaster") 1486 - return 1487 - } 1488 - 1489 - writeJSON(w, roaster, "roaster") 1490 - } 1491 - 1492 - // Manage page 1493 - func (h *Handler) HandleManage(w http.ResponseWriter, r *http.Request) { 1494 - // Require authentication 1495 - _, authenticated := h.getAtprotoStore(r) 1496 - if !authenticated { 1497 - http.Redirect(w, r, "/login", http.StatusFound) 1498 - return 1499 - } 1500 - 1501 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 1502 - userProfile := h.getUserProfile(r.Context(), didStr) 1503 - 1504 - // Create layout data 1505 - layoutData := h.buildLayoutData(r, "Manage", authenticated, didStr, userProfile) 1506 - 1507 - // Create manage props 1508 - manageProps := pages.ManageProps{} 1509 - 1510 - // Render using templ component 1511 - if err := pages.Manage(layoutData, manageProps).Render(r.Context(), w); err != nil { 1512 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 1513 - log.Error().Err(err).Msg("Failed to render manage page") 1514 - } 1515 - } 1516 - 1517 - // Bean update/delete handlers 1518 - func (h *Handler) HandleBeanUpdate(w http.ResponseWriter, r *http.Request) { 1519 - rkey := validateRKey(w, r.PathValue("id")) 1520 - if rkey == "" { 1521 - return 1522 - } 1523 - 1524 - // Require authentication 1525 - store, authenticated := h.getAtprotoStore(r) 1526 - if !authenticated { 1527 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1528 - return 1529 - } 1530 - 1531 - var req models.UpdateBeanRequest 1532 - 1533 - // Decode request (JSON or form) 1534 - if err := decodeRequest(r, &req, func() error { 1535 - req = models.UpdateBeanRequest{ 1536 - Name: r.FormValue("name"), 1537 - Origin: r.FormValue("origin"), 1538 - RoastLevel: r.FormValue("roast_level"), 1539 - Process: r.FormValue("process"), 1540 - Description: r.FormValue("description"), 1541 - RoasterRKey: r.FormValue("roaster_rkey"), 1542 - Closed: r.FormValue("closed") == "true", 1543 - } 1544 - log.Debug(). 1545 - Str("rkey", rkey). 1546 - Str("name", req.Name). 1547 - Str("closed_value", r.FormValue("closed")). 1548 - Bool("closed_parsed", req.Closed). 1549 - Msg("Parsed bean update form") 1550 - return nil 1551 - }); err != nil { 1552 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1553 - return 1554 - } 1555 - 1556 - // Validate request 1557 - if err := req.Validate(); err != nil { 1558 - http.Error(w, err.Error(), http.StatusBadRequest) 1559 - return 1560 - } 1561 - 1562 - // Validate optional roaster rkey 1563 - if errMsg := validateOptionalRKey(req.RoasterRKey, "Roaster selection"); errMsg != "" { 1564 - http.Error(w, errMsg, http.StatusBadRequest) 1565 - return 1566 - } 1567 - 1568 - if err := store.UpdateBeanByRKey(r.Context(), rkey, &req); err != nil { 1569 - http.Error(w, "Failed to update bean", http.StatusInternalServerError) 1570 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update bean") 1571 - return 1572 - } 1573 - 1574 - bean, err := store.GetBeanByRKey(r.Context(), rkey) 1575 - if err != nil { 1576 - http.Error(w, "Failed to fetch updated bean", http.StatusInternalServerError) 1577 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get bean after update") 1578 - return 1579 - } 1580 - 1581 - writeJSON(w, bean, "bean") 1582 - } 1583 - 1584 - func (h *Handler) HandleBeanDelete(w http.ResponseWriter, r *http.Request) { 1585 - rkey := validateRKey(w, r.PathValue("id")) 1586 - if rkey == "" { 1587 - return 1588 - } 1589 - 1590 - // Require authentication 1591 - store, authenticated := h.getAtprotoStore(r) 1592 - if !authenticated { 1593 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1594 - return 1595 - } 1596 - 1597 - if err := store.DeleteBeanByRKey(r.Context(), rkey); err != nil { 1598 - http.Error(w, "Failed to delete bean", http.StatusInternalServerError) 1599 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete bean") 1600 - return 1601 - } 1602 - 1603 - w.WriteHeader(http.StatusOK) 1604 - } 1605 - 1606 - // Roaster update/delete handlers 1607 - func (h *Handler) HandleRoasterUpdate(w http.ResponseWriter, r *http.Request) { 1608 - rkey := validateRKey(w, r.PathValue("id")) 1609 - if rkey == "" { 1610 - return 1611 - } 1612 - 1613 - // Require authentication 1614 - store, authenticated := h.getAtprotoStore(r) 1615 - if !authenticated { 1616 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1617 - return 1618 - } 1619 - 1620 - var req models.UpdateRoasterRequest 1621 - 1622 - // Decode request (JSON or form) 1623 - if err := decodeRequest(r, &req, func() error { 1624 - req = models.UpdateRoasterRequest{ 1625 - Name: r.FormValue("name"), 1626 - Location: r.FormValue("location"), 1627 - Website: r.FormValue("website"), 1628 - } 1629 - return nil 1630 - }); err != nil { 1631 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1632 - return 1633 - } 1634 - 1635 - // Validate request 1636 - if err := req.Validate(); err != nil { 1637 - http.Error(w, err.Error(), http.StatusBadRequest) 1638 - return 1639 - } 1640 - 1641 - if err := store.UpdateRoasterByRKey(r.Context(), rkey, &req); err != nil { 1642 - http.Error(w, "Failed to update roaster", http.StatusInternalServerError) 1643 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update roaster") 1644 - return 1645 - } 1646 - 1647 - roaster, err := store.GetRoasterByRKey(r.Context(), rkey) 1648 - if err != nil { 1649 - http.Error(w, "Failed to fetch updated roaster", http.StatusInternalServerError) 1650 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get roaster after update") 1651 - return 1652 - } 1653 - 1654 - writeJSON(w, roaster, "roaster") 1655 - } 1656 - 1657 - func (h *Handler) HandleRoasterDelete(w http.ResponseWriter, r *http.Request) { 1658 - rkey := validateRKey(w, r.PathValue("id")) 1659 - if rkey == "" { 1660 - return 1661 - } 1662 - 1663 - // Require authentication 1664 - store, authenticated := h.getAtprotoStore(r) 1665 - if !authenticated { 1666 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1667 - return 1668 - } 1669 - 1670 - if err := store.DeleteRoasterByRKey(r.Context(), rkey); err != nil { 1671 - http.Error(w, "Failed to delete roaster", http.StatusInternalServerError) 1672 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete roaster") 1673 - return 1674 - } 1675 - 1676 - w.WriteHeader(http.StatusOK) 1677 - } 1678 - 1679 - // Grinder CRUD handlers 1680 - func (h *Handler) HandleGrinderCreate(w http.ResponseWriter, r *http.Request) { 1681 - // Require authentication 1682 - store, authenticated := h.getAtprotoStore(r) 1683 - if !authenticated { 1684 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1685 - return 1686 - } 1687 - 1688 - var req models.CreateGrinderRequest 1689 - 1690 - // Decode request (JSON or form) 1691 - if err := decodeRequest(r, &req, func() error { 1692 - req = models.CreateGrinderRequest{ 1693 - Name: r.FormValue("name"), 1694 - GrinderType: r.FormValue("grinder_type"), 1695 - BurrType: r.FormValue("burr_type"), 1696 - Notes: r.FormValue("notes"), 1697 - } 1698 - return nil 1699 - }); err != nil { 1700 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1701 - return 1702 - } 1703 - 1704 - // Validate request 1705 - if err := req.Validate(); err != nil { 1706 - http.Error(w, err.Error(), http.StatusBadRequest) 1707 - return 1708 - } 1709 - 1710 - grinder, err := store.CreateGrinder(r.Context(), &req) 1711 - if err != nil { 1712 - http.Error(w, "Failed to create grinder", http.StatusInternalServerError) 1713 - log.Error().Err(err).Msg("Failed to create grinder") 1714 - return 1715 - } 1716 - 1717 - writeJSON(w, grinder, "grinder") 1718 - } 1719 - 1720 - func (h *Handler) HandleGrinderUpdate(w http.ResponseWriter, r *http.Request) { 1721 - rkey := validateRKey(w, r.PathValue("id")) 1722 - if rkey == "" { 1723 - return 1724 - } 1725 - 1726 - // Require authentication 1727 - store, authenticated := h.getAtprotoStore(r) 1728 - if !authenticated { 1729 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1730 - return 1731 - } 1732 - 1733 - var req models.UpdateGrinderRequest 1734 - 1735 - // Decode request (JSON or form) 1736 - if err := decodeRequest(r, &req, func() error { 1737 - req = models.UpdateGrinderRequest{ 1738 - Name: r.FormValue("name"), 1739 - GrinderType: r.FormValue("grinder_type"), 1740 - BurrType: r.FormValue("burr_type"), 1741 - Notes: r.FormValue("notes"), 1742 - } 1743 - return nil 1744 - }); err != nil { 1745 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1746 - return 1747 - } 1748 - 1749 - // Validate request 1750 - if err := req.Validate(); err != nil { 1751 - http.Error(w, err.Error(), http.StatusBadRequest) 1752 - return 1753 - } 1754 - 1755 - if err := store.UpdateGrinderByRKey(r.Context(), rkey, &req); err != nil { 1756 - http.Error(w, "Failed to update grinder", http.StatusInternalServerError) 1757 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update grinder") 1758 - return 1759 - } 1760 - 1761 - grinder, err := store.GetGrinderByRKey(r.Context(), rkey) 1762 - if err != nil { 1763 - http.Error(w, "Failed to fetch updated grinder", http.StatusInternalServerError) 1764 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get grinder after update") 1765 - return 1766 - } 1767 - 1768 - writeJSON(w, grinder, "grinder") 1769 - } 1770 - 1771 - func (h *Handler) HandleGrinderDelete(w http.ResponseWriter, r *http.Request) { 1772 - rkey := validateRKey(w, r.PathValue("id")) 1773 - if rkey == "" { 1774 - return 1775 - } 1776 - 1777 - // Require authentication 1778 - store, authenticated := h.getAtprotoStore(r) 1779 - if !authenticated { 1780 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1781 - return 1782 - } 1783 - 1784 - if err := store.DeleteGrinderByRKey(r.Context(), rkey); err != nil { 1785 - http.Error(w, "Failed to delete grinder", http.StatusInternalServerError) 1786 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete grinder") 1787 - return 1788 - } 1789 - 1790 - w.WriteHeader(http.StatusOK) 1791 - } 1792 - 1793 - // Brewer CRUD handlers 1794 - func (h *Handler) HandleBrewerCreate(w http.ResponseWriter, r *http.Request) { 1795 - // Require authentication 1796 - store, authenticated := h.getAtprotoStore(r) 1797 - if !authenticated { 1798 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1799 - return 1800 - } 1801 - 1802 - var req models.CreateBrewerRequest 1803 - 1804 - // Decode request (JSON or form) 1805 - if err := decodeRequest(r, &req, func() error { 1806 - req = models.CreateBrewerRequest{ 1807 - Name: r.FormValue("name"), 1808 - BrewerType: r.FormValue("brewer_type"), 1809 - Description: r.FormValue("description"), 1810 - } 1811 - return nil 1812 - }); err != nil { 1813 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1814 - return 1815 - } 1816 - 1817 - // Validate request 1818 - if err := req.Validate(); err != nil { 1819 - http.Error(w, err.Error(), http.StatusBadRequest) 1820 - return 1821 - } 1822 - 1823 - brewer, err := store.CreateBrewer(r.Context(), &req) 1824 - if err != nil { 1825 - http.Error(w, "Failed to create brewer", http.StatusInternalServerError) 1826 - log.Error().Err(err).Msg("Failed to create brewer") 1827 - return 1828 - } 1829 - 1830 - writeJSON(w, brewer, "brewer") 1831 - } 1832 - 1833 - func (h *Handler) HandleBrewerUpdate(w http.ResponseWriter, r *http.Request) { 1834 - rkey := validateRKey(w, r.PathValue("id")) 1835 - if rkey == "" { 1836 - return 1837 - } 1838 - 1839 - // Require authentication 1840 - store, authenticated := h.getAtprotoStore(r) 1841 - if !authenticated { 1842 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1843 - return 1844 - } 1845 - 1846 - var req models.UpdateBrewerRequest 1847 - 1848 - // Decode request (JSON or form) 1849 - if err := decodeRequest(r, &req, func() error { 1850 - req = models.UpdateBrewerRequest{ 1851 - Name: r.FormValue("name"), 1852 - BrewerType: r.FormValue("brewer_type"), 1853 - Description: r.FormValue("description"), 1854 - } 1855 - return nil 1856 - }); err != nil { 1857 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1858 - return 1859 - } 1860 - 1861 - // Validate request 1862 - if err := req.Validate(); err != nil { 1863 - http.Error(w, err.Error(), http.StatusBadRequest) 1864 - return 1865 - } 1866 - 1867 - if err := store.UpdateBrewerByRKey(r.Context(), rkey, &req); err != nil { 1868 - http.Error(w, "Failed to update brewer", http.StatusInternalServerError) 1869 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brewer") 1870 - return 1871 - } 1872 - 1873 - brewer, err := store.GetBrewerByRKey(r.Context(), rkey) 1874 - if err != nil { 1875 - http.Error(w, "Failed to fetch updated brewer", http.StatusInternalServerError) 1876 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brewer after update") 1877 - return 1878 - } 1879 - 1880 - writeJSON(w, brewer, "brewer") 1881 - } 1882 - 1883 - func (h *Handler) HandleBrewerDelete(w http.ResponseWriter, r *http.Request) { 1884 - rkey := validateRKey(w, r.PathValue("id")) 1885 - if rkey == "" { 1886 - return 1887 - } 1888 - 1889 - // Require authentication 1890 - store, authenticated := h.getAtprotoStore(r) 1891 - if !authenticated { 1892 - http.Error(w, "Authentication required", http.StatusUnauthorized) 1893 - return 1894 - } 1895 - 1896 - if err := store.DeleteBrewerByRKey(r.Context(), rkey); err != nil { 1897 - http.Error(w, "Failed to delete brewer", http.StatusInternalServerError) 1898 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brewer") 1899 - return 1900 - } 1901 - 1902 - w.WriteHeader(http.StatusOK) 1903 - } 1904 - 1905 - // About page 1906 - func (h *Handler) HandleAbout(w http.ResponseWriter, r *http.Request) { 1907 - // Check if user is authenticated 1908 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1909 - isAuthenticated := err == nil && didStr != "" 1910 - 1911 - var userProfile *bff.UserProfile 1912 - if isAuthenticated { 1913 - userProfile = h.getUserProfile(r.Context(), didStr) 1914 - } 1915 - 1916 - data := h.buildLayoutData(r, "About", isAuthenticated, didStr, userProfile) 1917 - 1918 - // Use templ component 1919 - if err := pages.About(data).Render(r.Context(), w); err != nil { 1920 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 1921 - log.Error().Err(err).Msg("Failed to render about page") 1922 - } 1923 - } 1924 - 1925 - // Terms of Service page 1926 - func (h *Handler) HandleTerms(w http.ResponseWriter, r *http.Request) { 1927 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1928 - isAuthenticated := err == nil 1929 - 1930 - var userProfile *bff.UserProfile 1931 - if isAuthenticated { 1932 - userProfile = h.getUserProfile(r.Context(), didStr) 1933 - } 1934 - 1935 - layoutData := h.buildLayoutData(r, "Terms of Service", isAuthenticated, didStr, userProfile) 1936 - 1937 - if err := pages.Terms(layoutData).Render(r.Context(), w); err != nil { 1938 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 1939 - log.Error().Err(err).Msg("Failed to render terms page") 1940 - } 1941 - } 1942 - 1943 - // HandleJoin renders the join request page. 1944 - func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { 1945 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1946 - isAuthenticated := err == nil && didStr != "" 1947 - 1948 - var userProfile *bff.UserProfile 1949 - if isAuthenticated { 1950 - userProfile = h.getUserProfile(r.Context(), didStr) 1951 - } 1952 - 1953 - layoutData := h.buildLayoutData(r, "Join Arabica", isAuthenticated, didStr, userProfile) 1954 - 1955 - if err := pages.Join(layoutData).Render(r.Context(), w); err != nil { 1956 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 1957 - log.Error().Err(err).Msg("Failed to render join page") 1958 - } 1959 - } 1960 - 1961 - // HandleJoinSubmit processes a join request form submission. 1962 - func (h *Handler) HandleJoinSubmit(w http.ResponseWriter, r *http.Request) { 1963 - if err := r.ParseForm(); err != nil { 1964 - http.Error(w, "Invalid form data", http.StatusBadRequest) 1965 - return 1966 - } 1967 - 1968 - // Honeypot check — if the hidden field is filled, silently reject 1969 - if r.FormValue("website") != "" { 1970 - // Show success page anyway so bots don't know they were caught 1971 - h.renderJoinSuccess(w, r) 1972 - return 1973 - } 1974 - 1975 - emailAddr := strings.TrimSpace(r.FormValue("email")) 1976 - message := strings.TrimSpace(r.FormValue("message")) 1977 - 1978 - // Basic email validation 1979 - if emailAddr == "" || !strings.Contains(emailAddr, "@") || !strings.Contains(emailAddr, ".") { 1980 - http.Error(w, "A valid email address is required", http.StatusBadRequest) 1981 - return 1982 - } 1983 - 1984 - // Create and save the join request 1985 - req := &boltstore.JoinRequest{ 1986 - ID: fmt.Sprintf("%d", time.Now().UnixNano()), 1987 - Email: emailAddr, 1988 - Message: message, 1989 - CreatedAt: time.Now().UTC(), 1990 - IP: middleware.GetClientIP(r), 1991 - } 1992 - 1993 - if h.joinStore != nil { 1994 - if err := h.joinStore.SaveRequest(req); err != nil { 1995 - log.Error().Err(err).Str("email", emailAddr).Msg("Failed to save join request") 1996 - http.Error(w, "Failed to save request, please try again", http.StatusInternalServerError) 1997 - return 1998 - } 1999 - log.Info().Str("email", emailAddr).Msg("Join request saved") 2000 - } 2001 - 2002 - // Send admin notification email (non-blocking) 2003 - if h.emailSender != nil && h.emailSender.Enabled() { 2004 - go func() { 2005 - subject := "New Arabica Join Request" 2006 - body := fmt.Sprintf("New account request:\n\nEmail: %s\nMessage: %s\nIP: %s\nTime: %s\n", 2007 - req.Email, req.Message, req.IP, req.CreatedAt.Format(time.RFC3339)) 2008 - 2009 - if err := h.emailSender.Send(h.emailSender.AdminEmail(), subject, body); err != nil { 2010 - log.Error().Err(err).Str("email", emailAddr).Msg("Failed to send admin notification") 2011 - } 2012 - }() 2013 - } 2014 - 2015 - h.renderJoinSuccess(w, r) 2016 - } 2017 - 2018 - func (h *Handler) renderJoinSuccess(w http.ResponseWriter, r *http.Request) { 2019 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2020 - isAuthenticated := err == nil && didStr != "" 2021 - 2022 - var userProfile *bff.UserProfile 2023 - if isAuthenticated { 2024 - userProfile = h.getUserProfile(r.Context(), didStr) 2025 - } 2026 - 2027 - layoutData := h.buildLayoutData(r, "Request Received", isAuthenticated, didStr, userProfile) 2028 - 2029 - if err := pages.JoinSuccess(layoutData).Render(r.Context(), w); err != nil { 2030 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 2031 - log.Error().Err(err).Msg("Failed to render join success page") 2032 - } 2033 - } 2034 - 2035 - // HandleCreateInvite creates a PDS invite code and emails it to the requester. 2036 - func (h *Handler) HandleCreateInvite(w http.ResponseWriter, r *http.Request) { 2037 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 2038 - if err != nil || userDID == "" { 2039 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2040 - return 2041 - } 2042 - if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 2043 - http.Error(w, "Access denied", http.StatusForbidden) 2044 - return 2045 - } 2046 - 2047 - if err := r.ParseForm(); err != nil { 2048 - http.Error(w, "Invalid request", http.StatusBadRequest) 2049 - return 2050 - } 2051 - reqID := r.FormValue("id") 2052 - reqEmail := r.FormValue("email") 2053 - if reqID == "" || reqEmail == "" { 2054 - http.Error(w, "Missing request ID or email", http.StatusBadRequest) 2055 - return 2056 - } 2057 - 2058 - if h.pdsAdminURL == "" || h.pdsAdminToken == "" { 2059 - http.Error(w, "PDS admin not configured", http.StatusInternalServerError) 2060 - return 2061 - } 2062 - 2063 - // Create invite code via PDS admin API 2064 - client := &xrpc.Client{ 2065 - Host: h.pdsAdminURL, 2066 - AdminToken: &h.pdsAdminToken, 2067 - } 2068 - out, err := comatproto.ServerCreateInviteCode(r.Context(), client, &comatproto.ServerCreateInviteCode_Input{ 2069 - UseCount: 1, 2070 - }) 2071 - if err != nil { 2072 - log.Error().Err(err).Str("email", reqEmail).Msg("Failed to create invite code") 2073 - http.Error(w, "Failed to create invite code", http.StatusInternalServerError) 2074 - return 2075 - } 2076 - 2077 - log.Info().Str("email", reqEmail).Str("code", out.Code).Str("by", userDID).Msg("Invite code created") 2078 - 2079 - // Email the invite code to the requester 2080 - if h.emailSender != nil && h.emailSender.Enabled() { 2081 - subject := "Your Arabica Invite Code" 2082 - // TODO: this should probably use the env var rather than hard coded 2083 - body := fmt.Sprintf("Welcome to Arabica!\n\nHere is your invite code to create an account on arabica.systems:\n\n %s\n\nVisit https://arabica.systems to sign up with this code.\n\nHappy brewing!\n", out.Code) 2084 - if err := h.emailSender.Send(reqEmail, subject, body); err != nil { 2085 - log.Error().Err(err).Str("email", reqEmail).Msg("Failed to send invite email") 2086 - http.Error(w, "Invite created but failed to send email. Code: "+out.Code, http.StatusInternalServerError) 2087 - return 2088 - } 2089 - log.Info().Str("email", reqEmail).Msg("Invite code emailed") 2090 - } 2091 - 2092 - // Remove the join request 2093 - if h.joinStore != nil { 2094 - if err := h.joinStore.DeleteRequest(reqID); err != nil { 2095 - log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 2096 - } 2097 - } 2098 - 2099 - w.Header().Set("HX-Trigger", "mod-action") 2100 - w.WriteHeader(http.StatusOK) 2101 - } 2102 - 2103 - // HandleDismissJoinRequest removes a join request without sending an invite. 2104 - func (h *Handler) HandleDismissJoinRequest(w http.ResponseWriter, r *http.Request) { 2105 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 2106 - if err != nil || userDID == "" { 2107 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2108 - return 2109 - } 2110 - if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 2111 - http.Error(w, "Access denied", http.StatusForbidden) 2112 - return 2113 - } 2114 - 2115 - if err := r.ParseForm(); err != nil { 2116 - http.Error(w, "Invalid request", http.StatusBadRequest) 2117 - return 2118 - } 2119 - reqID := r.FormValue("id") 2120 - if reqID == "" { 2121 - http.Error(w, "Missing request ID", http.StatusBadRequest) 2122 - return 2123 - } 2124 - 2125 - if h.joinStore != nil { 2126 - if err := h.joinStore.DeleteRequest(reqID); err != nil { 2127 - log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 2128 - http.Error(w, "Failed to dismiss request", http.StatusInternalServerError) 2129 - return 2130 - } 2131 - } 2132 - 2133 - log.Info().Str("id", reqID).Str("by", userDID).Msg("Join request dismissed") 2134 - 2135 - w.Header().Set("HX-Trigger", "mod-action") 2136 - w.WriteHeader(http.StatusOK) 2137 - } 2138 - 2139 - // HandleCreateAccount renders the account creation form (GET /join/create). 2140 - func (h *Handler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { 2141 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2142 - isAuthenticated := err == nil && didStr != "" 2143 - 2144 - var userProfile *bff.UserProfile 2145 - if isAuthenticated { 2146 - userProfile = h.getUserProfile(r.Context(), didStr) 2147 - } 2148 - 2149 - layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 2150 - 2151 - props := pages.CreateAccountProps{ 2152 - InviteCode: r.URL.Query().Get("code"), 2153 - HandleDomain: "arabica.systems", 2154 - } 2155 - 2156 - if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 2157 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 2158 - log.Error().Err(err).Msg("Failed to render create account page") 2159 - } 2160 - } 2161 - 2162 - // HandleCreateAccountSubmit processes the account creation form (POST /join/create). 2163 - func (h *Handler) HandleCreateAccountSubmit(w http.ResponseWriter, r *http.Request) { 2164 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2165 - isAuthenticated := err == nil && didStr != "" 2166 - 2167 - var userProfile *bff.UserProfile 2168 - if isAuthenticated { 2169 - userProfile = h.getUserProfile(r.Context(), didStr) 2170 - } 2171 - 2172 - if err := r.ParseForm(); err != nil { 2173 - http.Error(w, "Invalid request", http.StatusBadRequest) 2174 - return 2175 - } 2176 - 2177 - inviteCode := strings.TrimSpace(r.FormValue("invite_code")) 2178 - handle := strings.TrimSpace(r.FormValue("handle")) 2179 - emailAddr := strings.TrimSpace(r.FormValue("email")) 2180 - password := r.FormValue("password") 2181 - passwordConfirm := r.FormValue("password_confirm") 2182 - honeypot := r.FormValue("website") 2183 - 2184 - // Honeypot check — bots fill hidden fields; show fake success 2185 - if honeypot != "" { 2186 - layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 2187 - _ = pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: "user.arabica.systems"}).Render(r.Context(), w) 2188 - return 2189 - } 2190 - 2191 - handleDomain := "arabica.systems" 2192 - 2193 - // Render form with error helper 2194 - renderError := func(msg string) { 2195 - layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 2196 - props := pages.CreateAccountProps{ 2197 - Error: msg, 2198 - InviteCode: inviteCode, 2199 - Handle: handle, 2200 - Email: emailAddr, 2201 - HandleDomain: handleDomain, 2202 - } 2203 - if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 2204 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 2205 - } 2206 - } 2207 - 2208 - // Validate required fields 2209 - if inviteCode == "" || handle == "" || emailAddr == "" || password == "" { 2210 - renderError("All fields are required.") 2211 - return 2212 - } 2213 - if password != passwordConfirm { 2214 - renderError("Passwords do not match.") 2215 - return 2216 - } 2217 - 2218 - // Build full handle 2219 - fullHandle := handle + "." + handleDomain 2220 - 2221 - if h.pdsAdminURL == "" { 2222 - renderError("Account creation is not available at this time.") 2223 - log.Error().Msg("PDS admin URL not configured for account creation") 2224 - return 2225 - } 2226 - 2227 - // Call PDS createAccount (public endpoint, no admin token needed) 2228 - client := &xrpc.Client{Host: h.pdsAdminURL} 2229 - out, err := comatproto.ServerCreateAccount(r.Context(), client, &comatproto.ServerCreateAccount_Input{ 2230 - Handle: fullHandle, 2231 - Email: &emailAddr, 2232 - Password: &password, 2233 - InviteCode: &inviteCode, 2234 - }) 2235 - if err != nil { 2236 - errMsg := "Account creation failed. Please try again." 2237 - var xrpcErr *xrpc.Error 2238 - if errors.As(err, &xrpcErr) { 2239 - var inner *xrpc.XRPCError 2240 - if errors.As(xrpcErr.Wrapped, &inner) { 2241 - switch inner.ErrStr { 2242 - case "InvalidInviteCode": 2243 - errMsg = "Invalid or expired invite code." 2244 - case "HandleNotAvailable": 2245 - errMsg = "This handle is already taken." 2246 - case "InvalidHandle": 2247 - errMsg = "Invalid handle format. Use only letters, numbers, and hyphens." 2248 - default: 2249 - if inner.Message != "" { 2250 - errMsg = inner.Message 2251 - } 2252 - } 2253 - } 2254 - } 2255 - log.Error().Err(err).Str("handle", fullHandle).Msg("Failed to create account") 2256 - renderError(errMsg) 2257 - return 2258 - } 2259 - 2260 - log.Info().Str("handle", out.Handle).Str("did", out.Did).Msg("Account created") 2261 - 2262 - layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 2263 - if err := pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: out.Handle}).Render(r.Context(), w); err != nil { 2264 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 2265 - log.Error().Err(err).Msg("Failed to render create account success page") 2266 - } 2267 - } 2268 - 2269 - func (h *Handler) HandleATProto(w http.ResponseWriter, r *http.Request) { 2270 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2271 - isAuthenticated := err == nil 2272 - 2273 - var userProfile *bff.UserProfile 2274 - if isAuthenticated { 2275 - userProfile = h.getUserProfile(r.Context(), didStr) 2276 - } 2277 - 2278 - layoutData := h.buildLayoutData(r, "AT Protocol", isAuthenticated, didStr, userProfile) 2279 - 2280 - if err := pages.ATProto(layoutData).Render(r.Context(), w); err != nil { 2281 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 2282 - log.Error().Err(err).Msg("Failed to render AT Protocol page") 2283 - } 2284 - } 2285 - 2286 - // HandleProfile displays a user's public profile with their brews and gear 2287 - func (h *Handler) HandleProfile(w http.ResponseWriter, r *http.Request) { 2288 - actor := r.PathValue("actor") 2289 - if actor == "" { 2290 - http.Error(w, "Actor parameter is required", http.StatusBadRequest) 2291 - return 2292 - } 2293 - 2294 - ctx := r.Context() 2295 - publicClient := atproto.NewPublicClient() 2296 - 2297 - // Determine if actor is a DID or handle 2298 - var did string 2299 - var err error 2300 - 2301 - if strings.HasPrefix(actor, "did:") { 2302 - // It's already a DID 2303 - did = actor 2304 - } else { 2305 - // It's a handle, resolve to DID 2306 - did, err = publicClient.ResolveHandle(ctx, actor) 2307 - if err != nil { 2308 - log.Warn().Err(err).Str("handle", actor).Msg("Failed to resolve handle") 2309 - http.Error(w, "User not found", http.StatusNotFound) 2310 - return 2311 - } 2312 - 2313 - // Redirect to canonical URL with handle (we'll get the handle from profile) 2314 - // For now, continue with the DID we have 2315 - } 2316 - 2317 - // Fetch profile 2318 - profile, err := publicClient.GetProfile(ctx, did) 2319 - if err != nil { 2320 - log.Warn().Err(err).Str("did", did).Msg("Failed to fetch profile") 2321 - http.Error(w, "User not found", http.StatusNotFound) 2322 - return 2323 - } 2324 - 2325 - // If the URL used a DID but we have the handle, redirect to the canonical handle URL 2326 - if strings.HasPrefix(actor, "did:") && profile.Handle != "" { 2327 - http.Redirect(w, r, "/profile/"+profile.Handle, http.StatusFound) 2328 - return 2329 - } 2330 - 2331 - // Fetch all user data from their PDS 2332 - profileData, err := h.fetchUserProfileData(ctx, did, publicClient) 2333 - if err != nil { 2334 - log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data") 2335 - http.Error(w, "Failed to load profile data", http.StatusInternalServerError) 2336 - return 2337 - } 2338 - 2339 - // Check if current user is authenticated (for nav bar state) 2340 - didStr, err := atproto.GetAuthenticatedDID(ctx) 2341 - isAuthenticated := err == nil && didStr != "" 2342 - 2343 - var userProfile *bff.UserProfile 2344 - if isAuthenticated { 2345 - userProfile = h.getUserProfile(ctx, didStr) 2346 - } 2347 - 2348 - // Check if this is an Arabica user (has records or is registered in feed) 2349 - isArabicaUser := h.feedRegistry.IsRegistered(did) || 2350 - len(profileData.Brews) > 0 || len(profileData.Beans) > 0 || 2351 - len(profileData.Roasters) > 0 || len(profileData.Grinders) > 0 || 2352 - len(profileData.Brewers) > 0 2353 - 2354 - if !isArabicaUser { 2355 - layoutData := h.buildLayoutData(r, "Profile Not Found", isAuthenticated, didStr, userProfile) 2356 - w.WriteHeader(http.StatusNotFound) 2357 - if err := pages.ProfileNotFound(layoutData).Render(r.Context(), w); err != nil { 2358 - log.Error().Err(err).Msg("Failed to render profile not found page") 2359 - } 2360 - return 2361 - } 2362 - 2363 - // Check if the viewing user is the profile owner 2364 - isOwnProfile := isAuthenticated && didStr == did 2365 - 2366 - // Convert atproto.Profile to bff.UserProfile 2367 - viewedProfile := &bff.UserProfile{ 2368 - Handle: profile.Handle, 2369 - } 2370 - if profile.DisplayName != nil { 2371 - viewedProfile.DisplayName = *profile.DisplayName 2372 - } 2373 - if profile.Avatar != nil { 2374 - viewedProfile.Avatar = *profile.Avatar 2375 - } 2376 - 2377 - // Create layout data 2378 - pageTitle := "Profile" 2379 - if viewedProfile.DisplayName != "" { 2380 - pageTitle = viewedProfile.DisplayName + " - Profile" 2381 - } 2382 - layoutData := h.buildLayoutData(r, pageTitle, isAuthenticated, didStr, userProfile) 2383 - 2384 - // Create roaster options for own profile 2385 - var roasterOptions []pages.RoasterOption 2386 - if isOwnProfile { 2387 - for _, roaster := range profileData.Roasters { 2388 - roasterOptions = append(roasterOptions, pages.RoasterOption{ 2389 - RKey: roaster.RKey, 2390 - Name: roaster.Name, 2391 - }) 2392 - } 2393 - } 2394 - 2395 - // Create profile props 2396 - profileProps := pages.ProfileProps{ 2397 - Profile: viewedProfile, 2398 - IsOwnProfile: isOwnProfile, 2399 - Roasters: roasterOptions, 2400 - } 2401 - 2402 - // Render using templ component 2403 - if err := pages.Profile(layoutData, profileProps).Render(r.Context(), w); err != nil { 2404 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 2405 - log.Error().Err(err).Msg("Failed to render profile page") 2406 - } 2407 - } 2408 - 2409 - // HandleProfilePartial returns profile data content (loaded async via HTMX) 2410 - func (h *Handler) HandleProfilePartial(w http.ResponseWriter, r *http.Request) { 2411 - actor := r.PathValue("actor") 2412 - if actor == "" { 2413 - http.Error(w, "Actor parameter is required", http.StatusBadRequest) 2414 - return 2415 - } 2416 - 2417 - ctx := r.Context() 2418 - publicClient := atproto.NewPublicClient() 2419 - 2420 - // Determine if actor is a DID or handle 2421 - var did string 2422 - var err error 2423 - 2424 - if strings.HasPrefix(actor, "did:") { 2425 - did = actor 2426 - } else { 2427 - did, err = publicClient.ResolveHandle(ctx, actor) 2428 - if err != nil { 2429 - log.Warn().Err(err).Str("handle", actor).Msg("Failed to resolve handle") 2430 - http.Error(w, "User not found", http.StatusNotFound) 2431 - return 2432 - } 2433 - } 2434 - 2435 - // Fetch all user data from their PDS 2436 - profileData, err := h.fetchUserProfileData(ctx, did, publicClient) 2437 - if err != nil { 2438 - log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data for profile partial") 2439 - http.Error(w, "Failed to load profile data", http.StatusInternalServerError) 2440 - return 2441 - } 2442 - 2443 - // Check if this is an Arabica user (has records or is registered in feed) 2444 - isArabicaUser := h.feedRegistry.IsRegistered(did) || 2445 - len(profileData.Brews) > 0 || len(profileData.Beans) > 0 || 2446 - len(profileData.Roasters) > 0 || len(profileData.Grinders) > 0 || 2447 - len(profileData.Brewers) > 0 2448 - 2449 - if !isArabicaUser { 2450 - http.Error(w, "User not found", http.StatusNotFound) 2451 - return 2452 - } 2453 - 2454 - // Check if the viewing user is the profile owner 2455 - didStr, err := atproto.GetAuthenticatedDID(ctx) 2456 - isAuthenticated := err == nil && didStr != "" 2457 - isOwnProfile := isAuthenticated && didStr == did 2458 - 2459 - // Get profile for card rendering 2460 - profile, err := publicClient.GetProfile(ctx, did) 2461 - if err != nil { 2462 - log.Warn().Err(err).Str("did", did).Msg("Failed to fetch profile for profile partial") 2463 - // Continue without profile - cards will show limited info 2464 - } 2465 - 2466 - // Use handle from profile or fallback 2467 - profileHandle := actor 2468 - if profile != nil { 2469 - profileHandle = profile.Handle 2470 - } else if strings.HasPrefix(actor, "did:") { 2471 - profileHandle = did // Fallback to DID if we can't get handle 2472 - } 2473 - 2474 - // Get like counts and CIDs for brews from firehose index 2475 - brewLikeCounts := make(map[string]int) 2476 - brewLikedByUser := make(map[string]bool) 2477 - brewCIDs := make(map[string]string) 2478 - if h.feedIndex != nil && profile != nil { 2479 - for _, brew := range profileData.Brews { 2480 - subjectURI := atproto.BuildATURI(profile.DID, atproto.NSIDBrew, brew.RKey) 2481 - brewLikeCounts[brew.RKey] = h.feedIndex.GetLikeCount(subjectURI) 2482 - if isAuthenticated { 2483 - brewLikedByUser[brew.RKey] = h.feedIndex.HasUserLiked(didStr, subjectURI) 2484 - } 2485 - // Get CID from the firehose index record 2486 - if record, err := h.feedIndex.GetRecord(subjectURI); err == nil && record != nil { 2487 - brewCIDs[brew.RKey] = record.CID 2488 - } 2489 - } 2490 - } 2491 - 2492 - if err := components.ProfileContentPartial(components.ProfileContentPartialProps{ 2493 - Brews: profileData.Brews, 2494 - Beans: profileData.Beans, 2495 - Roasters: profileData.Roasters, 2496 - Grinders: profileData.Grinders, 2497 - Brewers: profileData.Brewers, 2498 - IsOwnProfile: isOwnProfile, 2499 - ProfileHandle: profileHandle, 2500 - Profile: profile, 2501 - BrewLikeCounts: brewLikeCounts, 2502 - BrewLikedByUser: brewLikedByUser, 2503 - BrewCIDs: brewCIDs, 2504 - IsAuthenticated: isAuthenticated, 2505 - }).Render(r.Context(), w); err != nil { 2506 - http.Error(w, "Failed to render content", http.StatusInternalServerError) 2507 - log.Error().Err(err).Msg("Failed to render profile partial") 2508 - } 2509 - } 2510 - 2511 - // Modal dialog handlers for entity management 2512 - 2513 - // HandleBeanModalNew renders a new bean modal dialog 2514 - func (h *Handler) HandleBeanModalNew(w http.ResponseWriter, r *http.Request) { 2515 - // Require authentication 2516 - store, authenticated := h.getAtprotoStore(r) 2517 - if !authenticated { 2518 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2519 - return 2520 - } 2521 - 2522 - // Fetch roasters for the select dropdown 2523 - roasters, err := store.ListRoasters(r.Context()) 2524 - if err != nil { 2525 - log.Warn().Err(err).Msg("Failed to fetch roasters for bean modal") 2526 - roasters = []*models.Roaster{} // Empty list on error 2527 - } 2528 - 2529 - // Convert to slice for template 2530 - roastersSlice := make([]models.Roaster, len(roasters)) 2531 - for i, r := range roasters { 2532 - roastersSlice[i] = *r 2533 - } 2534 - 2535 - if err := components.BeanDialogModal(nil, roastersSlice).Render(r.Context(), w); err != nil { 2536 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2537 - log.Error().Err(err).Msg("Failed to render bean modal") 2538 - } 2539 - } 2540 - 2541 - // HandleBeanModalEdit renders an edit bean modal dialog 2542 - func (h *Handler) HandleBeanModalEdit(w http.ResponseWriter, r *http.Request) { 2543 - rkey := validateRKey(w, r.PathValue("id")) 2544 - if rkey == "" { 2545 - return 2546 - } 2547 - 2548 - // Require authentication 2549 - store, authenticated := h.getAtprotoStore(r) 2550 - if !authenticated { 2551 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2552 - return 2553 - } 2554 - 2555 - // Fetch the bean 2556 - bean, err := store.GetBeanByRKey(r.Context(), rkey) 2557 - if err != nil { 2558 - http.Error(w, "Bean not found", http.StatusNotFound) 2559 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get bean for modal") 2560 - return 2561 - } 2562 - 2563 - // Fetch roasters for the select dropdown 2564 - roasters, err := store.ListRoasters(r.Context()) 2565 - if err != nil { 2566 - log.Warn().Err(err).Msg("Failed to fetch roasters for bean modal") 2567 - roasters = []*models.Roaster{} 2568 - } 2569 - 2570 - // Convert to slice for template 2571 - roastersSlice := make([]models.Roaster, len(roasters)) 2572 - for i, r := range roasters { 2573 - roastersSlice[i] = *r 2574 - } 2575 - 2576 - if err := components.BeanDialogModal(bean, roastersSlice).Render(r.Context(), w); err != nil { 2577 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2578 - log.Error().Err(err).Msg("Failed to render bean modal") 2579 - } 2580 - } 2581 - 2582 - // HandleGrinderModalNew renders a new grinder modal dialog 2583 - func (h *Handler) HandleGrinderModalNew(w http.ResponseWriter, r *http.Request) { 2584 - // Require authentication 2585 - _, authenticated := h.getAtprotoStore(r) 2586 - if !authenticated { 2587 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2588 - return 2589 - } 2590 - 2591 - if err := components.GrinderDialogModal(nil).Render(r.Context(), w); err != nil { 2592 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2593 - log.Error().Err(err).Msg("Failed to render grinder modal") 2594 - } 2595 - } 2596 - 2597 - // HandleGrinderModalEdit renders an edit grinder modal dialog 2598 - func (h *Handler) HandleGrinderModalEdit(w http.ResponseWriter, r *http.Request) { 2599 - rkey := validateRKey(w, r.PathValue("id")) 2600 - if rkey == "" { 2601 - return 2602 - } 2603 - 2604 - // Require authentication 2605 - store, authenticated := h.getAtprotoStore(r) 2606 - if !authenticated { 2607 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2608 - return 2609 - } 2610 - 2611 - // Fetch the grinder 2612 - grinder, err := store.GetGrinderByRKey(r.Context(), rkey) 2613 - if err != nil { 2614 - http.Error(w, "Grinder not found", http.StatusNotFound) 2615 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get grinder for modal") 2616 - return 2617 - } 2618 - 2619 - if err := components.GrinderDialogModal(grinder).Render(r.Context(), w); err != nil { 2620 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2621 - log.Error().Err(err).Msg("Failed to render grinder modal") 2622 - } 2623 - } 2624 - 2625 - // HandleBrewerModalNew renders a new brewer modal dialog 2626 - func (h *Handler) HandleBrewerModalNew(w http.ResponseWriter, r *http.Request) { 2627 - // Require authentication 2628 - _, authenticated := h.getAtprotoStore(r) 2629 - if !authenticated { 2630 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2631 - return 2632 - } 2633 - 2634 - if err := components.BrewerDialogModal(nil).Render(r.Context(), w); err != nil { 2635 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2636 - log.Error().Err(err).Msg("Failed to render brewer modal") 2637 - } 2638 - } 2639 - 2640 - // HandleBrewerModalEdit renders an edit brewer modal dialog 2641 - func (h *Handler) HandleBrewerModalEdit(w http.ResponseWriter, r *http.Request) { 2642 - rkey := validateRKey(w, r.PathValue("id")) 2643 - if rkey == "" { 2644 - return 2645 - } 2646 - 2647 - // Require authentication 2648 - store, authenticated := h.getAtprotoStore(r) 2649 - if !authenticated { 2650 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2651 - return 2652 - } 2653 - 2654 - // Fetch the brewer 2655 - brewer, err := store.GetBrewerByRKey(r.Context(), rkey) 2656 - if err != nil { 2657 - http.Error(w, "Brewer not found", http.StatusNotFound) 2658 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brewer for modal") 2659 - return 2660 - } 2661 - 2662 - if err := components.BrewerDialogModal(brewer).Render(r.Context(), w); err != nil { 2663 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2664 - log.Error().Err(err).Msg("Failed to render brewer modal") 2665 - } 2666 - } 2667 - 2668 - // HandleRoasterModalNew renders a new roaster modal dialog 2669 - func (h *Handler) HandleRoasterModalNew(w http.ResponseWriter, r *http.Request) { 2670 - // Require authentication 2671 - _, authenticated := h.getAtprotoStore(r) 2672 - if !authenticated { 2673 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2674 - return 2675 - } 2676 - 2677 - if err := components.RoasterDialogModal(nil).Render(r.Context(), w); err != nil { 2678 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2679 - log.Error().Err(err).Msg("Failed to render roaster modal") 2680 - } 2681 - } 2682 - 2683 - // HandleRoasterModalEdit renders an edit roaster modal dialog 2684 - func (h *Handler) HandleRoasterModalEdit(w http.ResponseWriter, r *http.Request) { 2685 - rkey := validateRKey(w, r.PathValue("id")) 2686 - if rkey == "" { 2687 - return 2688 - } 2689 - 2690 - // Require authentication 2691 - store, authenticated := h.getAtprotoStore(r) 2692 - if !authenticated { 2693 - http.Error(w, "Authentication required", http.StatusUnauthorized) 2694 - return 2695 - } 2696 - 2697 - // Fetch the roaster 2698 - roaster, err := store.GetRoasterByRKey(r.Context(), rkey) 2699 - if err != nil { 2700 - http.Error(w, "Roaster not found", http.StatusNotFound) 2701 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get roaster for modal") 2702 - return 2703 - } 2704 - 2705 - if err := components.RoasterDialogModal(roaster).Render(r.Context(), w); err != nil { 2706 - http.Error(w, "Failed to render modal", http.StatusInternalServerError) 2707 - log.Error().Err(err).Msg("Failed to render roaster modal") 2708 - } 2709 - } 2710 - 2711 - // HandleNotFound renders the 404 page 2712 - func (h *Handler) HandleNotFound(w http.ResponseWriter, r *http.Request) { 2713 - // Check if current user is authenticated (for nav bar state) 2714 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2715 - isAuthenticated := err == nil && didStr != "" 2716 - 2717 - var userProfile *bff.UserProfile 2718 - if isAuthenticated { 2719 - userProfile = h.getUserProfile(r.Context(), didStr) 2720 - } 2721 - 2722 - layoutData := h.buildLayoutData(r, "Page Not Found", isAuthenticated, didStr, userProfile) 2723 - 2724 - w.WriteHeader(http.StatusNotFound) 2725 - if err := pages.NotFound(layoutData).Render(r.Context(), w); err != nil { 2726 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 2727 - log.Error().Err(err).Msg("Failed to render 404 page") 2728 - } 2729 - } 2730 - 2731 - // Automod thresholds for automatic content hiding 2732 - const ( 2733 - // AutoHideThreshold is the number of reports on a single record before auto-hiding 2734 - AutoHideThreshold = 3 2735 - // AutoHideUserThreshold is the total reports across a user's records before auto-hiding new reports 2736 - AutoHideUserThreshold = 5 2737 - // ReportRateLimitPerHour is the maximum reports a user can submit per hour 2738 - ReportRateLimitPerHour = 10 2739 - // MaxReportReasonLength is the maximum length of a report reason 2740 - MaxReportReasonLength = 500 2741 - ) 2742 - 2743 - // ReportRequest represents the JSON request for submitting a report 2744 - type ReportRequest struct { 2745 - SubjectURI string `json:"subject_uri"` 2746 - SubjectCID string `json:"subject_cid"` 2747 - Reason string `json:"reason"` 2748 - } 2749 - 2750 - // ReportResponse represents the JSON response from report submission 2751 - type ReportResponse struct { 2752 - ID string `json:"id,omitempty"` 2753 - Status string `json:"status"` 2754 - Message string `json:"message"` 2755 - } 2756 - 2757 - // HandleReport handles content report submissions. 2758 - // Requires authentication, validates input, checks rate limits and duplicates, 2759 - // persists the report, and triggers automod if thresholds are reached. 2760 - func (h *Handler) HandleReport(w http.ResponseWriter, r *http.Request) { 2761 - ctx := r.Context() 2762 - 2763 - // Require authentication 2764 - reporterDID, err := atproto.GetAuthenticatedDID(ctx) 2765 - if err != nil || reporterDID == "" { 2766 - writeReportError(w, "Authentication required", http.StatusUnauthorized) 2767 - return 2768 - } 2769 - 2770 - // Check if moderation store is configured 2771 - if h.moderationStore == nil { 2772 - log.Error().Msg("moderation: store not configured") 2773 - writeReportError(w, "Reports are not enabled", http.StatusServiceUnavailable) 2774 - return 2775 - } 2776 - 2777 - // Parse request (supports both JSON and form data) 2778 - var req ReportRequest 2779 - if isJSONRequest(r) { 2780 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 2781 - writeReportError(w, "Invalid JSON", http.StatusBadRequest) 2782 - return 2783 - } 2784 - } else { 2785 - if err := r.ParseForm(); err != nil { 2786 - writeReportError(w, "Invalid form data", http.StatusBadRequest) 2787 - return 2788 - } 2789 - req.SubjectURI = r.FormValue("subject_uri") 2790 - req.SubjectCID = r.FormValue("subject_cid") 2791 - req.Reason = r.FormValue("reason") 2792 - } 2793 - 2794 - // Validate subject URI 2795 - if req.SubjectURI == "" { 2796 - writeReportError(w, "subject_uri is required", http.StatusBadRequest) 2797 - return 2798 - } 2799 - 2800 - // Parse the subject URI to get the content owner's DID 2801 - uriComponents, err := atproto.ResolveATURI(req.SubjectURI) 2802 - if err != nil { 2803 - writeReportError(w, "Invalid subject_uri format", http.StatusBadRequest) 2804 - return 2805 - } 2806 - subjectDID := uriComponents.DID 2807 - 2808 - // Prevent self-reporting 2809 - if subjectDID == reporterDID { 2810 - writeReportError(w, "You cannot report your own content", http.StatusBadRequest) 2811 - return 2812 - } 2813 - 2814 - // Validate and sanitize reason 2815 - reason := strings.TrimSpace(req.Reason) 2816 - if reason == "" { 2817 - reason = "No reason provided" 2818 - } 2819 - if len(reason) > MaxReportReasonLength { 2820 - reason = reason[:MaxReportReasonLength] 2821 - } 2822 - 2823 - // Check rate limit (10 reports per hour per user) 2824 - oneHourAgo := time.Now().Add(-1 * time.Hour) 2825 - recentCount, err := h.moderationStore.CountReportsFromUserSince(ctx, reporterDID, oneHourAgo) 2826 - if err != nil { 2827 - log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to check rate limit") 2828 - writeReportError(w, "Failed to process report", http.StatusInternalServerError) 2829 - return 2830 - } 2831 - if recentCount >= ReportRateLimitPerHour { 2832 - writeReportError(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests) 2833 - return 2834 - } 2835 - 2836 - // Check for duplicate report 2837 - alreadyReported, err := h.moderationStore.HasReportedURI(ctx, reporterDID, req.SubjectURI) 2838 - if err != nil { 2839 - log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to check duplicate") 2840 - writeReportError(w, "Failed to process report", http.StatusInternalServerError) 2841 - return 2842 - } 2843 - if alreadyReported { 2844 - writeReportError(w, "You have already reported this content", http.StatusConflict) 2845 - return 2846 - } 2847 - 2848 - // Create the report 2849 - report := moderation.Report{ 2850 - ID: generateTID(), 2851 - SubjectURI: req.SubjectURI, 2852 - SubjectDID: subjectDID, 2853 - ReporterDID: reporterDID, 2854 - Reason: reason, 2855 - CreatedAt: time.Now(), 2856 - Status: moderation.ReportStatusPending, 2857 - } 2858 - 2859 - // Persist the report 2860 - if err := h.moderationStore.CreateReport(ctx, report); err != nil { 2861 - log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to create report") 2862 - writeReportError(w, "Failed to save report", http.StatusInternalServerError) 2863 - return 2864 - } 2865 - 2866 - log.Info(). 2867 - Str("report_id", report.ID). 2868 - Str("subject_uri", report.SubjectURI). 2869 - Str("subject_did", report.SubjectDID). 2870 - Str("reporter_did", report.ReporterDID). 2871 - Str("reason", report.Reason). 2872 - Msg("moderation: report created") 2873 - 2874 - // Check automod thresholds and potentially auto-hide 2875 - h.checkAutomod(ctx, report) 2876 - 2877 - // Return success 2878 - w.Header().Set("Content-Type", "application/json") 2879 - w.WriteHeader(http.StatusOK) 2880 - json.NewEncoder(w).Encode(ReportResponse{ 2881 - ID: report.ID, 2882 - Status: "received", 2883 - Message: "Thank you for your report. It will be reviewed by a moderator.", 2884 - }) 2885 - } 2886 - 2887 - // checkAutomod checks if automod thresholds are met and auto-hides content if needed. 2888 - func (h *Handler) checkAutomod(ctx context.Context, report moderation.Report) { 2889 - // Skip if record is already hidden 2890 - if h.moderationStore.IsRecordHidden(ctx, report.SubjectURI) { 2891 - return 2892 - } 2893 - 2894 - // Check report count for this specific URI 2895 - uriReportCount, err := h.moderationStore.CountReportsForURI(ctx, report.SubjectURI) 2896 - if err != nil { 2897 - log.Error().Err(err).Str("uri", report.SubjectURI).Msg("moderation: failed to count URI reports for automod") 2898 - return 2899 - } 2900 - 2901 - // Check total report count for content by this user 2902 - didReportCount, err := h.moderationStore.CountReportsForDID(ctx, report.SubjectDID) 2903 - if err != nil { 2904 - log.Error().Err(err).Str("did", report.SubjectDID).Msg("moderation: failed to count DID reports for automod") 2905 - return 2906 - } 2907 - 2908 - // Determine if we should auto-hide 2909 - shouldAutoHide := false 2910 - autoHideReason := "" 2911 - 2912 - if uriReportCount >= AutoHideThreshold { 2913 - shouldAutoHide = true 2914 - autoHideReason = fmt.Sprintf("Auto-hidden: %d reports on this record", uriReportCount) 2915 - } else if didReportCount >= AutoHideUserThreshold { 2916 - shouldAutoHide = true 2917 - autoHideReason = fmt.Sprintf("Auto-hidden: %d total reports against user's content", didReportCount) 2918 - } 2919 - 2920 - if shouldAutoHide { 2921 - // Auto-hide the record 2922 - hiddenRecord := moderation.HiddenRecord{ 2923 - ATURI: report.SubjectURI, 2924 - HiddenAt: time.Now(), 2925 - HiddenBy: "automod", 2926 - Reason: autoHideReason, 2927 - AutoHidden: true, 2928 - } 2929 - 2930 - if err := h.moderationStore.HideRecord(ctx, hiddenRecord); err != nil { 2931 - log.Error().Err(err).Str("uri", report.SubjectURI).Msg("moderation: automod failed to hide record") 2932 - return 2933 - } 2934 - 2935 - // Log the automod action 2936 - auditEntry := moderation.AuditEntry{ 2937 - ID: generateTID(), 2938 - Action: moderation.AuditActionHideRecord, 2939 - ActorDID: "automod", 2940 - TargetURI: report.SubjectURI, 2941 - Reason: autoHideReason, 2942 - Timestamp: time.Now(), 2943 - AutoMod: true, 2944 - } 2945 - 2946 - if err := h.moderationStore.LogAction(ctx, auditEntry); err != nil { 2947 - log.Error().Err(err).Msg("moderation: failed to log automod action") 2948 - } 2949 - 2950 - log.Warn(). 2951 - Str("uri", report.SubjectURI). 2952 - Str("did", report.SubjectDID). 2953 - Int("uri_reports", uriReportCount). 2954 - Int("did_reports", didReportCount). 2955 - Str("reason", autoHideReason). 2956 - Msg("moderation: automod triggered - record hidden") 2957 - } 2958 - } 2959 - 2960 - // writeReportError writes a JSON error response for report endpoints 2961 - func writeReportError(w http.ResponseWriter, message string, status int) { 2962 - w.Header().Set("Content-Type", "application/json") 2963 - w.WriteHeader(status) 2964 - json.NewEncoder(w).Encode(ReportResponse{ 2965 - Status: "error", 2966 - Message: message, 2967 - }) 2968 - }
+345
internal/handlers/join.go
··· 1 + package handlers 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + "time" 9 + 10 + "arabica/internal/atproto" 11 + "arabica/internal/database/boltstore" 12 + "arabica/internal/middleware" 13 + "arabica/internal/web/bff" 14 + "arabica/internal/web/pages" 15 + 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/xrpc" 18 + "github.com/rs/zerolog/log" 19 + ) 20 + 21 + // HandleJoin renders the join request page. 22 + func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { 23 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 24 + isAuthenticated := err == nil && didStr != "" 25 + 26 + var userProfile *bff.UserProfile 27 + if isAuthenticated { 28 + userProfile = h.getUserProfile(r.Context(), didStr) 29 + } 30 + 31 + layoutData := h.buildLayoutData(r, "Join Arabica", isAuthenticated, didStr, userProfile) 32 + 33 + if err := pages.Join(layoutData).Render(r.Context(), w); err != nil { 34 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 35 + log.Error().Err(err).Msg("Failed to render join page") 36 + } 37 + } 38 + 39 + // HandleJoinSubmit processes a join request form submission. 40 + func (h *Handler) HandleJoinSubmit(w http.ResponseWriter, r *http.Request) { 41 + if err := r.ParseForm(); err != nil { 42 + http.Error(w, "Invalid form data", http.StatusBadRequest) 43 + return 44 + } 45 + 46 + // Honeypot check — if the hidden field is filled, silently reject 47 + if r.FormValue("website") != "" { 48 + // Show success page anyway so bots don't know they were caught 49 + h.renderJoinSuccess(w, r) 50 + return 51 + } 52 + 53 + emailAddr := strings.TrimSpace(r.FormValue("email")) 54 + message := strings.TrimSpace(r.FormValue("message")) 55 + 56 + // Basic email validation 57 + if emailAddr == "" || !strings.Contains(emailAddr, "@") || !strings.Contains(emailAddr, ".") { 58 + http.Error(w, "A valid email address is required", http.StatusBadRequest) 59 + return 60 + } 61 + 62 + // Create and save the join request 63 + req := &boltstore.JoinRequest{ 64 + ID: fmt.Sprintf("%d", time.Now().UnixNano()), 65 + Email: emailAddr, 66 + Message: message, 67 + CreatedAt: time.Now().UTC(), 68 + IP: middleware.GetClientIP(r), 69 + } 70 + 71 + if h.joinStore != nil { 72 + if err := h.joinStore.SaveRequest(req); err != nil { 73 + log.Error().Err(err).Str("email", emailAddr).Msg("Failed to save join request") 74 + http.Error(w, "Failed to save request, please try again", http.StatusInternalServerError) 75 + return 76 + } 77 + log.Info().Str("email", emailAddr).Msg("Join request saved") 78 + } 79 + 80 + // Send admin notification email (non-blocking) 81 + if h.emailSender != nil && h.emailSender.Enabled() { 82 + go func() { 83 + subject := "New Arabica Join Request" 84 + body := fmt.Sprintf("New account request:\n\nEmail: %s\nMessage: %s\nIP: %s\nTime: %s\n", 85 + req.Email, req.Message, req.IP, req.CreatedAt.Format(time.RFC3339)) 86 + 87 + if err := h.emailSender.Send(h.emailSender.AdminEmail(), subject, body); err != nil { 88 + log.Error().Err(err).Str("email", emailAddr).Msg("Failed to send admin notification") 89 + } 90 + }() 91 + } 92 + 93 + h.renderJoinSuccess(w, r) 94 + } 95 + 96 + func (h *Handler) renderJoinSuccess(w http.ResponseWriter, r *http.Request) { 97 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 98 + isAuthenticated := err == nil && didStr != "" 99 + 100 + var userProfile *bff.UserProfile 101 + if isAuthenticated { 102 + userProfile = h.getUserProfile(r.Context(), didStr) 103 + } 104 + 105 + layoutData := h.buildLayoutData(r, "Request Received", isAuthenticated, didStr, userProfile) 106 + 107 + if err := pages.JoinSuccess(layoutData).Render(r.Context(), w); err != nil { 108 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 109 + log.Error().Err(err).Msg("Failed to render join success page") 110 + } 111 + } 112 + 113 + // HandleCreateInvite creates a PDS invite code and emails it to the requester. 114 + func (h *Handler) HandleCreateInvite(w http.ResponseWriter, r *http.Request) { 115 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 116 + if err != nil || userDID == "" { 117 + http.Error(w, "Authentication required", http.StatusUnauthorized) 118 + return 119 + } 120 + if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 121 + http.Error(w, "Access denied", http.StatusForbidden) 122 + return 123 + } 124 + 125 + if err := r.ParseForm(); err != nil { 126 + http.Error(w, "Invalid request", http.StatusBadRequest) 127 + return 128 + } 129 + reqID := r.FormValue("id") 130 + reqEmail := r.FormValue("email") 131 + if reqID == "" || reqEmail == "" { 132 + http.Error(w, "Missing request ID or email", http.StatusBadRequest) 133 + return 134 + } 135 + 136 + if h.pdsAdminURL == "" || h.pdsAdminToken == "" { 137 + http.Error(w, "PDS admin not configured", http.StatusInternalServerError) 138 + return 139 + } 140 + 141 + // Create invite code via PDS admin API 142 + client := &xrpc.Client{ 143 + Host: h.pdsAdminURL, 144 + AdminToken: &h.pdsAdminToken, 145 + } 146 + out, err := comatproto.ServerCreateInviteCode(r.Context(), client, &comatproto.ServerCreateInviteCode_Input{ 147 + UseCount: 1, 148 + }) 149 + if err != nil { 150 + log.Error().Err(err).Str("email", reqEmail).Msg("Failed to create invite code") 151 + http.Error(w, "Failed to create invite code", http.StatusInternalServerError) 152 + return 153 + } 154 + 155 + log.Info().Str("email", reqEmail).Str("code", out.Code).Str("by", userDID).Msg("Invite code created") 156 + 157 + // Email the invite code to the requester 158 + if h.emailSender != nil && h.emailSender.Enabled() { 159 + subject := "Your Arabica Invite Code" 160 + // TODO: this should probably use the env var rather than hard coded 161 + body := fmt.Sprintf("Welcome to Arabica!\n\nHere is your invite code to create an account on arabica.systems:\n\n %s\n\nVisit https://arabica.systems to sign up with this code.\n\nHappy brewing!\n", out.Code) 162 + if err := h.emailSender.Send(reqEmail, subject, body); err != nil { 163 + log.Error().Err(err).Str("email", reqEmail).Msg("Failed to send invite email") 164 + http.Error(w, "Invite created but failed to send email. Code: "+out.Code, http.StatusInternalServerError) 165 + return 166 + } 167 + log.Info().Str("email", reqEmail).Msg("Invite code emailed") 168 + } 169 + 170 + // Remove the join request 171 + if h.joinStore != nil { 172 + if err := h.joinStore.DeleteRequest(reqID); err != nil { 173 + log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 174 + } 175 + } 176 + 177 + w.Header().Set("HX-Trigger", "mod-action") 178 + w.WriteHeader(http.StatusOK) 179 + } 180 + 181 + // HandleDismissJoinRequest removes a join request without sending an invite. 182 + func (h *Handler) HandleDismissJoinRequest(w http.ResponseWriter, r *http.Request) { 183 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 184 + if err != nil || userDID == "" { 185 + http.Error(w, "Authentication required", http.StatusUnauthorized) 186 + return 187 + } 188 + if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 189 + http.Error(w, "Access denied", http.StatusForbidden) 190 + return 191 + } 192 + 193 + if err := r.ParseForm(); err != nil { 194 + http.Error(w, "Invalid request", http.StatusBadRequest) 195 + return 196 + } 197 + reqID := r.FormValue("id") 198 + if reqID == "" { 199 + http.Error(w, "Missing request ID", http.StatusBadRequest) 200 + return 201 + } 202 + 203 + if h.joinStore != nil { 204 + if err := h.joinStore.DeleteRequest(reqID); err != nil { 205 + log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 206 + http.Error(w, "Failed to dismiss request", http.StatusInternalServerError) 207 + return 208 + } 209 + } 210 + 211 + log.Info().Str("id", reqID).Str("by", userDID).Msg("Join request dismissed") 212 + 213 + w.Header().Set("HX-Trigger", "mod-action") 214 + w.WriteHeader(http.StatusOK) 215 + } 216 + 217 + // HandleCreateAccount renders the account creation form (GET /join/create). 218 + func (h *Handler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { 219 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 220 + isAuthenticated := err == nil && didStr != "" 221 + 222 + var userProfile *bff.UserProfile 223 + if isAuthenticated { 224 + userProfile = h.getUserProfile(r.Context(), didStr) 225 + } 226 + 227 + layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 228 + 229 + props := pages.CreateAccountProps{ 230 + InviteCode: r.URL.Query().Get("code"), 231 + HandleDomain: "arabica.systems", 232 + } 233 + 234 + if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 235 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 236 + log.Error().Err(err).Msg("Failed to render create account page") 237 + } 238 + } 239 + 240 + // HandleCreateAccountSubmit processes the account creation form (POST /join/create). 241 + func (h *Handler) HandleCreateAccountSubmit(w http.ResponseWriter, r *http.Request) { 242 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 243 + isAuthenticated := err == nil && didStr != "" 244 + 245 + var userProfile *bff.UserProfile 246 + if isAuthenticated { 247 + userProfile = h.getUserProfile(r.Context(), didStr) 248 + } 249 + 250 + if err := r.ParseForm(); err != nil { 251 + http.Error(w, "Invalid request", http.StatusBadRequest) 252 + return 253 + } 254 + 255 + inviteCode := strings.TrimSpace(r.FormValue("invite_code")) 256 + handle := strings.TrimSpace(r.FormValue("handle")) 257 + emailAddr := strings.TrimSpace(r.FormValue("email")) 258 + password := r.FormValue("password") 259 + passwordConfirm := r.FormValue("password_confirm") 260 + honeypot := r.FormValue("website") 261 + 262 + // Honeypot check — bots fill hidden fields; show fake success 263 + if honeypot != "" { 264 + layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 265 + _ = pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: "user.arabica.systems"}).Render(r.Context(), w) 266 + return 267 + } 268 + 269 + handleDomain := "arabica.systems" 270 + 271 + // Render form with error helper 272 + renderError := func(msg string) { 273 + layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 274 + props := pages.CreateAccountProps{ 275 + Error: msg, 276 + InviteCode: inviteCode, 277 + Handle: handle, 278 + Email: emailAddr, 279 + HandleDomain: handleDomain, 280 + } 281 + if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 282 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 283 + } 284 + } 285 + 286 + // Validate required fields 287 + if inviteCode == "" || handle == "" || emailAddr == "" || password == "" { 288 + renderError("All fields are required.") 289 + return 290 + } 291 + if password != passwordConfirm { 292 + renderError("Passwords do not match.") 293 + return 294 + } 295 + 296 + // Build full handle 297 + fullHandle := handle + "." + handleDomain 298 + 299 + if h.pdsAdminURL == "" { 300 + renderError("Account creation is not available at this time.") 301 + log.Error().Msg("PDS admin URL not configured for account creation") 302 + return 303 + } 304 + 305 + // Call PDS createAccount (public endpoint, no admin token needed) 306 + client := &xrpc.Client{Host: h.pdsAdminURL} 307 + out, err := comatproto.ServerCreateAccount(r.Context(), client, &comatproto.ServerCreateAccount_Input{ 308 + Handle: fullHandle, 309 + Email: &emailAddr, 310 + Password: &password, 311 + InviteCode: &inviteCode, 312 + }) 313 + if err != nil { 314 + errMsg := "Account creation failed. Please try again." 315 + var xrpcErr *xrpc.Error 316 + if errors.As(err, &xrpcErr) { 317 + var inner *xrpc.XRPCError 318 + if errors.As(xrpcErr.Wrapped, &inner) { 319 + switch inner.ErrStr { 320 + case "InvalidInviteCode": 321 + errMsg = "Invalid or expired invite code." 322 + case "HandleNotAvailable": 323 + errMsg = "This handle is already taken." 324 + case "InvalidHandle": 325 + errMsg = "Invalid handle format. Use only letters, numbers, and hyphens." 326 + default: 327 + if inner.Message != "" { 328 + errMsg = inner.Message 329 + } 330 + } 331 + } 332 + } 333 + log.Error().Err(err).Str("handle", fullHandle).Msg("Failed to create account") 334 + renderError(errMsg) 335 + return 336 + } 337 + 338 + log.Info().Str("handle", out.Handle).Str("did", out.Did).Msg("Account created") 339 + 340 + layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 341 + if err := pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: out.Handle}).Render(r.Context(), w); err != nil { 342 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 343 + log.Error().Err(err).Msg("Failed to render create account success page") 344 + } 345 + }
+210
internal/handlers/modals.go
··· 1 + package handlers 2 + 3 + import ( 4 + "net/http" 5 + 6 + "arabica/internal/models" 7 + "arabica/internal/web/components" 8 + 9 + "github.com/rs/zerolog/log" 10 + ) 11 + 12 + // Modal dialog handlers for entity management 13 + 14 + // HandleBeanModalNew renders a new bean modal dialog 15 + func (h *Handler) HandleBeanModalNew(w http.ResponseWriter, r *http.Request) { 16 + // Require authentication 17 + store, authenticated := h.getAtprotoStore(r) 18 + if !authenticated { 19 + http.Error(w, "Authentication required", http.StatusUnauthorized) 20 + return 21 + } 22 + 23 + // Fetch roasters for the select dropdown 24 + roasters, err := store.ListRoasters(r.Context()) 25 + if err != nil { 26 + log.Warn().Err(err).Msg("Failed to fetch roasters for bean modal") 27 + roasters = []*models.Roaster{} // Empty list on error 28 + } 29 + 30 + // Convert to slice for template 31 + roastersSlice := make([]models.Roaster, len(roasters)) 32 + for i, r := range roasters { 33 + roastersSlice[i] = *r 34 + } 35 + 36 + if err := components.BeanDialogModal(nil, roastersSlice).Render(r.Context(), w); err != nil { 37 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 38 + log.Error().Err(err).Msg("Failed to render bean modal") 39 + } 40 + } 41 + 42 + // HandleBeanModalEdit renders an edit bean modal dialog 43 + func (h *Handler) HandleBeanModalEdit(w http.ResponseWriter, r *http.Request) { 44 + rkey := validateRKey(w, r.PathValue("id")) 45 + if rkey == "" { 46 + return 47 + } 48 + 49 + // Require authentication 50 + store, authenticated := h.getAtprotoStore(r) 51 + if !authenticated { 52 + http.Error(w, "Authentication required", http.StatusUnauthorized) 53 + return 54 + } 55 + 56 + // Fetch the bean 57 + bean, err := store.GetBeanByRKey(r.Context(), rkey) 58 + if err != nil { 59 + http.Error(w, "Bean not found", http.StatusNotFound) 60 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get bean for modal") 61 + return 62 + } 63 + 64 + // Fetch roasters for the select dropdown 65 + roasters, err := store.ListRoasters(r.Context()) 66 + if err != nil { 67 + log.Warn().Err(err).Msg("Failed to fetch roasters for bean modal") 68 + roasters = []*models.Roaster{} 69 + } 70 + 71 + // Convert to slice for template 72 + roastersSlice := make([]models.Roaster, len(roasters)) 73 + for i, r := range roasters { 74 + roastersSlice[i] = *r 75 + } 76 + 77 + if err := components.BeanDialogModal(bean, roastersSlice).Render(r.Context(), w); err != nil { 78 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 79 + log.Error().Err(err).Msg("Failed to render bean modal") 80 + } 81 + } 82 + 83 + // HandleGrinderModalNew renders a new grinder modal dialog 84 + func (h *Handler) HandleGrinderModalNew(w http.ResponseWriter, r *http.Request) { 85 + // Require authentication 86 + _, authenticated := h.getAtprotoStore(r) 87 + if !authenticated { 88 + http.Error(w, "Authentication required", http.StatusUnauthorized) 89 + return 90 + } 91 + 92 + if err := components.GrinderDialogModal(nil).Render(r.Context(), w); err != nil { 93 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 94 + log.Error().Err(err).Msg("Failed to render grinder modal") 95 + } 96 + } 97 + 98 + // HandleGrinderModalEdit renders an edit grinder modal dialog 99 + func (h *Handler) HandleGrinderModalEdit(w http.ResponseWriter, r *http.Request) { 100 + rkey := validateRKey(w, r.PathValue("id")) 101 + if rkey == "" { 102 + return 103 + } 104 + 105 + // Require authentication 106 + store, authenticated := h.getAtprotoStore(r) 107 + if !authenticated { 108 + http.Error(w, "Authentication required", http.StatusUnauthorized) 109 + return 110 + } 111 + 112 + // Fetch the grinder 113 + grinder, err := store.GetGrinderByRKey(r.Context(), rkey) 114 + if err != nil { 115 + http.Error(w, "Grinder not found", http.StatusNotFound) 116 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get grinder for modal") 117 + return 118 + } 119 + 120 + if err := components.GrinderDialogModal(grinder).Render(r.Context(), w); err != nil { 121 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 122 + log.Error().Err(err).Msg("Failed to render grinder modal") 123 + } 124 + } 125 + 126 + // HandleBrewerModalNew renders a new brewer modal dialog 127 + func (h *Handler) HandleBrewerModalNew(w http.ResponseWriter, r *http.Request) { 128 + // Require authentication 129 + _, authenticated := h.getAtprotoStore(r) 130 + if !authenticated { 131 + http.Error(w, "Authentication required", http.StatusUnauthorized) 132 + return 133 + } 134 + 135 + if err := components.BrewerDialogModal(nil).Render(r.Context(), w); err != nil { 136 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 137 + log.Error().Err(err).Msg("Failed to render brewer modal") 138 + } 139 + } 140 + 141 + // HandleBrewerModalEdit renders an edit brewer modal dialog 142 + func (h *Handler) HandleBrewerModalEdit(w http.ResponseWriter, r *http.Request) { 143 + rkey := validateRKey(w, r.PathValue("id")) 144 + if rkey == "" { 145 + return 146 + } 147 + 148 + // Require authentication 149 + store, authenticated := h.getAtprotoStore(r) 150 + if !authenticated { 151 + http.Error(w, "Authentication required", http.StatusUnauthorized) 152 + return 153 + } 154 + 155 + // Fetch the brewer 156 + brewer, err := store.GetBrewerByRKey(r.Context(), rkey) 157 + if err != nil { 158 + http.Error(w, "Brewer not found", http.StatusNotFound) 159 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brewer for modal") 160 + return 161 + } 162 + 163 + if err := components.BrewerDialogModal(brewer).Render(r.Context(), w); err != nil { 164 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 165 + log.Error().Err(err).Msg("Failed to render brewer modal") 166 + } 167 + } 168 + 169 + // HandleRoasterModalNew renders a new roaster modal dialog 170 + func (h *Handler) HandleRoasterModalNew(w http.ResponseWriter, r *http.Request) { 171 + // Require authentication 172 + _, authenticated := h.getAtprotoStore(r) 173 + if !authenticated { 174 + http.Error(w, "Authentication required", http.StatusUnauthorized) 175 + return 176 + } 177 + 178 + if err := components.RoasterDialogModal(nil).Render(r.Context(), w); err != nil { 179 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 180 + log.Error().Err(err).Msg("Failed to render roaster modal") 181 + } 182 + } 183 + 184 + // HandleRoasterModalEdit renders an edit roaster modal dialog 185 + func (h *Handler) HandleRoasterModalEdit(w http.ResponseWriter, r *http.Request) { 186 + rkey := validateRKey(w, r.PathValue("id")) 187 + if rkey == "" { 188 + return 189 + } 190 + 191 + // Require authentication 192 + store, authenticated := h.getAtprotoStore(r) 193 + if !authenticated { 194 + http.Error(w, "Authentication required", http.StatusUnauthorized) 195 + return 196 + } 197 + 198 + // Fetch the roaster 199 + roaster, err := store.GetRoasterByRKey(r.Context(), rkey) 200 + if err != nil { 201 + http.Error(w, "Roaster not found", http.StatusNotFound) 202 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get roaster for modal") 203 + return 204 + } 205 + 206 + if err := components.RoasterDialogModal(roaster).Render(r.Context(), w); err != nil { 207 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 208 + log.Error().Err(err).Msg("Failed to render roaster modal") 209 + } 210 + }
+86
internal/handlers/pages.go
··· 1 + package handlers 2 + 3 + import ( 4 + "net/http" 5 + 6 + "arabica/internal/atproto" 7 + "arabica/internal/web/bff" 8 + "arabica/internal/web/pages" 9 + 10 + "github.com/rs/zerolog/log" 11 + ) 12 + 13 + // About page 14 + func (h *Handler) HandleAbout(w http.ResponseWriter, r *http.Request) { 15 + // Check if user is authenticated 16 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 17 + isAuthenticated := err == nil && didStr != "" 18 + 19 + var userProfile *bff.UserProfile 20 + if isAuthenticated { 21 + userProfile = h.getUserProfile(r.Context(), didStr) 22 + } 23 + 24 + data := h.buildLayoutData(r, "About", isAuthenticated, didStr, userProfile) 25 + 26 + // Use templ component 27 + if err := pages.About(data).Render(r.Context(), w); err != nil { 28 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 29 + log.Error().Err(err).Msg("Failed to render about page") 30 + } 31 + } 32 + 33 + // Terms of Service page 34 + func (h *Handler) HandleTerms(w http.ResponseWriter, r *http.Request) { 35 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 36 + isAuthenticated := err == nil 37 + 38 + var userProfile *bff.UserProfile 39 + if isAuthenticated { 40 + userProfile = h.getUserProfile(r.Context(), didStr) 41 + } 42 + 43 + layoutData := h.buildLayoutData(r, "Terms of Service", isAuthenticated, didStr, userProfile) 44 + 45 + if err := pages.Terms(layoutData).Render(r.Context(), w); err != nil { 46 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 47 + log.Error().Err(err).Msg("Failed to render terms page") 48 + } 49 + } 50 + 51 + func (h *Handler) HandleATProto(w http.ResponseWriter, r *http.Request) { 52 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 53 + isAuthenticated := err == nil 54 + 55 + var userProfile *bff.UserProfile 56 + if isAuthenticated { 57 + userProfile = h.getUserProfile(r.Context(), didStr) 58 + } 59 + 60 + layoutData := h.buildLayoutData(r, "AT Protocol", isAuthenticated, didStr, userProfile) 61 + 62 + if err := pages.ATProto(layoutData).Render(r.Context(), w); err != nil { 63 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 64 + log.Error().Err(err).Msg("Failed to render AT Protocol page") 65 + } 66 + } 67 + 68 + // HandleNotFound renders the 404 page 69 + func (h *Handler) HandleNotFound(w http.ResponseWriter, r *http.Request) { 70 + // Check if current user is authenticated (for nav bar state) 71 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 72 + isAuthenticated := err == nil && didStr != "" 73 + 74 + var userProfile *bff.UserProfile 75 + if isAuthenticated { 76 + userProfile = h.getUserProfile(r.Context(), didStr) 77 + } 78 + 79 + layoutData := h.buildLayoutData(r, "Page Not Found", isAuthenticated, didStr, userProfile) 80 + 81 + w.WriteHeader(http.StatusNotFound) 82 + if err := pages.NotFound(layoutData).Render(r.Context(), w); err != nil { 83 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 84 + log.Error().Err(err).Msg("Failed to render 404 page") 85 + } 86 + }
+428
internal/handlers/profile.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "sort" 7 + "strings" 8 + 9 + "arabica/internal/atproto" 10 + "arabica/internal/models" 11 + "arabica/internal/web/bff" 12 + "arabica/internal/web/components" 13 + "arabica/internal/web/pages" 14 + 15 + "github.com/rs/zerolog/log" 16 + "golang.org/x/sync/errgroup" 17 + ) 18 + 19 + // ProfileDataBundle holds all user data fetched from their PDS for profile display 20 + type ProfileDataBundle struct { 21 + Beans []*models.Bean 22 + Roasters []*models.Roaster 23 + Grinders []*models.Grinder 24 + Brewers []*models.Brewer 25 + Brews []*models.Brew 26 + } 27 + 28 + // fetchUserProfileData fetches all user data from their PDS in parallel. 29 + // This includes beans, roasters, grinders, brewers, and brews with all references resolved. 30 + // Brews are sorted in reverse chronological order (newest first). 31 + func (h *Handler) fetchUserProfileData(ctx context.Context, did string, publicClient *atproto.PublicClient) (*ProfileDataBundle, error) { 32 + // Fetch all user data in parallel 33 + g, gCtx := errgroup.WithContext(ctx) 34 + 35 + var brews []*models.Brew 36 + var beans []*models.Bean 37 + var roasters []*models.Roaster 38 + var grinders []*models.Grinder 39 + var brewers []*models.Brewer 40 + 41 + // Maps for resolving references 42 + var beanMap map[string]*models.Bean 43 + var beanRoasterRefMap map[string]string 44 + var roasterMap map[string]*models.Roaster 45 + var brewerMap map[string]*models.Brewer 46 + var grinderMap map[string]*models.Grinder 47 + 48 + // Fetch beans 49 + g.Go(func() error { 50 + output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBean, 100) 51 + if err != nil { 52 + return err 53 + } 54 + beanMap = make(map[string]*models.Bean) 55 + beanRoasterRefMap = make(map[string]string) 56 + beans = make([]*models.Bean, 0, len(output.Records)) 57 + for _, record := range output.Records { 58 + bean, err := atproto.RecordToBean(record.Value, record.URI) 59 + if err != nil { 60 + continue 61 + } 62 + beans = append(beans, bean) 63 + beanMap[record.URI] = bean 64 + if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" { 65 + beanRoasterRefMap[record.URI] = roasterRef 66 + } 67 + } 68 + return nil 69 + }) 70 + 71 + // Fetch roasters 72 + g.Go(func() error { 73 + output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDRoaster, 100) 74 + if err != nil { 75 + return err 76 + } 77 + roasterMap = make(map[string]*models.Roaster) 78 + roasters = make([]*models.Roaster, 0, len(output.Records)) 79 + for _, record := range output.Records { 80 + roaster, err := atproto.RecordToRoaster(record.Value, record.URI) 81 + if err != nil { 82 + continue 83 + } 84 + roasters = append(roasters, roaster) 85 + roasterMap[record.URI] = roaster 86 + } 87 + return nil 88 + }) 89 + 90 + // Fetch grinders 91 + g.Go(func() error { 92 + output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDGrinder, 100) 93 + if err != nil { 94 + return err 95 + } 96 + grinderMap = make(map[string]*models.Grinder) 97 + grinders = make([]*models.Grinder, 0, len(output.Records)) 98 + for _, record := range output.Records { 99 + grinder, err := atproto.RecordToGrinder(record.Value, record.URI) 100 + if err != nil { 101 + continue 102 + } 103 + grinders = append(grinders, grinder) 104 + grinderMap[record.URI] = grinder 105 + } 106 + return nil 107 + }) 108 + 109 + // Fetch brewers 110 + g.Go(func() error { 111 + output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrewer, 100) 112 + if err != nil { 113 + return err 114 + } 115 + brewerMap = make(map[string]*models.Brewer) 116 + brewers = make([]*models.Brewer, 0, len(output.Records)) 117 + for _, record := range output.Records { 118 + brewer, err := atproto.RecordToBrewer(record.Value, record.URI) 119 + if err != nil { 120 + continue 121 + } 122 + brewers = append(brewers, brewer) 123 + brewerMap[record.URI] = brewer 124 + } 125 + return nil 126 + }) 127 + 128 + // Fetch brews 129 + g.Go(func() error { 130 + output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrew, 100) 131 + if err != nil { 132 + return err 133 + } 134 + brews = make([]*models.Brew, 0, len(output.Records)) 135 + for _, record := range output.Records { 136 + brew, err := atproto.RecordToBrew(record.Value, record.URI) 137 + if err != nil { 138 + continue 139 + } 140 + // Store the raw record for reference resolution later 141 + brew.BeanRKey = "" 142 + if beanRef, ok := record.Value["beanRef"].(string); ok { 143 + brew.BeanRKey = beanRef 144 + } 145 + if grinderRef, ok := record.Value["grinderRef"].(string); ok { 146 + brew.GrinderRKey = grinderRef 147 + } 148 + if brewerRef, ok := record.Value["brewerRef"].(string); ok { 149 + brew.BrewerRKey = brewerRef 150 + } 151 + brews = append(brews, brew) 152 + } 153 + return nil 154 + }) 155 + 156 + if err := g.Wait(); err != nil { 157 + return nil, err 158 + } 159 + 160 + // Resolve references for beans (roaster refs) 161 + for _, bean := range beans { 162 + if roasterRef, found := beanRoasterRefMap[atproto.BuildATURI(did, atproto.NSIDBean, bean.RKey)]; found { 163 + if roaster, found := roasterMap[roasterRef]; found { 164 + bean.Roaster = roaster 165 + } 166 + } 167 + } 168 + 169 + // Resolve references for brews 170 + for _, brew := range brews { 171 + // Resolve bean reference 172 + if brew.BeanRKey != "" { 173 + if bean, found := beanMap[brew.BeanRKey]; found { 174 + brew.Bean = bean 175 + } 176 + } 177 + // Resolve grinder reference 178 + if brew.GrinderRKey != "" { 179 + if grinder, found := grinderMap[brew.GrinderRKey]; found { 180 + brew.GrinderObj = grinder 181 + } 182 + } 183 + // Resolve brewer reference 184 + if brew.BrewerRKey != "" { 185 + if brewer, found := brewerMap[brew.BrewerRKey]; found { 186 + brew.BrewerObj = brewer 187 + } 188 + } 189 + } 190 + 191 + // Sort brews in reverse chronological order (newest first) 192 + sort.Slice(brews, func(i, j int) bool { 193 + return brews[i].CreatedAt.After(brews[j].CreatedAt) 194 + }) 195 + 196 + return &ProfileDataBundle{ 197 + Beans: beans, 198 + Roasters: roasters, 199 + Grinders: grinders, 200 + Brewers: brewers, 201 + Brews: brews, 202 + }, nil 203 + } 204 + 205 + // HandleProfile displays a user's public profile with their brews and gear 206 + func (h *Handler) HandleProfile(w http.ResponseWriter, r *http.Request) { 207 + actor := r.PathValue("actor") 208 + if actor == "" { 209 + http.Error(w, "Actor parameter is required", http.StatusBadRequest) 210 + return 211 + } 212 + 213 + ctx := r.Context() 214 + publicClient := atproto.NewPublicClient() 215 + 216 + // Determine if actor is a DID or handle 217 + var did string 218 + var err error 219 + 220 + if strings.HasPrefix(actor, "did:") { 221 + // It's already a DID 222 + did = actor 223 + } else { 224 + // It's a handle, resolve to DID 225 + did, err = publicClient.ResolveHandle(ctx, actor) 226 + if err != nil { 227 + log.Warn().Err(err).Str("handle", actor).Msg("Failed to resolve handle") 228 + http.Error(w, "User not found", http.StatusNotFound) 229 + return 230 + } 231 + 232 + // Redirect to canonical URL with handle (we'll get the handle from profile) 233 + // For now, continue with the DID we have 234 + } 235 + 236 + // Fetch profile 237 + profile, err := publicClient.GetProfile(ctx, did) 238 + if err != nil { 239 + log.Warn().Err(err).Str("did", did).Msg("Failed to fetch profile") 240 + http.Error(w, "User not found", http.StatusNotFound) 241 + return 242 + } 243 + 244 + // If the URL used a DID but we have the handle, redirect to the canonical handle URL 245 + if strings.HasPrefix(actor, "did:") && profile.Handle != "" { 246 + http.Redirect(w, r, "/profile/"+profile.Handle, http.StatusFound) 247 + return 248 + } 249 + 250 + // Fetch all user data from their PDS 251 + profileData, err := h.fetchUserProfileData(ctx, did, publicClient) 252 + if err != nil { 253 + log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data") 254 + http.Error(w, "Failed to load profile data", http.StatusInternalServerError) 255 + return 256 + } 257 + 258 + // Check if current user is authenticated (for nav bar state) 259 + didStr, err := atproto.GetAuthenticatedDID(ctx) 260 + isAuthenticated := err == nil && didStr != "" 261 + 262 + var userProfile *bff.UserProfile 263 + if isAuthenticated { 264 + userProfile = h.getUserProfile(ctx, didStr) 265 + } 266 + 267 + // Check if this is an Arabica user (has records or is registered in feed) 268 + isArabicaUser := h.feedRegistry.IsRegistered(did) || 269 + len(profileData.Brews) > 0 || len(profileData.Beans) > 0 || 270 + len(profileData.Roasters) > 0 || len(profileData.Grinders) > 0 || 271 + len(profileData.Brewers) > 0 272 + 273 + if !isArabicaUser { 274 + layoutData := h.buildLayoutData(r, "Profile Not Found", isAuthenticated, didStr, userProfile) 275 + w.WriteHeader(http.StatusNotFound) 276 + if err := pages.ProfileNotFound(layoutData).Render(r.Context(), w); err != nil { 277 + log.Error().Err(err).Msg("Failed to render profile not found page") 278 + } 279 + return 280 + } 281 + 282 + // Check if the viewing user is the profile owner 283 + isOwnProfile := isAuthenticated && didStr == did 284 + 285 + // Convert atproto.Profile to bff.UserProfile 286 + viewedProfile := &bff.UserProfile{ 287 + Handle: profile.Handle, 288 + } 289 + if profile.DisplayName != nil { 290 + viewedProfile.DisplayName = *profile.DisplayName 291 + } 292 + if profile.Avatar != nil { 293 + viewedProfile.Avatar = *profile.Avatar 294 + } 295 + 296 + // Create layout data 297 + pageTitle := "Profile" 298 + if viewedProfile.DisplayName != "" { 299 + pageTitle = viewedProfile.DisplayName + " - Profile" 300 + } 301 + layoutData := h.buildLayoutData(r, pageTitle, isAuthenticated, didStr, userProfile) 302 + 303 + // Create roaster options for own profile 304 + var roasterOptions []pages.RoasterOption 305 + if isOwnProfile { 306 + for _, roaster := range profileData.Roasters { 307 + roasterOptions = append(roasterOptions, pages.RoasterOption{ 308 + RKey: roaster.RKey, 309 + Name: roaster.Name, 310 + }) 311 + } 312 + } 313 + 314 + // Create profile props 315 + profileProps := pages.ProfileProps{ 316 + Profile: viewedProfile, 317 + IsOwnProfile: isOwnProfile, 318 + Roasters: roasterOptions, 319 + } 320 + 321 + // Render using templ component 322 + if err := pages.Profile(layoutData, profileProps).Render(r.Context(), w); err != nil { 323 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 324 + log.Error().Err(err).Msg("Failed to render profile page") 325 + } 326 + } 327 + 328 + // HandleProfilePartial returns profile data content (loaded async via HTMX) 329 + func (h *Handler) HandleProfilePartial(w http.ResponseWriter, r *http.Request) { 330 + actor := r.PathValue("actor") 331 + if actor == "" { 332 + http.Error(w, "Actor parameter is required", http.StatusBadRequest) 333 + return 334 + } 335 + 336 + ctx := r.Context() 337 + publicClient := atproto.NewPublicClient() 338 + 339 + // Determine if actor is a DID or handle 340 + var did string 341 + var err error 342 + 343 + if strings.HasPrefix(actor, "did:") { 344 + did = actor 345 + } else { 346 + did, err = publicClient.ResolveHandle(ctx, actor) 347 + if err != nil { 348 + log.Warn().Err(err).Str("handle", actor).Msg("Failed to resolve handle") 349 + http.Error(w, "User not found", http.StatusNotFound) 350 + return 351 + } 352 + } 353 + 354 + // Fetch all user data from their PDS 355 + profileData, err := h.fetchUserProfileData(ctx, did, publicClient) 356 + if err != nil { 357 + log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data for profile partial") 358 + http.Error(w, "Failed to load profile data", http.StatusInternalServerError) 359 + return 360 + } 361 + 362 + // Check if this is an Arabica user (has records or is registered in feed) 363 + isArabicaUser := h.feedRegistry.IsRegistered(did) || 364 + len(profileData.Brews) > 0 || len(profileData.Beans) > 0 || 365 + len(profileData.Roasters) > 0 || len(profileData.Grinders) > 0 || 366 + len(profileData.Brewers) > 0 367 + 368 + if !isArabicaUser { 369 + http.Error(w, "User not found", http.StatusNotFound) 370 + return 371 + } 372 + 373 + // Check if the viewing user is the profile owner 374 + didStr, err := atproto.GetAuthenticatedDID(ctx) 375 + isAuthenticated := err == nil && didStr != "" 376 + isOwnProfile := isAuthenticated && didStr == did 377 + 378 + // Get profile for card rendering 379 + profile, err := publicClient.GetProfile(ctx, did) 380 + if err != nil { 381 + log.Warn().Err(err).Str("did", did).Msg("Failed to fetch profile for profile partial") 382 + // Continue without profile - cards will show limited info 383 + } 384 + 385 + // Use handle from profile or fallback 386 + profileHandle := actor 387 + if profile != nil { 388 + profileHandle = profile.Handle 389 + } else if strings.HasPrefix(actor, "did:") { 390 + profileHandle = did // Fallback to DID if we can't get handle 391 + } 392 + 393 + // Get like counts and CIDs for brews from firehose index 394 + brewLikeCounts := make(map[string]int) 395 + brewLikedByUser := make(map[string]bool) 396 + brewCIDs := make(map[string]string) 397 + if h.feedIndex != nil && profile != nil { 398 + for _, brew := range profileData.Brews { 399 + subjectURI := atproto.BuildATURI(profile.DID, atproto.NSIDBrew, brew.RKey) 400 + brewLikeCounts[brew.RKey] = h.feedIndex.GetLikeCount(subjectURI) 401 + if isAuthenticated { 402 + brewLikedByUser[brew.RKey] = h.feedIndex.HasUserLiked(didStr, subjectURI) 403 + } 404 + // Get CID from the firehose index record 405 + if record, err := h.feedIndex.GetRecord(subjectURI); err == nil && record != nil { 406 + brewCIDs[brew.RKey] = record.CID 407 + } 408 + } 409 + } 410 + 411 + if err := components.ProfileContentPartial(components.ProfileContentPartialProps{ 412 + Brews: profileData.Brews, 413 + Beans: profileData.Beans, 414 + Roasters: profileData.Roasters, 415 + Grinders: profileData.Grinders, 416 + Brewers: profileData.Brewers, 417 + IsOwnProfile: isOwnProfile, 418 + ProfileHandle: profileHandle, 419 + Profile: profile, 420 + BrewLikeCounts: brewLikeCounts, 421 + BrewLikedByUser: brewLikedByUser, 422 + BrewCIDs: brewCIDs, 423 + IsAuthenticated: isAuthenticated, 424 + }).Render(r.Context(), w); err != nil { 425 + http.Error(w, "Failed to render content", http.StatusInternalServerError) 426 + log.Error().Err(err).Msg("Failed to render profile partial") 427 + } 428 + }
+254
internal/handlers/report.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "arabica/internal/atproto" 12 + "arabica/internal/moderation" 13 + 14 + "github.com/rs/zerolog/log" 15 + ) 16 + 17 + // Automod thresholds for automatic content hiding 18 + const ( 19 + // AutoHideThreshold is the number of reports on a single record before auto-hiding 20 + AutoHideThreshold = 3 21 + // AutoHideUserThreshold is the total reports across a user's records before auto-hiding new reports 22 + AutoHideUserThreshold = 5 23 + // ReportRateLimitPerHour is the maximum reports a user can submit per hour 24 + ReportRateLimitPerHour = 10 25 + // MaxReportReasonLength is the maximum length of a report reason 26 + MaxReportReasonLength = 500 27 + ) 28 + 29 + // ReportRequest represents the JSON request for submitting a report 30 + type ReportRequest struct { 31 + SubjectURI string `json:"subject_uri"` 32 + SubjectCID string `json:"subject_cid"` 33 + Reason string `json:"reason"` 34 + } 35 + 36 + // ReportResponse represents the JSON response from report submission 37 + type ReportResponse struct { 38 + ID string `json:"id,omitempty"` 39 + Status string `json:"status"` 40 + Message string `json:"message"` 41 + } 42 + 43 + // HandleReport handles content report submissions. 44 + // Requires authentication, validates input, checks rate limits and duplicates, 45 + // persists the report, and triggers automod if thresholds are reached. 46 + func (h *Handler) HandleReport(w http.ResponseWriter, r *http.Request) { 47 + ctx := r.Context() 48 + 49 + // Require authentication 50 + reporterDID, err := atproto.GetAuthenticatedDID(ctx) 51 + if err != nil || reporterDID == "" { 52 + writeReportError(w, "Authentication required", http.StatusUnauthorized) 53 + return 54 + } 55 + 56 + // Check if moderation store is configured 57 + if h.moderationStore == nil { 58 + log.Error().Msg("moderation: store not configured") 59 + writeReportError(w, "Reports are not enabled", http.StatusServiceUnavailable) 60 + return 61 + } 62 + 63 + // Parse request (supports both JSON and form data) 64 + var req ReportRequest 65 + if isJSONRequest(r) { 66 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 67 + writeReportError(w, "Invalid JSON", http.StatusBadRequest) 68 + return 69 + } 70 + } else { 71 + if err := r.ParseForm(); err != nil { 72 + writeReportError(w, "Invalid form data", http.StatusBadRequest) 73 + return 74 + } 75 + req.SubjectURI = r.FormValue("subject_uri") 76 + req.SubjectCID = r.FormValue("subject_cid") 77 + req.Reason = r.FormValue("reason") 78 + } 79 + 80 + // Validate subject URI 81 + if req.SubjectURI == "" { 82 + writeReportError(w, "subject_uri is required", http.StatusBadRequest) 83 + return 84 + } 85 + 86 + // Parse the subject URI to get the content owner's DID 87 + uriComponents, err := atproto.ResolveATURI(req.SubjectURI) 88 + if err != nil { 89 + writeReportError(w, "Invalid subject_uri format", http.StatusBadRequest) 90 + return 91 + } 92 + subjectDID := uriComponents.DID 93 + 94 + // Prevent self-reporting 95 + if subjectDID == reporterDID { 96 + writeReportError(w, "You cannot report your own content", http.StatusBadRequest) 97 + return 98 + } 99 + 100 + // Validate and sanitize reason 101 + reason := strings.TrimSpace(req.Reason) 102 + if reason == "" { 103 + reason = "No reason provided" 104 + } 105 + if len(reason) > MaxReportReasonLength { 106 + reason = reason[:MaxReportReasonLength] 107 + } 108 + 109 + // Check rate limit (10 reports per hour per user) 110 + oneHourAgo := time.Now().Add(-1 * time.Hour) 111 + recentCount, err := h.moderationStore.CountReportsFromUserSince(ctx, reporterDID, oneHourAgo) 112 + if err != nil { 113 + log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to check rate limit") 114 + writeReportError(w, "Failed to process report", http.StatusInternalServerError) 115 + return 116 + } 117 + if recentCount >= ReportRateLimitPerHour { 118 + writeReportError(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests) 119 + return 120 + } 121 + 122 + // Check for duplicate report 123 + alreadyReported, err := h.moderationStore.HasReportedURI(ctx, reporterDID, req.SubjectURI) 124 + if err != nil { 125 + log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to check duplicate") 126 + writeReportError(w, "Failed to process report", http.StatusInternalServerError) 127 + return 128 + } 129 + if alreadyReported { 130 + writeReportError(w, "You have already reported this content", http.StatusConflict) 131 + return 132 + } 133 + 134 + // Create the report 135 + report := moderation.Report{ 136 + ID: generateTID(), 137 + SubjectURI: req.SubjectURI, 138 + SubjectDID: subjectDID, 139 + ReporterDID: reporterDID, 140 + Reason: reason, 141 + CreatedAt: time.Now(), 142 + Status: moderation.ReportStatusPending, 143 + } 144 + 145 + // Persist the report 146 + if err := h.moderationStore.CreateReport(ctx, report); err != nil { 147 + log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to create report") 148 + writeReportError(w, "Failed to save report", http.StatusInternalServerError) 149 + return 150 + } 151 + 152 + log.Info(). 153 + Str("report_id", report.ID). 154 + Str("subject_uri", report.SubjectURI). 155 + Str("subject_did", report.SubjectDID). 156 + Str("reporter_did", report.ReporterDID). 157 + Str("reason", report.Reason). 158 + Msg("moderation: report created") 159 + 160 + // Check automod thresholds and potentially auto-hide 161 + h.checkAutomod(ctx, report) 162 + 163 + // Return success 164 + w.Header().Set("Content-Type", "application/json") 165 + w.WriteHeader(http.StatusOK) 166 + json.NewEncoder(w).Encode(ReportResponse{ 167 + ID: report.ID, 168 + Status: "received", 169 + Message: "Thank you for your report. It will be reviewed by a moderator.", 170 + }) 171 + } 172 + 173 + // checkAutomod checks if automod thresholds are met and auto-hides content if needed. 174 + func (h *Handler) checkAutomod(ctx context.Context, report moderation.Report) { 175 + // Skip if record is already hidden 176 + if h.moderationStore.IsRecordHidden(ctx, report.SubjectURI) { 177 + return 178 + } 179 + 180 + // Check report count for this specific URI 181 + uriReportCount, err := h.moderationStore.CountReportsForURI(ctx, report.SubjectURI) 182 + if err != nil { 183 + log.Error().Err(err).Str("uri", report.SubjectURI).Msg("moderation: failed to count URI reports for automod") 184 + return 185 + } 186 + 187 + // Check total report count for content by this user 188 + didReportCount, err := h.moderationStore.CountReportsForDID(ctx, report.SubjectDID) 189 + if err != nil { 190 + log.Error().Err(err).Str("did", report.SubjectDID).Msg("moderation: failed to count DID reports for automod") 191 + return 192 + } 193 + 194 + // Determine if we should auto-hide 195 + shouldAutoHide := false 196 + autoHideReason := "" 197 + 198 + if uriReportCount >= AutoHideThreshold { 199 + shouldAutoHide = true 200 + autoHideReason = fmt.Sprintf("Auto-hidden: %d reports on this record", uriReportCount) 201 + } else if didReportCount >= AutoHideUserThreshold { 202 + shouldAutoHide = true 203 + autoHideReason = fmt.Sprintf("Auto-hidden: %d total reports against user's content", didReportCount) 204 + } 205 + 206 + if shouldAutoHide { 207 + // Auto-hide the record 208 + hiddenRecord := moderation.HiddenRecord{ 209 + ATURI: report.SubjectURI, 210 + HiddenAt: time.Now(), 211 + HiddenBy: "automod", 212 + Reason: autoHideReason, 213 + AutoHidden: true, 214 + } 215 + 216 + if err := h.moderationStore.HideRecord(ctx, hiddenRecord); err != nil { 217 + log.Error().Err(err).Str("uri", report.SubjectURI).Msg("moderation: automod failed to hide record") 218 + return 219 + } 220 + 221 + // Log the automod action 222 + auditEntry := moderation.AuditEntry{ 223 + ID: generateTID(), 224 + Action: moderation.AuditActionHideRecord, 225 + ActorDID: "automod", 226 + TargetURI: report.SubjectURI, 227 + Reason: autoHideReason, 228 + Timestamp: time.Now(), 229 + AutoMod: true, 230 + } 231 + 232 + if err := h.moderationStore.LogAction(ctx, auditEntry); err != nil { 233 + log.Error().Err(err).Msg("moderation: failed to log automod action") 234 + } 235 + 236 + log.Warn(). 237 + Str("uri", report.SubjectURI). 238 + Str("did", report.SubjectDID). 239 + Int("uri_reports", uriReportCount). 240 + Int("did_reports", didReportCount). 241 + Str("reason", autoHideReason). 242 + Msg("moderation: automod triggered - record hidden") 243 + } 244 + } 245 + 246 + // writeReportError writes a JSON error response for report endpoints 247 + func writeReportError(w http.ResponseWriter, message string, status int) { 248 + w.Header().Set("Content-Type", "application/json") 249 + w.WriteHeader(status) 250 + json.NewEncoder(w).Encode(ReportResponse{ 251 + Status: "error", 252 + Message: message, 253 + }) 254 + }