···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"}
4545-{"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":"open","priority":"high","labels":["frontend","design","ux"],"created_at":"2026-02-03T01:29:39.841071728Z","updated_at":"2026-02-03T01:29:39.841071728Z"}
4545+{"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)"}]}
4646+{"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"}
4747+{"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"}
4848+{"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"}
4949+{"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"}
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"}
+3
internal/feed/service.go
···4848 SubjectURI string // AT-URI of this record (for like button)
4949 SubjectCID string // CID of this record (for like button)
5050 IsLikedByViewer bool // Whether the current viewer has liked this record
5151+5252+ // Ownership
5353+ IsOwner bool // Whether the current viewer owns this record
5154}
52555356// publicFeedCache holds cached feed items for unauthenticated users
+121-19
internal/handlers/handlers.go
···88 "sort"
99 "strconv"
1010 "strings"
1111+ "time"
11121213 "arabica/internal/atproto"
1314 "arabica/internal/database"
···422423 }
423424 }
424425425425- // Populate IsLikedByViewer for each feed item if user is authenticated
426426- if isAuthenticated && h.feedIndex != nil {
426426+ // Populate IsLikedByViewer and IsOwner for each feed item if user is authenticated
427427+ if isAuthenticated {
427428 for _, item := range feedItems {
428428- if item.SubjectURI != "" {
429429+ // Check if viewer owns this record
430430+ if item.Author != nil {
431431+ item.IsOwner = item.Author.DID == viewerDID
432432+ }
433433+ // Check if viewer liked this record
434434+ if h.feedIndex != nil && item.SubjectURI != "" {
429435 item.IsLikedByViewer = h.feedIndex.HasUserLiked(viewerDID, item.SubjectURI)
430436 }
431437 }
···19621968 isAuthenticated := err == nil && didStr != ""
19631969 isOwnProfile := isAuthenticated && didStr == did
1964197019651965- // Render profile content partial (use actor as handle, which is already the handle if provided as such)
19711971+ // Get profile for card rendering
19721972+ profile, err := publicClient.GetProfile(ctx, did)
19731973+ if err != nil {
19741974+ log.Warn().Err(err).Str("did", did).Msg("Failed to fetch profile for profile partial")
19751975+ // Continue without profile - cards will show limited info
19761976+ }
19771977+19781978+ // Use handle from profile or fallback
19661979 profileHandle := actor
19671967- if strings.HasPrefix(actor, "did:") {
19681968- // If actor was a DID, we need to resolve it to a handle
19691969- // We can get it from the first brew's author if available, or fetch profile
19701970- profile, err := publicClient.GetProfile(ctx, did)
19711971- if err == nil {
19721972- profileHandle = profile.Handle
19731973- } else {
19741974- profileHandle = did // Fallback to DID if we can't get handle
19801980+ if profile != nil {
19811981+ profileHandle = profile.Handle
19821982+ } else if strings.HasPrefix(actor, "did:") {
19831983+ profileHandle = did // Fallback to DID if we can't get handle
19841984+ }
19851985+19861986+ // Get like counts and CIDs for brews from firehose index
19871987+ brewLikeCounts := make(map[string]int)
19881988+ brewLikedByUser := make(map[string]bool)
19891989+ brewCIDs := make(map[string]string)
19901990+ if h.feedIndex != nil && profile != nil {
19911991+ for _, brew := range profileData.Brews {
19921992+ subjectURI := atproto.BuildATURI(profile.DID, atproto.NSIDBrew, brew.RKey)
19931993+ brewLikeCounts[brew.RKey] = h.feedIndex.GetLikeCount(subjectURI)
19941994+ if isAuthenticated {
19951995+ brewLikedByUser[brew.RKey] = h.feedIndex.HasUserLiked(didStr, subjectURI)
19961996+ }
19971997+ // Get CID from the firehose index record
19981998+ if record, err := h.feedIndex.GetRecord(subjectURI); err == nil && record != nil {
19991999+ brewCIDs[brew.RKey] = record.CID
20002000+ }
19752001 }
19762002 }
1977200319782004 if err := components.ProfileContentPartial(components.ProfileContentPartialProps{
19791979- Brews: profileData.Brews,
19801980- Beans: profileData.Beans,
19811981- Roasters: profileData.Roasters,
19821982- Grinders: profileData.Grinders,
19831983- Brewers: profileData.Brewers,
19841984- IsOwnProfile: isOwnProfile,
19851985- ProfileHandle: profileHandle,
20052005+ Brews: profileData.Brews,
20062006+ Beans: profileData.Beans,
20072007+ Roasters: profileData.Roasters,
20082008+ Grinders: profileData.Grinders,
20092009+ Brewers: profileData.Brewers,
20102010+ IsOwnProfile: isOwnProfile,
20112011+ ProfileHandle: profileHandle,
20122012+ Profile: profile,
20132013+ BrewLikeCounts: brewLikeCounts,
20142014+ BrewLikedByUser: brewLikedByUser,
20152015+ BrewCIDs: brewCIDs,
20162016+ IsAuthenticated: isAuthenticated,
19862017 }).Render(r.Context(), w); err != nil {
19872018 http.Error(w, "Failed to render content", http.StatusInternalServerError)
19882019 log.Error().Err(err).Msg("Failed to render profile partial")
···22082239 log.Error().Err(err).Msg("Failed to render 404 page")
22092240 }
22102241}
22422242+22432243+// HandleReport handles content report submissions
22442244+//
22452245+// TODO: Implement actual moderation system:
22462246+// - Store reports in database (BoltDB bucket or SQLite table)
22472247+// - Add admin interface to review reports
22482248+// - Implement report status workflow (pending -> reviewed -> dismissed/actioned)
22492249+//
22502250+// TODO: Reports should be rate limited more strictly by IP than other requests.
22512251+// Consider implementing a separate, stricter rate limit for this endpoint
22522252+// (e.g., 5 reports per hour per IP) to prevent abuse and report flooding.
22532253+func (h *Handler) HandleReport(w http.ResponseWriter, r *http.Request) {
22542254+ if err := r.ParseForm(); err != nil {
22552255+ http.Error(w, "Invalid form data", http.StatusBadRequest)
22562256+ return
22572257+ }
22582258+22592259+ subjectURI := r.FormValue("subject_uri")
22602260+ subjectCID := r.FormValue("subject_cid")
22612261+ reason := r.FormValue("reason")
22622262+22632263+ if subjectURI == "" {
22642264+ http.Error(w, "subject_uri is required", http.StatusBadRequest)
22652265+ return
22662266+ }
22672267+22682268+ // Validate reason
22692269+ validReasons := map[string]bool{"spam": true, "inappropriate": true, "other": true}
22702270+ if reason == "" || !validReasons[reason] {
22712271+ reason = "other"
22722272+ }
22732273+22742274+ // Get reporter info if authenticated
22752275+ reporterDID := "anonymous"
22762276+ if didStr, err := atproto.GetAuthenticatedDID(r.Context()); err == nil && didStr != "" {
22772277+ reporterDID = didStr
22782278+ }
22792279+22802280+ // Get reporter IP for rate limiting tracking
22812281+ reporterIP := r.RemoteAddr
22822282+ if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
22832283+ // Use first IP in chain (original client)
22842284+ reporterIP = strings.Split(forwarded, ",")[0]
22852285+ }
22862286+22872287+ // Create report record (not persisted yet - just for structured logging)
22882288+ report := &models.Report{
22892289+ SubjectURI: subjectURI,
22902290+ SubjectCID: subjectCID,
22912291+ Reason: reason,
22922292+ ReporterDID: reporterDID,
22932293+ ReporterIP: reporterIP,
22942294+ CreatedAt: time.Now(),
22952295+ Status: "pending",
22962296+ }
22972297+22982298+ // TODO: Persist report to database
22992299+ // For now, log the report for manual review
23002300+ log.Info().
23012301+ Str("subject_uri", report.SubjectURI).
23022302+ Str("subject_cid", report.SubjectCID).
23032303+ Str("reason", report.Reason).
23042304+ Str("reporter_did", report.ReporterDID).
23052305+ Str("reporter_ip", report.ReporterIP).
23062306+ Time("created_at", report.CreatedAt).
23072307+ Msg("Content report received")
23082308+23092309+ w.Header().Set("Content-Type", "application/json")
23102310+ w.WriteHeader(http.StatusOK)
23112311+ w.Write([]byte(`{"status": "received"}`))
23122312+}
+13
internal/models/models.go
···350350 }
351351 return nil
352352}
353353+354354+// Report represents a user-submitted content report
355355+// TODO: Store reports in database (BoltDB or SQLite) for moderation review
356356+type Report struct {
357357+ ID string `json:"id"`
358358+ SubjectURI string `json:"subject_uri"` // AT-URI of the reported content
359359+ SubjectCID string `json:"subject_cid"` // CID of the reported content
360360+ Reason string `json:"reason"` // spam, inappropriate, other
361361+ ReporterDID string `json:"reporter_did"` // DID of reporter, or "anonymous"
362362+ ReporterIP string `json:"reporter_ip"` // IP address for rate limiting
363363+ CreatedAt time.Time `json:"created_at"`
364364+ Status string `json:"status"` // pending, reviewed, dismissed, actioned
365365+}