···3636{"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"}
3737{"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"}
3838{"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"}
3939-{"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":"claimed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-01T02:10:19.29363426Z","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"}]}
3939+{"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"}]}
4040{"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"}
4141-{"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":"open","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-02T01:00:41.510646368Z"}
4141+{"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"}
4242{"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"}
4343{"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"}
4444{"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"}
···5050{"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"}
5151{"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"}]}
5252{"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"}
5353-\\\\\\\ to: kxrlzxyu 98a353d2 "fix: action menu divider fix" (rebased revision)
5454- {"id":"01KGK913NFHX2MMAXHCXT5S8HM","title":"Implement moderation service core with role-based permissions","description":"Create the core moderation service with JSON-based role configuration.\n\n## Requirements\n\n### JSON Config File (config/moderators.json)\n```json\n{\n \"roles\": {\n \"admin\": {\n \"description\": \"Full platform control\",\n \"permissions\": [\"hide_record\", \"unhide_record\", \"blacklist_user\", \"unblacklist_user\", \"view_reports\", \"dismiss_report\", \"view_audit_log\"]\n },\n \"moderator\": {\n \"description\": \"Content moderation\",\n \"permissions\": [\"hide_record\", \"unhide_record\", \"view_reports\", \"dismiss_report\"]\n }\n },\n \"users\": [\n {\"did\": \"did:plc:xxx\", \"handle\": \"arabica.social\", \"role\": \"admin\", \"note\": \"Platform owner\"}\n ]\n}\n```\n\n### ModerationService (internal/moderation/service.go)\n- Load config from JSON file (path from env var or flag)\n- `IsAdmin(did string) bool`\n- `IsModerator(did string) bool` (includes admins)\n- `HasPermission(did string, permission string) bool`\n- `GetRole(did string) (Role, bool)`\n- `ListModerators() []ModeratorUser`\n\n### Models (internal/moderation/models.go)\n- Role struct with permissions\n- ModeratorUser struct\n- Config struct\n\nAcceptance criteria:\n- Config file is validated on load (unknown roles, missing required fields)\n- Service is nil-safe (returns false/empty if not configured)\n- Reload capability for config changes without restart (nice to have)\n- Unit tests for permission checking","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend"],"created_at":"2026-02-04T02:52:13.871235963Z","updated_at":"2026-02-04T03:07:38.338966523Z","completed_at":"2026-02-04T03:07:38.323669324Z","notes":[{"timestamp":"2026-02-04T03:07:38.24039103Z","author":"patrick","message":"Implemented core moderation service:\n- Created internal/moderation/models.go with Role, Permission, ModeratorUser, and Config types\n- Created internal/moderation/service.go with role-based permission checking\n- Added 17 unit tests covering all functionality\n- Created config/moderators.json.example with example configuration\n- Updated CLAUDE.md with new env var ARABICA_MODERATORS_CONFIG and project structure"}]}
5555- {"id":"01KGK913QKZ96RKRH37VB99C7D","title":"Implement moderation storage in BoltDB","description":"Add BoltDB buckets and operations for moderation data.\n\n## Storage Requirements\n\n### Buckets\n- `moderation_hidden_records` - AT-URIs of hidden records\n- `moderation_blacklisted_users` - Blacklisted DIDs with metadata\n- `moderation_reports` - User reports on content\n- `moderation_audit_log` - All moderation actions\n\n### Models (internal/moderation/models.go additions)\n\n```go\ntype HiddenRecord struct {\n ATURI string `json:\"at_uri\"`\n HiddenAt time.Time `json:\"hidden_at\"`\n HiddenBy string `json:\"hidden_by\"` // DID of moderator\n Reason string `json:\"reason\"`\n AutoHidden bool `json:\"auto_hidden\"` // true if automod\n}\n\ntype BlacklistedUser struct {\n DID string `json:\"did\"`\n BlacklistedAt time.Time `json:\"blacklisted_at\"`\n BlacklistedBy string `json:\"blacklisted_by\"`\n Reason string `json:\"reason\"`\n}\n\ntype Report struct {\n ID string `json:\"id\"` // TID\n SubjectURI string `json:\"subject_uri\"` // AT-URI of reported content\n SubjectDID string `json:\"subject_did\"` // DID of content owner\n ReporterDID string `json:\"reporter_did\"`\n Reason string `json:\"reason\"`\n CreatedAt time.Time `json:\"created_at\"`\n Status string `json:\"status\"` // pending, dismissed, actioned\n ResolvedBy string `json:\"resolved_by\"`\n ResolvedAt *time.Time `json:\"resolved_at\"`\n}\n\ntype AuditEntry struct {\n ID string `json:\"id\"`\n Action string `json:\"action\"` // hide_record, blacklist_user, etc.\n ActorDID string `json:\"actor_did\"`\n TargetURI string `json:\"target_uri\"` // AT-URI or DID\n Reason string `json:\"reason\"`\n Timestamp time.Time `json:\"timestamp\"`\n AutoMod bool `json:\"auto_mod\"`\n}\n```\n\n### Store Interface (internal/moderation/store.go)\n```go\ntype Store interface {\n // Hidden records\n HideRecord(ctx context.Context, entry HiddenRecord) error\n UnhideRecord(ctx context.Context, atURI string) error\n IsRecordHidden(ctx context.Context, atURI string) bool\n ListHiddenRecords(ctx context.Context) ([]HiddenRecord, error)\n \n // Blacklist\n BlacklistUser(ctx context.Context, entry BlacklistedUser) error\n UnblacklistUser(ctx context.Context, did string) error\n IsBlacklisted(ctx context.Context, did string) bool\n ListBlacklistedUsers(ctx context.Context) ([]BlacklistedUser, error)\n \n // Reports\n CreateReport(ctx context.Context, report Report) error\n GetReport(ctx context.Context, id string) (*Report, error)\n ListPendingReports(ctx context.Context) ([]Report, error)\n ResolveReport(ctx context.Context, id string, status string, resolvedBy string) error\n CountReportsForURI(ctx context.Context, atURI string) (int, error)\n CountReportsForDID(ctx context.Context, did string) (int, error)\n \n // Audit\n LogAction(ctx context.Context, entry AuditEntry) error\n ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error)\n}\n```\n\nAcceptance criteria:\n- All operations are atomic\n- Audit logging happens automatically on moderation actions\n- Efficient lookups for IsRecordHidden and IsBlacklisted (called on every feed item)\n- Unit tests for store operations","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend","database"],"created_at":"2026-02-04T02:52:13.939164045Z","updated_at":"2026-02-04T03:12:29.407349995Z","completed_at":"2026-02-04T03:12:29.395926199Z","notes":[{"timestamp":"2026-02-04T03:12:29.301269019Z","author":"patrick","message":"Implemented BoltDB storage for moderation:\n- Added 6 new buckets for moderation data in store.go\n- Created moderation_store.go with full CRUD operations:\n - Hidden records: HideRecord, UnhideRecord, IsRecordHidden, GetHiddenRecord, ListHiddenRecords\n - Blacklist: BlacklistUser, UnblacklistUser, IsBlacklisted, GetBlacklistedUser, ListBlacklistedUsers\n - Reports: CreateReport, GetReport, ListPendingReports, ResolveReport, CountReportsForURI/DID, HasReportedURI\n - Audit: LogAction, ListAuditLog\n- Added indexes for efficient report counting by URI and DID\n- Created comprehensive tests (20 test cases)"}]}
5656- {"id":"01KGK91R3NWQEPPSSK9035JFZQ","title":"Integrate moderation filtering into feed service","description":"Hook moderation service into feed aggregation to filter hidden content and blacklisted users.\n\n## Requirements\n\n### Feed Service Changes (internal/feed/service.go)\n- Accept ModerationService dependency\n- Filter out hidden records before returning feed items\n- Filter out all records from blacklisted users\n- Consider performance: batch lookups vs per-item checks\n\n### Integration Points\n```go\ntype FeedService struct {\n // existing fields...\n moderation *moderation.Service\n}\n\nfunc (f *FeedService) GetFeed(ctx context.Context) ([]FeedItem, error) {\n items := f.getRawFeed(ctx)\n return f.filterModerated(ctx, items), nil\n}\n\nfunc (f *FeedService) filterModerated(ctx context.Context, items []FeedItem) []FeedItem {\n var filtered []FeedItem\n for _, item := range items {\n if f.moderation.IsBlacklisted(ctx, item.AuthorDID) {\n continue\n }\n if f.moderation.IsRecordHidden(ctx, item.ATURI) {\n continue\n }\n filtered = append(filtered, item)\n }\n return filtered\n}\n```\n\n### Public Brew View\n- Also filter on individual brew view endpoint (/brews/{did}/{rkey})\n- Return 404 for hidden records (or 451 Unavailable For Legal Reasons?)\n- Return 404 for blacklisted users' content\n\n### Handler Updates (internal/handlers/handlers.go)\n- Inject moderation service\n- Check moderation status on brew view routes\n\nAcceptance criteria:\n- Hidden records don't appear in feed\n- Blacklisted users' content doesn't appear anywhere\n- Minimal performance impact (consider caching blacklist/hidden sets)\n- Graceful degradation if moderation service unavailable","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend","feed"],"created_at":"2026-02-04T02:52:34.805956493Z","updated_at":"2026-02-04T12:45:54.18942141Z","completed_at":"2026-02-04T12:45:54.173769314Z"}
5757- {"id":"01KGK91R5RCE0B0PASS301ZZQT","title":"Implement user report submission system","description":"Allow authenticated users to report content with basic automod thresholds.\n\n## Requirements\n\n### Report Endpoint\n- POST /api/reports\n- Requires authentication\n- Rate limited (max 10 reports per user per hour)\n\n### Request/Response\n```go\ntype ReportRequest struct {\n SubjectURI string `json:\"subject_uri\"` // AT-URI of content\n Reason string `json:\"reason\"` // free text, max 500 chars\n}\n\ntype ReportResponse struct {\n ID string `json:\"id\"`\n Message string `json:\"message\"`\n}\n```\n\n### Automod Thresholds\nConfigurable via JSON config or constants:\n```go\nconst (\n AutoHideThreshold = 3 // reports on single record\n AutoHideUserThreshold = 5 // total reports across user's records\n NewUserDays = 7 // users \u003c 7 days old\n NewUserAutoHideThreshold = 2 // lower threshold for new users\n)\n```\n\nWhen threshold reached:\n1. Auto-hide the record (mark AutoHidden: true)\n2. Create audit log entry with AutoMod: true\n3. Keep report in pending state for moderator review\n\n### UI Component\n- Report button on brew cards in feed\n- Report button on brew detail page\n- Simple modal: textarea for reason + submit\n- Confirmation message after submission\n\n### Handler (internal/handlers/moderation.go)\n```go\nfunc (h *Handler) HandleSubmitReport(w http.ResponseWriter, r *http.Request)\n```\n\nAcceptance criteria:\n- Users can report any content (not their own)\n- Duplicate reports from same user are rejected\n- Automod triggers when thresholds reached\n- Rate limiting prevents abuse\n- Success/error feedback to user","status":"open","priority":"high","labels":["moderation","backend","frontend"],"created_at":"2026-02-04T02:52:34.872474383Z","updated_at":"2026-02-04T03:12:29.413298406Z"}
5858- {"id":"01KGK926RAW8Q3SQHFH4KKKM3K","title":"Implement admin/moderator dashboard UI","description":"Create the admin dashboard for moderators and admins to take moderation actions.\n\n## Requirements\n\n### Routes\n- GET /admin - Dashboard (moderators + admins)\n- GET /admin/reports - Pending reports view\n- GET /admin/audit - Audit log (admins only)\n- POST /admin/hide - Hide a record\n- POST /admin/unhide - Unhide a record\n- POST /admin/blacklist - Blacklist a user (admins only)\n- POST /admin/unblacklist - Unblacklist a user (admins only)\n- POST /admin/reports/{id}/resolve - Resolve a report\n\n### Access Control\n- All /admin routes require authentication\n- Check HasPermission() for each action\n- Return 403 if insufficient permissions\n- Hide UI elements user can't access\n\n### Dashboard Page (internal/web/pages/admin.templ)\n```\n┌─────────────────────────────────────────────────────┐\n│ Moderation Dashboard [Your Role] │\n├─────────────────────────────────────────────────────┤\n│ │\n│ Pending Reports (3) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ @user reported brew by @other │ │\n│ │ \"Spam content\" [View] [Hide] [Dismiss]│ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ Recently Hidden (auto-hidden marked) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ brew/abc by @user [AUTO] - 3 reports [Unhide] │ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ Blacklisted Users (admin only) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ @baduser - \"repeated spam\" [Unblacklist] │ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ [View Audit Log] (admin only) │\n└─────────────────────────────────────────────────────┘\n```\n\n### Components\n- AdminLayout component (reuses main layout but adds admin nav)\n- ReportCard component\n- HiddenRecordRow component\n- BlacklistedUserRow component\n- AuditLogTable component\n\n### HTMX Integration\n- Actions (hide/unhide/resolve) via HTMX POST\n- Optimistic UI updates\n- Error toast on failure\n\n### Handler (internal/handlers/admin.go)\nNew handler file for admin routes, or add to existing handlers.go\n\nAcceptance criteria:\n- Only accessible to moderators/admins\n- All actions require appropriate permissions\n- Actions create audit log entries\n- Responsive design\n- Confirmation dialogs for destructive actions (blacklist)","status":"completed","priority":"high","assignee":"patrick","blocked_by":["01KGK91R3NWQEPPSSK9035JFZQ","01KGK91R5RCE0B0PASS301ZZQT"],"labels":["moderation","frontend","backend"],"created_at":"2026-02-04T02:52:49.80269115Z","updated_at":"2026-02-05T02:05:48.756110333Z","completed_at":"2026-02-05T02:05:48.744596981Z"}
5959- {"id":"01KGMAQBRS5J9N0JHBJAAZ518Q","title":"Add moderator actions to feed item more menu","description":"Add moderation actions (hide/unhide) to the existing 'more' dropdown menu on feed items for users with appropriate permissions.\n\n## Requirements\n\n### UI Changes\n- Extend the existing 'more' menu (three dots) on feed/brew cards\n- Show moderation actions only if user has permission\n- Actions to add:\n - 'Hide from feed' (if not hidden, requires hide_record permission)\n - 'Unhide' (if hidden, requires unhide_record permission)\n - Visual indicator if record is currently hidden (for mods viewing hidden content)\n\n### Backend Changes\n- Pass moderation context to feed/brew templates:\n - `IsModerator bool`\n - `CanHideRecord bool`\n - `IsRecordHidden bool` (per item)\n- Add HTMX endpoints:\n - POST /admin/hide (accepts AT-URI, returns updated menu)\n - POST /admin/unhide (accepts AT-URI, returns updated menu)\n\n### Integration Points\n- Feed page (feed.templ) - brew cards in community feed\n- Profile page (profile.templ) - brew cards on user profiles\n- Brew view page (brew_view.templ) - single brew detail view\n\n### Behavior\n- Actions trigger HTMX POST requests\n- On success: swap the menu to show updated state\n- On error: show toast notification\n- Create audit log entry for each action\n\n### Permissions Check\n- Check ModerationService.HasPermission() before rendering actions\n- Check again server-side before executing action\n- Return 403 if permission denied\n\nAcceptance criteria:\n- Moderators see hide/unhide options in more menu\n- Regular users don't see moderation options\n- Actions work via HTMX without full page reload\n- Audit log entries created for all actions\n- Hidden records show visual indicator to moderators","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","frontend","backend"],"created_at":"2026-02-04T12:41:06.073520449Z","updated_at":"2026-02-04T13:06:16.292634304Z","completed_at":"2026-02-04T13:06:16.279224398Z"}
6060-+{"id":"01KGZQ1Q1CSCBNXY9AS6GQJK2B","title":"Fix more actions menu z-index on feed cards","description":"The action bar border-t divider line visually paints on top of the popup menu instead of behind it. Root cause: .action-menu has z-10 while .dropdown-menu has z-50, and .feed-card animation creates a new stacking context.\n\nFix:\n- Bump .action-menu z-index from z-10 to z-50 (matching .dropdown-menu) in static/css/app.css\n- Add isolation: isolate or relative z-index to action-bar wrapper if needed\n- Remove the two // FIX: comments in internal/web/components/action_bar.templ\n- Regenerate templ code\n- Rebuild CSS with Tailwind\n\nKey files:\n- static/css/app.css:309-311 — .action-menu definition\n- internal/web/components/action_bar.templ:92-93 — FIX comments\n- static/css/app.css:410-411 — .feed-card animation\n\nVerify on homepage feed and profile brews tab.","status":"open","priority":"normal","created_at":"2026-02-08T22:48:06.956664383Z","updated_at":"2026-02-08T22:48:06.956664383Z"}
+1-1
.gitignore
···50505151# Development files
5252known-dids.txt
5353-5353+moderators.json
5454roles.json
+22-1
BACKLOG.md
···2323## Far Future Considerations
24242525- Maybe swap from boltdb to sqlite
2626- - Use the non-cgo library
2626+ - Use the non-cgo library?
2727+ - Is there a compelling reason to do this?
2828+ - Might be good as a sort of witness-cache type thing (record refs to avoid hitting PDS's as often?)
2929+ - Probably not worth unless we keep a copy of all (or all recent) network data
3030+3131+- The profile, manage, and brews list pages all function in a similar fashion,
3232+ should one or more of them be consolidated?
3333+ - Manage + brews list together probably makes sense
3434+3535+- IMPORTANT: If this platform gains any traction, we will need some form of content moderation
3636+ - Due to the nature of arabica, this will only really need to be text based (text and hyperlinks)
3737+ - Malicious link scanning may be reasonable, not sure about deeper text analysis
3838+ - Need to do more research into security
3939+ - Need admin tooling at the app level that will allow deleting records (may not be possible),
4040+ removing from appview, blacklisting users (and maybe IPs?), possibly more
4141+ - Having accounts with admin rights may be an approach to this (configured with flags at startup time?)
4242+ @arabica.social, @pdewey.com, maybe others? (need trusted users in other time zones probably)
4343+ - Add on some piece to the TOS that mentions I reserve the right to de-list content from the platform
4444+ - Continue limiting firehose posts to users who have been previously authenticated (keep a permanent record of "trusted" users)
4545+ - By logging in users agree to TOS -- can create records to be displayed on the appview ("signal" records)
4646+ Attestation signature from appview (or pds -- use key from pds) was source of record being created
4747+ - This is a pretty important consideration going forward, lots to consider
27482849## Fixes
2950
+27
CLAUDE.md
···42424343All work items are tracked as cells. When starting new work, check for existing cells first.
44444545+## Workflow Rules
4646+4747+Do NOT spend more than 2-3 minutes exploring/reading files before beginning implementation. If the task is clear, start writing code immediately. Ask clarifying questions rather than endlessly reading the codebase. When given a specific implementation task, produce code changes in the same session.
4848+4949+## Dependencies
5050+5151+When implementing features, prefer standard library solutions over external dependencies. Only add a third-party dependency if the standard library genuinely cannot handle the requirement. For Go: check stdlib first (e.g., os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm packages.
5252+5353+## Task Agents
5454+5555+When spawning task agents, set a hard limit of 3 agents maximum. Each agent must have a clearly scoped deliverable and file output path. Do not poll agents in a loop—instead, give each agent its full instructions upfront and collect results at the end. If agents aren't producing results within 5 minutes, fall back to doing the work directly.
5656+5757+## Testing & Verification
5858+5959+For Go projects: always run `go vet ./...` and `go build ./...` after making changes. For JavaScript/CSS projects: verify template field names match backend struct fields before considering a task complete. Always test form submissions to verify content-type handling (JSON vs form-encoded).
6060+6161+### Using Go Tooling Effectively
6262+6363+- To see source files from a dependency, or to answer questions about a dependency, run `go mod download -json MODULE` and use the returned `Dir` path to read the files.
6464+- Use `go doc foo.Bar` or `go doc -all foo` to read documentation for packages, types, functions, etc.
6565+- Use `go run .` or `go run ./cmd/foo` instead of `go build` to run programs, to avoid leaving behind build artifacts.
6666+4567## Tech Stack
46684769- **Language:** Go 1.21+
···98120 feed/
99121 service.go # Community feed aggregation
100122 registry.go # User registration for feed
123123+ moderation/
124124+ models.go # Moderation types (roles, permissions, reports)
125125+ service.go # Role-based moderation service
101126 models/
102127 models.go # Domain models and request types
103128 middleware/
···105130 routing/
106131 routing.go # Router setup and middleware chain
107132lexicons/ # AT Protocol lexicon definitions (JSON)
133133+config/ # Configuration files (moderators.json.example)
108134static/ # CSS, JS, manifest
109135```
110136···453479| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy |
454480| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
455481| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
482482+| `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config |
456483| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
457484| `SECURE_COOKIES` | false | Set true for HTTPS |
458485| `LOG_LEVEL` | info | debug/info/warn/error |
+14
cmd/server/main.go
···1818 "arabica/internal/feed"
1919 "arabica/internal/firehose"
2020 "arabica/internal/handlers"
2121+ "arabica/internal/moderation"
2122 "arabica/internal/routing"
22232324 "github.com/rs/zerolog"
···184185 adapter := firehose.NewFeedIndexAdapter(feedIndex)
185186 feedService.SetFirehoseIndex(adapter)
186187188188+ // Wire up moderation filtering for the feed
189189+ moderationStore := store.ModerationStore()
190190+ feedService.SetModerationFilter(moderationStore)
191191+187192 log.Info().Msg("Firehose consumer started")
188193189194 // Log known DIDs from database (DIDs discovered via firehose)
···295300296301 // Wire up the feed index for like functionality
297302 h.SetFeedIndex(feedIndex)
303303+304304+ // Initialize moderation service and wire up to handler
305305+ moderatorsConfigPath := os.Getenv("ARABICA_MODERATORS_CONFIG")
306306+ moderationSvc, err := moderation.NewService(moderatorsConfigPath)
307307+ if err != nil {
308308+ log.Warn().Err(err).Msg("Failed to initialize moderation service, moderation disabled")
309309+ } else {
310310+ h.SetModeration(moderationSvc, moderationStore)
311311+ }
298312299313 // Setup router with middleware
300314 handler := routing.SetupRouter(routing.Config{
+510
internal/database/boltstore/moderation_store.go
···11+package boltstore
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "time"
88+99+ "arabica/internal/moderation"
1010+1111+ bolt "go.etcd.io/bbolt"
1212+)
1313+1414+// ModerationStore provides persistent storage for moderation data.
1515+type ModerationStore struct {
1616+ db *bolt.DB
1717+}
1818+1919+// HideRecord stores a hidden record entry.
2020+func (s *ModerationStore) HideRecord(ctx context.Context, entry moderation.HiddenRecord) error {
2121+ return s.db.Update(func(tx *bolt.Tx) error {
2222+ bucket := tx.Bucket(BucketModerationHiddenRecords)
2323+ if bucket == nil {
2424+ return fmt.Errorf("bucket not found: %s", BucketModerationHiddenRecords)
2525+ }
2626+2727+ data, err := json.Marshal(entry)
2828+ if err != nil {
2929+ return fmt.Errorf("failed to marshal hidden record: %w", err)
3030+ }
3131+3232+ return bucket.Put([]byte(entry.ATURI), data)
3333+ })
3434+}
3535+3636+// UnhideRecord removes a record from the hidden list.
3737+func (s *ModerationStore) UnhideRecord(ctx context.Context, atURI string) error {
3838+ return s.db.Update(func(tx *bolt.Tx) error {
3939+ bucket := tx.Bucket(BucketModerationHiddenRecords)
4040+ if bucket == nil {
4141+ return nil
4242+ }
4343+4444+ return bucket.Delete([]byte(atURI))
4545+ })
4646+}
4747+4848+// IsRecordHidden checks if a record is hidden.
4949+func (s *ModerationStore) IsRecordHidden(ctx context.Context, atURI string) bool {
5050+ var hidden bool
5151+5252+ s.db.View(func(tx *bolt.Tx) error {
5353+ bucket := tx.Bucket(BucketModerationHiddenRecords)
5454+ if bucket == nil {
5555+ return nil
5656+ }
5757+5858+ hidden = bucket.Get([]byte(atURI)) != nil
5959+ return nil
6060+ })
6161+6262+ return hidden
6363+}
6464+6565+// GetHiddenRecord retrieves a hidden record entry by AT-URI.
6666+func (s *ModerationStore) GetHiddenRecord(ctx context.Context, atURI string) (*moderation.HiddenRecord, error) {
6767+ var record *moderation.HiddenRecord
6868+6969+ err := s.db.View(func(tx *bolt.Tx) error {
7070+ bucket := tx.Bucket(BucketModerationHiddenRecords)
7171+ if bucket == nil {
7272+ return nil
7373+ }
7474+7575+ data := bucket.Get([]byte(atURI))
7676+ if data == nil {
7777+ return nil
7878+ }
7979+8080+ record = &moderation.HiddenRecord{}
8181+ return json.Unmarshal(data, record)
8282+ })
8383+8484+ return record, err
8585+}
8686+8787+// ListHiddenRecords returns all hidden records.
8888+func (s *ModerationStore) ListHiddenRecords(ctx context.Context) ([]moderation.HiddenRecord, error) {
8989+ var records []moderation.HiddenRecord
9090+9191+ err := s.db.View(func(tx *bolt.Tx) error {
9292+ bucket := tx.Bucket(BucketModerationHiddenRecords)
9393+ if bucket == nil {
9494+ return nil
9595+ }
9696+9797+ return bucket.ForEach(func(k, v []byte) error {
9898+ var record moderation.HiddenRecord
9999+ if err := json.Unmarshal(v, &record); err != nil {
100100+ return err
101101+ }
102102+ records = append(records, record)
103103+ return nil
104104+ })
105105+ })
106106+107107+ return records, err
108108+}
109109+110110+// BlacklistUser adds a user to the blacklist.
111111+func (s *ModerationStore) BlacklistUser(ctx context.Context, entry moderation.BlacklistedUser) error {
112112+ return s.db.Update(func(tx *bolt.Tx) error {
113113+ bucket := tx.Bucket(BucketModerationBlacklist)
114114+ if bucket == nil {
115115+ return fmt.Errorf("bucket not found: %s", BucketModerationBlacklist)
116116+ }
117117+118118+ data, err := json.Marshal(entry)
119119+ if err != nil {
120120+ return fmt.Errorf("failed to marshal blacklisted user: %w", err)
121121+ }
122122+123123+ return bucket.Put([]byte(entry.DID), data)
124124+ })
125125+}
126126+127127+// UnblacklistUser removes a user from the blacklist.
128128+func (s *ModerationStore) UnblacklistUser(ctx context.Context, did string) error {
129129+ return s.db.Update(func(tx *bolt.Tx) error {
130130+ bucket := tx.Bucket(BucketModerationBlacklist)
131131+ if bucket == nil {
132132+ return nil
133133+ }
134134+135135+ return bucket.Delete([]byte(did))
136136+ })
137137+}
138138+139139+// IsBlacklisted checks if a user is blacklisted.
140140+func (s *ModerationStore) IsBlacklisted(ctx context.Context, did string) bool {
141141+ var blacklisted bool
142142+143143+ s.db.View(func(tx *bolt.Tx) error {
144144+ bucket := tx.Bucket(BucketModerationBlacklist)
145145+ if bucket == nil {
146146+ return nil
147147+ }
148148+149149+ blacklisted = bucket.Get([]byte(did)) != nil
150150+ return nil
151151+ })
152152+153153+ return blacklisted
154154+}
155155+156156+// GetBlacklistedUser retrieves a blacklisted user entry by DID.
157157+func (s *ModerationStore) GetBlacklistedUser(ctx context.Context, did string) (*moderation.BlacklistedUser, error) {
158158+ var user *moderation.BlacklistedUser
159159+160160+ err := s.db.View(func(tx *bolt.Tx) error {
161161+ bucket := tx.Bucket(BucketModerationBlacklist)
162162+ if bucket == nil {
163163+ return nil
164164+ }
165165+166166+ data := bucket.Get([]byte(did))
167167+ if data == nil {
168168+ return nil
169169+ }
170170+171171+ user = &moderation.BlacklistedUser{}
172172+ return json.Unmarshal(data, user)
173173+ })
174174+175175+ return user, err
176176+}
177177+178178+// ListBlacklistedUsers returns all blacklisted users.
179179+func (s *ModerationStore) ListBlacklistedUsers(ctx context.Context) ([]moderation.BlacklistedUser, error) {
180180+ var users []moderation.BlacklistedUser
181181+182182+ err := s.db.View(func(tx *bolt.Tx) error {
183183+ bucket := tx.Bucket(BucketModerationBlacklist)
184184+ if bucket == nil {
185185+ return nil
186186+ }
187187+188188+ return bucket.ForEach(func(k, v []byte) error {
189189+ var user moderation.BlacklistedUser
190190+ if err := json.Unmarshal(v, &user); err != nil {
191191+ return err
192192+ }
193193+ users = append(users, user)
194194+ return nil
195195+ })
196196+ })
197197+198198+ return users, err
199199+}
200200+201201+// CreateReport stores a new report.
202202+func (s *ModerationStore) CreateReport(ctx context.Context, report moderation.Report) error {
203203+ return s.db.Update(func(tx *bolt.Tx) error {
204204+ // Store the report
205205+ bucket := tx.Bucket(BucketModerationReports)
206206+ if bucket == nil {
207207+ return fmt.Errorf("bucket not found: %s", BucketModerationReports)
208208+ }
209209+210210+ data, err := json.Marshal(report)
211211+ if err != nil {
212212+ return fmt.Errorf("failed to marshal report: %w", err)
213213+ }
214214+215215+ if err := bucket.Put([]byte(report.ID), data); err != nil {
216216+ return err
217217+ }
218218+219219+ // Index by subject URI
220220+ uriIndex := tx.Bucket(BucketModerationReportsByURI)
221221+ if uriIndex != nil {
222222+ // Store report ID in a list for this URI
223223+ key := []byte(report.SubjectURI + ":" + report.ID)
224224+ if err := uriIndex.Put(key, []byte(report.ID)); err != nil {
225225+ return err
226226+ }
227227+ }
228228+229229+ // Index by subject DID
230230+ didIndex := tx.Bucket(BucketModerationReportsByDID)
231231+ if didIndex != nil {
232232+ // Store report ID in a list for this DID
233233+ key := []byte(report.SubjectDID + ":" + report.ID)
234234+ if err := didIndex.Put(key, []byte(report.ID)); err != nil {
235235+ return err
236236+ }
237237+ }
238238+239239+ return nil
240240+ })
241241+}
242242+243243+// GetReport retrieves a report by ID.
244244+func (s *ModerationStore) GetReport(ctx context.Context, id string) (*moderation.Report, error) {
245245+ var report *moderation.Report
246246+247247+ err := s.db.View(func(tx *bolt.Tx) error {
248248+ bucket := tx.Bucket(BucketModerationReports)
249249+ if bucket == nil {
250250+ return nil
251251+ }
252252+253253+ data := bucket.Get([]byte(id))
254254+ if data == nil {
255255+ return nil
256256+ }
257257+258258+ report = &moderation.Report{}
259259+ return json.Unmarshal(data, report)
260260+ })
261261+262262+ return report, err
263263+}
264264+265265+// ListPendingReports returns all reports with pending status.
266266+func (s *ModerationStore) ListPendingReports(ctx context.Context) ([]moderation.Report, error) {
267267+ var reports []moderation.Report
268268+269269+ err := s.db.View(func(tx *bolt.Tx) error {
270270+ bucket := tx.Bucket(BucketModerationReports)
271271+ if bucket == nil {
272272+ return nil
273273+ }
274274+275275+ return bucket.ForEach(func(k, v []byte) error {
276276+ var report moderation.Report
277277+ if err := json.Unmarshal(v, &report); err != nil {
278278+ return err
279279+ }
280280+ if report.Status == moderation.ReportStatusPending {
281281+ reports = append(reports, report)
282282+ }
283283+ return nil
284284+ })
285285+ })
286286+287287+ return reports, err
288288+}
289289+290290+// ListAllReports returns all reports regardless of status.
291291+func (s *ModerationStore) ListAllReports(ctx context.Context) ([]moderation.Report, error) {
292292+ var reports []moderation.Report
293293+294294+ err := s.db.View(func(tx *bolt.Tx) error {
295295+ bucket := tx.Bucket(BucketModerationReports)
296296+ if bucket == nil {
297297+ return nil
298298+ }
299299+300300+ return bucket.ForEach(func(k, v []byte) error {
301301+ var report moderation.Report
302302+ if err := json.Unmarshal(v, &report); err != nil {
303303+ return err
304304+ }
305305+ reports = append(reports, report)
306306+ return nil
307307+ })
308308+ })
309309+310310+ return reports, err
311311+}
312312+313313+// ResolveReport updates a report's status and resolution info.
314314+func (s *ModerationStore) ResolveReport(ctx context.Context, id string, status moderation.ReportStatus, resolvedBy string) error {
315315+ return s.db.Update(func(tx *bolt.Tx) error {
316316+ bucket := tx.Bucket(BucketModerationReports)
317317+ if bucket == nil {
318318+ return fmt.Errorf("bucket not found: %s", BucketModerationReports)
319319+ }
320320+321321+ data := bucket.Get([]byte(id))
322322+ if data == nil {
323323+ return fmt.Errorf("report not found: %s", id)
324324+ }
325325+326326+ var report moderation.Report
327327+ if err := json.Unmarshal(data, &report); err != nil {
328328+ return err
329329+ }
330330+331331+ report.Status = status
332332+ report.ResolvedBy = resolvedBy
333333+ now := time.Now()
334334+ report.ResolvedAt = &now
335335+336336+ newData, err := json.Marshal(report)
337337+ if err != nil {
338338+ return err
339339+ }
340340+341341+ return bucket.Put([]byte(id), newData)
342342+ })
343343+}
344344+345345+// CountReportsForURI returns the number of reports for a given AT-URI.
346346+func (s *ModerationStore) CountReportsForURI(ctx context.Context, atURI string) (int, error) {
347347+ var count int
348348+349349+ err := s.db.View(func(tx *bolt.Tx) error {
350350+ bucket := tx.Bucket(BucketModerationReportsByURI)
351351+ if bucket == nil {
352352+ return nil
353353+ }
354354+355355+ cursor := bucket.Cursor()
356356+ prefix := []byte(atURI + ":")
357357+358358+ for k, _ := cursor.Seek(prefix); k != nil && hasPrefix(k, prefix); k, _ = cursor.Next() {
359359+ count++
360360+ }
361361+362362+ return nil
363363+ })
364364+365365+ return count, err
366366+}
367367+368368+// CountReportsForDID returns the number of reports for content by a given DID.
369369+func (s *ModerationStore) CountReportsForDID(ctx context.Context, did string) (int, error) {
370370+ var count int
371371+372372+ err := s.db.View(func(tx *bolt.Tx) error {
373373+ bucket := tx.Bucket(BucketModerationReportsByDID)
374374+ if bucket == nil {
375375+ return nil
376376+ }
377377+378378+ cursor := bucket.Cursor()
379379+ prefix := []byte(did + ":")
380380+381381+ for k, _ := cursor.Seek(prefix); k != nil && hasPrefix(k, prefix); k, _ = cursor.Next() {
382382+ count++
383383+ }
384384+385385+ return nil
386386+ })
387387+388388+ return count, err
389389+}
390390+391391+// HasReportedURI checks if a user has already reported a specific URI.
392392+func (s *ModerationStore) HasReportedURI(ctx context.Context, reporterDID, subjectURI string) (bool, error) {
393393+ var found bool
394394+395395+ err := s.db.View(func(tx *bolt.Tx) error {
396396+ bucket := tx.Bucket(BucketModerationReports)
397397+ if bucket == nil {
398398+ return nil
399399+ }
400400+401401+ return bucket.ForEach(func(k, v []byte) error {
402402+ var report moderation.Report
403403+ if err := json.Unmarshal(v, &report); err != nil {
404404+ return nil // Skip malformed entries
405405+ }
406406+ if report.ReporterDID == reporterDID && report.SubjectURI == subjectURI {
407407+ found = true
408408+ }
409409+ return nil
410410+ })
411411+ })
412412+413413+ return found, err
414414+}
415415+416416+// LogAction stores a moderation action in the audit log.
417417+func (s *ModerationStore) LogAction(ctx context.Context, entry moderation.AuditEntry) error {
418418+ return s.db.Update(func(tx *bolt.Tx) error {
419419+ bucket := tx.Bucket(BucketModerationAuditLog)
420420+ if bucket == nil {
421421+ return fmt.Errorf("bucket not found: %s", BucketModerationAuditLog)
422422+ }
423423+424424+ data, err := json.Marshal(entry)
425425+ if err != nil {
426426+ return fmt.Errorf("failed to marshal audit entry: %w", err)
427427+ }
428428+429429+ // Use timestamp-based key for chronological ordering
430430+ // Format: timestamp:id for uniqueness
431431+ key := fmt.Sprintf("%d:%s", entry.Timestamp.UnixNano(), entry.ID)
432432+433433+ return bucket.Put([]byte(key), data)
434434+ })
435435+}
436436+437437+// ListAuditLog returns the most recent audit log entries.
438438+// Entries are returned in reverse chronological order (newest first).
439439+func (s *ModerationStore) ListAuditLog(ctx context.Context, limit int) ([]moderation.AuditEntry, error) {
440440+ var entries []moderation.AuditEntry
441441+442442+ err := s.db.View(func(tx *bolt.Tx) error {
443443+ bucket := tx.Bucket(BucketModerationAuditLog)
444444+ if bucket == nil {
445445+ return nil
446446+ }
447447+448448+ // Collect all entries first (BoltDB cursors iterate in key order)
449449+ var all []moderation.AuditEntry
450450+ err := bucket.ForEach(func(k, v []byte) error {
451451+ var entry moderation.AuditEntry
452452+ if err := json.Unmarshal(v, &entry); err != nil {
453453+ return nil // Skip malformed entries
454454+ }
455455+ all = append(all, entry)
456456+ return nil
457457+ })
458458+ if err != nil {
459459+ return err
460460+ }
461461+462462+ // Reverse to get newest first
463463+ for i := len(all) - 1; i >= 0 && len(entries) < limit; i-- {
464464+ entries = append(entries, all[i])
465465+ }
466466+467467+ return nil
468468+ })
469469+470470+ return entries, err
471471+}
472472+473473+// CountReportsFromUserSince counts reports submitted by a user since a given time.
474474+// Used for rate limiting report submissions.
475475+func (s *ModerationStore) CountReportsFromUserSince(ctx context.Context, reporterDID string, since time.Time) (int, error) {
476476+ var count int
477477+478478+ err := s.db.View(func(tx *bolt.Tx) error {
479479+ bucket := tx.Bucket(BucketModerationReports)
480480+ if bucket == nil {
481481+ return nil
482482+ }
483483+484484+ return bucket.ForEach(func(k, v []byte) error {
485485+ var report moderation.Report
486486+ if err := json.Unmarshal(v, &report); err != nil {
487487+ return nil // Skip malformed entries
488488+ }
489489+ if report.ReporterDID == reporterDID && report.CreatedAt.After(since) {
490490+ count++
491491+ }
492492+ return nil
493493+ })
494494+ })
495495+496496+ return count, err
497497+}
498498+499499+// hasPrefix checks if a byte slice has a given prefix.
500500+func hasPrefix(s, prefix []byte) bool {
501501+ if len(s) < len(prefix) {
502502+ return false
503503+ }
504504+ for i, b := range prefix {
505505+ if s[i] != b {
506506+ return false
507507+ }
508508+ }
509509+ return true
510510+}