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
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:
@@ -101,3 +101,5 @@ e2e/.env
|
||||
logs/
|
||||
.vite/
|
||||
e2e/env-defaults.local.json
|
||||
|
||||
viewer-frontend/.data/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);"))
|
||||
|
||||
@@ -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
@@ -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
@@ -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**:
|
||||
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
@@ -46,3 +46,6 @@ next-env.d.ts
|
||||
# history files (from Local History extension)
|
||||
.history/
|
||||
*.history
|
||||
|
||||
# Local runtime state (pending-queue digest throttle)
|
||||
.data/
|
||||
|
||||
@@ -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(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user