diff --git a/.gitignore b/.gitignore index a608dd1..95a42d0 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,5 @@ e2e/.env logs/ .vite/ e2e/env-defaults.local.json + +viewer-frontend/.data/ diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md index 9244f06..f743a40 100644 --- a/DEPLOYMENT_CHECKLIST.md +++ b/DEPLOYMENT_CHECKLIST.md @@ -46,6 +46,11 @@ SMTP_PASS=your-mailbox-password SMTP_FROM_EMAIL=noreply@your-domain.com SMTP_FROM_NAME=PunimTag Viewer +# Optional: digest when pending face-IDs queue grows (same SMTP/Resend transport) +# ADMIN_NOTIFY_EMAIL=you@example.com +# PENDING_QUEUE_DIGEST_THRESHOLD=1 +# PENDING_QUEUE_DIGEST_COOLDOWN_MINUTES=60 + # Option B: Resend only (set EMAIL_PROVIDER=resend, or leave SMTP_* unset above) RESEND_API_KEY=re_xxx RESEND_FROM_EMAIL=onboarding@resend.dev diff --git a/ROADMAP.md b/ROADMAP.md index 13938ef..d78decd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,9 @@ Living plan for product quality, auth/email reliability, and automation. - [x] **CI: `actions/upload-artifact@v4` pinned to `v3`** — v4 doesn't work against this Gitea/act runner's artifact backend; report upload on e2e failure was silently broken - [x] **CI: npm cache corruption retry** — `viewer-unit`/`admin-unit`/`e2e` all retry `npm ci` once after `npm cache clean --force` on first failure (shared act_runner cache has corrupted `@next/swc-linux-x64-musl` before, redding CI with no product bug) - [x] ROADMAP (this file) +- [x] **Person contact fields** — optional email/phone on identify (viewer + admin), pending approve UI, Modify person +- [x] **Approve Identified bulk select** — Select All Approve/Deny + Approve Next 10 +- [x] **Admin pending-queue digest email** — `ADMIN_NOTIFY_EMAIL` via `noreply@levkine.ca` SMTP (throttled) ## Next (near-term) diff --git a/admin-frontend/src/api/faces.ts b/admin-frontend/src/api/faces.ts index af9bcf0..09d4a4d 100644 --- a/admin-frontend/src/api/faces.ts +++ b/admin-frontend/src/api/faces.ts @@ -85,6 +85,8 @@ export interface IdentifyFaceRequest { middle_name?: string maiden_name?: string date_of_birth?: string + email?: string + phone?: string additional_face_ids?: number[] } diff --git a/admin-frontend/src/api/pendingIdentifications.ts b/admin-frontend/src/api/pendingIdentifications.ts index 2109dfa..3cf68f7 100644 --- a/admin-frontend/src/api/pendingIdentifications.ts +++ b/admin-frontend/src/api/pendingIdentifications.ts @@ -12,6 +12,8 @@ export interface PendingIdentification { middle_name?: string | null maiden_name?: string | null date_of_birth?: string | null + email?: string | null + phone?: string | null status: string created_at: string updated_at: string diff --git a/admin-frontend/src/api/people.ts b/admin-frontend/src/api/people.ts index 4a0712a..aae5d0e 100644 --- a/admin-frontend/src/api/people.ts +++ b/admin-frontend/src/api/people.ts @@ -7,6 +7,8 @@ export interface Person { middle_name?: string | null maiden_name?: string | null date_of_birth?: string | null + email?: string | null + phone?: string | null } export interface PeopleListResponse { @@ -30,6 +32,8 @@ export interface PersonCreateRequest { middle_name?: string maiden_name?: string date_of_birth?: string | null + email?: string | null + phone?: string | null } export interface PersonUpdateRequest { @@ -38,6 +42,8 @@ export interface PersonUpdateRequest { middle_name?: string maiden_name?: string date_of_birth?: string | null + email?: string | null + phone?: string | null } export const peopleApi = { diff --git a/admin-frontend/src/api/videos.ts b/admin-frontend/src/api/videos.ts index 3ad7bdc..e501093 100644 --- a/admin-frontend/src/api/videos.ts +++ b/admin-frontend/src/api/videos.ts @@ -50,6 +50,8 @@ export interface IdentifyVideoRequest { middle_name?: string maiden_name?: string date_of_birth?: string | null + email?: string | null + phone?: string | null } export interface IdentifyVideoResponse { diff --git a/admin-frontend/src/pages/ApproveIdentified.tsx b/admin-frontend/src/pages/ApproveIdentified.tsx index 139c15c..4aef725 100644 --- a/admin-frontend/src/pages/ApproveIdentified.tsx +++ b/admin-frontend/src/pages/ApproveIdentified.tsx @@ -90,6 +90,42 @@ export default function ApproveIdentified() { }) } + + const actionableIds = pendingIdentifications + .filter((p) => p.status !== 'approved') + .map((p) => p.id) + + const setDecisionForIds = (ids: number[], decision: 'approve' | 'deny') => { + setDecisions((prev) => { + const updated = { ...prev } + ids.forEach((id) => { + updated[id] = decision + }) + return updated + }) + } + + const handleSelectAllApprove = () => { + setDecisionForIds(actionableIds, 'approve') + } + + const handleSelectAllDeny = () => { + setDecisionForIds(actionableIds, 'deny') + } + + const handleApproveNextOnScreen = (limit = 10) => { + const ids = actionableIds.slice(0, limit) + if (ids.length === 0) { + alert('No pending identifications to approve.') + return + } + setDecisionForIds(ids, 'approve') + } + + const handleClearDecisions = () => { + setDecisions({}) + } + const handleSubmit = async () => { // Get all decisions that have been made, for pending or denied items (not approved) const decisionsList = Object.entries(decisions) @@ -267,6 +303,39 @@ export default function ApproveIdentified() { /> Include denied + {pendingIdentifications.filter((p) => p.status !== 'approved').length > 0 && ( + <> + + Select All to Approve + + + Select All to Deny + + handleApproveNextOnScreen(10)} + className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium" + title="Mark the first 10 actionable rows on screen as Approve" + > + Approve Next 10 + + + Clear Selection + + > + )} d !== null).length === 0} @@ -292,6 +361,9 @@ export default function ApproveIdentified() { Date of Birth + + Contact + Face @@ -322,6 +394,14 @@ export default function ApproveIdentified() { {formatDate(pending.date_of_birth)} + + + {pending.email || '-'} + + + {pending.phone || '-'} + + {pending.photo_id ? ( diff --git a/admin-frontend/src/pages/Help.tsx b/admin-frontend/src/pages/Help.tsx index dd6b035..15a2a52 100644 --- a/admin-frontend/src/pages/Help.tsx +++ b/admin-frontend/src/pages/Help.tsx @@ -972,10 +972,10 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) { Pending Identifications List: View all face identifications submitted by users Face Preview: See the face thumbnail and click to view the full photo User Information: See which user submitted each identification - Person Details: View the person name and date of birth that the user entered + Person Details: View the person name, date of birth, and optional email/phone that the user entered Status Filtering: Filter by pending, approved, or denied status Include Denied: Option to view previously denied identifications - Bulk Decisions: Approve or deny multiple identifications at once + Bulk Decisions: Select All to Approve / Deny, Approve Next 10, then submit Identification Report: View statistics about user identifications Database Cleanup: Delete denied records (admin only) @@ -990,7 +990,7 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) { For each identification: Click on the face thumbnail to see the full photo - Review the person name and date of birth entered by the user + Review the person name, date of birth, and contact fields entered by the user Check which user submitted the identification @@ -1000,6 +1000,7 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) { Check "Deny" to reject the identification + Or use bulk tools: "Select All to Approve", "Select All to Deny", or "Approve Next 10" (first actionable rows on screen) Click "Submit Decisions" button to process all selected decisions View the summary showing how many were approved and denied @@ -1035,7 +1036,7 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) { Click on face thumbnails to see the full photo - this helps verify the identification is correct Review the person name carefully - if a person with that name already exists, the face will be linked to them - You can approve or deny multiple identifications at once by checking boxes and clicking "Submit Decisions" + You can approve or deny multiple identifications at once with Select All / Approve Next 10, then "Submit Decisions" Denied identifications can be viewed later by checking "Include denied" Use the identification report to track user activity and identify patterns diff --git a/admin-frontend/src/pages/Identify.tsx b/admin-frontend/src/pages/Identify.tsx index 7da2a24..204d90b 100644 --- a/admin-frontend/src/pages/Identify.tsx +++ b/admin-frontend/src/pages/Identify.tsx @@ -62,6 +62,8 @@ export default function Identify() { const [middleName, setMiddleName] = useState('') const [maidenName, setMaidenName] = useState('') const [dob, setDob] = useState('') + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') const [busy, setBusy] = useState(false) const [imageLoading, setImageLoading] = useState(false) const [filtersCollapsed, setFiltersCollapsed] = useState(true) @@ -108,6 +110,8 @@ export default function Identify() { const [videoMiddleName, setVideoMiddleName] = useState('') const [videoMaidenName, setVideoMaidenName] = useState('') const [videoDob, setVideoDob] = useState('') + const [videoEmail, setVideoEmail] = useState('') + const [videoPhone, setVideoPhone] = useState('') const [videoIdentifying, setVideoIdentifying] = useState(false) // Store form data per face ID (matching desktop behavior) @@ -118,6 +122,8 @@ export default function Identify() { middleName: string maidenName: string dob: string + email: string + phone: string }>>({}) // Track previous face ID to save data on navigation @@ -662,9 +668,11 @@ export default function Identify() { middleName, maidenName, dob, + email, + phone, }, })) - }, [currentFace?.id, personId, firstName, lastName, middleName, maidenName, dob]) + }, [currentFace?.id, personId, firstName, lastName, middleName, maidenName, dob, email, phone]) // Restore form data when face changes (matching desktop behavior) useEffect(() => { @@ -676,6 +684,8 @@ export default function Identify() { setMiddleName('') setMaidenName('') setDob('') + setEmail('') + setPhone('') prevFaceIdRef.current = undefined return } @@ -694,6 +704,8 @@ export default function Identify() { setMiddleName(saved.middleName) setMaidenName(saved.maidenName) setDob(saved.dob) + setEmail(saved.email || '') + setPhone(saved.phone || '') } else { // No saved data - clear form setPersonId(undefined) @@ -702,6 +714,8 @@ export default function Identify() { setMiddleName('') setMaidenName('') setDob('') + setEmail('') + setPhone('') } prevFaceIdRef.current = currentFace.id @@ -729,6 +743,8 @@ export default function Identify() { const trimmedMiddleName = middleName.trim() const trimmedMaidenName = maidenName.trim() const trimmedDob = dob.trim() + const trimmedEmail = email.trim() + const trimmedPhone = phone.trim() const additional = Object.entries(selectedSimilar) .filter(([, v]) => v) .map(([k]) => Number(k)) @@ -748,6 +764,12 @@ export default function Identify() { if (trimmedDob) { payload.date_of_birth = trimmedDob } + if (trimmedEmail) { + payload.email = trimmedEmail + } + if (trimmedPhone) { + payload.phone = trimmedPhone + } } console.log('Identifying face:', currentFace.id, 'with payload:', payload) @@ -879,6 +901,8 @@ export default function Identify() { setVideoMiddleName('') setVideoMaidenName('') setVideoDob('') + setVideoEmail('') + setVideoPhone('') } const handleVideoIdentify = async () => { @@ -889,6 +913,8 @@ export default function Identify() { const trimmedMiddleName = videoMiddleName.trim() const trimmedMaidenName = videoMaidenName.trim() const trimmedDob = videoDob.trim() + const trimmedEmail = videoEmail.trim() + const trimmedPhone = videoPhone.trim() if (!videoPersonId && (!trimmedFirstName || !trimmedLastName)) { alert('Please select an existing person or enter first name and last name.') @@ -912,6 +938,12 @@ export default function Identify() { if (trimmedDob) { payload.date_of_birth = trimmedDob } + if (trimmedEmail) { + payload.email = trimmedEmail + } + if (trimmedPhone) { + payload.phone = trimmedPhone + } } await videosApi.identifyPerson(selectedVideo.id, payload) @@ -934,6 +966,8 @@ export default function Identify() { setVideoMiddleName('') setVideoMaidenName('') setVideoDob('') + setVideoEmail('') + setVideoPhone('') } catch (error: any) { console.error('Failed to identify person in video:', error) alert(error.response?.data?.detail || 'Failed to identify person in video. Please try again.') @@ -1350,6 +1384,8 @@ export default function Identify() { setMiddleName(selectedPerson.middle_name || '') setMaidenName(selectedPerson.maiden_name || '') setDob(selectedPerson.date_of_birth || '') + setEmail(selectedPerson.email || '') + setPhone(selectedPerson.phone || '') } } else { // Clear fields when selection is cleared @@ -1358,6 +1394,8 @@ export default function Identify() { setMiddleName('') setMaidenName('') setDob('') + setEmail('') + setPhone('') } }} className="mt-1 block w-full border rounded px-2 py-1"> @@ -1420,6 +1458,28 @@ export default function Identify() { readOnly={!!personId} disabled={!!personId} /> + + Email + { + setEmail(e.target.value) + setPersonId(undefined) + }} + className="mt-1 block w-full border rounded px-2 py-1" + placeholder="Optional" + readOnly={!!personId} + disabled={!!personId} /> + + + Phone + { + setPhone(e.target.value) + setPersonId(undefined) + }} + className="mt-1 block w-full border rounded px-2 py-1" + placeholder="Optional" + readOnly={!!personId} + disabled={!!personId} /> + + + + Email + + setVideoEmail(e.target.value)} + disabled={!!videoPersonId} + placeholder="Optional" + className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100" + /> + + + + Phone + + setVideoPhone(e.target.value)} + disabled={!!videoPersonId} + placeholder="Optional" + className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100" + /> + (null) @@ -35,6 +37,8 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) { middle_name: middleName.trim() || undefined, maiden_name: maidenName.trim() || undefined, date_of_birth: dob.trim() || null, + email: email.trim() || null, + phone: phone.trim() || null, }) onClose() } catch (err: any) { @@ -123,6 +127,30 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) { className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> + + + Email + setEmail(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Optional" + /> + + + + Phone + setPhone(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Optional" + /> + diff --git a/backend/api/faces.py b/backend/api/faces.py index d2909b0..76c52f7 100644 --- a/backend/api/faces.py +++ b/backend/api/faces.py @@ -324,6 +324,8 @@ def identify_face( last_name = (request.last_name or "").strip() middle_name = request.middle_name.strip() if request.middle_name else None maiden_name = request.maiden_name.strip() if request.maiden_name else None + email = request.email.strip() if request.email else None + phone = request.phone.strip() if request.phone else None if not (first_name and last_name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -336,6 +338,8 @@ def identify_face( middle_name=middle_name, maiden_name=maiden_name, date_of_birth=request.date_of_birth, + email=email, + phone=phone, created_date=datetime.utcnow(), ) db.add(person) diff --git a/backend/api/pending_identifications.py b/backend/api/pending_identifications.py index 03d14c9..b9622f9 100644 --- a/backend/api/pending_identifications.py +++ b/backend/api/pending_identifications.py @@ -70,6 +70,8 @@ class PendingIdentificationResponse(BaseModel): middle_name: Optional[str] = None maiden_name: Optional[str] = None date_of_birth: Optional[date] = None + email: Optional[str] = None + phone: Optional[str] = None status: str created_at: str updated_at: str @@ -178,6 +180,8 @@ def list_pending_identifications( pi.middle_name, pi.maiden_name, pi.date_of_birth, + pi.email, + pi.phone, pi.status, pi.created_at, pi.updated_at @@ -199,6 +203,8 @@ def list_pending_identifications( pi.middle_name, pi.maiden_name, pi.date_of_birth, + pi.email, + pi.phone, pi.status, pi.created_at, pi.updated_at @@ -229,6 +235,8 @@ def list_pending_identifications( middle_name=row.middle_name, maiden_name=row.maiden_name, date_of_birth=row.date_of_birth, + email=getattr(row, "email", None), + phone=getattr(row, "phone", None), status=row.status, created_at=str(row.created_at) if row.created_at else '', updated_at=str(row.updated_at) if row.updated_at else '', @@ -277,7 +285,9 @@ def approve_deny_pending_identifications( pi.last_name, pi.middle_name, pi.maiden_name, - pi.date_of_birth + pi.date_of_birth, + pi.email, + pi.phone FROM pending_identifications pi WHERE pi.id = :id AND pi.status IN ('pending', 'denied') """), {"id": decision.id}) @@ -326,6 +336,8 @@ def approve_deny_pending_identifications( query = query.filter(Person.date_of_birth.is_(None)) person = query.first() + pending_email = getattr(row, "email", None) + pending_phone = getattr(row, "phone", None) # Create person if doesn't exist if not person: @@ -336,10 +348,19 @@ def approve_deny_pending_identifications( middle_name=row.middle_name, maiden_name=row.maiden_name, date_of_birth=row.date_of_birth, + email=pending_email, + phone=pending_phone, created_date=datetime.utcnow(), ) main_db.add(person) main_db.flush() # get person.id + else: + # Fill missing contact fields from the pending submission + if pending_email and not person.email: + person.email = pending_email + if pending_phone and not person.phone: + person.phone = pending_phone + main_db.add(person) # Link face to person # Use FrontEndUser to indicate this was approved through the frontend UI diff --git a/backend/api/people.py b/backend/api/people.py index 70f69ad..1915232 100644 --- a/backend/api/people.py +++ b/backend/api/people.py @@ -107,6 +107,8 @@ def list_people_with_faces( middle_name=person.middle_name, maiden_name=person.maiden_name, date_of_birth=person.date_of_birth, + email=person.email, + phone=person.phone, face_count=face_count or 0, # Convert None to 0 for people with no faces video_count=video_counts.get(person.id, 0), # Get video count or default to 0 ) @@ -123,6 +125,8 @@ def create_person(request: PersonCreateRequest, db: Session = Depends(get_db)) - last_name = request.last_name.strip() middle_name = request.middle_name.strip() if request.middle_name else None maiden_name = request.maiden_name.strip() if request.maiden_name else None + email = request.email.strip() if request.email else None + phone = request.phone.strip() if request.phone else None # Explicitly set created_date to ensure it's a valid datetime object person = Person( first_name=first_name, @@ -130,6 +134,8 @@ def create_person(request: PersonCreateRequest, db: Session = Depends(get_db)) - middle_name=middle_name, maiden_name=maiden_name, date_of_birth=request.date_of_birth, + email=email, + phone=phone, created_date=datetime.utcnow(), ) db.add(person) @@ -168,6 +174,8 @@ def update_person( person.middle_name = request.middle_name.strip() if request.middle_name else None person.maiden_name = request.maiden_name.strip() if request.maiden_name else None person.date_of_birth = request.date_of_birth + person.email = request.email.strip() if request.email else None + person.phone = request.phone.strip() if request.phone else None try: db.commit() diff --git a/backend/api/videos.py b/backend/api/videos.py index 35d1499..25ebadd 100644 --- a/backend/api/videos.py +++ b/backend/api/videos.py @@ -212,6 +212,8 @@ def identify_person_in_video_endpoint( middle_name=request.middle_name, maiden_name=request.maiden_name, date_of_birth=request.date_of_birth, + email=request.email, + phone=request.phone, user_id=user_id, ) diff --git a/backend/app.py b/backend/app.py index ad68058..4414fcd 100644 --- a/backend/app.py +++ b/backend/app.py @@ -406,6 +406,80 @@ def ensure_photo_person_linkage_table(inspector) -> None: print("✅ Created photo_person_linkage table") +def ensure_people_contact_columns(inspector) -> None: + """Ensure people table has optional email/phone contact columns.""" + if "people" not in inspector.get_table_names(): + return + + columns = {column["name"] for column in inspector.get_columns("people")} + missing = [col for col in ("email", "phone") if col not in columns] + if not missing: + print("ℹ️ people.email / people.phone columns already exist") + return + + print(f"🔄 Adding contact columns to people table: {', '.join(missing)}") + try: + with engine.connect() as connection: + with connection.begin(): + if "email" in missing: + connection.execute(text("ALTER TABLE people ADD COLUMN IF NOT EXISTS email TEXT")) + if "phone" in missing: + connection.execute(text("ALTER TABLE people ADD COLUMN IF NOT EXISTS phone TEXT")) + print("✅ Added people contact columns") + except Exception as exc: + print(f"⚠️ Failed to add people contact columns: {exc}") + + +def ensure_auth_pending_identification_contact_columns() -> None: + """Ensure auth pending_identifications has optional email/phone columns.""" + if auth_engine is None: + return + + try: + from sqlalchemy import inspect as sqlalchemy_inspect + + try: + auth_inspector = sqlalchemy_inspect(auth_engine) + except Exception as inspect_exc: + print(f"ℹ️ Could not inspect auth database: {inspect_exc}") + return + + if "pending_identifications" not in auth_inspector.get_table_names(): + return + + columns = { + column["name"] + for column in auth_inspector.get_columns("pending_identifications") + } + missing = [col for col in ("email", "phone") if col not in columns] + if not missing: + print("ℹ️ pending_identifications.email / phone columns already exist") + return + + print( + f"🔄 Adding contact columns to pending_identifications: {', '.join(missing)}" + ) + with auth_engine.connect() as connection: + with connection.begin(): + if "email" in missing: + connection.execute( + text( + "ALTER TABLE pending_identifications " + "ADD COLUMN IF NOT EXISTS email TEXT" + ) + ) + if "phone" in missing: + connection.execute( + text( + "ALTER TABLE pending_identifications " + "ADD COLUMN IF NOT EXISTS phone TEXT" + ) + ) + print("✅ Added pending_identifications contact columns") + except Exception as exc: + print(f"⚠️ Failed to add pending_identifications contact columns: {exc}") + + def ensure_auth_user_is_active_column() -> None: """Ensure auth database users table contains is_active column. @@ -681,11 +755,13 @@ async def lifespan(app: FastAPI): ensure_photo_person_linkage_table(inspector) ensure_face_excluded_column(inspector) ensure_role_permissions_table(inspector) + ensure_people_contact_columns(inspector) # Setup auth database tables for both frontends (viewer and admin) if auth_engine is not None: try: ensure_auth_user_is_active_column() + ensure_auth_pending_identification_contact_columns() # Import and call worker's setup function to create all auth tables # Note: This import may fail if dotenv is not installed in API environment # (worker.py imports dotenv at top level, but API doesn't need it) diff --git a/backend/db/models.py b/backend/db/models.py index 85e0c31..dc4ab5a 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -76,6 +76,8 @@ class Person(Base): middle_name = Column(Text, nullable=True) maiden_name = Column(Text, nullable=True) date_of_birth = Column(Date, nullable=True) + email = Column(Text, nullable=True) + phone = Column(Text, nullable=True) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) faces = relationship("Face", back_populates="person") diff --git a/backend/schemas/faces.py b/backend/schemas/faces.py index e55f6d8..53773be 100644 --- a/backend/schemas/faces.py +++ b/backend/schemas/faces.py @@ -142,6 +142,8 @@ class IdentifyFaceRequest(BaseModel): middle_name: Optional[str] = None maiden_name: Optional[str] = None date_of_birth: Optional[date] = None + email: Optional[str] = None + phone: Optional[str] = None # Optionally identify a batch of face IDs along with this one additional_face_ids: Optional[list[int]] = None diff --git a/backend/schemas/people.py b/backend/schemas/people.py index 53679cf..8eb7d2a 100644 --- a/backend/schemas/people.py +++ b/backend/schemas/people.py @@ -19,6 +19,8 @@ class PersonResponse(BaseModel): middle_name: Optional[str] = None maiden_name: Optional[str] = None date_of_birth: Optional[date] = None + email: Optional[str] = None + phone: Optional[str] = None class PersonCreateRequest(BaseModel): @@ -31,6 +33,8 @@ class PersonCreateRequest(BaseModel): middle_name: Optional[str] = None maiden_name: Optional[str] = None date_of_birth: Optional[date] = None + email: Optional[str] = None + phone: Optional[str] = None class PeopleListResponse(BaseModel): @@ -52,6 +56,8 @@ class PersonUpdateRequest(BaseModel): middle_name: Optional[str] = None maiden_name: Optional[str] = None date_of_birth: Optional[date] = None + email: Optional[str] = None + phone: Optional[str] = None class PersonWithFacesResponse(BaseModel): @@ -65,6 +71,8 @@ class PersonWithFacesResponse(BaseModel): middle_name: Optional[str] = None maiden_name: Optional[str] = None date_of_birth: Optional[date] = None + email: Optional[str] = None + phone: Optional[str] = None face_count: int video_count: int diff --git a/backend/schemas/videos.py b/backend/schemas/videos.py index 135838d..bceb4fc 100644 --- a/backend/schemas/videos.py +++ b/backend/schemas/videos.py @@ -69,6 +69,8 @@ class IdentifyVideoRequest(BaseModel): middle_name: Optional[str] = None maiden_name: Optional[str] = None date_of_birth: Optional[date] = None + email: Optional[str] = None + phone: Optional[str] = None class IdentifyVideoResponse(BaseModel): diff --git a/backend/services/video_service.py b/backend/services/video_service.py index 802b498..8f0101f 100644 --- a/backend/services/video_service.py +++ b/backend/services/video_service.py @@ -159,6 +159,8 @@ def identify_person_in_video( middle_name: Optional[str] = None, maiden_name: Optional[str] = None, date_of_birth: Optional[date] = None, + email: Optional[str] = None, + phone: Optional[str] = None, user_id: Optional[int] = None, ) -> Tuple[Person, bool]: """Identify a person in a video. @@ -172,6 +174,8 @@ def identify_person_in_video( middle_name: Middle name (optional) maiden_name: Maiden name (optional) date_of_birth: Date of birth (optional) + email: Optional contact email (new person only) + phone: Optional contact phone (new person only) user_id: User ID who is identifying (optional) Returns: @@ -207,6 +211,8 @@ def identify_person_in_video( last_name = last_name.strip() middle_name = middle_name.strip() if middle_name else None maiden_name = maiden_name.strip() if maiden_name else None + email = email.strip() if email else None + phone = phone.strip() if phone else None # Check if person already exists (unique constraint) existing_person = ( @@ -223,6 +229,11 @@ def identify_person_in_video( if existing_person: person = existing_person + # Fill missing contact fields if provided + if email and not person.email: + person.email = email + if phone and not person.phone: + person.phone = phone else: # Explicitly set created_date to ensure it's a valid datetime object person = Person( @@ -231,6 +242,8 @@ def identify_person_in_video( middle_name=middle_name, maiden_name=maiden_name, date_of_birth=date_of_birth, + email=email, + phone=phone, created_date=datetime.utcnow(), ) db.add(person) diff --git a/backend/worker.py b/backend/worker.py index 693865a..12c4f75 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -96,12 +96,20 @@ def setup_auth_database_tables() -> None: middle_name VARCHAR(255), maiden_name VARCHAR(255), date_of_birth DATE, + email TEXT, + phone TEXT, status VARCHAR(50) DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); """)) + conn.execute(text( + "ALTER TABLE pending_identifications ADD COLUMN IF NOT EXISTS email TEXT" + )) + conn.execute(text( + "ALTER TABLE pending_identifications ADD COLUMN IF NOT EXISTS phone TEXT" + )) # Create indexes for pending_identifications conn.execute(text("CREATE INDEX IF NOT EXISTS idx_pending_identifications_face_id ON pending_identifications(face_id);")) diff --git a/docs/PERSON_CONTACT_AND_PENDING_DIGEST.md b/docs/PERSON_CONTACT_AND_PENDING_DIGEST.md new file mode 100644 index 0000000..8f39cf1 --- /dev/null +++ b/docs/PERSON_CONTACT_AND_PENDING_DIGEST.md @@ -0,0 +1,31 @@ +# Person contact fields + pending-queue digest (2026-08) + +## What changed + +1. **Optional email + phone** when identifying a new person (viewer dialog, admin Identify, video identify, Modify person). Stored on `people` and on `pending_identifications`; copied onto the person when an admin approves. +2. **User Identified Faces** admin page: **Select All to Approve**, **Select All to Deny**, **Approve Next 10**, **Clear Selection**, plus a Contact column. +3. **Admin digest email** when the pending-identifications queue grows (viewer submit creates a new pending row). Uses existing SMTP/Resend (`noreply@levkine.ca` in QA/PROD). Disabled until `ADMIN_NOTIFY_EMAIL` is set. + +## Migrations + +Main DB: + +```bash +./scripts/run-psql-migration.sh migrations/add-people-contact-columns.sql +``` + +Auth DB: + +```bash +psql "$DATABASE_URL_AUTH" -f viewer-frontend/migrations/add-pending-identification-contact-columns.sql +``` + +FastAPI startup also attempts `ALTER TABLE … ADD COLUMN IF NOT EXISTS` for both (same pattern as other ensure_* helpers). After schema change, regenerate Prisma: + +```bash +cd viewer-frontend && npx prisma generate --schema=prisma/schema.prisma && npx prisma generate --schema=prisma/schema-auth.prisma +``` + +## Env + +See `viewer-frontend/.env.example` (`ADMIN_NOTIFY_EMAIL`, digest threshold/cooldown). Ansible: `vault_punimtag_admin_notify_email_{dev,qa,prod}` in `vault.example.yml`; `scripts/punimtag-sync-smtp.py` upserts it when set. diff --git a/docs/README.md b/docs/README.md index afea056..79a29b6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1045,4 +1045,6 @@ python3 photo_tagger.py modifyidentified # Opens GUI to view/mo python3 photo_tagger.py dashboard # Opens Dashboard with Browse buttons python3 photo_tagger.py tag-manager # Opens GUI for tag management python3 photo_tagger.py stats -``` \ No newline at end of file +``` + +- [Person contact + pending digest](PERSON_CONTACT_AND_PENDING_DIGEST.md) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 750759d..94f7bd5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -200,7 +200,9 @@ The application uses a **left sidebar navigation** with the following pages: - Last Name (required) - Middle Name (optional) - Maiden Name (optional) - - Date of Birth (required) + - Date of Birth (optional) + - Email (optional) + - Phone (optional) 5. Click "Identify" button to identify the face **Using Similar Faces**: diff --git a/migrations/add-people-contact-columns.sql b/migrations/add-people-contact-columns.sql new file mode 100644 index 0000000..ce66d4b --- /dev/null +++ b/migrations/add-people-contact-columns.sql @@ -0,0 +1,11 @@ +-- Migration: optional contact fields on people (main punimtag DB) +-- Usage: ./scripts/run-psql-migration.sh migrations/add-people-contact-columns.sql + +ALTER TABLE people + ADD COLUMN IF NOT EXISTS email TEXT; + +ALTER TABLE people + ADD COLUMN IF NOT EXISTS phone TEXT; + +COMMENT ON COLUMN people.email IS 'Optional contact email for the identified person'; +COMMENT ON COLUMN people.phone IS 'Optional contact phone for the identified person'; diff --git a/viewer-frontend/.env.example b/viewer-frontend/.env.example index a16cd44..c3c9e34 100644 --- a/viewer-frontend/.env.example +++ b/viewer-frontend/.env.example @@ -44,6 +44,14 @@ RESEND_FROM_NAME=PunimTag RESEND_REPLY_TO=support@your-domain.com UPLOAD_DIR="/mnt/db-server-uploads/pending-photos" +# Admin digest when pending face-identifications queue grows (optional). +# Uses the same SMTP/Resend transport as auth mail. Leave unset to disable. +# ADMIN_NOTIFY_EMAIL=you@example.com +# PENDING_QUEUE_DIGEST_THRESHOLD=1 +# PENDING_QUEUE_DIGEST_COOLDOWN_MINUTES=60 +# ADMIN_APP_URL=https://punimtag.example.com +# PENDING_QUEUE_DIGEST_STATE_PATH=/var/lib/punimtag/pending-queue-digest-state.json + # Site Configuration NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer" NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery" diff --git a/viewer-frontend/.gitignore b/viewer-frontend/.gitignore index 1cf6372..58f3189 100644 --- a/viewer-frontend/.gitignore +++ b/viewer-frontend/.gitignore @@ -46,3 +46,6 @@ next-env.d.ts # history files (from Local History extension) .history/ *.history + +# Local runtime state (pending-queue digest throttle) +.data/ diff --git a/viewer-frontend/EMAIL_VERIFICATION_SETUP.md b/viewer-frontend/EMAIL_VERIFICATION_SETUP.md index 0e691a7..f729925 100644 --- a/viewer-frontend/EMAIL_VERIFICATION_SETUP.md +++ b/viewer-frontend/EMAIL_VERIFICATION_SETUP.md @@ -148,9 +148,19 @@ RESEND_FROM_EMAIL="noreply@yourdomain.com" Once all steps are complete, email verification is fully functional. New users will need to verify their email before they can log in. +## Pending identification admin digest +When a viewer submits a **new** pending face identification, PunimTag can email an admin digest (throttled). +Set in `viewer-frontend/.env`: +```bash +ADMIN_NOTIFY_EMAIL=you@example.com +PENDING_QUEUE_DIGEST_THRESHOLD=1 +PENDING_QUEUE_DIGEST_COOLDOWN_MINUTES=60 +ADMIN_APP_URL=https://your-admin-host +``` +Uses the same SMTP / Resend path as auth mail (`noreply@levkine.ca` in QA/PROD). Leave `ADMIN_NOTIFY_EMAIL` empty to disable. - +Throttle state is stored under `viewer-frontend/.data/pending-queue-digest-state.json` (gitignored). diff --git a/viewer-frontend/app/api/faces/[id]/identify/route.ts b/viewer-frontend/app/api/faces/[id]/identify/route.ts index f5b2d1c..6c3d3ff 100644 --- a/viewer-frontend/app/api/faces/[id]/identify/route.ts +++ b/viewer-frontend/app/api/faces/[id]/identify/route.ts @@ -1,6 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma, prismaAuth } from '@/lib/db'; import { auth } from '@/app/api/auth/[...nextauth]/route'; +import { maybeNotifyPendingQueueDigest } from '@/lib/email'; + +function normalizeOptional(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} export async function POST( request: NextRequest, @@ -35,13 +44,15 @@ export async function POST( } const body = await request.json(); - const { personId, firstName, lastName, middleName, maidenName, dateOfBirth } = body; + const { personId, firstName, lastName, middleName, maidenName, dateOfBirth, email, phone } = body; let finalFirstName: string; let finalLastName: string; let finalMiddleName: string | null = null; let finalMaidenName: string | null = null; let finalDateOfBirth: Date | null = null; + let finalEmail: string | null = normalizeOptional(email); + let finalPhone: string | null = normalizeOptional(phone); // If personId is provided, fetch person data from database if (personId) { @@ -61,6 +72,9 @@ export async function POST( finalMiddleName = person.middle_name; finalMaidenName = person.maiden_name; finalDateOfBirth = person.date_of_birth; + // Prefer submitted contact fields; fall back to stored person contact + finalEmail = finalEmail || person.email || null; + finalPhone = finalPhone || person.phone || null; } else { // Validate required fields for new person if (!firstName || !lastName) { @@ -107,6 +121,16 @@ export async function POST( ); } + const pendingData = { + firstName: finalFirstName, + lastName: finalLastName, + middleName: finalMiddleName, + maidenName: finalMaidenName, + dateOfBirth: finalDateOfBirth, + email: finalEmail, + phone: finalPhone, + }; + // Check if there's already a pending identification for this face by this user // Use auth client (connects to punimtag_auth database) const existingPending = await prismaAuth.pendingIdentification.findFirst({ @@ -121,13 +145,7 @@ export async function POST( // Update existing pending identification const updated = await prismaAuth.pendingIdentification.update({ where: { id: existingPending.id }, - data: { - firstName: finalFirstName, - lastName: finalLastName, - middleName: finalMiddleName, - maidenName: finalMaidenName, - dateOfBirth: finalDateOfBirth, - }, + data: pendingData, }); return NextResponse.json({ @@ -141,15 +159,25 @@ export async function POST( data: { faceId, userId, - firstName: finalFirstName, - lastName: finalLastName, - middleName: finalMiddleName, - maidenName: finalMaidenName, - dateOfBirth: finalDateOfBirth, + ...pendingData, status: 'pending', }, }); + // Notify admin when the queue grows (throttled; no-op if ADMIN_NOTIFY_EMAIL unset) + try { + const pendingCount = await prismaAuth.pendingIdentification.count({ + where: { status: 'pending' }, + }); + await maybeNotifyPendingQueueDigest({ + pendingCount, + newestPersonName: `${finalFirstName} ${finalLastName}`.trim(), + submittedBy: session.user.email || session.user.name || undefined, + }); + } catch (notifyError) { + console.error('Pending queue digest notify failed:', notifyError); + } + return NextResponse.json({ message: 'Identification submitted and pending approval', pendingIdentification, @@ -171,4 +199,3 @@ export async function POST( ); } } - diff --git a/viewer-frontend/app/api/people/route.ts b/viewer-frontend/app/api/people/route.ts index ad4f9e1..162ded1 100644 --- a/viewer-frontend/app/api/people/route.ts +++ b/viewer-frontend/app/api/people/route.ts @@ -15,6 +15,8 @@ export async function GET(request: NextRequest) { middle_name: true, maiden_name: true, date_of_birth: true, + email: true, + phone: true, created_date: true, }, }); @@ -27,6 +29,8 @@ export async function GET(request: NextRequest) { middleName: person.middle_name, maidenName: person.maiden_name, dateOfBirth: person.date_of_birth, + email: person.email, + phone: person.phone, createdDate: person.created_date, })); @@ -58,6 +62,8 @@ export async function GET(request: NextRequest) { middleName: null, maidenName: null, dateOfBirth: null, + email: null, + phone: null, createdDate: null, })); diff --git a/viewer-frontend/app/search/page.tsx b/viewer-frontend/app/search/page.tsx index dda07c2..d691aca 100644 --- a/viewer-frontend/app/search/page.tsx +++ b/viewer-frontend/app/search/page.tsx @@ -16,6 +16,8 @@ async function getAllPeople() { middle_name: true, maiden_name: true, date_of_birth: true, + email: true, + phone: true, created_date: true, }, orderBy: [ @@ -37,6 +39,8 @@ async function getAllPeople() { middle_name: true, maiden_name: true, date_of_birth: true, + email: true, + phone: true, created_date: true, }, orderBy: [ diff --git a/viewer-frontend/components/IdentifyFaceDialog.tsx b/viewer-frontend/components/IdentifyFaceDialog.tsx index 5bc6975..0a20174 100644 --- a/viewer-frontend/components/IdentifyFaceDialog.tsx +++ b/viewer-frontend/components/IdentifyFaceDialog.tsx @@ -41,6 +41,8 @@ interface IdentifyFaceDialogProps { middleName?: string; maidenName?: string; dateOfBirth?: Date; + email?: string; + phone?: string; }) => Promise; } @@ -57,6 +59,8 @@ export function IdentifyFaceDialog({ const [lastName, setLastName] = useState(existingPerson?.lastName || ''); const [middleName, setMiddleName] = useState(existingPerson?.middleName || ''); const [maidenName, setMaidenName] = useState(existingPerson?.maidenName || ''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); const [isSaving, setIsSaving] = useState(false); const [errors, setErrors] = useState<{ firstName?: string; @@ -213,6 +217,8 @@ export function IdentifyFaceDialog({ lastName: lastName.trim(), middleName: middleName.trim() || undefined, maidenName: maidenName.trim() || undefined, + email: email.trim() || undefined, + phone: phone.trim() || undefined, }); // Show success message alert('Identification submitted successfully! It will be reviewed by an administrator before being applied.'); @@ -223,6 +229,8 @@ export function IdentifyFaceDialog({ setLastName(''); setMiddleName(''); setMaidenName(''); + setEmail(''); + setPhone(''); } } catch (error: any) { console.error('Error saving face identification:', error); @@ -414,6 +422,8 @@ export function IdentifyFaceDialog({ setLastName(''); setMiddleName(''); setMaidenName(''); + setEmail(''); + setPhone(''); setErrors({}); setMode('existing'); }} @@ -585,6 +595,34 @@ export function IdentifyFaceDialog({ /> + + + Email + + setEmail(e.target.value)} + placeholder="Optional email" + autoComplete="email" + /> + + + + + Phone + + setPhone(e.target.value)} + placeholder="Optional phone" + autoComplete="tel" + /> + + > )} diff --git a/viewer-frontend/components/PhotoViewerClient.tsx b/viewer-frontend/components/PhotoViewerClient.tsx index 8a4d14f..9e5d9fb 100644 --- a/viewer-frontend/components/PhotoViewerClient.tsx +++ b/viewer-frontend/components/PhotoViewerClient.tsx @@ -982,6 +982,8 @@ export function PhotoViewerClient({ middleName?: string; maidenName?: string; dateOfBirth?: Date; + email?: string; + phone?: string; }) => { if (!clickedFace) return; diff --git a/viewer-frontend/lib/email.ts b/viewer-frontend/lib/email.ts index 7856784..7fa1e86 100644 --- a/viewer-frontend/lib/email.ts +++ b/viewer-frontend/lib/email.ts @@ -1,4 +1,6 @@ import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; import nodemailer from 'nodemailer'; import { Resend } from 'resend'; @@ -283,3 +285,135 @@ PunimTag Viewer Team`; html, }); } + + +type PendingDigestState = { + lastSentAt: string | null; + lastCount: number; +}; + +function getDigestStatePath(): string { + const custom = process.env.PENDING_QUEUE_DIGEST_STATE_PATH; + if (custom) { + return custom; + } + return path.join(process.cwd(), '.data', 'pending-queue-digest-state.json'); +} + +function readDigestState(): PendingDigestState { + try { + const statePath = getDigestStatePath(); + if (!fs.existsSync(statePath)) { + return { lastSentAt: null, lastCount: 0 }; + } + const raw = JSON.parse(fs.readFileSync(statePath, 'utf8')) as PendingDigestState; + return { + lastSentAt: raw.lastSentAt ?? null, + lastCount: typeof raw.lastCount === 'number' ? raw.lastCount : 0, + }; + } catch { + return { lastSentAt: null, lastCount: 0 }; + } +} + +function writeDigestState(state: PendingDigestState): void { + try { + const statePath = getDigestStatePath(); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); + } catch (err) { + console.error('[EMAIL] Failed to write pending digest state:', err); + } +} + +/** + * Notify admin when the pending-identifications queue grows. + * Disabled unless ADMIN_NOTIFY_EMAIL is set. Throttled by cooldown. + */ +export async function maybeNotifyPendingQueueDigest(options: { + pendingCount: number; + newestPersonName?: string; + submittedBy?: string; +}): Promise { + const to = (process.env.ADMIN_NOTIFY_EMAIL || '').trim(); + if (!to) { + return; + } + + const threshold = Number(process.env.PENDING_QUEUE_DIGEST_THRESHOLD || '1'); + const cooldownMinutes = Number(process.env.PENDING_QUEUE_DIGEST_COOLDOWN_MINUTES || '60'); + const pendingCount = options.pendingCount; + + if (!Number.isFinite(threshold) || pendingCount < threshold) { + return; + } + + const state = readDigestState(); + const now = Date.now(); + const lastSentMs = state.lastSentAt ? Date.parse(state.lastSentAt) : NaN; + const cooldownMs = (Number.isFinite(cooldownMinutes) ? cooldownMinutes : 60) * 60 * 1000; + + // Only notify when the queue grew since last digest + if (pendingCount <= state.lastCount) { + return; + } + + if (Number.isFinite(lastSentMs) && now - lastSentMs < cooldownMs) { + return; + } + + const adminUrl = + process.env.ADMIN_APP_URL || + process.env.NEXT_PUBLIC_ADMIN_URL || + 'https://punimtag.levkin.ca'; + const personLine = options.newestPersonName + ? `Latest: ${options.newestPersonName}` + : ''; + const byLine = options.submittedBy + ? `Submitted by: ${options.submittedBy}` + : ''; + + const html = ` + + + + + Pending face identifications + The PunimTag approval queue has grown. + + Pending count: ${pendingCount} + ${personLine} + ${byLine} + + Open admin to review + Sent from noreply via PunimTag. Cooldown: ${cooldownMinutes} minutes. + + + + `; + + const text = [ + 'Pending face identifications', + `Pending count: ${pendingCount}`, + options.newestPersonName ? `Latest: ${options.newestPersonName}` : '', + options.submittedBy ? `Submitted by: ${options.submittedBy}` : '', + `Review: ${adminUrl}`, + ] + .filter(Boolean) + .join('\n'); + + try { + await sendEmailWithFallback({ + to, + subject: `PunimTag: ${pendingCount} pending face identification${pendingCount === 1 ? '' : 's'}`, + html, + text, + }); + writeDigestState({ + lastSentAt: new Date(now).toISOString(), + lastCount: pendingCount, + }); + } catch (err) { + console.error('[EMAIL] Pending queue digest failed:', err); + } +} diff --git a/viewer-frontend/migrations/add-pending-identification-contact-columns.sql b/viewer-frontend/migrations/add-pending-identification-contact-columns.sql new file mode 100644 index 0000000..c0f89c2 --- /dev/null +++ b/viewer-frontend/migrations/add-pending-identification-contact-columns.sql @@ -0,0 +1,12 @@ +-- Migration: optional contact fields on pending_identifications (punimtag_auth DB) +-- Run against DATABASE_URL_AUTH, e.g.: +-- psql "$DATABASE_URL_AUTH" -f viewer-frontend/migrations/add-pending-identification-contact-columns.sql + +ALTER TABLE pending_identifications + ADD COLUMN IF NOT EXISTS email TEXT; + +ALTER TABLE pending_identifications + ADD COLUMN IF NOT EXISTS phone TEXT; + +COMMENT ON COLUMN pending_identifications.email IS 'Optional email submitted with the identification'; +COMMENT ON COLUMN pending_identifications.phone IS 'Optional phone submitted with the identification'; diff --git a/viewer-frontend/prisma/schema-auth.prisma b/viewer-frontend/prisma/schema-auth.prisma index f325ca5..1a2941a 100644 --- a/viewer-frontend/prisma/schema-auth.prisma +++ b/viewer-frontend/prisma/schema-auth.prisma @@ -42,6 +42,8 @@ model PendingIdentification { middleName String? @map("middle_name") maidenName String? @map("maiden_name") dateOfBirth DateTime? @map("date_of_birth") @db.Date + email String? + phone String? status String @default("pending") // pending, approved, rejected createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/viewer-frontend/prisma/schema.prisma b/viewer-frontend/prisma/schema.prisma index 4983657..d21808c 100644 --- a/viewer-frontend/prisma/schema.prisma +++ b/viewer-frontend/prisma/schema.prisma @@ -59,6 +59,8 @@ model Person { middle_name String? maiden_name String? date_of_birth DateTime? + email String? + phone String? created_date DateTime Face Face[] PersonEncoding PersonEncoding[]
The PunimTag approval queue has grown.
Open admin to review
Sent from noreply via PunimTag. Cooldown: ${cooldownMinutes} minutes.