File size: 70,533 Bytes
6213763
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6213763
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79688a8
 
 
 
 
 
 
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79688a8
 
 
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79688a8
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79688a8
 
 
de1e3fc
 
 
 
 
 
79688a8
 
 
 
 
 
 
 
de1e3fc
79688a8
 
 
 
 
 
 
 
 
 
de1e3fc
 
79688a8
 
 
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79688a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6213763
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7d2eaf
 
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
# PawTrace β€” Codebase Guide

> A file-by-file walkthrough of the entire application, written for someone who is still
> learning to code. It assumes **no prior knowledge of this project**. Read it top to bottom
> and you should be able to explain how every piece works and how the pieces fit together.

---

## Table of contents

1. [What this app is (the 60-second version)](#1-what-this-app-is)
2. [The big mental model](#2-the-big-mental-model)
3. [Vocabulary you need first](#3-vocabulary-you-need-first)
4. [The end-to-end story of one match](#4-the-end-to-end-story-of-one-match)
5. [Backend β€” configuration & plumbing](#5-backend--configuration--plumbing)
6. [Backend β€” the data model (database tables)](#6-backend--the-data-model)
7. [Backend β€” schemas (the API's data contracts)](#7-backend--schemas)
8. [Backend β€” the four "swap points" (storage, ML, geo)](#8-backend--the-four-swap-points)
9. [Backend β€” services (the business logic)](#9-backend--services)
10. [Backend β€” the API routers (HTTP endpoints)](#10-backend--the-api-routers)
11. [Backend β€” scripts, migrations, tests](#11-backend--scripts-migrations-tests)
12. [Frontend β€” setup & shared infrastructure](#12-frontend--setup--shared-infrastructure)
13. [Frontend β€” reusable components](#13-frontend--reusable-components)
14. [Frontend β€” pages (one per screen)](#14-frontend--pages)
15. [How to run it](#15-how-to-run-it)
16. [Where to read next, depending on your goal](#16-where-to-read-next)

---

## 1. What this app is

**PawTrace** is a website that helps reunite lost dogs with their owners. There are two sides:

- **Owners** register their dogs (with photos). If a dog goes missing, the owner opens a
  "lost case." The system searches all the **found/sighted** dogs that strangers have reported and
  shows the owner the most visually similar ones.
- **Finders** (people who find or spot a stray) upload a photo of the dog they found β€” **no account
  required**. The system instantly searches all currently-**lost** dogs and, if there's a strong
  match, emails that dog's owner.

The "magic" is **photo similarity matching**: every uploaded photo is turned into a list of numbers
(a "vector" / "embedding"), and two photos are considered similar if their vectors point in nearly
the same direction. Matches are always **suggestions** β€” a human must confirm them. The system also
filters by **geographic distance** (ZIP code proximity), because a dog lost in Washington DC is
unlikely to be the dog found in Seattle.

There are three reference documents at the repo root that this guide complements:

- `PROJECT_SPEC_dog_reunification_V1.md` β€” the original "build brief" (what to build and why). The
  code is full of comments like `(spec Β§9.3)` that point back to sections of this file.
- `DECISIONS.md` β€” a log of choices the developer made where the spec left options open.
- `README.md` β€” quick start / run instructions.

---

## 2. The big mental model

The project has two halves that run as **separate programs**:

```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         HTTP requests          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   FRONTEND (the browser)    β”‚   ───────────────────────►     β”‚   BACKEND (the server)        β”‚
β”‚   React + TypeScript        β”‚   ◄───────────────────────     β”‚   FastAPI (Python)            β”‚
β”‚   folder: /frontend         β”‚       JSON responses           β”‚   folder: /backend            β”‚
β”‚                             β”‚                                β”‚                                β”‚
β”‚   Renders screens, forms,   β”‚                                β”‚   Validates requests, runs    β”‚
β”‚   buttons. Talks to the     β”‚                                β”‚   the matching logic, talks   β”‚
β”‚   backend over HTTP.        β”‚                                β”‚   to the database & files.    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

- The **frontend** is what the user sees: pages, buttons, photo uploads. It cannot do anything by
  itself β€” it asks the backend for data and tells the backend when the user does something.
- The **backend** holds all the real logic: it stores data in a database (SQLite), saves uploaded
  images to disk, computes photo similarity, and decides who's allowed to see what.

They communicate over **HTTP** using **JSON** (a text format for structured data). During local
development the frontend runs on `http://localhost:5173` and the backend on
`http://127.0.0.1:8000`; the frontend's dev server "proxies" (forwards) API calls to the backend so
they appear to come from the same place (see `frontend/vite.config.ts`).

The backend is deliberately built around **four "swap points"** β€” places where a simple default
implementation can be replaced by a fancier one later *without changing any other code*:

| Swap point      | Implementations                                  | Deployed demo runs | File |
|-----------------|--------------------------------------------------|--------------------|------|
| **Embedder**    | `MockEmbedder`, `HFEmbedder`, `ReIDEmbedder`      | `ReIDEmbedder`     | `backend/app/ml/embedder.py` |
| **BreedClassifier** | `MockBreedClassifier`, `HFBreedClassifier`    | `HFBreedClassifier`| `backend/app/ml/breed.py` |
| **VectorIndex** | `NumpyBruteForceIndex` (FAISS / sqlite-vec later) | brute force        | `backend/app/ml/index.py` |
| **StorageBackend** | `LocalStorage` (`S3Storage` later)             | local disk         | `backend/app/storage/backend.py` |
| **Notifier**    | `ConsoleNotifier`, `SMTPNotifier`                 | console            | `backend/app/services/notify.py` |

Each swap point is defined as an **interface** (a promise about which methods exist) plus one or
more implementations. Code elsewhere only depends on the interface, so the concrete choice can be
flipped with a config setting.

---

## 3. Vocabulary you need first

- **API (endpoint / route):** a URL on the backend that does one thing, e.g. `POST /auth/login`.
  "POST/GET/PATCH/DELETE" are HTTP *methods* meaning roughly create / read / update / delete.
- **ORM (SQLAlchemy):** library that lets you work with database rows as Python objects instead of
  writing raw SQL. A "model" class maps to a database table.
- **Schema (Pydantic):** a class describing the *shape* of data coming in or going out of the API
  (which fields, which types). FastAPI uses these to validate requests and format responses.
- **Embedding / vector:** a fixed-length list of numbers representing an image. Similar images β†’
  similar vectors. Here every vector is **L2-normalized** (scaled to length 1) so that comparing two
  vectors with a *dot product* gives their **cosine similarity** (a number from -1 to 1; closer to 1
  = more similar).
- **JWT (token):** after you log in, the server gives you a signed string (a "JSON Web Token"). Your
  browser sends it with every request to prove who you are. The server doesn't store sessions; it
  just verifies the signature β€” this is called "stateless" auth.
- **Dependency injection (FastAPI `Depends`):** instead of each endpoint creating its own database
  connection or looking up the current user, it *declares* "I need a DB session" / "I need the
  logged-in user" and FastAPI supplies them. You'll see `Depends(get_db)` everywhere.

---

## 4. The end-to-end story of one match

Before the file-by-file detail, here's the single most important flow β€” a finder reports a found
dog and it matches someone's lost dog. Following this once makes the rest of the code click.

1. **Finder fills the form** in the browser (`frontend/src/pages/ReportFound.tsx`) β€” photo + ZIP +
   contact info β€” and hits submit.
2. The frontend packs it into a `FormData` object and calls `POST /cases/found`
   (`frontend/src/api.ts` β†’ `api.createReport`).
3. The backend endpoint `create_found_case` (`backend/app/api/cases.py:237`) receives it, then calls
   the shared helper `_create_found_sighted` (`backend/app/api/cases.py:131`).
4. That helper: rate-limits the request, requires contact info, creates an `UnknownDog` row, then
   for each photo calls `process_and_store_picture` (`backend/app/services/images.py:58`).
5. **The image pipeline** validates the photo, strips its EXIF/GPS data, shrinks it, saves a JPEG +
   thumbnail to disk via `LocalStorage`, then runs the **embedder** to produce a vector and saves an
   `Embedding` row.
6. A `Case` row (type `found`) is created, and `run_matching_for_case`
   (`backend/app/services/matching.py:84`) runs: it gathers all currently-**lost** known dogs within
   the ZIP radius, scores each one's photos against the found dog's photo (cosine similarity), keeps
   the ones above `REVIEW_THRESHOLD`, ranks them, and saves the top N as `Match` rows.
7. If the #1 match is strong enough (`STRONG_THRESHOLD`), `notify_owner_of_strong_match`
   (`backend/app/services/matching.py:210`) emails that dog's owner (printed to the console by
   default).
8. The endpoint returns the case + the ranked matches + vet/shelter guidance as JSON.
9. The frontend shows a success screen with `MatchCard` components and (for found dogs) what to do
   next (`ReportFound.tsx`, the `if (result)` block).
10. Later, the owner logs in, opens the case (`CaseDetail.tsx`), reviews the candidates, and clicks
    **Confirm** β€” which calls `POST /matches/{id}/confirm` (`backend/app/api/matches.py:34`),
    marking both dogs "reunited" and the case "resolved."

Keep this flow in mind as you read the files below.

---

## 5. Backend β€” configuration & plumbing

These files are the app's skeleton: how it reads settings, connects to the database, and boots up.

### `backend/app/config.py` β€” all the tunable settings

**Purpose:** one central place for every configurable value (database location, thresholds, image
limits, secrets). Values come from environment variables or a `.env` file, with sensible defaults so
the app runs with zero configuration.

- **`class Settings(BaseSettings)`** (`config.py:16`): Uses `pydantic-settings`, which automatically
  reads each attribute from an environment variable of the same name (case-insensitive). For example
  the attribute `review_threshold` is filled from a `REVIEW_THRESHOLD` env var if present.
  - `model_config` (`config.py:17`) tells it to load from a `.env` file and to ignore unknown env
    vars (`extra="ignore"`).
  - The fields are grouped by area: database, storage, embedder/ML, **matching thresholds**
    (`config.py:33` β€” explicitly flagged as *placeholders to tune*, not known-correct values),
    radius levels, image limits, notifications, SMS (off), auth/JWT, geo, and misc.
  - **`_parse_radius_levels`** (`config.py:72`) and **`_parse_cors`** (`config.py:79`) are
    *validators*. Env vars are always strings, so a value like `"0,10,25,50,100,-1"` needs to be
    split into a Python list of ints. `mode="before"` means "run this before normal type-checking."
    This is a small but important design touch: the spec demanded that every threshold/radius be a
    *named config value*, never a magic number hardcoded in logic.
  - **`media_path`** (`config.py:87`) and **`max_image_bytes`** (`config.py:91`) are computed
    properties β€” convenience values derived from the raw settings (e.g. MB β†’ bytes).
- **`get_settings()`** (`config.py:95`): wrapped in `@lru_cache` so the settings object is built
  only once and reused (a lightweight singleton). `settings = get_settings()` (`config.py:100`)
  creates that single instance that the whole app imports.

**Connects to:** essentially everything β€” almost every backend file does `from ..config import
settings`.

### `backend/app/db.py` β€” the database connection

**Purpose:** create the database "engine" and hand out database "sessions" (short-lived connections
used to run queries within one request).

- **Directory creation** (`db.py:17`): if using SQLite (the default), make sure the folder for the
  `.db` file exists before connecting.
- **`engine`** (`db.py:24`): the core object that knows how to talk to the database. `connect_args`
  with `check_same_thread: False` (`db.py:21`) is a SQLite-specific tweak that lets the connection be
  used across threads (FastAPI needs this).
- **`_enable_sqlite_fk`** (`db.py:27`): an event listener that runs `PRAGMA foreign_keys=ON` on each
  new SQLite connection. SQLite ignores foreign-key constraints by default; this turns enforcement
  on so the database actually rejects orphaned references.
- **`SessionLocal`** (`db.py:36`): a factory that produces new `Session` objects.
  `expire_on_commit=False` means objects you've loaded stay usable after a commit (handy because
  endpoints often return data right after committing).
- **`get_db()`** (`db.py:39`): a generator used as a FastAPI dependency. It opens a session, `yield`s
  it to the endpoint, and **always closes it afterward** (the `finally` block) even if an error
  occurs. Every endpoint that touches the DB receives its session via `db: Session = Depends(get_db)`.

**Connects to:** `config.py` (for `database_url`), every API router, and `services` that need DB
access.

### `backend/app/main.py` β€” the application entry point

**Purpose:** build the FastAPI `app` object, wire up middleware, error handling, all the routers,
and the static-file server. This is what `uvicorn app.main:app` runs.

- **`lifespan`** (`main.py:22`): an async context manager that runs **once at startup**. It creates
  the media folder and calls `Base.metadata.create_all` to auto-create all database tables. (The
  comment notes that in production you'd use Alembic migrations instead, but for the MVP this is
  simpler.)
- **`app = FastAPI(...)`** (`main.py:30`): creates the application with a title/version (which also
  populates the auto-generated API docs at `/docs`).
- **CORS middleware** (`main.py:37`): "Cross-Origin Resource Sharing." Because the frontend
  (`localhost:5173`) and backend (`127.0.0.1:8000`) are different origins, browsers block requests
  between them unless the server explicitly allows it. The allowed origins come from
  `settings.cors_origins`.
- **Error handlers** (`main.py:47` and `main.py:55`): these guarantee a *consistent error shape*. Any
  HTTP error returns `{"error": {"code": ..., "message": ...}}`, and validation errors (422) include
  a `details` list. The frontend's `api.ts` relies on this shape to extract error messages. (There's
  a test for it: `test_error_envelope_shape` in `test_auth.py`.)
- **Routers** (`main.py:64`–`70`): `include_router` attaches each group of endpoints (auth, users,
  dogs, cases, matches, admin, geo). Each router lives in its own file under `app/api/`.
- **Static media mount** (`main.py:74`): serves the saved image files under the `/media/...` URL so
  the browser can display them directly.

**Connects to:** all the routers, `config`, `db`, and the models.

### `backend/app/security.py` β€” passwords & login tokens

**Purpose:** everything about proving identity β€” hashing passwords, creating/verifying JWT tokens,
and the FastAPI dependencies that fetch the "current user."

- **`hash_password`** (`security.py:27`) / **`verify_password`** (`security.py:31`): use **bcrypt** to
  one-way-hash passwords. You never store the real password β€” only the hash. `verify_password`
  re-hashes the attempt and checks it matches. Note the `[:_BCRYPT_MAX]` truncation to 72 bytes
  (`security.py:24`): bcrypt only looks at the first 72 bytes, so they truncate consistently in both
  functions to avoid a subtle mismatch bug. `verify_password` returns `False` instead of crashing on
  malformed input β€” defensive design.
- **`create_access_token`** (`security.py:38`): builds a JWT containing the user id (`sub`) and an
  expiry (`exp`), signed with the secret from settings.
- **`_decode_user_id`** (`security.py:46`): reverses it β€” verifies the signature and returns the user
  id, or `None` if the token is invalid/expired. Catching the exceptions and returning `None` keeps
  callers simple.
- **`get_current_user_optional`** (`security.py:56`): a dependency that returns the logged-in `User`
  **or `None`** if there's no/invalid token. This is the key to "anonymous finders allowed" β€” note
  `HTTPBearer(auto_error=False)` (`security.py:21`) which means a missing token is *not* an automatic
  error.
- **`get_current_user`** (`security.py:68`): wraps the optional one and **raises 401** if there's no
  user. Endpoints that require login use this.
- **`require_admin`** (`security.py:80`): builds on `get_current_user` and raises 403 unless the
  user's role is `admin`. Used by the admin endpoints.

**Connects to:** `config` (JWT secret), `db` + `User` model (to load the user), and is consumed by
every protected endpoint via `Depends(...)`.

---

## 6. Backend β€” the data model

These files define the database tables. They all live in `backend/app/models/`. Each class
inheriting `Base` becomes a table; each `Mapped[...]` attribute becomes a column.

### `backend/app/models/base.py` β€” shared foundations & enums

**Purpose:** the declarative `Base` class, a timestamp mixin, and all the **enums** (fixed sets of
allowed string values like statuses and types).

- **`class Base(DeclarativeBase)`** (`base.py:15`): the parent class for every model. SQLAlchemy
  collects all subclasses' table definitions onto `Base.metadata`, which is how
  `create_all`/Alembic know what tables to build.
- **`TimestampMixin`** (`base.py:23`): adds `created_at` and `updated_at` columns to any model that
  inherits it. `default=_utcnow` sets them in Python; `onupdate=_utcnow` bumps `updated_at` on every
  change. A *mixin* is a small reusable class you add via multiple inheritance β€” here it avoids
  repeating the timestamp columns on every table.
- **The enums** (`base.py:38`–`94`): each is `(str, enum.Enum)` so the value is stored in the DB as a
  plain string (e.g. `"lost"`) but referenced in code as `CaseType.lost`. Important ones:
  - `UserRole`: `owner` / `finder` / `admin`.
  - `KnownDogStatus`: `home` β†’ `lost` β†’ `reunited`.
  - `UnknownDogStatus`: `pending` / `lost` (sighted, not in custody) / `at_shelter` / `claimed` /
    `reunited` β€” the comments (`base.py:51`) document exactly what each means.
  - `CaseType`: `lost` / `found` / `sighted`; `CaseStatus`: `open` β†’ `matched` β†’ `resolved` /
    `closed`.
  - `SubjectType`: `known` / `unknown` β€” used to say which *kind* of dog a picture or match refers to
    (this is the "polymorphic" link explained below).
  - `MatchStatus`, `NotificationChannel`, `NotificationStatus`.

**Connects to:** imported by every other model file; the enums are also reused by the Pydantic
schemas and the matching service.

### `backend/app/models/__init__.py` β€” the model registry

**Purpose:** importing this package (`from app.models import ...`) imports *all* model classes,
which registers every table on `Base.metadata`. If a model file isn't imported, its table won't be
created. The `__all__` list (`__init__.py:11`) declares the public names.

### `backend/app/models/user.py` β€” the `users` table

**Purpose:** registered accounts (owners and admins).

- **`class User`** (`user.py:9`): inherits `Base` and `TimestampMixin`.
  - `email` is `unique=True, index=True` (`user.py:14`) β€” the login identifier; indexing makes
    lookups fast.
  - `password_hash` is **nullable** (`user.py:18`) because the data model allows for lightweight
    accounts without a usable password (though in practice finders skip accounts entirely).
  - `role` uses `SAEnum(UserRole, native_enum=False)` (`user.py:19`). `native_enum=False` stores the
    value as a plain string column rather than a database-native ENUM type β€” more portable across
    databases.
  - **`dogs` relationship** (`user.py:23`): links a user to their `KnownDog` rows.
    `cascade="all, delete-orphan"` means deleting a user deletes their dogs too. `back_populates`
    pairs with the `owner` attribute on `KnownDog` so both sides stay in sync in memory.

**Connects to:** `KnownDog` (owns dogs), `Case` (a case's `person_id`), `Match.reviewed_by`,
`Notification.user_id`.

### `backend/app/models/dog.py` β€” `known_dogs` and `unknown_dogs`

**Purpose:** two tables, one file. A **`KnownDog`** is a dog an owner registered. An **`UnknownDog`**
is a stray someone found/sighted whose identity isn't known yet.

- **`class KnownDog`** (`dog.py:9`):
  - `owner_id` (`dog.py:15`) is a foreign key to `users.id`.
  - Appearance fields: `breed`, `age` (kept as a flexible string like "β‰ˆ3 yrs"), `color`, `size`
    (a `DogSize` enum). `color` and `size` matter because the matcher uses them as a cheap pre-filter.
  - `last_known_zip` (`dog.py:25`) is set when the dog is reported lost β€” it's the location the
    matcher uses for distance filtering.
  - `status` (`dog.py:26`) defaults to `home`.
  - `owner` relationship (`dog.py:30`) is the other half of `User.dogs`.
- **`class UnknownDog`** (`dog.py:33`):
  - Has *estimated* fields (`est_breed`, `est_age`) since nobody knows for sure.
  - `current_zip` (`dog.py:47`) is where the dog is now β€” used for distance filtering on this side.
  - **`current_location_detail`** (`dog.py:49`): the shelter/vet name. The comment stresses this is
    **never exposed publicly** (privacy, spec Β§14). You'll see this enforced later in `hydrate.py`
    and the `UnknownDogOut` schema.

**Connects to:** `User` (owner), `Case` (a case points to one known or one unknown dog), `Picture`
(by `subject_type`/`subject_id` convention), and the matching service.

### `backend/app/models/case.py` β€” the `cases` table

**Purpose:** a `Case` is one "incident" β€” a dog reported lost, found, or sighted. It ties together a
person (or anonymous finder contact), a dog, a location, a date, and a status.

- **`class Case`** (`case.py:11`):
  - `person_id` (`case.py:18`) is **nullable** β€” null for anonymous finders.
  - `finder_name/finder_email/finder_phone` (`case.py:22`): contact details stored directly on the
    case when there's no account.
  - `known_dog_id` and `unknown_dog_id` (`case.py:26`, `:29`): a lost case links to a `KnownDog`; a
    found/sighted case links to an `UnknownDog`. Only one is set.
  - `type` (`case.py:33`): lost/found/sighted.
  - `event_zip` (`case.py:34`, indexed) and `event_date`: where/when it happened.
  - **`search_radius_miles`** (`case.py:38`): the *current* widening level. Starts at the first
    radius level and grows when the user clicks "Widen search."
  - `status` (`case.py:40`): open/matched/resolved/closed.

**Connects to:** `User`, `KnownDog`/`UnknownDog`, `Match` (matches belong to a case), and is the
central object the matching service operates on.

### `backend/app/models/picture.py` β€” the `pictures` table

**Purpose:** one row per stored image. Crucially, **the image bytes are NOT in the database** β€” only
a file path (the actual file lives on disk via the storage backend).

- **`class Picture`** (`picture.py:11`):
  - **`subject_type` + `subject_id`** (`picture.py:20`, `:23`): this is a **polymorphic association**.
    Instead of two separate "known_dog_pictures" and "unknown_dog_pictures" tables, one table serves
    both: `subject_type` says which kind (`known`/`unknown`) and `subject_id` is that dog's id. The
    comment calls `subject_id` an "FK-by-convention" β€” it's not a real database foreign key (it can't
    be, since it points to one of two tables), so the application is responsible for integrity.
  - `file_path` / `thumb_path` (`picture.py:24`, `:25`): relative keys into the storage backend.
  - `is_primary` (`picture.py:29`): marks the representative photo shown in lists/cards.
  - `is_probably_not_dog` (`picture.py:30`): a flag from the original design, meant to warn that an
    upload may not show a dog while still storing it. Nothing sets it today β€” the planned detector was
    never built, and the breed model cannot stand in for one (see `ml/breed.py` below).
  - **`embeddings` relationship** (`picture.py:37`): one picture can have multiple embeddings (one per
    ML model version), with cascade delete.

**Connects to:** `Embedding` (one-to-many), and logically to `KnownDog`/`UnknownDog` via the
subject convention. Created by the image pipeline (`services/images.py`).

### `backend/app/models/embedding.py` β€” the `embeddings` table

**Purpose:** stores the numeric vector for one picture under one ML model. Kept in its own table
(rather than a column on `pictures`) so you can recompute vectors with a new model **without losing
the old ones** β€” vital for the "swap in a better model later" goal.

- **`class Embedding`** (`embedding.py:20`):
  - `__table_args__` with a **`UniqueConstraint`** (`embedding.py:29`) on
    `(picture_id, model_name, model_version)`: a given picture can have only one vector per model
    version (no duplicates).
  - `dim` (`embedding.py:39`): the vector length (64 for the mock, 2,048 for the ResNet-101 models).
  - **`vector`** (`embedding.py:40`): stored as `LargeBinary` (raw bytes). The comment notes it's
    float32 and **L2-normalized at write time** so cosine similarity is just a dot product.
  - **`as_array()`** (`embedding.py:49`): converts the stored bytes back into a NumPy array for math.
  - **`to_bytes()`** (`embedding.py:52`, a `@staticmethod`): converts a NumPy array into the bytes to
    store. The image pipeline calls `Embedding.to_bytes(vec)` when saving.

**Connects to:** `Picture` (parent), the embedder (`ml/embedder.py`) which produces the vectors, and
the matching service which reads them back via `as_array()`.

### `backend/app/models/match.py` β€” the `matches` table

**Purpose:** a saved candidate match β€” "case X's dog might be the same as dog Y, with similarity
score Z." These are the rows a human later confirms or rejects.

- **`class Match`** (`match.py:20`):
  - `case_id` (`match.py:26`): which case this match belongs to.
  - `candidate_type` + `candidate_id` (`match.py:27`, `:30`): the matched dog, again using the
    polymorphic `known`/`unknown` convention.
  - `candidate_case_id` (`match.py:31`): the *other* case the candidate dog came from, if any β€” used
    so confirming a match can resolve both cases.
  - `similarity_score` (`match.py:34`) and `rank` (`match.py:35`, 1 = best).
  - `model_name`/`model_version` (`match.py:36`): which embedder produced it β€” so old matches from a
    different model are distinguishable.
  - `status` (`match.py:38`): pending/confirmed/rejected. `reviewed` (`match.py:41`) and
    `reviewed_by` (`match.py:42`) record the human decision.

**Connects to:** `Case` (parent + candidate case), `User` (reviewer), `KnownDog`/`UnknownDog`
(candidate). Created by `services/matching.py`, read/updated by `api/matches.py` and `api/admin.py`.

### `backend/app/models/notification.py` β€” the `notifications` table

**Purpose:** an audit record of every notification the system tried to send (e.g. "emailed Alice
about a match"). Storing these lets you see what was sent and whether it succeeded.

- **`class Notification`** (`notification.py:18`): links optionally to a `user`, `case`, and `match`;
  records the `channel` (email/sms), `status` (queued/sent/failed), `to_address`, the `payload`
  (the rendered message text), and `sent_at`.

**Connects to:** written by `send_notification` in `services/notify.py`.

---

## 7. Backend β€” schemas

Schemas (in `backend/app/schemas/`) are **Pydantic models** that describe the JSON going in and out
of the API. They're separate from the database models on purpose: the database model might have
private fields (like `password_hash` or `current_location_detail`) that should never appear in an
API response. Schemas are the controlled "public view."

A recurring detail: `model_config = ConfigDict(from_attributes=True)` lets a schema be built directly
from a database object via `Schema.model_validate(db_object)` β€” Pydantic reads the matching
attributes off the ORM object.

### `backend/app/schemas/common.py`

- **`Page[T]`** (`common.py:10`): a generic wrapper for paginated lists β€” `items`, `total`, `limit`,
  `offset`. Generic (`Generic[T]`) means `Page[KnownDog]`, `Page[Case]`, etc. all reuse it.
- **`Message`** (`common.py:17`): a trivial `{ "detail": "..." }` response (e.g. logout).

### `backend/app/schemas/auth.py`

- **`RegisterRequest`** (`auth.py:8`): registration input. Note `Field(min_length=8, ...)` on the
  password and `EmailStr` (validates email format β€” requires the `email-validator` package).
- **`LoginRequest`** (`auth.py:16`), **`TokenResponse`** (`auth.py:21`, the `{access_token, token_type}`
  returned on login/register).
- **`UserOut`** (`auth.py:26`): the safe view of a user β€” note it **omits `password_hash`**.
- **`UserUpdate`** (`auth.py:37`): all-optional fields for `PATCH /users/me`.

### `backend/app/schemas/dog.py`

- **`PictureOut`** (`dog.py:10`): a picture for the API. It adds **`url`** and **`thumb_url`**
  (`dog.py:23`, `:24`) which aren't database columns β€” they're filled in by `hydrate_picture` in
  `api/helpers.py` so the frontend gets ready-to-use image URLs.
- **`KnownDogCreate`** (`dog.py:27`) / **`KnownDogUpdate`** (`dog.py:37`): create vs. update inputs
  (update is all-optional).
- **`KnownDogOut`** (`dog.py:47`): full owner-facing dog view, including its `pictures`.
- **`UnknownDogOut`** (`dog.py:65`): the **privacy-safe** view of a found/sighted dog. The docstring
  and the field list make the point: it has `current_zip` (ZIP-level only) but **no**
  `current_location_detail`. This is privacy enforced by *what the schema includes*.

### `backend/app/schemas/case.py`

- **`LostCaseCreate`** (`case.py:10`): opening a lost case. Either supply `known_dog_id` for an
  existing dog, **or** the `new_dog_*` fields to register a dog inline in the same step.
- **`FoundSightedCreate`** (`case.py:26`): a found/sighted report. Includes finder contact fields and
  `current_location_detail` (the private shelter name). *(Note: the actual found/sighted endpoints
  use individual `Form(...)` parameters rather than this schema because they're multipart uploads β€”
  this schema documents the conceptual shape.)*
- **`CaseUpdate`** (`case.py:48`): edit notes or close a case.
- **`MatchOut`** (`case.py:53`): a match for the API. The interesting field is
  **`candidate: dict | None`** (`case.py:67`) β€” a flexible bag holding the hydrated, privacy-filtered
  candidate dog (filled by `api/hydrate.py`).
- **`CaseOut`** (`case.py:70`): the case view.
- **`FoundReportResponse`** (`case.py:87`): the combined response for create-lost / create-found /
  widen β€” the case, its matches, and optional `vet_guidance`. The frontend's `FoundReportResponse`
  TypeScript type mirrors this exactly.

**Connects to:** the API routers (which declare these as `response_model=...`), and the frontend's
`types.ts` mirrors them by hand.

---

## 8. Backend β€” the four swap points

These are the pluggable interfaces from the mental model. Each defines an abstract interface plus a
default implementation, and a `get_*()` function that returns the configured one.

### `backend/app/storage/backend.py` β€” file storage

**Purpose:** decide *where* image files physically live. Default = local disk; the seam allows cloud
storage (S3) later.

- **`class StorageBackend(ABC)`** (`backend.py:14`): the interface. `ABC` = "Abstract Base Class";
  the `@abstractmethod` decorators mean any subclass **must** implement `save`, `open`, `delete`,
  `abs_path`. You can't instantiate the abstract class directly.
- **`class LocalStorage`** (`backend.py:32`): writes files under a root media directory.
  - **`_full(key)`** (`backend.py:37`): turns a relative key into an absolute path **and guards
    against path traversal** β€” it refuses keys that would escape the media root (e.g. `../../etc/...`).
    A small but real security measure.
  - `save` / `open` / `delete` / `abs_path` (`backend.py:44`–`59`): the obvious file operations.
    `abs_path` returns the on-disk path (used so the embedder can read the file directly).
- **`class S3Storage`** (`backend.py:62`): a **stub** β€” every method is empty and the constructor
  raises `NotImplementedError`. It exists to show exactly what you'd implement for cloud storage.
  `# pragma: no cover` tells the test-coverage tool to ignore it.
- **`get_storage()`** (`backend.py:77`): returns a single cached backend instance based on
  `settings.storage_backend`. The `global _backend` pattern is a simple lazy singleton.

**Connects to:** `services/images.py` (saves photos), `api/dogs.py` (deletes photos), and the
`/media` static mount in `main.py`.

### `backend/app/ml/embedder.py` β€” turning images into vectors

**Purpose:** the heart of the "AI." Converts an image file into an L2-normalized vector. Three
implementations ship: a deterministic stand-in for tests, and two real models. The deployed demo
runs `ReIDEmbedder`.

- **`class Embedder(Protocol)`** (`embedder.py:18`): the interface, written as a `Protocol` (Python's
  "structural typing" β€” any class with `name`, `version`, `dim`, and an `embed` method counts,
  without explicitly inheriting). `embed(image_paths)` returns one vector per path.
- **`_l2_normalize`** (`embedder.py:28`): scales a vector to length 1 (so dot product = cosine).
  Handles the zero-vector edge case to avoid dividing by zero.
- **`class MockEmbedder`** (`embedder.py:39`): selected by `EMBEDDER=mock`, and what the test suite
  uses.
  - **`_vector_for`**: opens the image, shrinks it to 32Γ—32 grayscale, hashes the raw pixels with
    SHA-256, then seeds a random-number generator with that hash to produce a stable vector.
    **Same image β†’ same vector every time.** The docstring is explicit that
    *similar-but-not-identical* images are **not** close β€” this is intentional. It exists so the
    whole pipeline runs and is testable without GPUs, weights, or network access; it is not meant to
    produce real-world matches. (This is why seed/test data reuses the *same image seed* to force a
    match β€” see `scripts/seed.py`.)
- **`class HFEmbedder`** (`embedder.py:73`): selected by `EMBEDDER=hf`. Uses the pooled
  pre-classifier features of the HuggingFace breed model as the vector. When the breed classifier is
  the same HF repo, it can return the embedding and the breed softmax from **one** forward pass,
  which is what `scripts/process_dataset.py` relies on.
- **`class ReIDEmbedder`** (`embedder.py:159`): selected by `EMBEDDER=reid`, and what the deployed
  demo runs. Loads the fine-tuned checkpoint at `REID_MODEL_PATH` into the same ResNet base and emits
  the L2-normalized 2,048-dim pooled features. Preprocessing matches **training** (Resize 224 +
  ImageNet normalization), not the HF image processor β€” a mismatch here silently degrades every
  score. This model produces no breed labels; those come from the separate classifier.
- **`get_embedder()`** (`embedder.py:207`) and **`reset_embedder_cache()`** (`embedder.py:219`):
  lazy singleton selection by config, plus a test hook to force re-selection after changing settings.

Both real embedders **lazily import** torch/transformers inside `__init__`, so the mock path never
needs those heavy packages installed. Both carry `# pragma: no cover` because tests run on the mock.

**Connects to:** `services/images.py` (embeds on upload), `services/matching.py` (reads the active
model name/version), and `scripts/eval_matching.py`.

### `backend/app/ml/index.py` β€” searching vectors

**Purpose:** given a query vector and a list of candidate vectors, return the most similar ones.

- **`class VectorIndex(Protocol)`** (`index.py:14`): the interface β€” a `search(query, candidates,
  top_k)` method returning `(id, similarity)` pairs.
- **`class NumpyBruteForceIndex`** (`index.py:25`): the default. **`search`** (`index.py:26`) stacks
  all candidate vectors into a matrix and computes `mat @ q` (matrix-times-vector = a dot product
  with each candidate). Because vectors are normalized, those dot products *are* cosine similarities.
  Then it sorts descending and takes the top `k`. "Brute force" = compare against everything;
  perfectly fine and exact at this app's small scale.
- **`get_index()`** (`index.py:45`): the cached singleton.

**Connects to:** `services/matching.py` (uses it for scoring).

### `backend/app/ml/breed.py` β€” predicting a breed

**Purpose:** guess a dog's breed from a photo. This is a **separate model** from the one that matches
individual dogs, and its labels are never used to rank photo-search results.

- **`class BreedClassifier(Protocol)`** (`breed.py:24`): the interface β€” `predict(image_paths)`
  returns `(label, score)` pairs per image.
- **`normalize_label`** (`breed.py:41`): tidies raw HuggingFace class names (underscores, casing) into
  something displayable, and gives the breed gate a stable key to compare against.
- **`class MockBreedClassifier`** (`breed.py:51`): deterministic stand-in, same rationale as the mock
  embedder β€” tests run offline with no weights.
- **`class HFBreedClassifier`** (`breed.py:95`): the real one, a 120-class ResNet-101 breed model
  pulled from HuggingFace on first use. This is what the deployed demo runs.
- **`get_breed_classifier()`** (`breed.py:140`) / **`reset_breed_classifier_cache()`**
  (`breed.py:150`): the cached singleton and its test hook.

**A limitation worth knowing:** with 120 classes and no "none of these" option, softmax must pick a
winner, so out-of-distribution inputs (noise, a blank square, a photo of text) can score extremely
high on some breed. A confidence threshold cannot separate those from genuine photos, because real
dogs also score in the high 90s. The interface states the limitation rather than filtering.

**Connects to:** `services/images.py` (predicts on upload, alongside embedding),
`services/matching.py` (the optional breed gate on the *case matcher*, not on photo search).

### `backend/app/ml/__init__.py` and `storage/__init__.py`

These just re-export the public names so other modules can write `from ..ml import get_embedder,
get_index` cleanly.

---

## 9. Backend β€” services

Services hold the **business logic** β€” the real work, separated from the HTTP layer so it can be
tested and reused. Located in `backend/app/services/`.

### `backend/app/services/geo.py` β€” distance between ZIP codes

**Purpose:** convert ZIP codes to latitude/longitude and compute real-world distances, which drives
the radius filtering.

- **`class GeoService`** (`geo.py:15`):
  - **`__init__`** (`geo.py:16`) + **`_load`** (`geo.py:22`): read the bundled
    `data/zip_centroids.csv` once into a dict mapping ZIP β†’ (lat, lng). It tolerates bad rows.
  - **`_norm`** (`geo.py:36`): normalizes a ZIP to 5 digits (zero-padded), so `"7030"` and `"07030"`
    match.
  - **`centroid`** (`geo.py:42`): look up a ZIP's coordinates.
  - **`distance_miles`** (`geo.py:46`): returns the great-circle distance between two ZIPs, or `None`
    if either ZIP is unknown.
  - **`_haversine`** (`geo.py:54`): the haversine formula β€” standard math for distance between two
    points on a sphere (Earth), here returning miles.
  - **`within_radius`** (`geo.py:62`): the function the matcher actually calls. `radius == -1` means
    "nationwide / no filter β†’ always True." **Key design choice: if a ZIP is unknown it returns
    `True` (fail-open)** so missing geo data never *hides* a possible match β€” the app prioritizes
    recall plus human review over precision. (Tested in `test_geo.py`.)
- **`get_geo()`** (`geo.py:78`): cached singleton (`@lru_cache`).

**Connects to:** `services/matching.py` (filtering), `api/geo.py` (the distance/shelter endpoints),
and reads the file path from `config`.

### `backend/app/services/images.py` β€” the image pipeline

**Purpose:** the full "what happens when a photo is uploaded" pipeline (spec Β§8): validate β†’
normalize β†’ store β†’ thumbnail β†’ embed.

- **`ImageValidationError`** (`images.py:24`): a custom exception; routers catch it and turn it into
  an HTTP 400.
- **`_validate_and_decode`** (`images.py:28`): checks the byte size against the limit, then uses
  Pillow's `verify()` for a cheap integrity check. Note it **re-opens** the image afterward
  (`images.py:36`) because `verify()` leaves the image object unusable β€” a real Pillow gotcha.
- **`_normalize`** (`images.py:42`): `exif_transpose` bakes in the correct rotation and, by not
  copying EXIF, **drops GPS/PII metadata** (a privacy requirement). Converts to RGB and downscales so
  the longest side ≀ the configured max.
- **`_encode`** (`images.py:52`): re-encodes to JPEG bytes.
- **`process_and_store_picture`** (`images.py:58`): the orchestrator, called by the routers.
  1. Validate + normalize (`images.py:67`).
  2. Build a unique storage key like `known/42/<uuid>.jpg` (`images.py:72`) and save the main image.
  3. Make and save a 320Γ—320 thumbnail (`images.py:77`).
  4. Insert the `Picture` row and `db.flush()` to assign its id (`images.py:92`) β€” `flush` sends the
     INSERT to the DB but doesn't commit the transaction yet.
  5. Read the saved file back, run the embedder, and insert an `Embedding` row (`images.py:96`). It
     embeds from the *stored* file so the vector reflects exactly what was persisted. The
     `if abs_path is not None` guard accommodates non-local storage backends.

  Note it does **not** commit β€” the calling endpoint commits, so multiple photos + matching can all
  succeed or fail together as one transaction.

**Connects to:** `storage` (save files), `ml` (embed), the `Picture`/`Embedding` models, and is
called by `api/dogs.py` and `api/cases.py`.

### `backend/app/services/notify.py` β€” sending notifications

**Purpose:** send an email (or pretend to) and record it. Default prints to the console.

- **`class Notifier(ABC)`** (`notify.py:24`): interface with a single `send(to, subject, body)`.
- **`ConsoleNotifier`** (`notify.py:30`): logs and prints the email β€” the dev default, so no real
  mail is sent.
- **`SMTPNotifier`** (`notify.py:37`): sends real email via SMTP, only used when configured. Returns
  `False` on failure rather than crashing.
- **`get_notifier()`** (`notify.py:56`): picks SMTP only if `notifier=smtp` *and* a host is set;
  otherwise console.
- **`send_notification`** (`notify.py:62`): the function the rest of the app calls. It first writes a
  `Notification` row with status `queued`, attempts the send, then updates the row to `sent` or
  `failed` and stamps `sent_at`. This guarantees an audit trail regardless of outcome.

**Connects to:** `services/matching.py` (triggers emails on strong matches), the `Notification`
model.

### `backend/app/services/matching.py` β€” the core matching engine

**Purpose:** the most important service. Given a case, find and rank candidate dogs, save them as
`Match` rows, update the case status, and trigger notifications. (spec Β§9.)

- **`CandidateScore`** (`matching.py:33`): a small dataclass holding a candidate's type, id, score,
  and originating case id while ranking.
- **`_active_model`** (`matching.py:41`): returns the current embedder's `(name, version)` so we only
  compare vectors made by the *same* model.
- **`_vectors_for_subject`** (`matching.py:46`): loads all embedding vectors for one dog's pictures,
  filtered to the active model. Joins `Embedding` β†’ `Picture` and filters by subject + model.
- **`_metadata_compatible`** (`matching.py:63`): the cheap appearance gate. It only excludes a
  candidate on a **definite conflict** (both have a color and they differ, or both have a size and
  they differ). Missing values never exclude β€” again, prioritizing recall.
- **`_dog_level_score`** (`matching.py:73`): a dog may have several photos. This computes the **max**
  cosine similarity over every (query photo Γ— candidate photo) pair. Using the max makes it robust to
  extra or bad photos β€” one good matching angle is enough.
- **`run_matching_for_case`** (`matching.py:84`): the orchestrator. Step by step:
  1. **Resolve the query subject** (`matching.py:94`): for a lost case the query is the known dog;
     for found/sighted it's the unknown dog. Grabs its color/size for the metadata gate. Returns `[]`
     early if there's no dog or no embedded photos yet.
  2. **Build the candidate pool** (`matching.py:113`):
     - A lost (known) dog searches the **unknown** pool with status in
       `pending`/`lost`/`at_shelter` (`matching.py:116`).
     - A found/sighted (unknown) dog searches the **known** pool with status `lost`
       (`matching.py:145`).
     - For each candidate: skip if outside the ZIP radius (`geo.within_radius`), skip on a metadata
       conflict, skip if it has no vectors, otherwise compute its dog-level score. It also looks up
       the candidate's own case id so a confirm can resolve both sides.
  3. **Filter, sort, cap** (`matching.py:172`): keep scores β‰₯ `REVIEW_THRESHOLD`, sort descending,
     take the top `TOP_N`.
  4. **Persist** (`matching.py:177`): delete this case's prior *pending* matches (so re-running
     replaces stale results but preserves confirmed/rejected ones), then insert fresh ranked `Match`
     rows.
  5. **Drive status** (`matching.py:199`): if there are matches and the case is still `open`, bump it
     to `matched`. It **never auto-resolves** β€” a human must confirm.
- **`is_strong`** (`matching.py:206`): true only if the rank-1 match's score β‰₯ `STRONG_THRESHOLD`.
- **`notify_owner_of_strong_match`** (`matching.py:210`): for a found/sighted case whose top match is
  a strong *known* dog, email that dog's owner. The email body (`matching.py:232`) deliberately
  contains only a case link and **no finder address** β€” contact stays mediated.
- **`rematch_open_lost_cases_against`** (`matching.py:244`): when a *new* found/sighted dog appears,
  re-run every open/matched lost case so existing owners can be alerted to the newcomer. Runs
  synchronously for the MVP; the docstring notes a background job is the future path.

**Connects to:** `ml` (embedder + index), `geo`, `notify`, all the dog/case/match models, and is
called by `api/cases.py` and `api/dogs.py`.

---

## 10. Backend β€” the API routers

Each file in `backend/app/api/` defines an `APIRouter` β€” a group of related endpoints. They are the
HTTP "surface" of the app; they validate input (via schemas), enforce permissions, call services,
and shape responses. All are attached to the app in `main.py`.

### `backend/app/api/helpers.py` β€” shared router utilities

- **`media_url`** (`helpers.py:19`): turns a stored file key into a `/media/...` URL.
- **`hydrate_picture`** (`helpers.py:23`): converts a `Picture` row into a `PictureOut` schema and
  fills in `url`/`thumb_url`.
- **`pictures_for`** (`helpers.py:30`): fetches all pictures for a subject, primary first, hydrated.
- **`rate_limit`** (`helpers.py:43`): a **basic in-memory fixed-window rate limiter**. It keeps a
  `deque` of recent request timestamps per client IP (`_buckets`, `helpers.py:40`), drops entries
  older than 60 seconds, and raises HTTP 429 if the count exceeds the configured limit. The comment
  context tells you this is intentionally simple (per-process, resets on restart) β€” fine for the MVP,
  not for a clustered production deployment.

### `backend/app/api/hydrate.py` β€” privacy-aware candidate hydration

**Purpose:** turn a `Match` into a `MatchOut` with its `candidate` dict filled in β€” **enforcing
privacy**. This is where the rule "never leak the shelter/vet location" is implemented for matches.

- **`build_match_out`** (`hydrate.py:17`): if the candidate is an unknown dog, it includes
  `current_zip` (ZIP-level) but **deliberately omits `current_location_detail`** (`hydrate.py:22`).
  If it's a known dog, it includes appearance fields and `last_known_zip` but no owner contact.
  Either way it attaches the candidate's hydrated pictures. There's a dedicated test
  (`test_unknown_dog_location_detail_not_exposed`) proving the secret never appears.

### `backend/app/api/auth.py` β€” register / login / me

Router prefix `/auth`.

- **`register`** (`auth.py:27`): rejects duplicate emails (409), hashes the password, creates an
  owner, commits, and returns a fresh JWT.
- **`login`** (`auth.py:46`): verifies email + password, returns a JWT, or 401. Note the combined
  check (`auth.py:49`) avoids revealing whether the email exists.
- **`logout`** (`auth.py:54`): a no-op for stateless JWT β€” the client just discards the token. Exists
  for API symmetry.
- **`me`** (`auth.py:60`): returns the current user (requires auth).

### `backend/app/api/users.py` β€” the current user's profile

Router prefix `/users`.

- **`get_me`** (`users.py:14`): return the profile.
- **`update_me`** (`users.py:19`): patch name/phone/zip. `model_dump(exclude_unset=True)`
  (`users.py:25`) means only fields the client actually sent are updated β€” partial update done right.

### `backend/app/api/dogs.py` β€” managing owned dogs & their photos

Router prefix `/dogs`. All endpoints require login.

- **`_get_owned_dog`** (`dogs.py:20`): the authorization helper β€” 404 if missing, 403 unless you own
  it (or are an admin). Reused by every dog endpoint.
- **`_to_out`** (`dogs.py:29`): build a `KnownDogOut` with its pictures attached.
- **`create_dog`** (`dogs.py:35`), **`list_my_dogs`** (`dogs.py:48`, paginated and clamped to ≀100),
  **`get_dog`** (`dogs.py:69`), **`update_dog`** (`dogs.py:78`).
- **`upload_photos`** (`dogs.py:93`): the busy one.
  - Enforces the per-dog photo cap (`dogs.py:106`).
  - Runs `process_and_store_picture` for each file, marking the first photo of a fresh dog as primary
    (`dogs.py:120`). Catches `ImageValidationError` β†’ 400.
  - **Then** (`dogs.py:131`) checks whether this dog has an open lost case and, if so, re-runs
    matching β€” because new photos can produce new matches (spec Β§8 step 6). Note the imports are
    *inside* the function (`dogs.py:127`) to avoid circular-import problems at module load.
  - Commits once at the end.
- **`delete_photo`** (`dogs.py:147`): validates ownership and that the picture belongs to this dog,
  deletes the files from storage, then deletes the row.

### `backend/app/api/cases.py` β€” the heart of the workflows

Router prefix `/cases`. This is the largest router; it implements lost/found/sighted creation,
listing, widening, and updates.

- **`_first_radius`** (`cases.py:52`) / **`_next_radius`** (`cases.py:56`): read the configured
  `radius_levels` list to find the starting radius and the next one when widening.
- **`_owns_case`** (`cases.py:65`): authorization β€” owner or admin only.
- **`create_lost_case`** (`cases.py:76`): requires login.
  - Either validates the existing `known_dog_id` (and ownership) or **inline-creates** a new dog from
    the `new_dog_*` fields (`cases.py:92`).
  - Marks the dog `lost` and sets its `last_known_zip` (`cases.py:103`).
  - Creates the `Case`, runs matching, commits, and returns case + matches.
- **`_create_found_sighted`** (`cases.py:131`): the shared engine for both found and sighted reports
  (so the two endpoints don't duplicate logic).
  - Rate-limits (`cases.py:153`).
  - **Requires contact info when anonymous** (`cases.py:156`) β€” 400 otherwise.
  - Requires at least one photo and enforces the photo cap.
  - Computes the unknown dog's initial status (`cases.py:169`): a *found* dog with a shelter detail β†’
    `at_shelter`; a plain *found* β†’ `pending`; a *sighted* dog β†’ `lost` (seen but not in custody).
  - Creates the `UnknownDog`, stores its photos, creates the `Case` (copying logged-in user's name/
    email as finder contact if present, `cases.py:203`).
  - Runs matching, notifies owners of strong matches, **and** re-matches open lost cases against this
    new dog (`cases.py:218`).
  - For *found* reports, attaches `vet_guidance` from `nearby_shelters` (`cases.py:228`).
- **`create_found_case`** (`cases.py:237`) and **`create_sighted_case`** (`cases.py:281`): the actual
  endpoints. They take **`Form(...)` fields + `File(...)`** because uploads are `multipart/form-data`,
  not JSON. They use `get_current_user_optional` so anonymous users are allowed. The sighted endpoint
  passes `None` for the found-only "where is it now" fields.
- **`list_my_cases`** (`cases.py:325`), **`get_case`** (`cases.py:348`, ownership-checked),
  **`get_case_matches`** (`cases.py:362`, returns hydrated matches ordered by rank).
- **`widen_case`** (`cases.py:381`): advance to the next radius level (400 if already widest) and
  re-run matching β€” letting an owner cast a wider net.
- **`update_case`** (`cases.py:405`): edit notes and/or close the case.

### `backend/app/api/matches.py` β€” confirm / reject a match

Router prefix `/matches`. Requires login.

- **`_load_owned_match`** (`matches.py:22`): fetches the match + its case and checks ownership/admin.
- **`confirm_match`** (`matches.py:34`): the resolution logic. Marks the match `confirmed`, then
  **resolves the case and marks both dogs `reunited`** β€” handling both directions (the candidate is
  an unknown dog vs. a known dog), and also resolves the candidate's own case if there is one
  (`matches.py:64`). This is what "drives the case toward resolution" concretely means.
- **`reject_match`** (`matches.py:74`): marks the match `rejected` and records the reviewer; leaves
  the case open so other candidates can still be confirmed.

### `backend/app/api/shelters.py` β€” static vet/shelter guidance

- **`nearby_shelters`** (`shelters.py:29`): returns a small static list of national resources plus
  (if a ZIP is given) a generic "animal control for ZIP X" entry. The module docstring is honest that
  this is **not** a real geolocated directory β€” it's MVP guidance, with real per-ZIP data flagged as
  future work.

### `backend/app/api/geo.py` β€” geo/utility endpoints

- **`get_nearby_shelters`** (`geo.py:11`): wraps `nearby_shelters`.
- **`get_distance`** (`geo.py:16`): returns miles between two ZIPs (handy for debugging the matcher).
- **`healthz`** (`geo.py:22`): a trivial health check returning `{"status": "ok"}` β€” used to verify
  the server is up.

### `backend/app/api/admin.py` β€” the thin admin slice

Router prefix `/admin`. Every endpoint uses `Depends(require_admin)`.

- **`pending_matches`** (`admin.py:18`): all matches awaiting review.
- **`all_cases`** (`admin.py:29`): every case in the system.
- **`close_case`** (`admin.py:38`): force-close any case.
- **`flag_spam`** (`admin.py:53`): mark a match rejected (the spam action). Note an admin can act on
  any match, unlike a regular user.

---

## 11. Backend β€” scripts, migrations, tests

### `backend/scripts/make_sample_images.py`

- **`make_image(seed, size)`** (`make_sample_images.py:13`): generates a deterministic, colorful
  "dog-ish" JPEG from a numeric seed β€” same seed β†’ same image bytes. No network, no real photos. This
  determinism is what lets the mock embedder produce *predictable* matches in seeds/tests (two
  subjects given the same seed get identical vectors β†’ a guaranteed strong match).

### `backend/scripts/seed.py`

- **`run()`** (`seed.py:27`): populates a fresh database so the app is demo-ready. It skips if data
  already exists (`seed.py:31`). It creates an admin, two owners (Alice, Bob), Alice's **lost** dog
  "Rex" in DC, a **found** dog near DC built with the *same image seed as Rex* (`seed.py:93`, so the
  mock embedder yields a strong match), and a distant Seattle sighting that should *not* match within
  a tight radius. Finally it runs matching for Rex's case so the demo opens with a match already
  waiting. Prints the seeded logins. Run with `python -m scripts.seed`.

### `backend/scripts/eval_matching.py`

- **Purpose:** the threshold-tuning harness (spec Β§9.6). Given a CSV of labeled image pairs
  (same-dog vs different-dog), it measures how well the *current* embedder separates them.
- **`_load_pairs`** (`eval_matching.py:25`): read the CSV.
- **`evaluate`** (`eval_matching.py:37`): embed every pair, compute cosine similarity, then sweep 101
  thresholds computing precision/recall and a trapezoidal **ROC-AUC**. This is the objective basis on
  which `REVIEW_THRESHOLD`/`STRONG_THRESHOLD` should eventually be set (rather than the placeholder
  defaults).
- **`main`** (`eval_matching.py:72`): CLI entry β€” `python -m scripts.eval_matching pairs.csv`.

### `backend/alembic/env.py` and `backend/alembic.ini`

- **Purpose:** Alembic is the database **migration** tool β€” for evolving the schema over time in
  production (instead of the dev-only `create_all`). `env.py` points Alembic at the app's
  `settings.database_url` and `Base.metadata` so generated migrations match the models, with
  `render_as_batch=True` for SQLite compatibility. `alembic.ini` is its config/logging. (The
  `versions/` folder is currently empty β€” no migrations have been generated yet, consistent with the
  MVP using `create_all`.)

### `backend/tests/` β€” the test suite

Run with `pytest`. Uses the deterministic mock embedder so results are reproducible.

- **`conftest.py`**: shared fixtures. Crucially it sets environment variables **before importing the
  app** (`conftest.py:12`) β€” pointing at a throwaway temp SQLite DB and media dir, forcing the mock
  embedder and console notifier, and raising the rate limit so tests don't trip it. **`_fresh_db`**
  (`conftest.py:31`, `autouse=True`) drops and recreates all tables around every test for isolation.
  **`client`** (`conftest.py:39`) is FastAPI's `TestClient`. **`owner_token`** (`conftest.py:55`)
  registers a user and returns a JWT; **`auth`** (`conftest.py:70`) builds the `Authorization` header.
- **`test_auth.py`**: registration, duplicate-email rejection, wrong password, auth-required, and the
  error-envelope shape.
- **`test_dogs.py`**: create/list a dog, that uploading a photo creates a picture + embedding, that
  non-images are rejected (400), and that you can't access someone else's dog (403).
- **`test_geo.py`**: close ZIPs are near, coast-to-coast is far, radius levels work, and unknown ZIPs
  fail open.
- **`test_matching.py`**: the end-to-end ones β€” a found report matches a lost dog (and gets vet
  guidance), anonymous reports require contact, a distant dog doesn't match until you widen to
  nationwide, confirming a match resolves the case, the private location detail is never exposed, and
  you can't view another person's case matches. These tests are an excellent **executable
  specification** of the whole system β€” read them to confirm your understanding.

---

## 12. Frontend β€” setup & shared infrastructure

The frontend is a **React + TypeScript Single-Page Application (SPA)** built with **Vite** and styled
with **Tailwind CSS**. "Single-page" means the browser loads one HTML page and JavaScript swaps the
visible content as you navigate β€” no full page reloads.

### `frontend/package.json`

Declares dependencies (`react`, `react-dom`, `react-router-dom` for routing) and scripts: `npm run
dev` (start the dev server), `build`, `preview`. Tailwind/PostCSS/TypeScript are dev dependencies.

### `frontend/vite.config.ts`

Configures the dev server on port 5173 and **proxies** API paths (`/auth`, `/dogs`, `/cases`,
`/media`, etc.) to the backend at `127.0.0.1:8000` (`vite.config.ts:9`). This is why the frontend can
call `/cases/found` directly without worrying about CORS or full URLs during development.

### `frontend/index.html` + `frontend/src/main.tsx`

- `index.html` is the single page; it has an empty `<div id="root">` and loads `main.tsx`.
- **`main.tsx`** mounts React into that div and wraps the whole app in three providers
  (`main.tsx:9`): `React.StrictMode` (dev safety checks), `BrowserRouter` (enables URL routing), and
  `AuthProvider` (makes login state available everywhere).

### `frontend/src/App.tsx` β€” routing

**Purpose:** map URLs to page components.

- **`RequireAuth`** (`App.tsx:17`): a guard component. While auth state is loading it shows "Loading…";
  if there's no user it **redirects to `/login`**; otherwise it renders the protected page.
- **`App`** (`App.tsx:24`): wraps everything in `Layout` and declares all the `Route`s. Public routes:
  home, login, register, report found/sighted. Protected routes (wrapped in `RequireAuth`): my dogs,
  dog detail, report lost, my cases, case detail, admin. The `path="*"` route (`App.tsx:81`) redirects
  unknown URLs home. Notice `ReportFound` is reused for both `found` and `sighted` via a `kind` prop.

### `frontend/src/types.ts` β€” TypeScript types

**Purpose:** hand-written TypeScript interfaces that **mirror the backend Pydantic schemas** so the
editor can type-check API data. The comment (`types.ts:1`) notes these could be auto-generated from
the backend's OpenAPI schema, but are hand-written for clarity. If you change a backend schema, update
the matching type here.

### `frontend/src/api.ts` β€” the typed fetch wrapper

**Purpose:** one place that knows how to talk to the backend. Every component calls `api.something()`
instead of using `fetch` directly.

- **Token helpers** (`api.ts:13`): store/read the JWT in `localStorage` under `pawtrace_token`.
- **`ApiError`** (`api.ts:21`): a custom error carrying the HTTP status.
- **`request<T>`** (`api.ts:29`): the core. It attaches the `Authorization: Bearer` header if logged
  in (`api.ts:31`), sets JSON content-type **unless the body is `FormData`** (`api.ts:33`, important
  for file uploads), handles 204 No Content, and on failure extracts the message from the backend's
  `{error:{message}}` envelope and throws `ApiError`.
- **`api`** (`api.ts:46`): the catalog of typed methods grouped by area (auth, dogs, cases, matches,
  geo). For example `createReport` (`api.ts:68`) sends `FormData` to `/cases/found|sighted`;
  `uploadDogPhotos` (`api.ts:59`) builds `FormData` from a `File[]`.

### `frontend/src/auth.tsx` β€” global login state

**Purpose:** a React **Context** that holds the current user and the login/register/logout functions,
so any component can call `useAuth()`.

- **`AuthProvider`** (`auth.tsx:15`): on mount, if a token exists it calls `api.me()` to load the user
  (and clears the token if it's invalid) β€” this is how you stay logged in across page refreshes.
  `login`/`register` (`auth.tsx:31`, `:37`) store the token then fetch the user; `logout`
  (`auth.tsx:43`) clears both.
- **`useAuth`** (`auth.tsx:55`): the hook components use; it throws if used outside the provider (a
  helpful guard).

### `frontend/src/index.css` + `tailwind.config.js` + `postcss.config.js`

- **`index.css`** pulls in Tailwind and defines reusable component classes with `@apply` (`.btn`,
  `.btn-primary`, `.input`, `.card`, `.badge`, etc.) so markup stays tidy (`btn-primary` instead of a
  dozen utility classes).
- **`tailwind.config.js`** defines the custom `brand` blue color palette and tells Tailwind which
  files to scan.
- **`postcss.config.js`** wires Tailwind + autoprefixer into the build.

---

## 13. Frontend β€” reusable components

In `frontend/src/components/`. These are the shared building blocks used across pages.

### `Layout.tsx`

The page chrome: a sticky header with the logo and navigation (which changes based on whether you're
logged in and whether you're an admin β€” `Layout.tsx:26`, `:34`), the main content area, and a footer
that restates the privacy/"matches are suggestions" message. `navClass` (`Layout.tsx:5`) highlights
the active nav link.

### `ConfidenceBar.tsx`

A small visual bar for a similarity score (`ConfidenceBar.tsx:2`). It converts the 0–1 score to a
percentage and picks a color and label β€” **Strong** (β‰₯80%, green), **Possible** (β‰₯60%, yellow), or
**Weak** (gray). It uses `role="progressbar"` for accessibility. This is the UI embodiment of "show
similarity clearly, as a suggestion."

### `MatchCard.tsx`

Displays one candidate match (`MatchCard.tsx:11`): the candidate's primary photo (falling back to a
placeholder), a title, breed/color/ZIP line, the `ConfidenceBar`, and β€” only for **pending** matches
when handlers are provided β€” **Confirm / Not my dog** buttons (`MatchCard.tsx:56`). The `act` helper
(`MatchCard.tsx:18`) disables the buttons while the request is in flight (the `busy` state). Reused on
the case detail page, the finder result screen, and the admin page.

### `PhotoUpload.tsx`

A multi-photo picker with live previews (`PhotoUpload.tsx:10`). Key detail: the hidden file input uses
`accept="image/*" capture="environment"` (`PhotoUpload.tsx:51`) so **on a phone it opens the rear
camera directly** β€” a core requirement for finders in the field. It enforces a max count, shows
removable thumbnails (built from `URL.createObjectURL`), and calls `onChange` with the current `File[]`
so the parent form can submit them.

---

## 14. Frontend β€” pages

In `frontend/src/pages/`. One component per screen. They follow a consistent pattern: local state via
`useState`, data loading via `useEffect` + `api.*`, and a `busy`/`error` pair for form submission.

### `Home.tsx`

The landing page (`Home.tsx:4`). Three calls to action ("I found a dog", "My dog is lost" / "Register
my dog" depending on login, "I sighted a dog") and a simple 3-step explainer.

### `Login.tsx` / `Register.tsx`

Standard auth forms. Each holds form state, calls `useAuth().login`/`register` on submit, shows an
error on failure, and navigates to `/dogs` on success (`Login.tsx:19`, `Register.tsx:22`).
`Register.tsx` collects name/email/password/ZIP/optional phone.

### `MyDogs.tsx`

Lists the owner's dogs as cards with a status badge (`MyDogs.tsx:48`), plus a collapsible
**`AddDogForm`** (`MyDogs.tsx:76`) that creates the dog and then uploads any chosen photos
(`MyDogs.tsx:94`) before reloading the list. Empty and loading states are handled.

### `DogDetail.tsx`

Shows one dog's details and photos, with a **Report lost** button (or a "Reported lost" badge if it
already is β€” `DogDetail.tsx:39`). Has its own `PhotoUpload` so you can add more photos after creation
(`DogDetail.tsx:68`); uploading reloads the dog. The little `Field` helper (`DogDetail.tsx:79`) renders
a label/value pair, showing "β€”" when empty.

### `ReportLost.tsx`

The owner's lost-case form (`ReportLost.tsx:7`). It lets you pick an existing dog **or** create one
inline (the `mode` toggle, `ReportLost.tsx:10`), captures last-seen ZIP/date/notes, calls
`api.createLost`, and navigates to the new case page. It auto-selects "new dog" mode if you have no
dogs yet (`ReportLost.tsx:22`).

### `ReportFound.tsx`

The finder flow, reused for both **found** and **sighted** via the `kind` prop (`ReportFound.tsx:11`).
Highlights:
- Builds a `FormData` (because of the photos) and only includes found-only fields for `found`, and
  finder-contact fields only when not logged in (`ReportFound.tsx:50`, `:55`).
- After submit it switches to a **results view** (the `if (result)` block, `ReportFound.tsx:69`)
  showing a friendly summary, the `vet_guidance` list for found dogs, and a `MatchCard` per match
  (read-only β€” no confirm buttons, since the finder isn't the owner).
- The shelter/vet field is clearly labeled "kept private" (`ReportFound.tsx:162`), matching the
  backend's privacy handling.

### `MyCases.tsx`

Lists the user's cases with type, ZIP, date, radius label, and a status badge
(`MyCases.tsx:33`). "nationwide" is shown when the radius is `-1` (`MyCases.tsx:38`).

### `CaseDetail.tsx`

The case workspace (`CaseDetail.tsx:7`). It loads the case and its matches in parallel
(`CaseDetail.tsx:16`), and provides the full owner workflow:
- **Widen search** (`CaseDetail.tsx:26`) β€” disabled at nationwide.
- **Confirm / reject** each match via `MatchCard` handlers (`CaseDetail.tsx:38`), reloading after.
- **Save notes** and **Close case** (`CaseDetail.tsx:46`, `:50`).
- A persistent reminder that matches are suggestions to confirm only if confident
  (`CaseDetail.tsx:93`).

### `Admin.tsx`

The thin admin screen (`Admin.tsx:6`). It lists pending matches and offers a **Flag as spam** button
per match. Note it calls the `/admin/...` endpoints with `fetch` directly (reading the token from
`localStorage`) rather than going through `api.ts` (`Admin.tsx:14`) β€” a small inconsistency, since the
admin endpoints simply weren't added to the shared `api` object.

---

## 15. How to run it

From the `README.md`, condensed:

**Backend** (from `backend/`):
```
python -m venv .venv
.venv\Scripts\Activate.ps1        # Windows PowerShell
pip install -r requirements.txt
python -m scripts.seed            # demo data
uvicorn app.main:app --reload --port 8000
```
API docs are auto-generated at `http://127.0.0.1:8000/docs`. Tests: `pytest -q`.

**Frontend** (from `frontend/`):
```
npm install
npm run dev                       # http://localhost:5173
```

Seeded logins: the demo seeder creates an owner (with lost dog Rex and a match waiting) and an
admin. Passwords are generated at seed time and printed once, never hardcoded.

---

## 16. Where to read next

Depending on what you want to be able to explain:

- **"How does the AI matching work?"** β†’ `ml/embedder.py` β†’ `ml/index.py` β†’ `services/matching.py`,
  then `tests/test_matching.py` to see it proven.
- **"How does a request flow through the system?"** β†’ re-read [section 4](#4-the-end-to-end-story-of-one-match),
  then follow `api/cases.py` into `services/`.
- **"How is privacy enforced?"** β†’ `schemas/dog.py` (`UnknownDogOut`), `api/hydrate.py`,
  `services/images.py` (`_normalize` strips EXIF), and the privacy tests.
- **"How does login work?"** β†’ `security.py` β†’ `api/auth.py` β†’ frontend `auth.tsx` + `api.ts`.
- **"How does the UI work?"** β†’ `App.tsx` (routing) β†’ `auth.tsx` (state) β†’ a page like
  `CaseDetail.tsx` β†’ the components it uses.

A good way to cement understanding: pick one of the `tests/test_matching.py` scenarios, then trace
every file it touches from the HTTP call down to the database and back. By the end you'll be able to
narrate the whole system confidently.