diff --git a/ROADMAP.md b/ROADMAP.md index d78decd..b93d686 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,7 @@ Living plan for product quality, auth/email reliability, and automation. - [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) +- [x] **UX Sprint A — match-all filters, slim search payloads, DEV `next start`** — see [UX sprint](#ux-sprint--trust-mobile-admin-throughput-2026-08) ## Next (near-term) @@ -65,9 +66,17 @@ Living plan for product quality, auth/email reliability, and automation. - [ ] **Burn-in / deploy-smoke CLI** (playkit ROADMAP v0.4/v0.5+) — playkit's own ROADMAP has an explicit "adoption pause" until the `v0.3.1` network helpers + release job soak for a few days in this repo's CI; revisit once that settles rather than adding more moving parts at once. +### UX sprint — trust, mobile, admin throughput (2026-08) + +#### Sprint A — quick trust + speed (shipped) + +- [x] **Match-all people/tags filters** — `/api/search` raw SQL uses `GROUP BY` + `HAVING COUNT(DISTINCT …)` when `peopleMode=all` / `tagsMode=all` (`lib/search-filter-sql.ts` + Vitest) +- [x] **Slim gallery face payloads** — home SSR + search API return id, photo_id, person_id, location, person names only +- [x] **DEV viewer `next start`** — `scripts/deploy-viewer.sh`; `ecosystem.config.js.example` uses `next start -p 3001` + ## Later -- [ ] Proper DEV deploy (`next start` + CI image) instead of long-lived `next dev` +- [x] Proper DEV deploy (`next start` + CI image) instead of long-lived `next dev` — `scripts/deploy-viewer.sh`, `ecosystem.config.js.example` uses `next start -p 3001` - [ ] Ansible/app_setup path aligned with `/opt/punimtag/viewer-frontend/.env` (not `/srv/app`) - [ ] OpenAPI-driven contract suite (playkit / consumer) - [ ] Multi-browser matrix (firefox/webkit) diff --git a/e2e/README.md b/e2e/README.md index 9e888d2..94aedf3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -56,7 +56,7 @@ not enabled in this CI job yet. 10. API catalog: people/tags lists, login 422/401, users/roles 401, missing photo 404|401 11. Viewer `/api/auth/session` returns email when storageState is loaded 12. FastAPI role-permission write gates: viewer 403 vs admin 200 on users, role-permissions, bulk-delete -13. Public gallery search (`/api/search`): tag filter, person filter, and combined-filter narrowing return correct subsets; UI tag-filter interaction updates URL + result count +13. Public gallery search (`/api/search`): tag filter, person filter, **match-all** (`peopleMode=all` / `tagsMode=all`) combined narrowing, and UI tag-filter interaction updates URL + result count 14. Logged-in-only filters: Favorites checkbox toggles `favoritesOnly` URL param; People filter UI selection updates URL + result count (uses storageState) 15. Silent-failure network monitor (`startNetworkErrorMonitor`): gallery home, search+filter, upload, and Manage Users overlay all stay free of background 4xx/5xx while the UI loads/interacts 16. `interceptNetworkCall` spies on the real upload POST (status + `{ message, photos }` shape) and the filtered `/api/search?tags=` GET (status + Zod-validated shape) instead of brittle `waitForResponse`/text scraping diff --git a/ecosystem.config.js.example b/ecosystem.config.js.example index 90a0eef..27de305 100644 --- a/ecosystem.config.js.example +++ b/ecosystem.config.js.example @@ -60,12 +60,15 @@ module.exports = { }, { name: 'punimtag-viewer', - script: 'npm', - args: 'run start:3001', + // Production: run `npm run build` in viewer-frontend before `pm2 start`. + // Do not use `next dev` on long-lived hosts (slow, wrong behavior vs prod). + script: './scripts/with-sharp-libpath.sh', + args: 'next start -p 3001', cwd: '/opt/punimtag/viewer-frontend', // CHANGE: Update to your deployment directory - interpreter: 'node', + interpreter: 'bash', env: { PORT: '3001', + NODE_ENV: 'production', }, error_file: '/home/appuser/.pm2/logs/punimtag-viewer-error.log', // CHANGE: Update user home directory out_file: '/home/appuser/.pm2/logs/punimtag-viewer-out.log', // CHANGE: Update user home directory diff --git a/scripts/README.md b/scripts/README.md index 3c41676..c791ea4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -41,6 +41,22 @@ Project-specific scripts remain in the repository root: - `stop_backend.sh` - Stop backend server - `run_worker.sh` - Run RQ worker - `demo.sh` - Demo helper script +- `deploy-viewer.sh` - Build viewer (`next build`) and recreate PM2 `punimtag-viewer` with `next start` (use after viewer deploys; `pm2 restart` alone does not pick up script-arg changes) + +### Viewer deploy (DEV / prod-like) + +```bash +cd /opt/punimtag +git pull +bash scripts/deploy-viewer.sh --skip-install # when node_modules already current +``` + +Admin static build (served by Caddy or `pm2 serve`): + +```bash +cd /opt/punimtag/admin-frontend && npm run build +# restart API if backend changed: pm2 restart punimtag-api +``` ## Database Shell Scripts diff --git a/scripts/deploy-viewer.sh b/scripts/deploy-viewer.sh new file mode 100755 index 0000000..8a532af --- /dev/null +++ b/scripts/deploy-viewer.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Build viewer for production and restart PM2 process. +# Usage: ./scripts/deploy-viewer.sh [--skip-install] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VIEWER="$ROOT/viewer-frontend" +SKIP_INSTALL=0 +for arg in "$@"; do + case "$arg" in + --skip-install) SKIP_INSTALL=1 ;; + esac +done + +cd "$VIEWER" +if [[ "$SKIP_INSTALL" -eq 0 ]]; then + npm ci --prefer-offline +fi +npm run build + +cd "$ROOT" +if pm2 describe punimtag-viewer >/dev/null 2>&1; then + pm2 restart punimtag-viewer --update-env +else + pm2 start ecosystem.config.js --only punimtag-viewer +fi + +echo "Viewer deployed (next start). Logs: pm2 logs punimtag-viewer --lines 30" diff --git a/viewer-frontend/app/api/search/route.ts b/viewer-frontend/app/api/search/route.ts index cf1382d..828e7b2 100644 --- a/viewer-frontend/app/api/search/route.ts +++ b/viewer-frontend/app/api/search/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma, prismaAuth } from '@/lib/db'; import { serializePhotos } from '@/lib/serialize'; import { auth } from '@/app/api/auth/[...nextauth]/route'; +import { photoIdsMatchingAll } from '@/lib/search-filter-sql'; +import { gridFaceSelect } from '@/lib/grid-face-select'; export async function GET(request: NextRequest) { try { @@ -184,20 +186,13 @@ export async function GET(request: NextRequest) { } } - // Handle people filter - embed IDs directly since they're safe integers + // People / tags filters — honour any vs all (raw SQL path must match Prisma where above) if (people.length > 0) { - const peopleIds = people.join(','); - whereConditions.push(`id IN ( - SELECT DISTINCT photo_id FROM faces WHERE person_id IN (${peopleIds}) - )`); + whereConditions.push(photoIdsMatchingAll('faces', 'person_id', people, peopleMode)); } - - // Handle tags filter - embed IDs directly since they're safe integers + if (tags.length > 0) { - const tagIds = tags.join(','); - whereConditions.push(`id IN ( - SELECT DISTINCT photo_id FROM phototaglinkage WHERE tag_id IN (${tagIds}) - )`); + whereConditions.push(photoIdsMatchingAll('phototaglinkage', 'tag_id', tags, tagsMode)); } // Handle favorites filter - embed IDs directly since they're safe integers @@ -271,38 +266,7 @@ export async function GET(request: NextRequest) { try { faces = await prisma.face.findMany({ where: { photo_id: { in: photoIds } }, - select: { - id: true, - photo_id: true, - person_id: true, - location: true, - confidence: true, - quality_score: true, - is_primary_encoding: true, - detector_backend: true, - model_name: true, - face_confidence: true, - exif_orientation: true, - pose_mode: true, - yaw_angle: true, - pitch_angle: true, - roll_angle: true, - landmarks: true, - identified_by_user_id: true, - excluded: true, - Person: { - select: { - id: true, - first_name: true, - last_name: true, - middle_name: true, - maiden_name: true, - date_of_birth: true, - created_date: true, - }, - }, - // Exclude encoding field (Bytes) to avoid P2023 conversion errors - }, + select: gridFaceSelect, }); } catch (faceError: any) { if (faceError?.code === 'P2023' || faceError?.message?.includes('Conversion failed')) { diff --git a/viewer-frontend/app/page.tsx b/viewer-frontend/app/page.tsx index 4a0acce..5baca89 100644 --- a/viewer-frontend/app/page.tsx +++ b/viewer-frontend/app/page.tsx @@ -1,8 +1,8 @@ import { Suspense } from 'react'; import { prisma } from '@/lib/db'; import { HomePageContent } from './HomePageContent'; -import { Photo } from '@prisma/client'; import { serializePhotos, serializePeople, serializeTags } from '@/lib/serialize'; +import { gridFaceSelect } from '@/lib/grid-face-select'; // Force dynamic rendering to prevent database queries during build export const dynamic = 'force-dynamic'; @@ -171,38 +171,7 @@ export default async function HomePage() { const photoIds = photosBase.map(p => p.id); const faces = await prisma.face.findMany({ where: { photo_id: { in: photoIds } }, - select: { - id: true, - photo_id: true, - person_id: true, - location: true, - confidence: true, - quality_score: true, - is_primary_encoding: true, - detector_backend: true, - model_name: true, - face_confidence: true, - exif_orientation: true, - pose_mode: true, - yaw_angle: true, - pitch_angle: true, - roll_angle: true, - landmarks: true, - identified_by_user_id: true, - excluded: true, - Person: { - select: { - id: true, - first_name: true, - last_name: true, - middle_name: true, - maiden_name: true, - date_of_birth: true, - created_date: true, - }, - }, - // Exclude encoding field (Bytes) to avoid P2023 conversion errors - }, + select: gridFaceSelect, }); // Combine the data manually diff --git a/viewer-frontend/lib/grid-face-select.ts b/viewer-frontend/lib/grid-face-select.ts new file mode 100644 index 0000000..76b5298 --- /dev/null +++ b/viewer-frontend/lib/grid-face-select.ts @@ -0,0 +1,21 @@ +/** + * Minimal face fields for gallery grid (tooltips / hover names only). + * Avoids loading landmarks, pose, detector metadata on every home/search request. + */ +export const gridFaceSelect = { + id: true, + photo_id: true, + person_id: true, + location: true, + Person: { + select: { + id: true, + first_name: true, + last_name: true, + middle_name: true, + maiden_name: true, + date_of_birth: true, + created_date: true, + }, + }, +} as const; diff --git a/viewer-frontend/lib/search-filter-sql.test.ts b/viewer-frontend/lib/search-filter-sql.test.ts new file mode 100644 index 0000000..9515527 --- /dev/null +++ b/viewer-frontend/lib/search-filter-sql.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { photoIdsMatchingAll } from './search-filter-sql'; + +describe('photoIdsMatchingAll', () => { + it('uses DISTINCT for any mode', () => { + const sql = photoIdsMatchingAll('faces', 'person_id', [1, 2], 'any'); + expect(sql).toContain('DISTINCT photo_id'); + expect(sql).toContain('person_id IN (1,2)'); + expect(sql).not.toContain('GROUP BY'); + }); + + it('uses HAVING COUNT for all mode with multiple ids', () => { + const sql = photoIdsMatchingAll('faces', 'person_id', [1, 2, 3], 'all'); + expect(sql).toContain('GROUP BY photo_id'); + expect(sql).toContain('HAVING COUNT(DISTINCT person_id) = 3'); + }); + + it('falls back to DISTINCT when all mode has single id', () => { + const sql = photoIdsMatchingAll('phototaglinkage', 'tag_id', [5], 'all'); + expect(sql).toContain('DISTINCT photo_id'); + expect(sql).not.toContain('GROUP BY'); + }); +}); diff --git a/viewer-frontend/lib/search-filter-sql.ts b/viewer-frontend/lib/search-filter-sql.ts new file mode 100644 index 0000000..8e2893f --- /dev/null +++ b/viewer-frontend/lib/search-filter-sql.ts @@ -0,0 +1,20 @@ +/** Build a photo_id subquery for people/tags filters (any = OR, all = AND). */ +export function photoIdsMatchingAll( + table: 'faces' | 'phototaglinkage', + column: 'person_id' | 'tag_id', + ids: number[], + mode: 'any' | 'all' +): string { + const idList = ids.join(','); + if (mode === 'all' && ids.length > 1) { + return `id IN ( + SELECT photo_id FROM ${table} + WHERE ${column} IN (${idList}) + GROUP BY photo_id + HAVING COUNT(DISTINCT ${column}) = ${ids.length} + )`; + } + return `id IN ( + SELECT DISTINCT photo_id FROM ${table} WHERE ${column} IN (${idList}) + )`; +}