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

feat: record create suggestions and correlating (#17)

authored by

Patrick Dewey and committed by
GitHub
48cf573a f7275050

+1214 -172
+144 -91
grafana/arabica-logs.json
··· 26 26 "type": "row" 27 27 }, 28 28 { 29 - "datasource": { "type": "loki", "uid": "loki" }, 29 + "datasource": { "type": "loki", "uid": "Loki" }, 30 30 "fieldConfig": { 31 31 "defaults": { 32 32 "color": { "mode": "thresholds" }, 33 33 "thresholds": { 34 - "steps": [ 35 - { "color": "green", "value": null } 36 - ] 34 + "steps": [{ "color": "green", "value": null }] 37 35 } 38 36 }, 39 37 "overrides": [] ··· 52 50 "type": "stat", 53 51 "targets": [ 54 52 { 55 - "datasource": { "type": "loki", "uid": "loki" }, 53 + "datasource": { "type": "loki", "uid": "Loki" }, 56 54 "expr": "count_over_time({${log_selector}} |= `HTTP request` [$__range])", 57 55 "refId": "A" 58 56 } 59 57 ] 60 58 }, 61 59 { 62 - "datasource": { "type": "loki", "uid": "loki" }, 60 + "datasource": { "type": "loki", "uid": "Loki" }, 63 61 "fieldConfig": { 64 62 "defaults": { 65 63 "color": { "mode": "thresholds" }, ··· 87 85 "type": "stat", 88 86 "targets": [ 89 87 { 90 - "datasource": { "type": "loki", "uid": "loki" }, 88 + "datasource": { "type": "loki", "uid": "Loki" }, 91 89 "expr": "count_over_time({${log_selector}} |= `HTTP request` | json | status >= 500 [$__range])", 92 90 "refId": "A" 93 91 } 94 92 ] 95 93 }, 96 94 { 97 - "datasource": { "type": "loki", "uid": "loki" }, 95 + "datasource": { "type": "loki", "uid": "Loki" }, 98 96 "fieldConfig": { 99 97 "defaults": { 100 98 "color": { "mode": "thresholds" }, ··· 122 120 "type": "stat", 123 121 "targets": [ 124 122 { 125 - "datasource": { "type": "loki", "uid": "loki" }, 123 + "datasource": { "type": "loki", "uid": "Loki" }, 126 124 "expr": "count_over_time({${log_selector}} |= `HTTP request` | json | status >= 400 | status < 500 [$__range])", 127 125 "refId": "A" 128 126 } 129 127 ] 130 128 }, 131 129 { 132 - "datasource": { "type": "loki", "uid": "loki" }, 130 + "datasource": { "type": "loki", "uid": "Loki" }, 133 131 "fieldConfig": { 134 132 "defaults": { 135 133 "color": { "mode": "thresholds" }, 136 134 "thresholds": { 137 - "steps": [ 138 - { "color": "blue", "value": null } 139 - ] 135 + "steps": [{ "color": "blue", "value": null }] 140 136 } 141 137 }, 142 138 "overrides": [] ··· 155 151 "type": "stat", 156 152 "targets": [ 157 153 { 158 - "datasource": { "type": "loki", "uid": "loki" }, 154 + "datasource": { "type": "loki", "uid": "Loki" }, 159 155 "expr": "count_over_time({${log_selector}} |= `User logged in successfully` [$__range])", 160 156 "refId": "A" 161 157 } 162 158 ] 163 159 }, 164 160 { 165 - "datasource": { "type": "loki", "uid": "loki" }, 161 + "datasource": { "type": "loki", "uid": "Loki" }, 166 162 "fieldConfig": { 167 163 "defaults": { 168 164 "color": { "mode": "thresholds" }, 169 165 "thresholds": { 170 - "steps": [ 171 - { "color": "purple", "value": null } 172 - ] 166 + "steps": [{ "color": "purple", "value": null }] 173 167 } 174 168 }, 175 169 "overrides": [] ··· 188 182 "type": "stat", 189 183 "targets": [ 190 184 { 191 - "datasource": { "type": "loki", "uid": "loki" }, 185 + "datasource": { "type": "loki", "uid": "Loki" }, 192 186 "expr": "count_over_time({${log_selector}} |= `Report created successfully` [$__range])", 193 187 "refId": "A" 194 188 } 195 189 ] 196 190 }, 197 191 { 198 - "datasource": { "type": "loki", "uid": "loki" }, 192 + "datasource": { "type": "loki", "uid": "Loki" }, 199 193 "fieldConfig": { 200 194 "defaults": { 201 195 "color": { "mode": "thresholds" }, 202 196 "thresholds": { 203 - "steps": [ 204 - { "color": "orange", "value": null } 205 - ] 197 + "steps": [{ "color": "orange", "value": null }] 206 198 } 207 199 }, 208 200 "overrides": [] ··· 221 213 "type": "stat", 222 214 "targets": [ 223 215 { 224 - "datasource": { "type": "loki", "uid": "loki" }, 216 + "datasource": { "type": "loki", "uid": "Loki" }, 225 217 "expr": "count_over_time({${log_selector}} |= `Join request saved` [$__range])", 226 218 "refId": "A" 227 219 } ··· 235 227 "type": "row" 236 228 }, 237 229 { 238 - "datasource": { "type": "loki", "uid": "loki" }, 230 + "datasource": { "type": "loki", "uid": "Loki" }, 239 231 "fieldConfig": { 240 232 "defaults": { 241 233 "color": { "mode": "palette-classic" }, ··· 252 244 }, 253 245 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, 254 246 "id": 10, 255 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 247 + "options": { 248 + "legend": { "displayMode": "list", "placement": "bottom" }, 249 + "tooltip": { "mode": "multi" } 250 + }, 256 251 "title": "Requests by Status", 257 252 "type": "timeseries", 258 253 "targets": [ 259 254 { 260 - "datasource": { "type": "loki", "uid": "loki" }, 255 + "datasource": { "type": "loki", "uid": "Loki" }, 261 256 "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 200 | status < 300 [$__auto]))", 262 257 "legendFormat": "2xx", 263 258 "refId": "A" 264 259 }, 265 260 { 266 - "datasource": { "type": "loki", "uid": "loki" }, 261 + "datasource": { "type": "loki", "uid": "Loki" }, 267 262 "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 300 | status < 400 [$__auto]))", 268 263 "legendFormat": "3xx", 269 264 "refId": "B" 270 265 }, 271 266 { 272 - "datasource": { "type": "loki", "uid": "loki" }, 267 + "datasource": { "type": "loki", "uid": "Loki" }, 273 268 "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 400 | status < 500 [$__auto]))", 274 269 "legendFormat": "4xx", 275 270 "refId": "C" 276 271 }, 277 272 { 278 - "datasource": { "type": "loki", "uid": "loki" }, 273 + "datasource": { "type": "loki", "uid": "Loki" }, 279 274 "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 500 [$__auto]))", 280 275 "legendFormat": "5xx", 281 276 "refId": "D" ··· 283 278 ] 284 279 }, 285 280 { 286 - "datasource": { "type": "loki", "uid": "loki" }, 281 + "datasource": { "type": "loki", "uid": "Loki" }, 287 282 "fieldConfig": { 288 283 "defaults": { 289 284 "color": { "mode": "palette-classic" }, ··· 300 295 }, 301 296 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, 302 297 "id": 11, 303 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 298 + "options": { 299 + "legend": { "displayMode": "list", "placement": "bottom" }, 300 + "tooltip": { "mode": "multi" } 301 + }, 304 302 "title": "Requests by Method", 305 303 "type": "timeseries", 306 304 "targets": [ 307 305 { 308 - "datasource": { "type": "loki", "uid": "loki" }, 306 + "datasource": { "type": "loki", "uid": "Loki" }, 309 307 "expr": "sum by (method) (count_over_time({${log_selector}} |= `HTTP request` | json [$__auto]))", 310 308 "legendFormat": "{{method}}", 311 309 "refId": "A" ··· 313 311 ] 314 312 }, 315 313 { 316 - "datasource": { "type": "loki", "uid": "loki" }, 314 + "datasource": { "type": "loki", "uid": "Loki" }, 317 315 "fieldConfig": { 318 316 "defaults": { 319 317 "color": { "mode": "palette-classic" }, ··· 330 328 }, 331 329 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, 332 330 "id": 12, 333 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 331 + "options": { 332 + "legend": { "displayMode": "list", "placement": "bottom" }, 333 + "tooltip": { "mode": "multi" } 334 + }, 334 335 "title": "Top Paths", 335 336 "type": "timeseries", 336 337 "targets": [ 337 338 { 338 - "datasource": { "type": "loki", "uid": "loki" }, 339 + "datasource": { "type": "loki", "uid": "Loki" }, 339 340 "expr": "sum by (path) (count_over_time({${log_selector}} |= `HTTP request` | json | path !~ `/static/.*` [$__auto])) ", 340 341 "legendFormat": "{{path}}", 341 342 "refId": "A" ··· 343 344 ] 344 345 }, 345 346 { 346 - "datasource": { "type": "loki", "uid": "loki" }, 347 + "datasource": { "type": "loki", "uid": "Loki" }, 347 348 "fieldConfig": { 348 349 "defaults": { 349 350 "color": { "mode": "palette-classic" }, ··· 360 361 }, 361 362 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, 362 363 "id": 13, 363 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 364 + "options": { 365 + "legend": { "displayMode": "list", "placement": "bottom" }, 366 + "tooltip": { "mode": "multi" } 367 + }, 364 368 "title": "Response Time (avg per interval)", 365 369 "type": "timeseries", 366 370 "targets": [ 367 371 { 368 - "datasource": { "type": "loki", "uid": "loki" }, 372 + "datasource": { "type": "loki", "uid": "Loki" }, 369 373 "expr": "avg_over_time({${log_selector}} |= `HTTP request` | json | path !~ `/static/.*` | unwrap duration [$__auto]) / 1000000", 370 374 "legendFormat": "avg latency", 371 375 "refId": "A" ··· 380 384 "type": "row" 381 385 }, 382 386 { 383 - "datasource": { "type": "loki", "uid": "loki" }, 387 + "datasource": { "type": "loki", "uid": "Loki" }, 384 388 "fieldConfig": { 385 389 "defaults": { 386 390 "color": { "mode": "palette-classic" }, ··· 397 401 }, 398 402 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 }, 399 403 "id": 20, 400 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 404 + "options": { 405 + "legend": { "displayMode": "list", "placement": "bottom" }, 406 + "tooltip": { "mode": "multi" } 407 + }, 401 408 "title": "Firehose Events by Collection", 402 409 "type": "timeseries", 403 410 "targets": [ 404 411 { 405 - "datasource": { "type": "loki", "uid": "loki" }, 412 + "datasource": { "type": "loki", "uid": "Loki" }, 406 413 "expr": "sum by (collection) (count_over_time({${log_selector}} |= `firehose: processing event` | json [$__auto]))", 407 414 "legendFormat": "{{collection}}", 408 415 "refId": "A" ··· 410 417 ] 411 418 }, 412 419 { 413 - "datasource": { "type": "loki", "uid": "loki" }, 420 + "datasource": { "type": "loki", "uid": "Loki" }, 414 421 "fieldConfig": { 415 422 "defaults": { 416 423 "color": { "mode": "palette-classic" }, ··· 427 434 }, 428 435 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 }, 429 436 "id": 21, 430 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 437 + "options": { 438 + "legend": { "displayMode": "list", "placement": "bottom" }, 439 + "tooltip": { "mode": "multi" } 440 + }, 431 441 "title": "Firehose Events by Operation", 432 442 "type": "timeseries", 433 443 "targets": [ 434 444 { 435 - "datasource": { "type": "loki", "uid": "loki" }, 445 + "datasource": { "type": "loki", "uid": "Loki" }, 436 446 "expr": "sum by (operation) (count_over_time({${log_selector}} |= `firehose: processing event` | json [$__auto]))", 437 447 "legendFormat": "{{operation}}", 438 448 "refId": "A" ··· 440 450 ] 441 451 }, 442 452 { 443 - "datasource": { "type": "loki", "uid": "loki" }, 453 + "datasource": { "type": "loki", "uid": "Loki" }, 444 454 "fieldConfig": { 445 455 "defaults": { 446 456 "color": { "mode": "thresholds" }, ··· 456 466 }, 457 467 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 }, 458 468 "id": 22, 459 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 469 + "options": { 470 + "legend": { "displayMode": "list", "placement": "bottom" }, 471 + "tooltip": { "mode": "multi" } 472 + }, 460 473 "title": "Firehose Errors", 461 474 "type": "timeseries", 462 475 "targets": [ 463 476 { 464 - "datasource": { "type": "loki", "uid": "loki" }, 477 + "datasource": { "type": "loki", "uid": "Loki" }, 465 478 "expr": "count_over_time({${log_selector}} |= `firehose:` |~ `\"level\":\"(warn|error)\"` [$__auto])", 466 479 "legendFormat": "errors", 467 480 "refId": "A" ··· 469 482 ] 470 483 }, 471 484 { 472 - "datasource": { "type": "loki", "uid": "loki" }, 485 + "datasource": { "type": "loki", "uid": "Loki" }, 473 486 "fieldConfig": { 474 487 "defaults": { 475 488 "color": { "mode": "palette-classic" }, ··· 485 498 }, 486 499 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 }, 487 500 "id": 23, 488 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 501 + "options": { 502 + "legend": { "displayMode": "list", "placement": "bottom" }, 503 + "tooltip": { "mode": "multi" } 504 + }, 489 505 "title": "Backfills", 490 506 "type": "timeseries", 491 507 "targets": [ 492 508 { 493 - "datasource": { "type": "loki", "uid": "loki" }, 509 + "datasource": { "type": "loki", "uid": "Loki" }, 494 510 "expr": "count_over_time({${log_selector}} |= `backfill complete` [$__auto])", 495 511 "legendFormat": "completed", 496 512 "refId": "A" 497 513 }, 498 514 { 499 - "datasource": { "type": "loki", "uid": "loki" }, 515 + "datasource": { "type": "loki", "uid": "Loki" }, 500 516 "expr": "count_over_time({${log_selector}} |= `backfilling user records` [$__auto])", 501 517 "legendFormat": "started", 502 518 "refId": "B" ··· 511 527 "type": "row" 512 528 }, 513 529 { 514 - "datasource": { "type": "loki", "uid": "loki" }, 530 + "datasource": { "type": "loki", "uid": "Loki" }, 515 531 "fieldConfig": { 516 532 "defaults": { 517 533 "color": { "mode": "palette-classic" }, ··· 527 543 }, 528 544 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, 529 545 "id": 30, 530 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 546 + "options": { 547 + "legend": { "displayMode": "list", "placement": "bottom" }, 548 + "tooltip": { "mode": "multi" } 549 + }, 531 550 "title": "Logins", 532 551 "type": "timeseries", 533 552 "targets": [ 534 553 { 535 - "datasource": { "type": "loki", "uid": "loki" }, 554 + "datasource": { "type": "loki", "uid": "Loki" }, 536 555 "expr": "count_over_time({${log_selector}} |= `User logged in successfully` [$__auto])", 537 556 "legendFormat": "successful logins", 538 557 "refId": "A" 539 558 }, 540 559 { 541 - "datasource": { "type": "loki", "uid": "loki" }, 560 + "datasource": { "type": "loki", "uid": "Loki" }, 542 561 "expr": "count_over_time({${log_selector}} |= `Failed to initiate login` [$__auto])", 543 562 "legendFormat": "failed logins", 544 563 "refId": "B" 545 564 }, 546 565 { 547 - "datasource": { "type": "loki", "uid": "loki" }, 566 + "datasource": { "type": "loki", "uid": "Loki" }, 548 567 "expr": "count_over_time({${log_selector}} |= `Failed to complete OAuth flow` [$__auto])", 549 568 "legendFormat": "failed OAuth callbacks", 550 569 "refId": "C" ··· 552 571 ] 553 572 }, 554 573 { 555 - "datasource": { "type": "loki", "uid": "loki" }, 574 + "datasource": { "type": "loki", "uid": "Loki" }, 556 575 "fieldConfig": { 557 576 "defaults": { 558 577 "color": { "mode": "palette-classic" }, ··· 568 587 }, 569 588 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 40 }, 570 589 "id": 31, 571 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 590 + "options": { 591 + "legend": { "displayMode": "list", "placement": "bottom" }, 592 + "tooltip": { "mode": "multi" } 593 + }, 572 594 "title": "Join Requests & Invites", 573 595 "type": "timeseries", 574 596 "targets": [ 575 597 { 576 - "datasource": { "type": "loki", "uid": "loki" }, 598 + "datasource": { "type": "loki", "uid": "Loki" }, 577 599 "expr": "count_over_time({${log_selector}} |= `Join request saved` [$__auto])", 578 600 "legendFormat": "join requests", 579 601 "refId": "A" 580 602 }, 581 603 { 582 - "datasource": { "type": "loki", "uid": "loki" }, 604 + "datasource": { "type": "loki", "uid": "Loki" }, 583 605 "expr": "count_over_time({${log_selector}} |= `Invite code created` [$__auto])", 584 606 "legendFormat": "invites created", 585 607 "refId": "B" 586 608 }, 587 609 { 588 - "datasource": { "type": "loki", "uid": "loki" }, 610 + "datasource": { "type": "loki", "uid": "Loki" }, 589 611 "expr": "count_over_time({${log_selector}} |= `Account created` [$__auto])", 590 612 "legendFormat": "accounts created", 591 613 "refId": "C" ··· 600 622 "type": "row" 601 623 }, 602 624 { 603 - "datasource": { "type": "loki", "uid": "loki" }, 625 + "datasource": { "type": "loki", "uid": "Loki" }, 604 626 "fieldConfig": { 605 627 "defaults": { 606 628 "color": { "mode": "palette-classic" }, ··· 616 638 }, 617 639 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 49 }, 618 640 "id": 40, 619 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 641 + "options": { 642 + "legend": { "displayMode": "list", "placement": "bottom" }, 643 + "tooltip": { "mode": "multi" } 644 + }, 620 645 "title": "Moderation Actions", 621 646 "type": "timeseries", 622 647 "targets": [ 623 648 { 624 - "datasource": { "type": "loki", "uid": "loki" }, 649 + "datasource": { "type": "loki", "uid": "Loki" }, 625 650 "expr": "count_over_time({${log_selector}} |= `Report created successfully` [$__auto])", 626 651 "legendFormat": "reports", 627 652 "refId": "A" 628 653 }, 629 654 { 630 - "datasource": { "type": "loki", "uid": "loki" }, 655 + "datasource": { "type": "loki", "uid": "Loki" }, 631 656 "expr": "count_over_time({${log_selector}} |= `Record hidden successfully` [$__auto])", 632 657 "legendFormat": "records hidden", 633 658 "refId": "B" 634 659 }, 635 660 { 636 - "datasource": { "type": "loki", "uid": "loki" }, 661 + "datasource": { "type": "loki", "uid": "Loki" }, 637 662 "expr": "count_over_time({${log_selector}} |= `Record unhidden successfully` [$__auto])", 638 663 "legendFormat": "records unhidden", 639 664 "refId": "C" 640 665 }, 641 666 { 642 - "datasource": { "type": "loki", "uid": "loki" }, 667 + "datasource": { "type": "loki", "uid": "Loki" }, 643 668 "expr": "count_over_time({${log_selector}} |= `User blocked successfully` [$__auto])", 644 669 "legendFormat": "users blocked", 645 670 "refId": "D" ··· 647 672 ] 648 673 }, 649 674 { 650 - "datasource": { "type": "loki", "uid": "loki" }, 675 + "datasource": { "type": "loki", "uid": "Loki" }, 651 676 "fieldConfig": { 652 677 "defaults": { 653 678 "color": { "mode": "palette-classic" }, ··· 663 688 }, 664 689 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 49 }, 665 690 "id": 41, 666 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 691 + "options": { 692 + "legend": { "displayMode": "list", "placement": "bottom" }, 693 + "tooltip": { "mode": "multi" } 694 + }, 667 695 "title": "Permission Denials", 668 696 "type": "timeseries", 669 697 "targets": [ 670 698 { 671 - "datasource": { "type": "loki", "uid": "loki" }, 699 + "datasource": { "type": "loki", "uid": "Loki" }, 672 700 "expr": "count_over_time({${log_selector}} |= `Denied:` [$__auto])", 673 701 "legendFormat": "denied", 674 702 "refId": "A" ··· 683 711 "type": "row" 684 712 }, 685 713 { 686 - "datasource": { "type": "loki", "uid": "loki" }, 714 + "datasource": { "type": "loki", "uid": "Loki" }, 687 715 "fieldConfig": { 688 716 "defaults": { 689 717 "color": { "mode": "palette-classic" }, ··· 700 728 }, 701 729 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 58 }, 702 730 "id": 50, 703 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 731 + "options": { 732 + "legend": { "displayMode": "list", "placement": "bottom" }, 733 + "tooltip": { "mode": "multi" } 734 + }, 704 735 "title": "PDS Requests by Method", 705 736 "type": "timeseries", 706 737 "targets": [ 707 738 { 708 - "datasource": { "type": "loki", "uid": "loki" }, 739 + "datasource": { "type": "loki", "uid": "Loki" }, 709 740 "expr": "sum by (method) (count_over_time({${log_selector}} |= `PDS request completed` | json [$__auto]))", 710 741 "legendFormat": "{{method}}", 711 742 "refId": "A" ··· 713 744 ] 714 745 }, 715 746 { 716 - "datasource": { "type": "loki", "uid": "loki" }, 747 + "datasource": { "type": "loki", "uid": "Loki" }, 717 748 "fieldConfig": { 718 749 "defaults": { 719 750 "color": { "mode": "palette-classic" }, ··· 730 761 }, 731 762 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 58 }, 732 763 "id": 51, 733 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 764 + "options": { 765 + "legend": { "displayMode": "list", "placement": "bottom" }, 766 + "tooltip": { "mode": "multi" } 767 + }, 734 768 "title": "PDS Latency", 735 769 "type": "timeseries", 736 770 "targets": [ 737 771 { 738 - "datasource": { "type": "loki", "uid": "loki" }, 772 + "datasource": { "type": "loki", "uid": "Loki" }, 739 773 "expr": "avg_over_time({${log_selector}} |= `PDS request completed` | json | unwrap duration [$__auto]) / 1000000", 740 774 "legendFormat": "avg", 741 775 "refId": "A" ··· 743 777 ] 744 778 }, 745 779 { 746 - "datasource": { "type": "loki", "uid": "loki" }, 780 + "datasource": { "type": "loki", "uid": "Loki" }, 747 781 "fieldConfig": { 748 782 "defaults": { 749 783 "color": { "mode": "thresholds" }, ··· 758 792 }, 759 793 "gridPos": { "h": 8, "w": 12, "x": 0, "y": 66 }, 760 794 "id": 52, 761 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 795 + "options": { 796 + "legend": { "displayMode": "list", "placement": "bottom" }, 797 + "tooltip": { "mode": "multi" } 798 + }, 762 799 "title": "PDS Errors", 763 800 "type": "timeseries", 764 801 "targets": [ 765 802 { 766 - "datasource": { "type": "loki", "uid": "loki" }, 803 + "datasource": { "type": "loki", "uid": "Loki" }, 767 804 "expr": "count_over_time({${log_selector}} |= `PDS request failed` [$__auto])", 768 805 "legendFormat": "PDS failures", 769 806 "refId": "A" ··· 771 808 ] 772 809 }, 773 810 { 774 - "datasource": { "type": "loki", "uid": "loki" }, 811 + "datasource": { "type": "loki", "uid": "Loki" }, 775 812 "fieldConfig": { 776 813 "defaults": { 777 814 "color": { "mode": "palette-classic" }, ··· 788 825 }, 789 826 "gridPos": { "h": 8, "w": 12, "x": 12, "y": 66 }, 790 827 "id": 53, 791 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 828 + "options": { 829 + "legend": { "displayMode": "list", "placement": "bottom" }, 830 + "tooltip": { "mode": "multi" } 831 + }, 792 832 "title": "PDS Requests by Collection", 793 833 "type": "timeseries", 794 834 "targets": [ 795 835 { 796 - "datasource": { "type": "loki", "uid": "loki" }, 836 + "datasource": { "type": "loki", "uid": "Loki" }, 797 837 "expr": "sum by (collection) (count_over_time({${log_selector}} |= `PDS request completed` | json [$__auto]))", 798 838 "legendFormat": "{{collection}}", 799 839 "refId": "A" ··· 808 848 "type": "row" 809 849 }, 810 850 { 811 - "datasource": { "type": "loki", "uid": "loki" }, 851 + "datasource": { "type": "loki", "uid": "Loki" }, 812 852 "fieldConfig": { 813 853 "defaults": { 814 854 "color": { "mode": "palette-classic" }, ··· 824 864 "overrides": [ 825 865 { 826 866 "matcher": { "id": "byName", "options": "error" }, 827 - "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] 867 + "properties": [ 868 + { 869 + "id": "color", 870 + "value": { "fixedColor": "red", "mode": "fixed" } 871 + } 872 + ] 828 873 }, 829 874 { 830 875 "matcher": { "id": "byName", "options": "warn" }, 831 - "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] 876 + "properties": [ 877 + { 878 + "id": "color", 879 + "value": { "fixedColor": "yellow", "mode": "fixed" } 880 + } 881 + ] 832 882 } 833 883 ] 834 884 }, 835 885 "gridPos": { "h": 8, "w": 24, "x": 0, "y": 75 }, 836 886 "id": 60, 837 - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 887 + "options": { 888 + "legend": { "displayMode": "list", "placement": "bottom" }, 889 + "tooltip": { "mode": "multi" } 890 + }, 838 891 "title": "Errors & Warnings Over Time", 839 892 "type": "timeseries", 840 893 "targets": [ 841 894 { 842 - "datasource": { "type": "loki", "uid": "loki" }, 895 + "datasource": { "type": "loki", "uid": "Loki" }, 843 896 "expr": "count_over_time({${log_selector}} |~ `\"level\":\"error\"` [$__auto])", 844 897 "legendFormat": "error", 845 898 "refId": "A" 846 899 }, 847 900 { 848 - "datasource": { "type": "loki", "uid": "loki" }, 901 + "datasource": { "type": "loki", "uid": "Loki" }, 849 902 "expr": "count_over_time({${log_selector}} |~ `\"level\":\"warn\"` [$__auto])", 850 903 "legendFormat": "warn", 851 904 "refId": "B" ··· 853 906 ] 854 907 }, 855 908 { 856 - "datasource": { "type": "loki", "uid": "loki" }, 909 + "datasource": { "type": "loki", "uid": "Loki" }, 857 910 "gridPos": { "h": 12, "w": 24, "x": 0, "y": 83 }, 858 911 "id": 61, 859 912 "options": { ··· 870 923 "type": "logs", 871 924 "targets": [ 872 925 { 873 - "datasource": { "type": "loki", "uid": "loki" }, 926 + "datasource": { "type": "loki", "uid": "Loki" }, 874 927 "expr": "{${log_selector}} |~ `\"level\":\"error\"`", 875 928 "refId": "A" 876 929 }
+24
internal/atproto/records.go
··· 183 183 } 184 184 // Always include closed field (defaults to false) 185 185 record["closed"] = bean.Closed 186 + if bean.SourceRef != "" { 187 + record["sourceRef"] = bean.SourceRef 188 + } 186 189 187 190 return record, nil 188 191 } ··· 233 236 } 234 237 if closed, ok := record["closed"].(bool); ok { 235 238 bean.Closed = closed 239 + } 240 + if sourceRef, ok := record["sourceRef"].(string); ok { 241 + bean.SourceRef = sourceRef 236 242 } 237 243 238 244 return bean, nil ··· 255 261 if roaster.Website != "" { 256 262 record["website"] = roaster.Website 257 263 } 264 + if roaster.SourceRef != "" { 265 + record["sourceRef"] = roaster.SourceRef 266 + } 258 267 259 268 return record, nil 260 269 } ··· 296 305 } 297 306 if website, ok := record["website"].(string); ok { 298 307 roaster.Website = website 308 + } 309 + if sourceRef, ok := record["sourceRef"].(string); ok { 310 + roaster.SourceRef = sourceRef 299 311 } 300 312 301 313 return roaster, nil ··· 321 333 if grinder.Notes != "" { 322 334 record["notes"] = grinder.Notes 323 335 } 336 + if grinder.SourceRef != "" { 337 + record["sourceRef"] = grinder.SourceRef 338 + } 324 339 325 340 return record, nil 326 341 } ··· 365 380 } 366 381 if notes, ok := record["notes"].(string); ok { 367 382 grinder.Notes = notes 383 + } 384 + if sourceRef, ok := record["sourceRef"].(string); ok { 385 + grinder.SourceRef = sourceRef 368 386 } 369 387 370 388 return grinder, nil ··· 387 405 if brewer.BrewerType != "" { 388 406 record["brewerType"] = brewer.BrewerType 389 407 } 408 + if brewer.SourceRef != "" { 409 + record["sourceRef"] = brewer.SourceRef 410 + } 390 411 391 412 return record, nil 392 413 } ··· 428 449 } 429 450 if brewerType, ok := record["brewerType"].(string); ok { 430 451 brewer.BrewerType = brewerType 452 + } 453 + if sourceRef, ok := record["sourceRef"].(string); ok { 454 + brewer.SourceRef = sourceRef 431 455 } 432 456 433 457 return brewer, nil
+8
internal/atproto/store.go
··· 521 521 Process: bean.Process, 522 522 Description: bean.Description, 523 523 RoasterRKey: bean.RoasterRKey, 524 + SourceRef: bean.SourceRef, 524 525 CreatedAt: time.Now(), 525 526 } 526 527 ··· 668 669 Description: bean.Description, 669 670 RoasterRKey: bean.RoasterRKey, 670 671 Closed: bean.Closed, 672 + SourceRef: bean.SourceRef, 671 673 CreatedAt: existing.CreatedAt, 672 674 } 673 675 ··· 713 715 Name: roaster.Name, 714 716 Location: roaster.Location, 715 717 Website: roaster.Website, 718 + SourceRef: roaster.SourceRef, 716 719 CreatedAt: time.Now(), 717 720 } 718 721 ··· 811 814 Name: roaster.Name, 812 815 Location: roaster.Location, 813 816 Website: roaster.Website, 817 + SourceRef: roaster.SourceRef, 814 818 CreatedAt: existing.CreatedAt, 815 819 } 816 820 ··· 857 861 GrinderType: grinder.GrinderType, 858 862 BurrType: grinder.BurrType, 859 863 Notes: grinder.Notes, 864 + SourceRef: grinder.SourceRef, 860 865 CreatedAt: time.Now(), 861 866 } 862 867 ··· 956 961 GrinderType: grinder.GrinderType, 957 962 BurrType: grinder.BurrType, 958 963 Notes: grinder.Notes, 964 + SourceRef: grinder.SourceRef, 959 965 CreatedAt: existing.CreatedAt, 960 966 } 961 967 ··· 1001 1007 Name: brewer.Name, 1002 1008 BrewerType: brewer.BrewerType, 1003 1009 Description: brewer.Description, 1010 + SourceRef: brewer.SourceRef, 1004 1011 CreatedAt: time.Now(), 1005 1012 } 1006 1013 ··· 1099 1106 Name: brewer.Name, 1100 1107 BrewerType: brewer.BrewerType, 1101 1108 Description: brewer.Description, 1109 + SourceRef: brewer.SourceRef, 1102 1110 CreatedAt: existing.CreatedAt, 1103 1111 } 1104 1112
+6
internal/firehose/consumer.go
··· 402 402 if err := c.index.DeleteLike(event.DID, subjectURI); err != nil { 403 403 log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete like index") 404 404 } 405 + c.index.DeleteLikeNotification(event.DID, subjectURI) 405 406 } 406 407 } 407 408 } ··· 418 419 if err := json.Unmarshal(existingRecord.Record, &recordData); err == nil { 419 420 if subject, ok := recordData["subject"].(map[string]interface{}); ok { 420 421 if subjectURI, ok := subject["uri"].(string); ok { 422 + var parentURI string 423 + if parent, ok := recordData["parent"].(map[string]interface{}); ok { 424 + parentURI, _ = parent["uri"].(string) 425 + } 421 426 if err := c.index.DeleteComment(event.DID, commit.RKey, subjectURI); err != nil { 422 427 log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete comment index") 423 428 } 429 + c.index.DeleteCommentNotification(event.DID, subjectURI, parentURI) 424 430 } 425 431 } 426 432 }
+75 -24
internal/firehose/index.go
··· 1129 1129 1130 1130 if err := idx.UpsertRecord(did, collection, rkey, record.CID, recordJSON, 0); err != nil { 1131 1131 log.Warn().Err(err).Str("uri", record.URI).Msg("failed to upsert record during backfill") 1132 - } else { 1133 - recordCount++ 1132 + continue 1133 + } 1134 + recordCount++ 1135 + 1136 + // Index likes and comments into their specialized buckets 1137 + switch collection { 1138 + case atproto.NSIDLike: 1139 + if subject, ok := record.Value["subject"].(map[string]interface{}); ok { 1140 + if subjectURI, ok := subject["uri"].(string); ok { 1141 + if err := idx.UpsertLike(did, rkey, subjectURI); err != nil { 1142 + log.Warn().Err(err).Str("uri", record.URI).Msg("failed to index like during backfill") 1143 + } 1144 + } 1145 + } 1146 + case atproto.NSIDComment: 1147 + if subject, ok := record.Value["subject"].(map[string]interface{}); ok { 1148 + if subjectURI, ok := subject["uri"].(string); ok { 1149 + text, _ := record.Value["text"].(string) 1150 + var createdAt time.Time 1151 + if createdAtStr, ok := record.Value["createdAt"].(string); ok { 1152 + if parsed, err := time.Parse(time.RFC3339, createdAtStr); err == nil { 1153 + createdAt = parsed 1154 + } else { 1155 + createdAt = time.Now() 1156 + } 1157 + } else { 1158 + createdAt = time.Now() 1159 + } 1160 + var parentURI string 1161 + if parent, ok := record.Value["parent"].(map[string]interface{}); ok { 1162 + parentURI, _ = parent["uri"].(string) 1163 + } 1164 + if err := idx.UpsertComment(did, rkey, subjectURI, parentURI, record.CID, text, createdAt); err != nil { 1165 + log.Warn().Err(err).Str("uri", record.URI).Msg("failed to index comment during backfill") 1166 + } 1167 + } 1168 + } 1134 1169 } 1135 1170 } 1136 1171 } ··· 1398 1433 1399 1434 actorKey := []byte(actorDID + ":" + rkey) 1400 1435 1401 - // Check if comment exists and get subject URI from index 1436 + // Get subject URI from the actor index, or use the provided one 1402 1437 existingSubject := commentsByActor.Get(actorKey) 1403 - if existingSubject == nil { 1404 - return nil // Comment doesn't exist, nothing to do 1405 - } 1406 - 1407 - // Use the subject URI from the index if not provided 1408 - if subjectURI == "" { 1438 + if existingSubject != nil && subjectURI == "" { 1409 1439 subjectURI = string(existingSubject) 1410 1440 } 1411 1441 1412 - // Find and delete the comment by iterating over comments with matching subject 1442 + // Find and delete the comment from BucketComments 1413 1443 var parentURI string 1414 - prefix := []byte(subjectURI + ":") 1415 - c := comments.Cursor() 1416 - for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() { 1417 - // Check if this key contains our actor and rkey 1418 - if strings.HasSuffix(string(k), ":"+actorDID+":"+rkey) { 1419 - // Parse the comment to get parent URI for cleanup 1420 - var comment IndexedComment 1421 - if err := json.Unmarshal(v, &comment); err == nil { 1422 - parentURI = comment.ParentURI 1444 + suffix := ":" + actorDID + ":" + rkey 1445 + 1446 + if subjectURI != "" { 1447 + // Fast path: we know the subject URI, scan only that prefix 1448 + prefix := []byte(subjectURI + ":") 1449 + c := comments.Cursor() 1450 + for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() { 1451 + if strings.HasSuffix(string(k), suffix) { 1452 + var comment IndexedComment 1453 + if err := json.Unmarshal(v, &comment); err == nil { 1454 + parentURI = comment.ParentURI 1455 + } 1456 + if err := comments.Delete(k); err != nil { 1457 + return fmt.Errorf("failed to delete comment: %w", err) 1458 + } 1459 + break 1423 1460 } 1424 - if err := comments.Delete(k); err != nil { 1425 - return fmt.Errorf("failed to delete comment: %w", err) 1461 + } 1462 + } else { 1463 + // Slow path: scan all comments to find this actor+rkey 1464 + c := comments.Cursor() 1465 + for k, v := c.First(); k != nil; k, v = c.Next() { 1466 + if strings.HasSuffix(string(k), suffix) { 1467 + var comment IndexedComment 1468 + if err := json.Unmarshal(v, &comment); err == nil { 1469 + parentURI = comment.ParentURI 1470 + subjectURI = comment.SubjectURI 1471 + } 1472 + if err := comments.Delete(k); err != nil { 1473 + return fmt.Errorf("failed to delete comment: %w", err) 1474 + } 1475 + break 1426 1476 } 1427 - break 1428 1477 } 1429 1478 } 1430 1479 1431 1480 // Delete actor lookup 1432 - if err := commentsByActor.Delete(actorKey); err != nil { 1433 - return fmt.Errorf("failed to delete comment by actor: %w", err) 1481 + if existingSubject != nil { 1482 + if err := commentsByActor.Delete(actorKey); err != nil { 1483 + return fmt.Errorf("failed to delete comment by actor: %w", err) 1484 + } 1434 1485 } 1435 1486 1436 1487 // Delete parent-child relationship if this was a reply
+63
internal/firehose/notifications.go
··· 199 199 return did 200 200 } 201 201 202 + // DeleteNotification removes a notification matching (type + actorDID + subjectURI) 203 + // from the target user's notification list. No-op if not found. 204 + func (idx *FeedIndex) DeleteNotification(targetDID string, notifType models.NotificationType, actorDID, subjectURI string) { 205 + if targetDID == "" { 206 + return 207 + } 208 + 209 + err := idx.db.Update(func(tx *bolt.Tx) error { 210 + b := tx.Bucket(BucketNotifications) 211 + prefix := []byte(targetDID + ":") 212 + c := b.Cursor() 213 + for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() { 214 + var existing models.Notification 215 + if err := json.Unmarshal(v, &existing); err != nil { 216 + continue 217 + } 218 + if existing.Type == notifType && existing.ActorDID == actorDID && existing.SubjectURI == subjectURI { 219 + return b.Delete(k) 220 + } 221 + } 222 + return nil 223 + }) 224 + if err != nil { 225 + log.Warn().Err(err).Str("target", targetDID).Str("actor", actorDID).Msg("failed to delete notification") 226 + } 227 + } 228 + 229 + // DeleteLikeNotification removes the notification for a like that was undone 230 + func (idx *FeedIndex) DeleteLikeNotification(actorDID, subjectURI string) { 231 + targetDID := parseTargetDID(subjectURI) 232 + idx.DeleteNotification(targetDID, models.NotificationLike, actorDID, subjectURI) 233 + } 234 + 235 + // DeleteCommentNotification removes notifications for a deleted comment 236 + func (idx *FeedIndex) DeleteCommentNotification(actorDID, subjectURI, parentURI string) { 237 + // Remove the comment notification sent to the brew owner 238 + targetDID := parseTargetDID(subjectURI) 239 + idx.DeleteNotification(targetDID, models.NotificationComment, actorDID, subjectURI) 240 + 241 + // Remove the reply notification sent to the parent comment's author 242 + if parentURI != "" { 243 + parentAuthorDID := parseTargetDID(parentURI) 244 + if parentAuthorDID != targetDID { 245 + idx.DeleteNotification(parentAuthorDID, models.NotificationCommentReply, actorDID, subjectURI) 246 + } 247 + } 248 + } 249 + 250 + // GetCommentSubjectURI returns the subject URI for a comment by actor+rkey. 251 + // Returns empty string if not found. 252 + func (idx *FeedIndex) GetCommentSubjectURI(actorDID, rkey string) string { 253 + var subjectURI string 254 + _ = idx.db.View(func(tx *bolt.Tx) error { 255 + b := tx.Bucket(BucketCommentsByActor) 256 + v := b.Get([]byte(actorDID + ":" + rkey)) 257 + if v != nil { 258 + subjectURI = string(v) 259 + } 260 + return nil 261 + }) 262 + return subjectURI 263 + } 264 + 202 265 // CreateLikeNotification creates a notification for a like event 203 266 func (idx *FeedIndex) CreateLikeNotification(actorDID, subjectURI string) { 204 267 targetDID := parseTargetDID(subjectURI)
+220
internal/firehose/suggestions.go
··· 1 + package firehose 2 + 3 + import ( 4 + "encoding/json" 5 + "sort" 6 + "strings" 7 + 8 + "arabica/internal/atproto" 9 + 10 + bolt "go.etcd.io/bbolt" 11 + ) 12 + 13 + // EntitySuggestion represents a suggestion for auto-completing an entity 14 + type EntitySuggestion struct { 15 + Name string `json:"name"` 16 + SourceURI string `json:"source_uri"` 17 + Fields map[string]string `json:"fields"` 18 + Count int `json:"count"` 19 + } 20 + 21 + // entityFieldConfig defines which fields to extract and search for each entity type 22 + type entityFieldConfig struct { 23 + allFields []string 24 + searchFields []string 25 + nameField string 26 + } 27 + 28 + var entityConfigs = map[string]entityFieldConfig{ 29 + atproto.NSIDRoaster: { 30 + allFields: []string{"name", "location", "website"}, 31 + searchFields: []string{"name", "location", "website"}, 32 + nameField: "name", 33 + }, 34 + atproto.NSIDGrinder: { 35 + allFields: []string{"name", "grinderType", "burrType"}, 36 + searchFields: []string{"name", "grinderType", "burrType"}, 37 + nameField: "name", 38 + }, 39 + atproto.NSIDBrewer: { 40 + allFields: []string{"name", "brewerType", "description"}, 41 + searchFields: []string{"name", "brewerType"}, 42 + nameField: "name", 43 + }, 44 + atproto.NSIDBean: { 45 + allFields: []string{"name", "origin", "roastLevel", "process"}, 46 + searchFields: []string{"name", "origin", "roastLevel"}, 47 + nameField: "name", 48 + }, 49 + } 50 + 51 + // SearchEntitySuggestions searches indexed records for entity suggestions matching a query. 52 + // It scans BucketByCollection for the given collection, matches against searchable fields, 53 + // deduplicates by normalized name, and returns results sorted by popularity. 54 + func (idx *FeedIndex) SearchEntitySuggestions(collection, query string, limit int) ([]EntitySuggestion, error) { 55 + if limit <= 0 { 56 + limit = 10 57 + } 58 + 59 + config, ok := entityConfigs[collection] 60 + if !ok { 61 + return nil, nil 62 + } 63 + 64 + queryLower := strings.ToLower(strings.TrimSpace(query)) 65 + if len(queryLower) < 2 { 66 + return nil, nil 67 + } 68 + 69 + // dedupKey -> aggregated suggestion 70 + type candidate struct { 71 + suggestion EntitySuggestion 72 + fieldCount int // number of non-empty fields (to pick best representative) 73 + dids map[string]struct{} 74 + } 75 + candidates := make(map[string]*candidate) 76 + 77 + err := idx.db.View(func(tx *bolt.Tx) error { 78 + byCollection := tx.Bucket(BucketByCollection) 79 + recordsBucket := tx.Bucket(BucketRecords) 80 + 81 + prefix := []byte(collection + ":") 82 + c := byCollection.Cursor() 83 + 84 + for k, _ := c.Seek(prefix); k != nil; k, _ = c.Next() { 85 + if !hasPrefix(k, prefix) { 86 + break 87 + } 88 + 89 + // Extract URI from collection key 90 + uri := extractURIFromCollectionKey(k, collection) 91 + if uri == "" { 92 + continue 93 + } 94 + 95 + data := recordsBucket.Get([]byte(uri)) 96 + if data == nil { 97 + continue 98 + } 99 + 100 + var indexed IndexedRecord 101 + if err := json.Unmarshal(data, &indexed); err != nil { 102 + continue 103 + } 104 + 105 + var recordData map[string]interface{} 106 + if err := json.Unmarshal(indexed.Record, &recordData); err != nil { 107 + continue 108 + } 109 + 110 + // Extract fields 111 + fields := make(map[string]string) 112 + for _, f := range config.allFields { 113 + if v, ok := recordData[f].(string); ok && v != "" { 114 + fields[f] = v 115 + } 116 + } 117 + 118 + name := fields[config.nameField] 119 + if name == "" { 120 + continue 121 + } 122 + 123 + // Check if any searchable field matches the query 124 + matched := false 125 + for _, sf := range config.searchFields { 126 + val := strings.ToLower(fields[sf]) 127 + if val == "" { 128 + continue 129 + } 130 + if strings.HasPrefix(val, queryLower) || strings.Contains(val, queryLower) { 131 + matched = true 132 + break 133 + } 134 + } 135 + if !matched { 136 + continue 137 + } 138 + 139 + // Deduplicate by normalized name 140 + normalizedName := strings.ToLower(strings.TrimSpace(name)) 141 + 142 + if existing, ok := candidates[normalizedName]; ok { 143 + existing.dids[indexed.DID] = struct{}{} 144 + // Keep the record with more complete fields 145 + nonEmpty := 0 146 + for _, v := range fields { 147 + if v != "" { 148 + nonEmpty++ 149 + } 150 + } 151 + if nonEmpty > existing.fieldCount { 152 + existing.suggestion.Name = name 153 + existing.suggestion.Fields = fields 154 + existing.suggestion.SourceURI = indexed.URI 155 + existing.fieldCount = nonEmpty 156 + } 157 + } else { 158 + nonEmpty := 0 159 + for _, v := range fields { 160 + if v != "" { 161 + nonEmpty++ 162 + } 163 + } 164 + candidates[normalizedName] = &candidate{ 165 + suggestion: EntitySuggestion{ 166 + Name: name, 167 + SourceURI: indexed.URI, 168 + Fields: fields, 169 + }, 170 + fieldCount: nonEmpty, 171 + dids: map[string]struct{}{indexed.DID: {}}, 172 + } 173 + } 174 + } 175 + 176 + return nil 177 + }) 178 + if err != nil { 179 + return nil, err 180 + } 181 + 182 + // Build results with counts 183 + results := make([]EntitySuggestion, 0, len(candidates)) 184 + for _, c := range candidates { 185 + c.suggestion.Count = len(c.dids) 186 + results = append(results, c.suggestion) 187 + } 188 + 189 + // Sort: prefix matches first, then by count desc, then alphabetically 190 + sort.Slice(results, func(i, j int) bool { 191 + iPrefix := strings.HasPrefix(strings.ToLower(results[i].Name), queryLower) 192 + jPrefix := strings.HasPrefix(strings.ToLower(results[j].Name), queryLower) 193 + if iPrefix != jPrefix { 194 + return iPrefix 195 + } 196 + if results[i].Count != results[j].Count { 197 + return results[i].Count > results[j].Count 198 + } 199 + return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name) 200 + }) 201 + 202 + if len(results) > limit { 203 + results = results[:limit] 204 + } 205 + 206 + return results, nil 207 + } 208 + 209 + // hasPrefix checks if a byte slice starts with a prefix (avoids import of bytes) 210 + func hasPrefix(s, prefix []byte) bool { 211 + if len(s) < len(prefix) { 212 + return false 213 + } 214 + for i, b := range prefix { 215 + if s[i] != b { 216 + return false 217 + } 218 + } 219 + return true 220 + }
+224
internal/firehose/suggestions_test.go
··· 1 + package firehose 2 + 3 + import ( 4 + "encoding/json" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + "time" 9 + 10 + "arabica/internal/atproto" 11 + 12 + "github.com/stretchr/testify/assert" 13 + ) 14 + 15 + func newTestFeedIndex(t *testing.T) *FeedIndex { 16 + t.Helper() 17 + dir := t.TempDir() 18 + path := filepath.Join(dir, "test-index.db") 19 + idx, err := NewFeedIndex(path, 1*time.Hour) 20 + assert.NoError(t, err) 21 + t.Cleanup(func() { 22 + idx.Close() 23 + os.Remove(path) 24 + }) 25 + return idx 26 + } 27 + 28 + func insertRecord(t *testing.T, idx *FeedIndex, did, collection, rkey string, fields map[string]interface{}) { 29 + t.Helper() 30 + fields["$type"] = collection 31 + fields["createdAt"] = time.Now().Format(time.RFC3339) 32 + data, err := json.Marshal(fields) 33 + assert.NoError(t, err) 34 + err = idx.UpsertRecord(did, collection, rkey, "cid-"+rkey, data, 0) 35 + assert.NoError(t, err) 36 + } 37 + 38 + func TestSearchEntitySuggestions_PrefixMatch(t *testing.T) { 39 + idx := newTestFeedIndex(t) 40 + 41 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDRoaster, "r1", map[string]interface{}{ 42 + "name": "Black & White Coffee", 43 + "location": "Raleigh, NC", 44 + }) 45 + insertRecord(t, idx, "did:plc:bob", atproto.NSIDRoaster, "r2", map[string]interface{}{ 46 + "name": "Blue Bottle", 47 + "location": "Oakland, CA", 48 + }) 49 + 50 + results, err := idx.SearchEntitySuggestions(atproto.NSIDRoaster, "bl", 10) 51 + assert.NoError(t, err) 52 + assert.Len(t, results, 2) 53 + // Both match prefix "bl" 54 + assert.Equal(t, "Black & White Coffee", results[0].Name) 55 + assert.Equal(t, "Blue Bottle", results[1].Name) 56 + } 57 + 58 + func TestSearchEntitySuggestions_CaseInsensitive(t *testing.T) { 59 + idx := newTestFeedIndex(t) 60 + 61 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDRoaster, "r1", map[string]interface{}{ 62 + "name": "Stumptown Coffee", 63 + }) 64 + 65 + results, err := idx.SearchEntitySuggestions(atproto.NSIDRoaster, "STUMP", 10) 66 + assert.NoError(t, err) 67 + assert.Len(t, results, 1) 68 + assert.Equal(t, "Stumptown Coffee", results[0].Name) 69 + } 70 + 71 + func TestSearchEntitySuggestions_SubstringMatch(t *testing.T) { 72 + idx := newTestFeedIndex(t) 73 + 74 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDRoaster, "r1", map[string]interface{}{ 75 + "name": "Red Rooster Coffee", 76 + "location": "Floyd, VA", 77 + }) 78 + 79 + // Search by location substring 80 + results, err := idx.SearchEntitySuggestions(atproto.NSIDRoaster, "floyd", 10) 81 + assert.NoError(t, err) 82 + assert.Len(t, results, 1) 83 + assert.Equal(t, "Red Rooster Coffee", results[0].Name) 84 + } 85 + 86 + func TestSearchEntitySuggestions_Deduplication(t *testing.T) { 87 + idx := newTestFeedIndex(t) 88 + 89 + // Two users have the same roaster (different case/whitespace) 90 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDRoaster, "r1", map[string]interface{}{ 91 + "name": "Counter Culture", 92 + "location": "Durham, NC", 93 + "website": "https://counterculturecoffee.com", 94 + }) 95 + insertRecord(t, idx, "did:plc:bob", atproto.NSIDRoaster, "r2", map[string]interface{}{ 96 + "name": "Counter Culture", 97 + }) 98 + 99 + results, err := idx.SearchEntitySuggestions(atproto.NSIDRoaster, "counter", 10) 100 + assert.NoError(t, err) 101 + assert.Len(t, results, 1) 102 + assert.Equal(t, 2, results[0].Count) 103 + // Should keep the more complete record (alice's with location + website) 104 + assert.Equal(t, "Durham, NC", results[0].Fields["location"]) 105 + } 106 + 107 + func TestSearchEntitySuggestions_Limit(t *testing.T) { 108 + idx := newTestFeedIndex(t) 109 + 110 + for i := 0; i < 5; i++ { 111 + rkey := "r" + string(rune('0'+i)) 112 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDGrinder, rkey, map[string]interface{}{ 113 + "name": "Grinder " + string(rune('A'+i)), 114 + "grinderType": "hand", 115 + }) 116 + } 117 + 118 + results, err := idx.SearchEntitySuggestions(atproto.NSIDGrinder, "grinder", 3) 119 + assert.NoError(t, err) 120 + assert.Len(t, results, 3) 121 + } 122 + 123 + func TestSearchEntitySuggestions_ShortQuery(t *testing.T) { 124 + idx := newTestFeedIndex(t) 125 + 126 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDRoaster, "r1", map[string]interface{}{ 127 + "name": "ABC", 128 + }) 129 + 130 + // Query too short (< 2 chars) 131 + results, err := idx.SearchEntitySuggestions(atproto.NSIDRoaster, "a", 10) 132 + assert.NoError(t, err) 133 + assert.Empty(t, results) 134 + 135 + // 2 chars should work 136 + results, err = idx.SearchEntitySuggestions(atproto.NSIDRoaster, "ab", 10) 137 + assert.NoError(t, err) 138 + assert.Len(t, results, 1) 139 + } 140 + 141 + func TestSearchEntitySuggestions_EmptyQuery(t *testing.T) { 142 + idx := newTestFeedIndex(t) 143 + 144 + results, err := idx.SearchEntitySuggestions(atproto.NSIDRoaster, "", 10) 145 + assert.NoError(t, err) 146 + assert.Empty(t, results) 147 + } 148 + 149 + func TestSearchEntitySuggestions_UnknownCollection(t *testing.T) { 150 + idx := newTestFeedIndex(t) 151 + 152 + results, err := idx.SearchEntitySuggestions("unknown.collection", "test", 10) 153 + assert.NoError(t, err) 154 + assert.Empty(t, results) 155 + } 156 + 157 + func TestSearchEntitySuggestions_GrinderFields(t *testing.T) { 158 + idx := newTestFeedIndex(t) 159 + 160 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDGrinder, "g1", map[string]interface{}{ 161 + "name": "1Zpresso JX Pro", 162 + "grinderType": "hand", 163 + "burrType": "conical", 164 + }) 165 + 166 + results, err := idx.SearchEntitySuggestions(atproto.NSIDGrinder, "1zp", 10) 167 + assert.NoError(t, err) 168 + assert.Len(t, results, 1) 169 + assert.Equal(t, "hand", results[0].Fields["grinderType"]) 170 + assert.Equal(t, "conical", results[0].Fields["burrType"]) 171 + } 172 + 173 + func TestSearchEntitySuggestions_BeanFields(t *testing.T) { 174 + idx := newTestFeedIndex(t) 175 + 176 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDBean, "b1", map[string]interface{}{ 177 + "name": "Ethiopian Yirgacheffe", 178 + "origin": "Ethiopia", 179 + "roastLevel": "Light", 180 + "process": "Washed", 181 + }) 182 + 183 + // Search by origin 184 + results, err := idx.SearchEntitySuggestions(atproto.NSIDBean, "ethiopia", 10) 185 + assert.NoError(t, err) 186 + assert.Len(t, results, 1) 187 + assert.Equal(t, "Ethiopian Yirgacheffe", results[0].Name) 188 + assert.Equal(t, "Light", results[0].Fields["roastLevel"]) 189 + } 190 + 191 + func TestSearchEntitySuggestions_BrewerFields(t *testing.T) { 192 + idx := newTestFeedIndex(t) 193 + 194 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDBrewer, "br1", map[string]interface{}{ 195 + "name": "Hario V60", 196 + "brewerType": "Pour-Over", 197 + }) 198 + 199 + results, err := idx.SearchEntitySuggestions(atproto.NSIDBrewer, "hario", 10) 200 + assert.NoError(t, err) 201 + assert.Len(t, results, 1) 202 + assert.Equal(t, "Pour-Over", results[0].Fields["brewerType"]) 203 + } 204 + 205 + func TestSearchEntitySuggestions_SortOrder(t *testing.T) { 206 + idx := newTestFeedIndex(t) 207 + 208 + // "Alpha Roasters" used by 3 people 209 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDRoaster, "r1", map[string]interface{}{"name": "Alpha Roasters"}) 210 + insertRecord(t, idx, "did:plc:bob", atproto.NSIDRoaster, "r2", map[string]interface{}{"name": "Alpha Roasters"}) 211 + insertRecord(t, idx, "did:plc:charlie", atproto.NSIDRoaster, "r3", map[string]interface{}{"name": "Alpha Roasters"}) 212 + 213 + // "Alpha Beta" used by 1 person 214 + insertRecord(t, idx, "did:plc:dave", atproto.NSIDRoaster, "r4", map[string]interface{}{"name": "Alpha Beta"}) 215 + 216 + results, err := idx.SearchEntitySuggestions(atproto.NSIDRoaster, "alpha", 10) 217 + assert.NoError(t, err) 218 + assert.Len(t, results, 2) 219 + // More popular first 220 + assert.Equal(t, "Alpha Roasters", results[0].Name) 221 + assert.Equal(t, 3, results[0].Count) 222 + assert.Equal(t, "Alpha Beta", results[1].Name) 223 + assert.Equal(t, 1, results[1].Count) 224 + }
+14 -6
internal/handlers/entities.go
··· 169 169 Description: r.FormValue("description"), 170 170 RoasterRKey: r.FormValue("roaster_rkey"), 171 171 Closed: r.FormValue("closed") == "true", 172 + SourceRef: r.FormValue("source_ref"), 172 173 } 173 174 log.Debug(). 174 175 Str("name", req.Name). ··· 220 221 // Decode request (JSON or form) 221 222 if err := decodeRequest(r, &req, func() error { 222 223 req = models.CreateRoasterRequest{ 223 - Name: r.FormValue("name"), 224 - Location: r.FormValue("location"), 225 - Website: r.FormValue("website"), 224 + Name: r.FormValue("name"), 225 + Location: r.FormValue("location"), 226 + Website: r.FormValue("website"), 227 + SourceRef: r.FormValue("source_ref"), 226 228 } 227 229 return nil 228 230 }); err != nil { ··· 295 297 Description: r.FormValue("description"), 296 298 RoasterRKey: r.FormValue("roaster_rkey"), 297 299 Closed: r.FormValue("closed") == "true", 300 + SourceRef: r.FormValue("source_ref"), 298 301 } 299 302 log.Debug(). 300 303 Str("rkey", rkey). ··· 367 370 // Decode request (JSON or form) 368 371 if err := decodeRequest(r, &req, func() error { 369 372 req = models.UpdateRoasterRequest{ 370 - Name: r.FormValue("name"), 371 - Location: r.FormValue("location"), 372 - Website: r.FormValue("website"), 373 + Name: r.FormValue("name"), 374 + Location: r.FormValue("location"), 375 + Website: r.FormValue("website"), 376 + SourceRef: r.FormValue("source_ref"), 373 377 } 374 378 return nil 375 379 }); err != nil { ··· 428 432 GrinderType: r.FormValue("grinder_type"), 429 433 BurrType: r.FormValue("burr_type"), 430 434 Notes: r.FormValue("notes"), 435 + SourceRef: r.FormValue("source_ref"), 431 436 } 432 437 return nil 433 438 }); err != nil { ··· 475 480 GrinderType: r.FormValue("grinder_type"), 476 481 BurrType: r.FormValue("burr_type"), 477 482 Notes: r.FormValue("notes"), 483 + SourceRef: r.FormValue("source_ref"), 478 484 } 479 485 return nil 480 486 }); err != nil { ··· 532 538 Name: r.FormValue("name"), 533 539 BrewerType: r.FormValue("brewer_type"), 534 540 Description: r.FormValue("description"), 541 + SourceRef: r.FormValue("source_ref"), 535 542 } 536 543 return nil 537 544 }); err != nil { ··· 578 585 Name: r.FormValue("name"), 579 586 BrewerType: r.FormValue("brewer_type"), 580 587 Description: r.FormValue("description"), 588 + SourceRef: r.FormValue("source_ref"), 581 589 } 582 590 return nil 583 591 }); err != nil {
+1
internal/handlers/feed.go
··· 202 202 if err := h.feedIndex.DeleteLike(didStr, subjectURI); err != nil { 203 203 log.Warn().Err(err).Str("did", didStr).Str("subject_uri", subjectURI).Msg("Failed to delete like from feed index") 204 204 } 205 + h.feedIndex.DeleteLikeNotification(didStr, subjectURI) 205 206 likeCount = h.feedIndex.GetLikeCount(subjectURI) 206 207 } 207 208 } else {
+9 -2
internal/handlers/handlers.go
··· 385 385 // Delete the comment from the user's PDS 386 386 if err := store.DeleteCommentByRKey(r.Context(), rkey); err != nil { 387 387 http.Error(w, "Failed to delete comment", http.StatusInternalServerError) 388 - log.Error().Err(err).Msg("Failed to delete comment") 388 + log.Error().Err(err).Str("rkey", rkey).Str("did", didStr).Msg("Failed to delete comment from PDS") 389 389 return 390 390 } 391 391 392 392 metrics.CommentsTotal.WithLabelValues("delete").Inc() 393 393 394 - // Update firehose index 394 + // Update firehose index and remove notifications 395 395 if h.feedIndex != nil { 396 + // Look up subject URI before deletion for notification cleanup 397 + subjectURI := h.feedIndex.GetCommentSubjectURI(didStr, rkey) 398 + 396 399 if err := h.feedIndex.DeleteComment(didStr, rkey, ""); err != nil { 397 400 log.Warn().Err(err).Str("did", didStr).Str("rkey", rkey).Msg("Failed to delete comment from feed index") 401 + } 402 + 403 + if subjectURI != "" { 404 + h.feedIndex.DeleteCommentNotification(didStr, subjectURI, "") 398 405 } 399 406 } 400 407
+75
internal/handlers/suggestions.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "arabica/internal/atproto" 9 + "arabica/internal/firehose" 10 + 11 + "github.com/rs/zerolog/log" 12 + ) 13 + 14 + // entityTypeToNSID maps URL path segments to collection NSIDs 15 + var entityTypeToNSID = map[string]string{ 16 + "roasters": atproto.NSIDRoaster, 17 + "grinders": atproto.NSIDGrinder, 18 + "brewers": atproto.NSIDBrewer, 19 + "beans": atproto.NSIDBean, 20 + } 21 + 22 + // HandleEntitySuggestions returns typeahead suggestions for entity creation 23 + func (h *Handler) HandleEntitySuggestions(w http.ResponseWriter, r *http.Request) { 24 + // Require authentication 25 + if _, authenticated := h.getAtprotoStore(r); !authenticated { 26 + http.Error(w, "Authentication required", http.StatusUnauthorized) 27 + return 28 + } 29 + 30 + entityType := r.PathValue("entity") 31 + nsid, ok := entityTypeToNSID[entityType] 32 + if !ok { 33 + http.Error(w, "Unknown entity type", http.StatusBadRequest) 34 + return 35 + } 36 + 37 + query := r.URL.Query().Get("q") 38 + if len(query) < 2 { 39 + w.Header().Set("Content-Type", "application/json") 40 + w.Write([]byte("[]")) 41 + return 42 + } 43 + 44 + limit := 10 45 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 46 + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { 47 + limit = parsed 48 + } 49 + } 50 + if limit > 20 { 51 + limit = 20 52 + } 53 + 54 + if h.feedIndex == nil { 55 + w.Header().Set("Content-Type", "application/json") 56 + w.Write([]byte("[]")) 57 + return 58 + } 59 + 60 + suggestions, err := h.feedIndex.SearchEntitySuggestions(nsid, query, limit) 61 + if err != nil { 62 + log.Error().Err(err).Str("entity", entityType).Str("query", query).Msg("Failed to search suggestions") 63 + http.Error(w, "Failed to search suggestions", http.StatusInternalServerError) 64 + return 65 + } 66 + 67 + if suggestions == nil { 68 + suggestions = []firehose.EntitySuggestion{} 69 + } 70 + 71 + w.Header().Set("Content-Type", "application/json") 72 + if err := json.NewEncoder(w).Encode(suggestions); err != nil { 73 + log.Error().Err(err).Msg("Failed to encode suggestions response") 74 + } 75 + }
+8 -10
internal/middleware/logging.go
··· 141 141 } 142 142 143 143 func getCookies(r *http.Request) string { 144 - // sb := strings.Builder{} 145 - // loggedCookies := []string{"account_did", "session_id"} 146 - // for _, name := range loggedCookies { 147 - // // TODO: check if `c.Domain == "arabica.social"` if we start adding it 148 - // if c, err := r.Cookie(name); err == nil { 149 - // _, _ = sb.WriteString(c.Name + "=" + c.Value + "; ") 150 - // } 151 - // } 152 - // return sb.String() 153 - return r.Header.Get("cookies") 144 + loggedCookies := []string{"account_did", "session_id"} 145 + cookies := make([]string, 0, len(loggedCookies)) 146 + for _, name := range loggedCookies { 147 + if c, err := r.Cookie(name); err == nil { 148 + cookies = append(cookies, name+"="+c.Value) 149 + } 150 + } 151 + return strings.Join(cookies, "; ") 154 152 }
+18 -6
internal/models/models.go
··· 49 49 Description string `json:"description"` 50 50 RoasterRKey string `json:"roaster_rkey"` // AT Protocol reference 51 51 Closed bool `json:"closed"` // Whether the bag is closed/finished 52 + SourceRef string `json:"source_ref,omitempty"` 52 53 CreatedAt time.Time `json:"created_at"` 53 54 54 55 // Joined data for display ··· 60 61 Name string `json:"name"` 61 62 Location string `json:"location"` 62 63 Website string `json:"website"` 64 + SourceRef string `json:"source_ref,omitempty"` 63 65 CreatedAt time.Time `json:"created_at"` 64 66 } 65 67 ··· 69 71 GrinderType string `json:"grinder_type"` // Hand, Electric, Portable Electric 70 72 BurrType string `json:"burr_type"` // Conical, Flat, Blade, or empty 71 73 Notes string `json:"notes"` 74 + SourceRef string `json:"source_ref,omitempty"` 72 75 CreatedAt time.Time `json:"created_at"` 73 76 } 74 77 ··· 77 80 Name string `json:"name"` 78 81 BrewerType string `json:"brewer_type"` 79 82 Description string `json:"description"` 83 + SourceRef string `json:"source_ref,omitempty"` 80 84 CreatedAt time.Time `json:"created_at"` 81 85 } 82 86 ··· 137 141 Description string `json:"description"` 138 142 RoasterRKey string `json:"roaster_rkey"` 139 143 Closed bool `json:"closed"` 144 + SourceRef string `json:"source_ref,omitempty"` 140 145 } 141 146 142 147 type CreateRoasterRequest struct { 143 - Name string `json:"name"` 144 - Location string `json:"location"` 145 - Website string `json:"website"` 148 + Name string `json:"name"` 149 + Location string `json:"location"` 150 + Website string `json:"website"` 151 + SourceRef string `json:"source_ref,omitempty"` 146 152 } 147 153 148 154 type CreateGrinderRequest struct { ··· 150 156 GrinderType string `json:"grinder_type"` 151 157 BurrType string `json:"burr_type"` 152 158 Notes string `json:"notes"` 159 + SourceRef string `json:"source_ref,omitempty"` 153 160 } 154 161 155 162 type CreateBrewerRequest struct { 156 163 Name string `json:"name"` 157 164 BrewerType string `json:"brewer_type"` 158 165 Description string `json:"description"` 166 + SourceRef string `json:"source_ref,omitempty"` 159 167 } 160 168 161 169 type UpdateBeanRequest struct { ··· 166 174 Description string `json:"description"` 167 175 RoasterRKey string `json:"roaster_rkey"` 168 176 Closed bool `json:"closed"` 177 + SourceRef string `json:"source_ref,omitempty"` 169 178 } 170 179 171 180 type UpdateRoasterRequest struct { 172 - Name string `json:"name"` 173 - Location string `json:"location"` 174 - Website string `json:"website"` 181 + Name string `json:"name"` 182 + Location string `json:"location"` 183 + Website string `json:"website"` 184 + SourceRef string `json:"source_ref,omitempty"` 175 185 } 176 186 177 187 type UpdateGrinderRequest struct { ··· 179 189 GrinderType string `json:"grinder_type"` 180 190 BurrType string `json:"burr_type"` 181 191 Notes string `json:"notes"` 192 + SourceRef string `json:"source_ref,omitempty"` 182 193 } 183 194 184 195 type UpdateBrewerRequest struct { 185 196 Name string `json:"name"` 186 197 BrewerType string `json:"brewer_type"` 187 198 Description string `json:"description"` 199 + SourceRef string `json:"source_ref,omitempty"` 188 200 } 189 201 190 202 // Like represents a like on an Arabica record
+3
internal/routing/routing.go
··· 42 42 // Auth-protected but accessible without HTMX header (called from JavaScript) 43 43 mux.HandleFunc("GET /api/data", h.HandleAPIListAll) 44 44 45 + // Suggestion routes for entity typeahead (auth-protected, read-only GET) 46 + mux.HandleFunc("GET /api/suggestions/{entity}", h.HandleEntitySuggestions) 47 + 45 48 // HTMX partials (loaded async via HTMX) 46 49 // These return HTML fragments and should only be accessed via HTMX 47 50 mux.Handle("GET /api/feed", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleFeedPartial)))
+188 -33
internal/web/components/dialog_modals.templ
··· 30 30 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 31 31 class="space-y-4" 32 32 > 33 - <input 34 - type="text" 35 - name="name" 36 - value={ getStringValue(bean, "name") } 37 - placeholder="Name *" 38 - required 39 - class="w-full form-input" 40 - /> 33 + if bean == nil { 34 + <div x-data="entitySuggest('/api/suggestions/beans')" class="relative"> 35 + <input 36 + type="text" 37 + name="name" 38 + placeholder="Name *" 39 + required 40 + class="w-full form-input" 41 + x-model="query" 42 + @input.debounce.300ms="search()" 43 + @blur.debounce.200ms="showSuggestions = false" 44 + @focus="if (suggestions.length > 0) showSuggestions = true" 45 + autocomplete="off" 46 + /> 47 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 48 + <template x-if="showSuggestions && suggestions.length > 0"> 49 + <div class="suggestions-dropdown"> 50 + <template x-for="s in suggestions" :key="s.source_uri"> 51 + <button 52 + type="button" 53 + class="suggestions-item" 54 + @mousedown.prevent="selectBeanSuggestion(s)" 55 + > 56 + <span class="font-medium" x-text="s.name"></span> 57 + <template x-if="s.fields.origin"> 58 + <span class="text-xs text-brown-500" x-text="s.fields.origin"></span> 59 + </template> 60 + <template x-if="s.count > 1"> 61 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 62 + </template> 63 + </button> 64 + </template> 65 + </div> 66 + </template> 67 + </div> 68 + } else { 69 + <input 70 + type="text" 71 + name="name" 72 + value={ getStringValue(bean, "name") } 73 + placeholder="Name *" 74 + required 75 + class="w-full form-input" 76 + /> 77 + } 41 78 <input 42 79 type="text" 43 80 name="origin" ··· 148 185 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 149 186 class="space-y-4" 150 187 > 151 - <input 152 - type="text" 153 - name="name" 154 - value={ getStringValue(grinder, "name") } 155 - placeholder="Name *" 156 - required 188 + if grinder == nil { 189 + <div x-data="entitySuggest('/api/suggestions/grinders')" class="relative"> 190 + <input 191 + type="text" 192 + name="name" 193 + placeholder="Name *" 194 + required 195 + class="w-full form-input" 196 + x-model="query" 197 + @input.debounce.300ms="search()" 198 + @blur.debounce.200ms="showSuggestions = false" 199 + @focus="if (suggestions.length > 0) showSuggestions = true" 200 + autocomplete="off" 201 + /> 202 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 203 + <template x-if="showSuggestions && suggestions.length > 0"> 204 + <div class="suggestions-dropdown"> 205 + <template x-for="s in suggestions" :key="s.source_uri"> 206 + <button 207 + type="button" 208 + class="suggestions-item" 209 + @mousedown.prevent="selectGrinderSuggestion(s)" 210 + > 211 + <span class="font-medium" x-text="s.name"></span> 212 + <template x-if="s.fields.grinderType"> 213 + <span class="text-xs text-brown-500" x-text="s.fields.grinderType"></span> 214 + </template> 215 + <template x-if="s.count > 1"> 216 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 217 + </template> 218 + </button> 219 + </template> 220 + </div> 221 + </template> 222 + </div> 223 + } else { 224 + <input 225 + type="text" 226 + name="name" 227 + value={ getStringValue(grinder, "name") } 228 + placeholder="Name *" 229 + required 230 + class="w-full form-input" 231 + /> 232 + } 233 + <select 234 + name="grinder_type" 157 235 class="w-full form-input" 158 - /> 159 - <select name="grinder_type" class="w-full form-input" required> 236 + required 237 + > 160 238 <option value="">Select Grinder Type *</option> 161 239 for _, gType := range models.GrinderTypes { 162 240 <option ··· 169 247 </option> 170 248 } 171 249 </select> 172 - <select name="burr_type" class="w-full form-input"> 250 + <select 251 + name="burr_type" 252 + class="w-full form-input" 253 + > 173 254 <option value="">Select Burr Type (Optional)</option> 174 255 for _, bType := range models.BurrTypes { 175 256 <option ··· 227 308 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 228 309 class="space-y-4" 229 310 > 230 - <input 231 - type="text" 232 - name="name" 233 - value={ getStringValue(brewer, "name") } 234 - placeholder="Name *" 235 - required 236 - class="w-full form-input" 237 - /> 311 + if brewer == nil { 312 + <div x-data="entitySuggest('/api/suggestions/brewers')" class="relative"> 313 + <input 314 + type="text" 315 + name="name" 316 + placeholder="Name *" 317 + required 318 + class="w-full form-input" 319 + x-model="query" 320 + @input.debounce.300ms="search()" 321 + @blur.debounce.200ms="showSuggestions = false" 322 + @focus="if (suggestions.length > 0) showSuggestions = true" 323 + autocomplete="off" 324 + /> 325 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 326 + <template x-if="showSuggestions && suggestions.length > 0"> 327 + <div class="suggestions-dropdown"> 328 + <template x-for="s in suggestions" :key="s.source_uri"> 329 + <button 330 + type="button" 331 + class="suggestions-item" 332 + @mousedown.prevent="selectBrewerSuggestion(s)" 333 + > 334 + <span class="font-medium" x-text="s.name"></span> 335 + <template x-if="s.fields.brewerType"> 336 + <span class="text-xs text-brown-500" x-text="s.fields.brewerType"></span> 337 + </template> 338 + <template x-if="s.count > 1"> 339 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 340 + </template> 341 + </button> 342 + </template> 343 + </div> 344 + </template> 345 + </div> 346 + } else { 347 + <input 348 + type="text" 349 + name="name" 350 + value={ getStringValue(brewer, "name") } 351 + placeholder="Name *" 352 + required 353 + class="w-full form-input" 354 + /> 355 + } 238 356 <input 239 357 type="text" 240 358 name="brewer_type" ··· 287 405 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 288 406 class="space-y-4" 289 407 > 290 - <input 291 - type="text" 292 - name="name" 293 - value={ getStringValue(roaster, "name") } 294 - placeholder="Name *" 295 - required 296 - class="w-full form-input" 297 - /> 408 + if roaster == nil { 409 + <div x-data="entitySuggest('/api/suggestions/roasters')" class="relative"> 410 + <input 411 + type="text" 412 + name="name" 413 + placeholder="Name *" 414 + required 415 + class="w-full form-input" 416 + x-model="query" 417 + @input.debounce.300ms="search()" 418 + @blur.debounce.200ms="showSuggestions = false" 419 + @focus="if (suggestions.length > 0) showSuggestions = true" 420 + autocomplete="off" 421 + /> 422 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 423 + <template x-if="showSuggestions && suggestions.length > 0"> 424 + <div class="suggestions-dropdown"> 425 + <template x-for="s in suggestions" :key="s.source_uri"> 426 + <button 427 + type="button" 428 + class="suggestions-item" 429 + @mousedown.prevent="selectRoasterSuggestion(s)" 430 + > 431 + <span class="font-medium" x-text="s.name"></span> 432 + <template x-if="s.fields.location"> 433 + <span class="text-xs text-brown-500" x-text="s.fields.location"></span> 434 + </template> 435 + <template x-if="s.count > 1"> 436 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 437 + </template> 438 + </button> 439 + </template> 440 + </div> 441 + </template> 442 + </div> 443 + } else { 444 + <input 445 + type="text" 446 + name="name" 447 + value={ getStringValue(roaster, "name") } 448 + placeholder="Name *" 449 + required 450 + class="w-full form-input" 451 + /> 452 + } 298 453 <input 299 454 type="text" 300 455 name="location"
+1
internal/web/components/layout.templ
··· 115 115 <script src="/static/js/dropdown-manager.js?v=0.1.0"></script> 116 116 <!-- Load Alpine components BEFORE Alpine.js initializes --> 117 117 <script src="/static/js/brew-form.js?v=0.3.2"></script> 118 + <script src="/static/js/entity-suggest.js?v=0.1.0"></script> 118 119 <!-- Load Alpine.js core with defer (will initialize after DOM loads) --> 119 120 <script src="/static/js/alpine.min.js?v=0.2.0" defer></script> 120 121 <!-- Load HTMX and other utilities -->
+5
lexicons/social.arabica.alpha.bean.json
··· 48 48 "type": "string", 49 49 "format": "datetime", 50 50 "description": "Timestamp when the bean record was created" 51 + }, 52 + "sourceRef": { 53 + "type": "string", 54 + "format": "at-uri", 55 + "description": "AT-URI of the record this entity was sourced from" 51 56 } 52 57 } 53 58 }
+5
lexicons/social.arabica.alpha.brewer.json
··· 29 29 "type": "string", 30 30 "format": "datetime", 31 31 "description": "Timestamp when the brewer record was created" 32 + }, 33 + "sourceRef": { 34 + "type": "string", 35 + "format": "at-uri", 36 + "description": "AT-URI of the record this entity was sourced from" 32 37 } 33 38 } 34 39 }
+5
lexicons/social.arabica.alpha.grinder.json
··· 36 36 "type": "string", 37 37 "format": "datetime", 38 38 "description": "Timestamp when the grinder record was created" 39 + }, 40 + "sourceRef": { 41 + "type": "string", 42 + "format": "at-uri", 43 + "description": "AT-URI of the record this entity was sourced from" 39 44 } 40 45 } 41 46 }
+5
lexicons/social.arabica.alpha.roaster.json
··· 30 30 "type": "string", 31 31 "format": "datetime", 32 32 "description": "Timestamp when the roaster record was created" 33 + }, 34 + "sourceRef": { 35 + "type": "string", 36 + "format": "at-uri", 37 + "description": "AT-URI of the record this entity was sourced from" 33 38 } 34 39 } 35 40 }
+9
static/css/app.css
··· 191 191 @apply bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-2xl p-6 w-full border border-brown-300 max-h-[90vh] overflow-y-auto; 192 192 } 193 193 194 + /* Entity suggestion typeahead dropdown */ 195 + .suggestions-dropdown { 196 + @apply absolute z-50 left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-brown-200 max-h-48 overflow-y-auto; 197 + } 198 + 199 + .suggestions-item { 200 + @apply w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-brown-50 transition-colors cursor-pointer border-b border-brown-100 last:border-b-0; 201 + } 202 + 194 203 /* Typography */ 195 204 .section-title { 196 205 @apply text-2xl font-bold text-brown-900 mb-4;
+104
static/js/entity-suggest.js
··· 1 + // entitySuggest - Alpine.js component for typeahead suggestions in entity creation modals 2 + // Usage: x-data="entitySuggest('/api/suggestions/roasters')" 3 + function entitySuggest(endpoint) { 4 + return { 5 + query: '', 6 + suggestions: [], 7 + showSuggestions: false, 8 + sourceRef: '', 9 + originalName: '', 10 + 11 + async search() { 12 + if (this.query.length < 2) { 13 + this.suggestions = []; 14 + this.showSuggestions = false; 15 + return; 16 + } 17 + 18 + // Clear sourceRef if name changed from the selected suggestion 19 + if (this.originalName && this.query.toLowerCase() !== this.originalName.toLowerCase()) { 20 + this.sourceRef = ''; 21 + this.originalName = ''; 22 + } 23 + 24 + try { 25 + const resp = await fetch(endpoint + '?q=' + encodeURIComponent(this.query) + '&limit=10'); 26 + if (resp.ok) { 27 + this.suggestions = await resp.json(); 28 + this.showSuggestions = this.suggestions.length > 0; 29 + } 30 + } catch (e) { 31 + // Silently fail - suggestions are optional 32 + } 33 + }, 34 + 35 + // Entity-specific selection methods that populate the right form fields 36 + 37 + selectRoasterSuggestion(s) { 38 + this.query = s.name; 39 + this.sourceRef = s.source_uri; 40 + this.originalName = s.name; 41 + this.showSuggestions = false; 42 + 43 + const form = this.$el.closest('form'); 44 + if (s.fields.location) this._setInput(form, 'location', s.fields.location); 45 + if (s.fields.website) this._setInput(form, 'website', s.fields.website); 46 + }, 47 + 48 + selectGrinderSuggestion(s) { 49 + this.query = s.name; 50 + this.sourceRef = s.source_uri; 51 + this.originalName = s.name; 52 + this.showSuggestions = false; 53 + 54 + const form = this.$el.closest('form'); 55 + if (s.fields.grinderType) this._setSelect(form, 'grinder_type', s.fields.grinderType); 56 + if (s.fields.burrType) this._setSelect(form, 'burr_type', s.fields.burrType); 57 + }, 58 + 59 + selectBrewerSuggestion(s) { 60 + this.query = s.name; 61 + this.sourceRef = s.source_uri; 62 + this.originalName = s.name; 63 + this.showSuggestions = false; 64 + 65 + const form = this.$el.closest('form'); 66 + if (s.fields.brewerType) this._setInput(form, 'brewer_type', s.fields.brewerType); 67 + }, 68 + 69 + selectBeanSuggestion(s) { 70 + this.query = s.name; 71 + this.sourceRef = s.source_uri; 72 + this.originalName = s.name; 73 + this.showSuggestions = false; 74 + 75 + const form = this.$el.closest('form'); 76 + if (s.fields.origin) this._setInput(form, 'origin', s.fields.origin); 77 + if (s.fields.roastLevel) this._setSelect(form, 'roast_level', s.fields.roastLevel); 78 + if (s.fields.process) this._setInput(form, 'process', s.fields.process); 79 + }, 80 + 81 + // Helper: set value on an input/textarea by name 82 + _setInput(form, name, value) { 83 + const el = form.querySelector('[name="' + name + '"]'); 84 + if (el) { 85 + el.value = value; 86 + el.dispatchEvent(new Event('input', { bubbles: true })); 87 + } 88 + }, 89 + 90 + // Helper: set value on a select by name 91 + _setSelect(form, name, value) { 92 + const el = form.querySelector('[name="' + name + '"]'); 93 + if (!el) return; 94 + // Try exact match first, then case-insensitive 95 + for (const opt of el.options) { 96 + if (opt.value === value || opt.value.toLowerCase() === value.toLowerCase()) { 97 + el.value = opt.value; 98 + el.dispatchEvent(new Event('change', { bubbles: true })); 99 + return; 100 + } 101 + } 102 + }, 103 + }; 104 + }