Merge pull request 'Sprint A: match-all filters, slim payloads, DEV next start' (#82) from sprint-a-ux-trust-speed into master
CI / skip-ci-check (push) Successful in 29s
CI / docker-ci (push) Successful in 31s
CI / python-lint (push) Successful in 32s
CI / secret-scan (push) Successful in 38s
CI / e2e (push) Successful in 1m56s
CI / viewer-unit (push) Successful in 2m27s
CI / admin-unit (push) Successful in 2m45s

This commit was merged in pull request #82.
This commit is contained in:
2026-08-04 20:57:45 -05:00
10 changed files with 134 additions and 81 deletions
+10 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+6 -3
View File
@@ -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
+16
View File
@@ -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
+28
View File
@@ -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"
+7 -43
View File
@@ -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')) {
+2 -33
View File
@@ -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
+21
View File
@@ -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;
@@ -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');
});
});
+20
View File
@@ -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})
)`;
}