···402402 if err := c.index.DeleteLike(event.DID, subjectURI); err != nil {
403403 log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete like index")
404404 }
405405+ c.index.DeleteLikeNotification(event.DID, subjectURI)
405406 }
406407 }
407408 }
···418419 if err := json.Unmarshal(existingRecord.Record, &recordData); err == nil {
419420 if subject, ok := recordData["subject"].(map[string]interface{}); ok {
420421 if subjectURI, ok := subject["uri"].(string); ok {
422422+ var parentURI string
423423+ if parent, ok := recordData["parent"].(map[string]interface{}); ok {
424424+ parentURI, _ = parent["uri"].(string)
425425+ }
421426 if err := c.index.DeleteComment(event.DID, commit.RKey, subjectURI); err != nil {
422427 log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete comment index")
423428 }
429429+ c.index.DeleteCommentNotification(event.DID, subjectURI, parentURI)
424430 }
425431 }
426432 }
+75-24
internal/firehose/index.go
···1129112911301130 if err := idx.UpsertRecord(did, collection, rkey, record.CID, recordJSON, 0); err != nil {
11311131 log.Warn().Err(err).Str("uri", record.URI).Msg("failed to upsert record during backfill")
11321132- } else {
11331133- recordCount++
11321132+ continue
11331133+ }
11341134+ recordCount++
11351135+11361136+ // Index likes and comments into their specialized buckets
11371137+ switch collection {
11381138+ case atproto.NSIDLike:
11391139+ if subject, ok := record.Value["subject"].(map[string]interface{}); ok {
11401140+ if subjectURI, ok := subject["uri"].(string); ok {
11411141+ if err := idx.UpsertLike(did, rkey, subjectURI); err != nil {
11421142+ log.Warn().Err(err).Str("uri", record.URI).Msg("failed to index like during backfill")
11431143+ }
11441144+ }
11451145+ }
11461146+ case atproto.NSIDComment:
11471147+ if subject, ok := record.Value["subject"].(map[string]interface{}); ok {
11481148+ if subjectURI, ok := subject["uri"].(string); ok {
11491149+ text, _ := record.Value["text"].(string)
11501150+ var createdAt time.Time
11511151+ if createdAtStr, ok := record.Value["createdAt"].(string); ok {
11521152+ if parsed, err := time.Parse(time.RFC3339, createdAtStr); err == nil {
11531153+ createdAt = parsed
11541154+ } else {
11551155+ createdAt = time.Now()
11561156+ }
11571157+ } else {
11581158+ createdAt = time.Now()
11591159+ }
11601160+ var parentURI string
11611161+ if parent, ok := record.Value["parent"].(map[string]interface{}); ok {
11621162+ parentURI, _ = parent["uri"].(string)
11631163+ }
11641164+ if err := idx.UpsertComment(did, rkey, subjectURI, parentURI, record.CID, text, createdAt); err != nil {
11651165+ log.Warn().Err(err).Str("uri", record.URI).Msg("failed to index comment during backfill")
11661166+ }
11671167+ }
11681168+ }
11341169 }
11351170 }
11361171 }
···1398143313991434 actorKey := []byte(actorDID + ":" + rkey)
1400143514011401- // Check if comment exists and get subject URI from index
14361436+ // Get subject URI from the actor index, or use the provided one
14021437 existingSubject := commentsByActor.Get(actorKey)
14031403- if existingSubject == nil {
14041404- return nil // Comment doesn't exist, nothing to do
14051405- }
14061406-14071407- // Use the subject URI from the index if not provided
14081408- if subjectURI == "" {
14381438+ if existingSubject != nil && subjectURI == "" {
14091439 subjectURI = string(existingSubject)
14101440 }
1411144114121412- // Find and delete the comment by iterating over comments with matching subject
14421442+ // Find and delete the comment from BucketComments
14131443 var parentURI string
14141414- prefix := []byte(subjectURI + ":")
14151415- c := comments.Cursor()
14161416- for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() {
14171417- // Check if this key contains our actor and rkey
14181418- if strings.HasSuffix(string(k), ":"+actorDID+":"+rkey) {
14191419- // Parse the comment to get parent URI for cleanup
14201420- var comment IndexedComment
14211421- if err := json.Unmarshal(v, &comment); err == nil {
14221422- parentURI = comment.ParentURI
14441444+ suffix := ":" + actorDID + ":" + rkey
14451445+14461446+ if subjectURI != "" {
14471447+ // Fast path: we know the subject URI, scan only that prefix
14481448+ prefix := []byte(subjectURI + ":")
14491449+ c := comments.Cursor()
14501450+ for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() {
14511451+ if strings.HasSuffix(string(k), suffix) {
14521452+ var comment IndexedComment
14531453+ if err := json.Unmarshal(v, &comment); err == nil {
14541454+ parentURI = comment.ParentURI
14551455+ }
14561456+ if err := comments.Delete(k); err != nil {
14571457+ return fmt.Errorf("failed to delete comment: %w", err)
14581458+ }
14591459+ break
14231460 }
14241424- if err := comments.Delete(k); err != nil {
14251425- return fmt.Errorf("failed to delete comment: %w", err)
14611461+ }
14621462+ } else {
14631463+ // Slow path: scan all comments to find this actor+rkey
14641464+ c := comments.Cursor()
14651465+ for k, v := c.First(); k != nil; k, v = c.Next() {
14661466+ if strings.HasSuffix(string(k), suffix) {
14671467+ var comment IndexedComment
14681468+ if err := json.Unmarshal(v, &comment); err == nil {
14691469+ parentURI = comment.ParentURI
14701470+ subjectURI = comment.SubjectURI
14711471+ }
14721472+ if err := comments.Delete(k); err != nil {
14731473+ return fmt.Errorf("failed to delete comment: %w", err)
14741474+ }
14751475+ break
14261476 }
14271427- break
14281477 }
14291478 }
1430147914311480 // Delete actor lookup
14321432- if err := commentsByActor.Delete(actorKey); err != nil {
14331433- return fmt.Errorf("failed to delete comment by actor: %w", err)
14811481+ if existingSubject != nil {
14821482+ if err := commentsByActor.Delete(actorKey); err != nil {
14831483+ return fmt.Errorf("failed to delete comment by actor: %w", err)
14841484+ }
14341485 }
1435148614361487 // Delete parent-child relationship if this was a reply
+63
internal/firehose/notifications.go
···199199 return did
200200}
201201202202+// DeleteNotification removes a notification matching (type + actorDID + subjectURI)
203203+// from the target user's notification list. No-op if not found.
204204+func (idx *FeedIndex) DeleteNotification(targetDID string, notifType models.NotificationType, actorDID, subjectURI string) {
205205+ if targetDID == "" {
206206+ return
207207+ }
208208+209209+ err := idx.db.Update(func(tx *bolt.Tx) error {
210210+ b := tx.Bucket(BucketNotifications)
211211+ prefix := []byte(targetDID + ":")
212212+ c := b.Cursor()
213213+ for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() {
214214+ var existing models.Notification
215215+ if err := json.Unmarshal(v, &existing); err != nil {
216216+ continue
217217+ }
218218+ if existing.Type == notifType && existing.ActorDID == actorDID && existing.SubjectURI == subjectURI {
219219+ return b.Delete(k)
220220+ }
221221+ }
222222+ return nil
223223+ })
224224+ if err != nil {
225225+ log.Warn().Err(err).Str("target", targetDID).Str("actor", actorDID).Msg("failed to delete notification")
226226+ }
227227+}
228228+229229+// DeleteLikeNotification removes the notification for a like that was undone
230230+func (idx *FeedIndex) DeleteLikeNotification(actorDID, subjectURI string) {
231231+ targetDID := parseTargetDID(subjectURI)
232232+ idx.DeleteNotification(targetDID, models.NotificationLike, actorDID, subjectURI)
233233+}
234234+235235+// DeleteCommentNotification removes notifications for a deleted comment
236236+func (idx *FeedIndex) DeleteCommentNotification(actorDID, subjectURI, parentURI string) {
237237+ // Remove the comment notification sent to the brew owner
238238+ targetDID := parseTargetDID(subjectURI)
239239+ idx.DeleteNotification(targetDID, models.NotificationComment, actorDID, subjectURI)
240240+241241+ // Remove the reply notification sent to the parent comment's author
242242+ if parentURI != "" {
243243+ parentAuthorDID := parseTargetDID(parentURI)
244244+ if parentAuthorDID != targetDID {
245245+ idx.DeleteNotification(parentAuthorDID, models.NotificationCommentReply, actorDID, subjectURI)
246246+ }
247247+ }
248248+}
249249+250250+// GetCommentSubjectURI returns the subject URI for a comment by actor+rkey.
251251+// Returns empty string if not found.
252252+func (idx *FeedIndex) GetCommentSubjectURI(actorDID, rkey string) string {
253253+ var subjectURI string
254254+ _ = idx.db.View(func(tx *bolt.Tx) error {
255255+ b := tx.Bucket(BucketCommentsByActor)
256256+ v := b.Get([]byte(actorDID + ":" + rkey))
257257+ if v != nil {
258258+ subjectURI = string(v)
259259+ }
260260+ return nil
261261+ })
262262+ return subjectURI
263263+}
264264+202265// CreateLikeNotification creates a notification for a like event
203266func (idx *FeedIndex) CreateLikeNotification(actorDID, subjectURI string) {
204267 targetDID := parseTargetDID(subjectURI)
+220
internal/firehose/suggestions.go
···11+package firehose
22+33+import (
44+ "encoding/json"
55+ "sort"
66+ "strings"
77+88+ "arabica/internal/atproto"
99+1010+ bolt "go.etcd.io/bbolt"
1111+)
1212+1313+// EntitySuggestion represents a suggestion for auto-completing an entity
1414+type EntitySuggestion struct {
1515+ Name string `json:"name"`
1616+ SourceURI string `json:"source_uri"`
1717+ Fields map[string]string `json:"fields"`
1818+ Count int `json:"count"`
1919+}
2020+2121+// entityFieldConfig defines which fields to extract and search for each entity type
2222+type entityFieldConfig struct {
2323+ allFields []string
2424+ searchFields []string
2525+ nameField string
2626+}
2727+2828+var entityConfigs = map[string]entityFieldConfig{
2929+ atproto.NSIDRoaster: {
3030+ allFields: []string{"name", "location", "website"},
3131+ searchFields: []string{"name", "location", "website"},
3232+ nameField: "name",
3333+ },
3434+ atproto.NSIDGrinder: {
3535+ allFields: []string{"name", "grinderType", "burrType"},
3636+ searchFields: []string{"name", "grinderType", "burrType"},
3737+ nameField: "name",
3838+ },
3939+ atproto.NSIDBrewer: {
4040+ allFields: []string{"name", "brewerType", "description"},
4141+ searchFields: []string{"name", "brewerType"},
4242+ nameField: "name",
4343+ },
4444+ atproto.NSIDBean: {
4545+ allFields: []string{"name", "origin", "roastLevel", "process"},
4646+ searchFields: []string{"name", "origin", "roastLevel"},
4747+ nameField: "name",
4848+ },
4949+}
5050+5151+// SearchEntitySuggestions searches indexed records for entity suggestions matching a query.
5252+// It scans BucketByCollection for the given collection, matches against searchable fields,
5353+// deduplicates by normalized name, and returns results sorted by popularity.
5454+func (idx *FeedIndex) SearchEntitySuggestions(collection, query string, limit int) ([]EntitySuggestion, error) {
5555+ if limit <= 0 {
5656+ limit = 10
5757+ }
5858+5959+ config, ok := entityConfigs[collection]
6060+ if !ok {
6161+ return nil, nil
6262+ }
6363+6464+ queryLower := strings.ToLower(strings.TrimSpace(query))
6565+ if len(queryLower) < 2 {
6666+ return nil, nil
6767+ }
6868+6969+ // dedupKey -> aggregated suggestion
7070+ type candidate struct {
7171+ suggestion EntitySuggestion
7272+ fieldCount int // number of non-empty fields (to pick best representative)
7373+ dids map[string]struct{}
7474+ }
7575+ candidates := make(map[string]*candidate)
7676+7777+ err := idx.db.View(func(tx *bolt.Tx) error {
7878+ byCollection := tx.Bucket(BucketByCollection)
7979+ recordsBucket := tx.Bucket(BucketRecords)
8080+8181+ prefix := []byte(collection + ":")
8282+ c := byCollection.Cursor()
8383+8484+ for k, _ := c.Seek(prefix); k != nil; k, _ = c.Next() {
8585+ if !hasPrefix(k, prefix) {
8686+ break
8787+ }
8888+8989+ // Extract URI from collection key
9090+ uri := extractURIFromCollectionKey(k, collection)
9191+ if uri == "" {
9292+ continue
9393+ }
9494+9595+ data := recordsBucket.Get([]byte(uri))
9696+ if data == nil {
9797+ continue
9898+ }
9999+100100+ var indexed IndexedRecord
101101+ if err := json.Unmarshal(data, &indexed); err != nil {
102102+ continue
103103+ }
104104+105105+ var recordData map[string]interface{}
106106+ if err := json.Unmarshal(indexed.Record, &recordData); err != nil {
107107+ continue
108108+ }
109109+110110+ // Extract fields
111111+ fields := make(map[string]string)
112112+ for _, f := range config.allFields {
113113+ if v, ok := recordData[f].(string); ok && v != "" {
114114+ fields[f] = v
115115+ }
116116+ }
117117+118118+ name := fields[config.nameField]
119119+ if name == "" {
120120+ continue
121121+ }
122122+123123+ // Check if any searchable field matches the query
124124+ matched := false
125125+ for _, sf := range config.searchFields {
126126+ val := strings.ToLower(fields[sf])
127127+ if val == "" {
128128+ continue
129129+ }
130130+ if strings.HasPrefix(val, queryLower) || strings.Contains(val, queryLower) {
131131+ matched = true
132132+ break
133133+ }
134134+ }
135135+ if !matched {
136136+ continue
137137+ }
138138+139139+ // Deduplicate by normalized name
140140+ normalizedName := strings.ToLower(strings.TrimSpace(name))
141141+142142+ if existing, ok := candidates[normalizedName]; ok {
143143+ existing.dids[indexed.DID] = struct{}{}
144144+ // Keep the record with more complete fields
145145+ nonEmpty := 0
146146+ for _, v := range fields {
147147+ if v != "" {
148148+ nonEmpty++
149149+ }
150150+ }
151151+ if nonEmpty > existing.fieldCount {
152152+ existing.suggestion.Name = name
153153+ existing.suggestion.Fields = fields
154154+ existing.suggestion.SourceURI = indexed.URI
155155+ existing.fieldCount = nonEmpty
156156+ }
157157+ } else {
158158+ nonEmpty := 0
159159+ for _, v := range fields {
160160+ if v != "" {
161161+ nonEmpty++
162162+ }
163163+ }
164164+ candidates[normalizedName] = &candidate{
165165+ suggestion: EntitySuggestion{
166166+ Name: name,
167167+ SourceURI: indexed.URI,
168168+ Fields: fields,
169169+ },
170170+ fieldCount: nonEmpty,
171171+ dids: map[string]struct{}{indexed.DID: {}},
172172+ }
173173+ }
174174+ }
175175+176176+ return nil
177177+ })
178178+ if err != nil {
179179+ return nil, err
180180+ }
181181+182182+ // Build results with counts
183183+ results := make([]EntitySuggestion, 0, len(candidates))
184184+ for _, c := range candidates {
185185+ c.suggestion.Count = len(c.dids)
186186+ results = append(results, c.suggestion)
187187+ }
188188+189189+ // Sort: prefix matches first, then by count desc, then alphabetically
190190+ sort.Slice(results, func(i, j int) bool {
191191+ iPrefix := strings.HasPrefix(strings.ToLower(results[i].Name), queryLower)
192192+ jPrefix := strings.HasPrefix(strings.ToLower(results[j].Name), queryLower)
193193+ if iPrefix != jPrefix {
194194+ return iPrefix
195195+ }
196196+ if results[i].Count != results[j].Count {
197197+ return results[i].Count > results[j].Count
198198+ }
199199+ return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
200200+ })
201201+202202+ if len(results) > limit {
203203+ results = results[:limit]
204204+ }
205205+206206+ return results, nil
207207+}
208208+209209+// hasPrefix checks if a byte slice starts with a prefix (avoids import of bytes)
210210+func hasPrefix(s, prefix []byte) bool {
211211+ if len(s) < len(prefix) {
212212+ return false
213213+ }
214214+ for i, b := range prefix {
215215+ if s[i] != b {
216216+ return false
217217+ }
218218+ }
219219+ return true
220220+}
···4242 // Auth-protected but accessible without HTMX header (called from JavaScript)
4343 mux.HandleFunc("GET /api/data", h.HandleAPIListAll)
44444545+ // Suggestion routes for entity typeahead (auth-protected, read-only GET)
4646+ mux.HandleFunc("GET /api/suggestions/{entity}", h.HandleEntitySuggestions)
4747+4548 // HTMX partials (loaded async via HTMX)
4649 // These return HTML fragments and should only be accessed via HTMX
4750 mux.Handle("GET /api/feed", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleFeedPartial)))
···115115 <script src="/static/js/dropdown-manager.js?v=0.1.0"></script>
116116 <!-- Load Alpine components BEFORE Alpine.js initializes -->
117117 <script src="/static/js/brew-form.js?v=0.3.2"></script>
118118+ <script src="/static/js/entity-suggest.js?v=0.1.0"></script>
118119 <!-- Load Alpine.js core with defer (will initialize after DOM loads) -->
119120 <script src="/static/js/alpine.min.js?v=0.2.0" defer></script>
120121 <!-- Load HTMX and other utilities -->
+5
lexicons/social.arabica.alpha.bean.json
···4848 "type": "string",
4949 "format": "datetime",
5050 "description": "Timestamp when the bean record was created"
5151+ },
5252+ "sourceRef": {
5353+ "type": "string",
5454+ "format": "at-uri",
5555+ "description": "AT-URI of the record this entity was sourced from"
5156 }
5257 }
5358 }
+5
lexicons/social.arabica.alpha.brewer.json
···2929 "type": "string",
3030 "format": "datetime",
3131 "description": "Timestamp when the brewer record was created"
3232+ },
3333+ "sourceRef": {
3434+ "type": "string",
3535+ "format": "at-uri",
3636+ "description": "AT-URI of the record this entity was sourced from"
3237 }
3338 }
3439 }
+5
lexicons/social.arabica.alpha.grinder.json
···3636 "type": "string",
3737 "format": "datetime",
3838 "description": "Timestamp when the grinder record was created"
3939+ },
4040+ "sourceRef": {
4141+ "type": "string",
4242+ "format": "at-uri",
4343+ "description": "AT-URI of the record this entity was sourced from"
3944 }
4045 }
4146 }
+5
lexicons/social.arabica.alpha.roaster.json
···3030 "type": "string",
3131 "format": "datetime",
3232 "description": "Timestamp when the roaster record was created"
3333+ },
3434+ "sourceRef": {
3535+ "type": "string",
3636+ "format": "at-uri",
3737+ "description": "AT-URI of the record this entity was sourced from"
3338 }
3439 }
3540 }