Merge pull request 'Person contact fields, approve bulk select, pending-queue digest' (#74) from feature/person-contact-pending-digest into master
CI / skip-ci-check (push) Successful in 29s
CI / docker-ci (push) Successful in 32s
CI / python-lint (push) Successful in 31s
CI / secret-scan (push) Successful in 35s
CI / viewer-unit (push) Successful in 1m41s
CI / admin-unit (push) Successful in 1m57s
CI / e2e (push) Successful in 3m20s

This commit was merged in pull request #74.
This commit is contained in:
2026-08-04 11:27:34 -05:00
38 changed files with 682 additions and 23 deletions
+2
View File
@@ -101,3 +101,5 @@ e2e/.env
logs/
.vite/
e2e/env-defaults.local.json
viewer-frontend/.data/
+5
View File
@@ -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
+3
View File
@@ -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)
+2
View File
@@ -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[]
}
@@ -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
+6
View File
@@ -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 = {
+2
View File
@@ -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 {
@@ -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() {
/>
<span className="text-sm text-gray-700">Include denied</span>
</label>
{pendingIdentifications.filter((p) => p.status !== 'approved').length > 0 && (
<>
<button
type="button"
onClick={handleSelectAllApprove}
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Approve
</button>
<button
type="button"
onClick={handleSelectAllDeny}
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Deny
</button>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={handleClearDecisions}
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-600 rounded-md hover:bg-gray-200 font-medium"
>
Clear Selection
</button>
</>
)}
<button
onClick={handleSubmit}
disabled={submitting || Object.values(decisions).filter(d => d !== null).length === 0}
@@ -292,6 +361,9 @@ export default function ApproveIdentified() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date of Birth
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Contact
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Face
</th>
@@ -322,6 +394,14 @@ export default function ApproveIdentified() {
{formatDate(pending.date_of_birth)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{pending.email || '-'}
</div>
<div className="text-sm text-gray-500">
{pending.phone || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{pending.photo_id ? (
+5 -4
View File
@@ -972,10 +972,10 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) {
<li><strong>Pending Identifications List:</strong> View all face identifications submitted by users</li>
<li><strong>Face Preview:</strong> See the face thumbnail and click to view the full photo</li>
<li><strong>User Information:</strong> See which user submitted each identification</li>
<li><strong>Person Details:</strong> View the person name and date of birth that the user entered</li>
<li><strong>Person Details:</strong> View the person name, date of birth, and optional email/phone that the user entered</li>
<li><strong>Status Filtering:</strong> Filter by pending, approved, or denied status</li>
<li><strong>Include Denied:</strong> Option to view previously denied identifications</li>
<li><strong>Bulk Decisions:</strong> Approve or deny multiple identifications at once</li>
<li><strong>Bulk Decisions:</strong> Select All to Approve / Deny, Approve Next 10, then submit</li>
<li><strong>Identification Report:</strong> View statistics about user identifications</li>
<li><strong>Database Cleanup:</strong> Delete denied records (admin only)</li>
</ul>
@@ -990,7 +990,7 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) {
<li>For each identification:
<ul className="list-disc list-inside ml-4 mt-1">
<li>Click on the face thumbnail to see the full photo</li>
<li>Review the person name and date of birth entered by the user</li>
<li>Review the person name, date of birth, and contact fields entered by the user</li>
<li>Check which user submitted the identification</li>
</ul>
</li>
@@ -1000,6 +1000,7 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) {
<li>Check "Deny" to reject the identification</li>
</ul>
</li>
<li>Or use bulk tools: "Select All to Approve", "Select All to Deny", or "Approve Next 10" (first actionable rows on screen)</li>
<li>Click "Submit Decisions" button to process all selected decisions</li>
<li>View the summary showing how many were approved and denied</li>
</ol>
@@ -1035,7 +1036,7 @@ function UserIdentifiedFacesPageHelp({ onBack }: { onBack: () => void }) {
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Click on face thumbnails to see the full photo - this helps verify the identification is correct</li>
<li>Review the person name carefully - if a person with that name already exists, the face will be linked to them</li>
<li>You can approve or deny multiple identifications at once by checking boxes and clicking "Submit Decisions"</li>
<li>You can approve or deny multiple identifications at once with Select All / Approve Next 10, then "Submit Decisions"</li>
<li>Denied identifications can be viewed later by checking "Include denied"</li>
<li>Use the identification report to track user activity and identify patterns</li>
</ul>
+89 -1
View File
@@ -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} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input type="email" value={email} onChange={(e) => {
setEmail(e.target.value)
setPersonId(undefined)
}}
className="mt-1 block w-full border rounded px-2 py-1"
placeholder="Optional"
readOnly={!!personId}
disabled={!!personId} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Phone</label>
<input type="tel" value={phone} onChange={(e) => {
setPhone(e.target.value)
setPersonId(undefined)
}}
className="mt-1 block w-full border rounded px-2 py-1"
placeholder="Optional"
readOnly={!!personId}
disabled={!!personId} />
</div>
<div className="col-span-2 flex gap-2 mt-2">
<button
type="button"
@@ -1993,6 +2053,8 @@ export default function Identify() {
setVideoMiddleName('')
setVideoMaidenName('')
setVideoDob('')
setVideoEmail('')
setVideoPhone('')
}
}
}}
@@ -2070,6 +2132,32 @@ export default function Identify() {
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={videoEmail}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<input
type="tel"
value={videoPhone}
onChange={(e) => 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"
/>
</div>
</div>
<button
onClick={handleVideoIdentify}
+28
View File
@@ -17,6 +17,8 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
const [middleName, setMiddleName] = useState(person.middle_name || '')
const [maidenName, setMaidenName] = useState(person.maiden_name || '')
const [dob, setDob] = useState(person.date_of_birth || '')
const [email, setEmail] = useState(person.email || '')
const [phone, setPhone] = useState(person.phone || '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => 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"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
+4
View File
@@ -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)
+22 -1
View File
@@ -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
+8
View File
@@ -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()
+2
View File
@@ -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,
)
+76
View File
@@ -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)
+2
View File
@@ -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")
+2
View File
@@ -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
+8
View File
@@ -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
+2
View File
@@ -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):
+13
View File
@@ -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)
+8
View File
@@ -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);"))
+31
View File
@@ -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.
+3 -1
View File
@@ -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
```
```
- [Person contact + pending digest](PERSON_CONTACT_AND_PENDING_DIGEST.md)
+3 -1
View File
@@ -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**:
+11
View File
@@ -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';
+8
View File
@@ -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"
+3
View File
@@ -46,3 +46,6 @@ next-env.d.ts
# history files (from Local History extension)
.history/
*.history
# Local runtime state (pending-queue digest throttle)
.data/
+11 -1
View File
@@ -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).
@@ -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(
);
}
}
+6
View File
@@ -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,
}));
+4
View File
@@ -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: [
@@ -41,6 +41,8 @@ interface IdentifyFaceDialogProps {
middleName?: string;
maidenName?: string;
dateOfBirth?: Date;
email?: string;
phone?: string;
}) => Promise<void>;
}
@@ -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({
/>
</div>
<div className="grid gap-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Optional email"
autoComplete="email"
/>
</div>
<div className="grid gap-2">
<label htmlFor="phone" className="text-sm font-medium">
Phone
</label>
<Input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="Optional phone"
autoComplete="tel"
/>
</div>
</>
)}
</div>
@@ -982,6 +982,8 @@ export function PhotoViewerClient({
middleName?: string;
maidenName?: string;
dateOfBirth?: Date;
email?: string;
phone?: string;
}) => {
if (!clickedFace) return;
+134
View File
@@ -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<void> {
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
? `<li><strong>Latest:</strong> ${options.newestPersonName}</li>`
: '';
const byLine = options.submittedBy
? `<li><strong>Submitted by:</strong> ${options.submittedBy}</li>`
: '';
const html = `
<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background-color: #f8f9fa; padding: 24px; border-radius: 8px;">
<h1 style="color: #2563eb; margin-top: 0; font-size: 20px;">Pending face identifications</h1>
<p>The PunimTag approval queue has grown.</p>
<ul>
<li><strong>Pending count:</strong> ${pendingCount}</li>
${personLine}
${byLine}
</ul>
<p><a href="${adminUrl}">Open admin to review</a></p>
<p style="color: #6b7280; font-size: 13px;">Sent from noreply via PunimTag. Cooldown: ${cooldownMinutes} minutes.</p>
</div>
</body>
</html>
`;
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);
}
}
@@ -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';
@@ -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")
+2
View File
@@ -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[]