diff --git a/.gitignore b/.gitignore index e9f9ef54..a34f43a7 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,5 @@ www/js-flipbook/** www/admin/_V4/** www/csv/** www/admin/_sounds/** -www/mp3/** \ No newline at end of file +www/mp3/** +faceai/logs/** \ No newline at end of file diff --git a/FACEAI_INTEGRATION_PLAN.md b/FACEAI_INTEGRATION_PLAN.md index 4e7f4622..22d7e7ce 100644 --- a/FACEAI_INTEGRATION_PLAN.md +++ b/FACEAI_INTEGRATION_PLAN.md @@ -61,6 +61,7 @@ Use three deployable parts: - Read the handoff token or FaceAI session cookie. - Show the legacy-like header and navigation. +- Check whether the mounted FaceAI dataset exists for the selected race before enabling uploads. - Let the user upload a selfie. - Create a race-scoped search request. - Poll job status or show queued state. @@ -72,7 +73,7 @@ Use three deployable parts: - Receive a race-scoped search job. - Queue requests and process them one by one. -- Run the external face-recognition program. +- Resolve `year/monthFolder/raceFolder` inside the mounted dataset root, take the first `.pkl` file in that race directory, and run the external face-recognition program against it. - Return match results with confidence and photo ids or file identifiers. - Return a completed result set usable by the legacy filter handoff. @@ -95,6 +96,10 @@ Instead: - access flags for FaceAI - race id - race slug or descriptor + - race storage metadata needed to resolve the mounted FaceAI dataset: + - `year` + - `monthFolder` like `04.APRILE` + - `raceFolder` like `LIVORNO` or `PISA` - current page URL as `returnUrl` - expiry time, ideally 1 to 5 minutes 3. Browser is redirected to `https://faceai.regalamiunsorriso.it/auth/callback?token=...` @@ -138,7 +143,7 @@ The lowest-risk way to do that is to update `www/_js/rus-ecom-240621.js` so that - removes that select from the rendered UI - inserts a `Face ID` button in the same area - builds the launch URL using the current race context and current page URL -- carries `raceId`, race description or slug, language, and exact `returnUrl` +- carries `raceId`, race description or slug, `raceYear`, `raceMonthFolder`, `raceFolder`, language, and exact `returnUrl` This avoids fragile JSP layout edits and keeps the change deployable as a single JS asset update. @@ -172,7 +177,7 @@ This is preferable to putting the matched ids directly in the browser URL, becau ## FaceAI App Structure -The requested target folder is `faceai/`. It does not currently exist in this workspace, so this plan assumes it will be created as a new app. +The target folder is `faceai/`, and this workspace now contains an implemented scaffold there. Suggested structure: @@ -198,10 +203,11 @@ faceai/ 5. FaceAI shows a page styled like the old site, including a matching header and a clear `Back to race page` action. 6. User uploads a selfie. 7. FaceAI creates a search job with `userId`, `raceId`, `requestId`, and selfie file reference. -8. FaceAI polls until the processing job completes. -9. Once the result is ready, FaceAI redirects the browser back to the original race page on `www`. -10. The legacy site resolves the matched photo ids and renders the race page filtered to those photos only, similar in spirit to the existing pettorale-based flow. -11. User opens and downloads photos exactly as they do today, through the legacy site. +8. FaceAI checks the mounted race directory immediately and, if no `.pkl` is present for that race, disables processing and offers only the return path. +9. FaceAI polls until the processing job completes. +10. Once the result is ready, FaceAI redirects the browser back to the original race page on `www`. +11. The legacy site resolves the matched photo ids and renders the race page filtered to those photos only, similar in spirit to the existing pettorale-based flow. +12. User opens and downloads photos exactly as they do today, through the legacy site. ## Result And Download Strategy @@ -255,6 +261,20 @@ For v1, `photoId` is the most important field. If the legacy page is the final r Race scope is mandatory. The service must never search globally by default. +The mounted dataset layout is now assumed to be: + +```text +/mounted-pkl-root/ + 2026/ + 04.APRILE/ + PISA/ + any-file-name.pkl + LIVORNO/ + any-file-name.pkl +``` + +The `.pkl` filename does not matter. The first `.pkl` found at the race root is the one passed to the matcher. + ## Async Processing Design Use an API plus worker model. @@ -275,6 +295,7 @@ Input job: - request id - race id +- race storage metadata: `year`, `monthFolder`, `raceFolder` - selfie storage path - user id - email @@ -370,7 +391,7 @@ This is safer than trying to embed the old JSP header directly into a Node app. - Update `www/_js/rus-ecom-240621.js` to remove the dropdown from the UI and insert the FaceAI button. - Add the legacy auth bridge endpoint. -- Pass `raceId`, `lang`, and `returnUrl` into the FaceAI launch. +- Pass `raceId`, `lang`, `returnUrl`, `raceYear`, `raceMonthFolder`, and `raceFolder` into the FaceAI launch. - Add the legacy return endpoint or result-aware race filter path. ### Phase 3: FaceAI app shell diff --git a/faceai/.env.example b/faceai/.env.example index 0888f410..6b185e47 100644 --- a/faceai/.env.example +++ b/faceai/.env.example @@ -6,3 +6,10 @@ FACEAI_ENABLE_LOCAL_LEGACY_STATIC=1 FACEAI_LOCAL_LEGACY_STATIC_ROOT=k:\various\regalamiunsorriso\www FACEAI_SHARED_SECRET=change-me FACEAI_SESSION_COOKIE=rus_faceai_session +FACEAI_REDIS_URL=redis://redis:6379 +FACEAI_QUEUE_NAME=faceai-searches +FACEAI_RUNTIME_ROOT=/data/runtime +FACEAI_UPLOAD_ROOT=/data/runtime/uploads +FACEAI_LOG_ROOT=/data/logs +FACEAI_PKL_ROOT=/data/pkl +FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher diff --git a/faceai/.gitignore b/faceai/.gitignore index 6466bba2..32304d25 100644 --- a/faceai/.gitignore +++ b/faceai/.gitignore @@ -1,3 +1,5 @@ node_modules/ apps/frontend/dist/ .env +playwright-report/ +test-results/ diff --git a/faceai/README.md b/faceai/README.md index 5c36e47c..a805a508 100644 --- a/faceai/README.md +++ b/faceai/README.md @@ -76,15 +76,33 @@ The checked-in `docker-compose.yml` starts: The local stack also mounts: - `../bin/Face_Recognition_Unix` into the processor container as the matcher binary source -- `../test_pkl` into the processor container as fallback PKL test data +- `../test_pkl` into both the public FaceAI container and the processor container as the shared read-only PKL dataset root +- `./logs` into both the public FaceAI container and the processor container as the persistent diagnostics directory - `../www` into the PHP container so the real bridge files are used +The `processor` service is built from `docker/processor.Dockerfile`, which uses a Debian Trixie-based Node 22 image, applies the current package upgrades available during build, and installs `libxcb1` so the bundled Linux `face_matcher` binary can run locally. + +### Persistent Logs + +The checked-in local Compose stack now redirects the relevant Node service logs into `faceai/logs` on the host. + +After `docker compose up --build`, inspect: + +- `faceai/logs/backend.log` for backend startup and API-side failures +- `faceai/logs/processor.log` for worker startup, queue processing, and uncaught processor errors +- `faceai/logs/searches//worker.log` for the per-search processor trace +- `faceai/logs/searches//matcher.log` for the native `face_matcher` output + +This keeps the useful processor diagnostics outside the Docker-managed runtime volume so they survive container rebuilds and can be inspected directly from the workspace. + +The current bundled Linux `face_matcher` binary is a PyInstaller build that requires `GLIBC_2.38` or newer and the `libxcb.so.1` runtime library. The checked-in local processor image satisfies that requirement. + ### Run The Browser Test Open: ```text -http://localhost:8080/faceai_simulator.php?raceId=101&lang=it +http://localhost:8080/faceai_simulator.php?raceId=202&lang=it ``` That page simulates the legacy race page, loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the real PHP handoff bridge at `www/faceai_handoff.php`. @@ -115,6 +133,43 @@ If you want to stop and remove the local containers afterward, run: docker compose down ``` +### Automated End-To-End Test + +The workspace now includes a Playwright suite that drives the PHP simulator, the FaceAI app, and the processor end to end. + +From this folder, run: + +```bash +npm install +npm run test:e2e:install +npm run test:e2e +``` + +The suite will: + +- build the frontend bundle +- start `docker compose up --build -d` +- open `http://localhost:8080/faceai_simulator.php?raceId=202&lang=it` +- click the `Face ID` launch button injected by `www/_js/rus-ecom-240621.js` +- upload `test_pkl/test_images/DSC_1960.JPG` +- wait for the processor to complete and for FaceAI to redirect to `faceai_return.php` +- assert the filtered legacy result contains the expected `6` matches and includes `DSC_1960.JPG` +- validate `faceai/logs/backend.log`, `faceai/logs/processor.log`, and the per-search `worker.log` and `matcher.log` for the run +- stop the Compose stack automatically when the suite finishes + +The default deterministic fixture can be overridden with environment variables if the dataset changes: + +```bash +FACEAI_E2E_SELFIE=DSC_1960.JPG +FACEAI_E2E_EXPECTED_MATCH_COUNT=6 +``` + +If you want to keep the local containers running after the test for manual inspection, set: + +```bash +FACEAI_E2E_KEEP_STACK=1 +``` + ## Optional Backend And Frontend Dev Loop If you only want to iterate on the app without the PHP simulator, you can still run the public site and the processor separately. The queue-backed flow now requires Redis and the processor, so `npm run dev` alone is no longer the full stack. @@ -138,6 +193,8 @@ The public FaceAI site and the matcher runner can both use the same application - `npm run start` for the public site - `npm run start:processor` for the matcher runner +If that shared image also embeds or mounts the current Linux `face_matcher` build, make sure the base OS provides `GLIBC_2.38` or newer and includes `libxcb1`. A Debian Trixie-based image with that package installed satisfies the requirement; a Bookworm-based image does not. + ### Production Compose Example Replace the registry path, secrets, and host paths with the real deployment values. @@ -148,6 +205,7 @@ services: image: registry.example.com/my-namespace/faceai:latest container_name: regalami-faceai restart: unless-stopped + command: sh -c "mkdir -p /data/logs && npm run start >> /data/logs/backend.log 2>&1" environment: NODE_ENV: production PORT: 3001 @@ -160,9 +218,13 @@ services: FACEAI_QUEUE_NAME: faceai-searches FACEAI_RUNTIME_ROOT: /data/runtime FACEAI_UPLOAD_ROOT: /data/runtime/uploads + FACEAI_LOG_ROOT: /data/logs + FACEAI_PKL_ROOT: /data/pkl FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 0 volumes: - faceai-runtime:/data/runtime + - /srv/faceai/logs:/data/logs + - /srv/faceai/pkl:/data/pkl:ro ports: - "127.0.0.1:3001:3001" depends_on: @@ -172,18 +234,20 @@ services: image: registry.example.com/my-namespace/faceai:latest container_name: regalami-faceai-processor restart: unless-stopped - command: npm run start:processor + command: sh -c "mkdir -p /data/logs && npm run start:processor >> /data/logs/processor.log 2>&1" environment: NODE_ENV: production FACEAI_REDIS_URL: redis://redis:6379 FACEAI_QUEUE_NAME: faceai-searches FACEAI_RUNTIME_ROOT: /data/runtime + FACEAI_LOG_ROOT: /data/logs FACEAI_PKL_ROOT: /data/pkl FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher FACEAI_WORKER_CONCURRENCY: 2 FACEAI_WORKER_TIMEOUT_MS: 300000 volumes: - faceai-runtime:/data/runtime + - /srv/faceai/logs:/data/logs - /srv/faceai/pkl:/data/pkl:ro - /srv/faceai/bin/Face_Recognition_Unix:/opt/face-recognition:ro depends_on: @@ -211,6 +275,7 @@ Shared application settings: | `FACEAI_REDIS_URL` | yes | `redis://redis:6379` | queue and search-state backend | | `FACEAI_QUEUE_NAME` | optional | `faceai-searches` | BullMQ queue name | | `FACEAI_RUNTIME_ROOT` | yes | `/data/runtime` | shared writable runtime root between site and processor | +| `FACEAI_LOG_ROOT` | recommended | `/data/logs` | persistent host-mounted diagnostics root for backend, processor, and per-search logs | | `FACEAI_SHARED_SECRET` | yes | long random secret | trust boundary between FaceAI and the legacy bridge | Public site settings: @@ -230,11 +295,22 @@ Processor settings: | Variable | Required | Example | Purpose | | --- | --- | --- | --- | | `FACEAI_PKL_ROOT` | yes | `/data/pkl` | mounted race-to-PKL dataset root | -| `FACEAI_TEST_PKL_ROOT` | optional | `/data/pkl/test` | local-only fallback PKL location | | `FACEAI_MATCHER_BINARY` | yes | `/opt/face-recognition/face_matcher` | matcher executable inside the processor container | | `FACEAI_WORKER_CONCURRENCY` | optional | `2` | BullMQ worker concurrency | | `FACEAI_WORKER_TIMEOUT_MS` | optional | `300000` | matcher timeout in milliseconds | +The mounted PKL root is expected to use this structure: + +```text +/data/pkl/ + 2026/ + 04.APRILE/ + PISA/ + any-file-name.pkl +``` + +The public FaceAI site mounts the same path read-only so it can check availability during session bootstrap and refuse uploads immediately when the race has no `.pkl` data. + Do not enable `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` in production. That mode exists only for local simulator flows. ### Legacy-Side Configuration That Must Match @@ -276,7 +352,7 @@ This scaffold can now be deployed with the public site, processor, and Redis, bu - search state is short-lived in Redis and is not backed by a durable database - runtime uploads and matcher output still need an agreed production retention and cleanup policy -- the final production PKL/NAS layout is not yet locked down +- the PKL mount contract is now defined, but final NAS operations and cleanup policy still need to be hardened - the backend currently sets the FaceAI session cookie with `secure: false`, which should be hardened before final public rollout - the local simulator endpoints under `/dev/*` are still present in the app and should be treated as non-production scaffolding - the processor CSV parser is still based on the current scaffolded matcher output assumptions @@ -298,8 +374,8 @@ FACEAI_REDIS_URL=redis://redis:6379 FACEAI_QUEUE_NAME=faceai-searches FACEAI_RUNTIME_ROOT=/data/runtime FACEAI_UPLOAD_ROOT=/data/runtime/uploads +FACEAI_LOG_ROOT=/data/logs FACEAI_PKL_ROOT=/data/pkl -FACEAI_TEST_PKL_ROOT=/data/pkl/test FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher ``` @@ -311,6 +387,16 @@ In the provided Docker Compose stack, that wiring is already done with: FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php ``` +The log wiring is also already done in the checked-in Compose file with a host bind mount for `./logs:/data/logs`, so both the backend and the processor write persistent diagnostics into the workspace. + +The local PHP simulator also needs the legacy bridge feature flag enabled: + +```text +FACEAI_FEATURE_ENABLED=1 +``` + +The checked-in `docker-compose.yml` now sets that on the `legacy-php` service so the simulator can launch the FaceAI handoff flow locally. + ## Notes - Search orchestration now uses Redis and a dedicated processor worker. diff --git a/faceai/apps/backend/src/config.js b/faceai/apps/backend/src/config.js index 7cc03656..d16182bd 100644 --- a/faceai/apps/backend/src/config.js +++ b/faceai/apps/backend/src/config.js @@ -9,6 +9,8 @@ export const config = { frontendUrl: process.env.FACEAI_FRONTEND_URL || 'http://localhost:5173', publicBaseUrl: process.env.FACEAI_PUBLIC_BASE_URL || 'http://localhost:3001', legacyReturnUrl: process.env.FACEAI_LEGACY_RETURN_URL || 'http://localhost:3001/dev/legacy/return', + legacyHomeUrl: process.env.FACEAI_LEGACY_HOME_URL || 'http://localhost:8080/index.jsp', + pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl', enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC ? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1' : process.env.NODE_ENV !== 'production', diff --git a/faceai/apps/backend/src/race-storage.js b/faceai/apps/backend/src/race-storage.js new file mode 100644 index 00000000..0a8ee4f7 --- /dev/null +++ b/faceai/apps/backend/src/race-storage.js @@ -0,0 +1,130 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const ITALIAN_MONTH_NAMES = [ + 'GENNAIO', + 'FEBBRAIO', + 'MARZO', + 'APRILE', + 'MAGGIO', + 'GIUGNO', + 'LUGLIO', + 'AGOSTO', + 'SETTEMBRE', + 'OTTOBRE', + 'NOVEMBRE', + 'DICEMBRE' +]; + +function sanitizePathSegment(value) { + const normalized = String(value || '').trim(); + + if (!normalized) { + return ''; + } + + if (normalized === '.' || normalized === '..' || normalized.includes('..') || /[\\/]/.test(normalized)) { + throw new Error('Invalid race storage path segment'); + } + + return normalized; +} + +export function normalizeRaceFolderName(value) { + return String(value || '') + .trim() + .replace(/[<>:"/\\|?*]/g, ' ') + .replace(/\s+/g, ' ') + .toUpperCase(); +} + +export function buildMonthFolder(year, monthIndex) { + const safeYear = sanitizePathSegment(year); + const normalizedMonthIndex = Number(monthIndex); + if (!safeYear || Number.isNaN(normalizedMonthIndex) || normalizedMonthIndex < 1 || normalizedMonthIndex > 12) { + return ''; + } + + return `${String(normalizedMonthIndex).padStart(2, '0')}.${ITALIAN_MONTH_NAMES[normalizedMonthIndex - 1]}`; +} + +export function buildRaceStorage(storageInput = {}) { + const year = sanitizePathSegment(storageInput.year); + const monthFolder = sanitizePathSegment(storageInput.monthFolder); + const raceFolder = sanitizePathSegment(normalizeRaceFolderName(storageInput.raceFolder)); + + if (!year || !monthFolder || !raceFolder) { + return null; + } + + return { + year, + monthFolder, + raceFolder, + relativeDir: path.posix.join(year, monthFolder, raceFolder) + }; +} + +export async function resolveRacePklAvailability({ pklRoot, race }) { + if (!pklRoot) { + return { + available: false, + reasonCode: 'PKL_ROOT_NOT_CONFIGURED', + message: 'The PKL root is not configured for this FaceAI environment.', + storage: null + }; + } + + const storage = buildRaceStorage(race?.storage || race); + if (!storage) { + return { + available: false, + reasonCode: 'MISSING_RACE_STORAGE', + message: 'The legacy handoff did not provide the folder metadata required to resolve FaceAI data for this race.', + storage: null + }; + } + + const raceDir = path.join(pklRoot, storage.year, storage.monthFolder, storage.raceFolder); + + let entries; + try { + entries = await fs.readdir(raceDir, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') { + return { + available: false, + reasonCode: 'RACE_DIRECTORY_NOT_FOUND', + message: `No FaceAI dataset directory exists for ${storage.relativeDir}.`, + storage, + raceDir + }; + } + + throw error; + } + + const pklEntry = entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.pkl')) + .sort((left, right) => left.name.localeCompare(right.name, 'en'))[0]; + + if (!pklEntry) { + return { + available: false, + reasonCode: 'PKL_FILE_NOT_FOUND', + message: `The race directory ${storage.relativeDir} exists, but it does not contain any .pkl file.`, + storage, + raceDir + }; + } + + return { + available: true, + reasonCode: null, + message: `Using ${storage.relativeDir}/${pklEntry.name}`, + storage, + raceDir, + pklPath: path.join(raceDir, pklEntry.name), + pklFileName: pklEntry.name + }; +} \ No newline at end of file diff --git a/faceai/apps/backend/src/redis-store.js b/faceai/apps/backend/src/redis-store.js index 343de8bc..d3196860 100644 --- a/faceai/apps/backend/src/redis-store.js +++ b/faceai/apps/backend/src/redis-store.js @@ -100,12 +100,13 @@ export async function markSearchProcessing(redis, searchId, ttlSeconds = 24 * 60 }), ttlSeconds); } -export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds) { +export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds, metadata = {}) { return updateSearchRecord(redis, searchId, (current) => ({ ...current, status: 'completed', resultId, matchCount, + completionCode: metadata.completionCode || null, completedAt: Date.now() }), ttlSeconds); } diff --git a/faceai/apps/backend/src/server.js b/faceai/apps/backend/src/server.js index 1f202237..da9f901f 100644 --- a/faceai/apps/backend/src/server.js +++ b/faceai/apps/backend/src/server.js @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { config } from './config.js'; import { signPayload, verifySignedPayload } from './auth.js'; import { createSession, getSession, mockCatalog } from './store.js'; +import { buildRaceStorage, resolveRacePklAvailability } from './race-storage.js'; import { acquireActiveSearchLock, createRedisConnection, @@ -67,7 +68,10 @@ function getFaceAiSession(req) { function requireSession(req, res, next) { const session = getFaceAiSession(req); if (!session) { - res.status(401).json({ error: 'Not authenticated with FaceAI' }); + res.status(401).json({ + error: 'Not authenticated with FaceAI', + redirectUrl: config.legacyHomeUrl + }); return; } @@ -94,8 +98,24 @@ async function enforceSearchRateLimit(req, res, next) { next(); } -function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) { - const race = mockCatalog[raceId] || { id: raceId, slug: raceSlug || `race-${raceId}`, name: raceSlug || `Race ${raceId}` }; +function normalizeRaceForSession(raceInput) { + return { + ...raceInput, + storage: buildRaceStorage(raceInput?.storage || {}) + }; +} + +async function buildRaceAvailability(race) { + return resolveRacePklAvailability({ pklRoot: config.pklRoot, race }); +} + +function issueHandoffToken({ raceId, raceSlug, raceName, raceStorage, lang, returnUrl }) { + const race = mockCatalog[raceId] || { + id: raceId, + slug: raceSlug || `race-${raceId}`, + name: raceName || raceSlug || `Race ${raceId}`, + storage: buildRaceStorage(raceStorage || {}) + }; return signPayload({ type: 'handoff', @@ -108,7 +128,8 @@ function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) { race: { id: race.id, slug: race.slug, - name: race.name + name: race.name, + storage: buildRaceStorage(raceStorage || race.storage || {}) }, lang: lang || 'it', returnUrl, @@ -231,9 +252,21 @@ app.get('/dev/legacy/race', (req, res) => { app.get('/dev/legacy/launch', (req, res) => { const raceId = String(req.query.raceId || '101'); const raceSlug = String(req.query.raceSlug || mockCatalog[raceId]?.slug || `race-${raceId}`); + const raceName = String(req.query.raceName || mockCatalog[raceId]?.name || raceSlug); const lang = String(req.query.lang || 'it'); const returnUrl = String(req.query.returnUrl || `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(raceId)}&lang=${encodeURIComponent(lang)}`); - const token = issueHandoffToken({ raceId, raceSlug, lang, returnUrl }); + const token = issueHandoffToken({ + raceId, + raceSlug, + raceName, + raceStorage: { + year: String(req.query.raceYear || mockCatalog[raceId]?.storage?.year || ''), + monthFolder: String(req.query.raceMonthFolder || mockCatalog[raceId]?.storage?.monthFolder || ''), + raceFolder: String(req.query.raceFolder || mockCatalog[raceId]?.storage?.raceFolder || '') + }, + lang, + returnUrl + }); res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`); }); @@ -261,7 +294,7 @@ app.get('/dev/legacy/return', async (req, res) => { } }); -app.post('/api/auth/exchange', (req, res) => { +app.post('/api/auth/exchange', async (req, res) => { try { const { token } = req.body; const payload = verifySignedPayload(token, config.sharedSecret); @@ -269,13 +302,18 @@ app.post('/api/auth/exchange', (req, res) => { throw new Error('Wrong token type'); } + const race = normalizeRaceForSession(payload.race); + const availability = await buildRaceAvailability(race); + const faceAiAllowed = payload.user.membershipStatus === 'active' && availability.available; + const sessionId = createSession({ user: payload.user, - race: payload.race, + race, lang: payload.lang, returnUrl: payload.returnUrl, + availability, access: { - faceAiAllowed: payload.user.membershipStatus === 'active' + faceAiAllowed } }); @@ -288,11 +326,12 @@ app.post('/api/auth/exchange', (req, res) => { res.json({ user: payload.user, - race: payload.race, + race, lang: payload.lang, returnUrl: payload.returnUrl, + availability, access: { - faceAiAllowed: true + faceAiAllowed } }); } catch (error) { @@ -308,6 +347,19 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single( try { const raceId = String(req.body.raceId || req.faceaiSession.race.id); const userId = String(req.faceaiSession.user.id); + const race = normalizeRaceForSession(raceId === req.faceaiSession.race.id + ? req.faceaiSession.race + : (mockCatalog[raceId] || req.faceaiSession.race)); + const availability = await buildRaceAvailability(race); + + if (!availability.available) { + res.status(409).json({ + error: availability.message, + code: availability.reasonCode || 'RACE_PKL_UNAVAILABLE' + }); + return; + } + const activeSearchId = await getActiveSearchId(redis, userId); if (activeSearchId) { @@ -327,10 +379,10 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single( return; } - const race = mockCatalog[raceId] || req.faceaiSession.race; const search = await createSearchRecord(redis, { raceId, raceName: race?.name || raceId, + raceStorage: race?.storage || availability.storage, userId, returnUrl: req.faceaiSession.returnUrl, lang: req.faceaiSession.lang, @@ -371,6 +423,7 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single( id: updatedSearch.id, status: updatedSearch.status, raceId: updatedSearch.raceId, + raceStorage: updatedSearch.raceStorage, selfieName: updatedSearch.selfieName, matchCount: updatedSearch.matchCount, errorCode: updatedSearch.errorCode, @@ -396,6 +449,7 @@ app.get('/api/searches/:id', requireSession, async (req, res) => { createdAt: search.createdAt, completedAt: search.completedAt, matchCount: search.matchCount || 0, + completionCode: search.completionCode || null, errorCode: search.errorCode, errorMessage: search.errorMessage }); diff --git a/faceai/apps/backend/src/store.js b/faceai/apps/backend/src/store.js index 03350a1c..a1b1ec33 100644 --- a/faceai/apps/backend/src/store.js +++ b/faceai/apps/backend/src/store.js @@ -5,6 +5,11 @@ export const mockCatalog = { id: '101', slug: 'mezza-di-firenze', name: 'Mezza di Firenze', + storage: { + year: '2026', + monthFolder: '04.APRILE', + raceFolder: 'PISA' + }, photos: [ { id: 'f101-001', label: 'Arrivo 001', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-001.jpg' }, { id: 'f101-002', label: 'Arrivo 002', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-002.jpg' }, @@ -22,14 +27,33 @@ export const mockCatalog = { }, '202': { id: '202', - slug: 'trail-del-chianti', - name: 'Trail del Chianti', + slug: 'mezza-di-pisa', + name: 'Mezza di Pisa', + storage: { + year: '2026', + monthFolder: '04.APRILE', + raceFolder: 'PISA' + }, photos: [ { id: 'f202-001', label: 'Bosco 001', bib: '77', checkpoint: 'Bosco', thumb: 'thumb-bosco-001.jpg' }, { id: 'f202-002', label: 'Salita 002', bib: '77', checkpoint: 'Salita', thumb: 'thumb-salita-002.jpg' }, { id: 'f202-003', label: 'Arrivo 003', bib: '77', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-003.jpg' }, { id: 'f202-004', label: 'Bosco 004', bib: '19', checkpoint: 'Bosco', thumb: 'thumb-bosco-004.jpg' } ] + }, + '303': { + id: '303', + slug: 'corsa-di-lucca', + name: 'Corsa di Lucca', + storage: { + year: '2026', + monthFolder: '04.APRILE', + raceFolder: 'LUCCA' + }, + photos: [ + { id: 'f303-001', label: 'Mura 001', bib: '33', checkpoint: 'Mura', thumb: 'thumb-mura-001.jpg' }, + { id: 'f303-002', label: 'Centro 002', bib: '33', checkpoint: 'Centro', thumb: 'thumb-centro-002.jpg' } + ] } }; diff --git a/faceai/apps/frontend/src/components/FaceAiFeedbackPanel.vue b/faceai/apps/frontend/src/components/FaceAiFeedbackPanel.vue new file mode 100644 index 00000000..7cd6b41d --- /dev/null +++ b/faceai/apps/frontend/src/components/FaceAiFeedbackPanel.vue @@ -0,0 +1,56 @@ + + + + + \ No newline at end of file diff --git a/faceai/apps/frontend/src/components/FaceAiHeroCard.vue b/faceai/apps/frontend/src/components/FaceAiHeroCard.vue new file mode 100644 index 00000000..f04505c2 --- /dev/null +++ b/faceai/apps/frontend/src/components/FaceAiHeroCard.vue @@ -0,0 +1,110 @@ + + + + + \ No newline at end of file diff --git a/faceai/apps/frontend/src/components/FaceAiUploadPanel.vue b/faceai/apps/frontend/src/components/FaceAiUploadPanel.vue new file mode 100644 index 00000000..f4a01068 --- /dev/null +++ b/faceai/apps/frontend/src/components/FaceAiUploadPanel.vue @@ -0,0 +1,394 @@ + + + + + \ No newline at end of file diff --git a/faceai/apps/frontend/src/components/LegacyHeader.vue b/faceai/apps/frontend/src/components/LegacyHeader.vue index 3963c337..b003f409 100644 --- a/faceai/apps/frontend/src/components/LegacyHeader.vue +++ b/faceai/apps/frontend/src/components/LegacyHeader.vue @@ -1,9 +1,19 @@