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
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:
+10
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+28
@@ -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"
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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})
|
||||
)`;
|
||||
}
|
||||
Reference in New Issue
Block a user