Compare commits

...

2 commits

Author SHA1 Message Date
2218c9a84c End to end tests
All checks were successful
Publish FaceAI Container / publish (push) Successful in 2m42s
2026-04-12 19:31:12 +02:00
c67bb02173 Refactor code structure for improved readability and maintainability 2026-04-12 17:26:17 +02:00
44 changed files with 2751 additions and 358 deletions

1
.gitignore vendored
View file

@ -67,3 +67,4 @@ www/admin/_V4/**
www/csv/**
www/admin/_sounds/**
www/mp3/**
faceai/logs/**

View file

@ -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

View file

@ -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

2
faceai/.gitignore vendored
View file

@ -1,3 +1,5 @@
node_modules/
apps/frontend/dist/
.env
playwright-report/
test-results/

View file

@ -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/<searchId>/worker.log` for the per-search processor trace
- `faceai/logs/searches/<searchId>/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.

View file

@ -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',

View file

@ -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
};
}

View file

@ -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);
}

View file

@ -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
});

View file

@ -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' }
]
}
};

View file

@ -0,0 +1,56 @@
<script setup>
defineProps({
statusLabel: {
type: String,
required: true
},
isWorking: {
type: Boolean,
required: true
},
busyLabel: {
type: String,
required: true
},
activeSearch: {
type: Object,
default: null
},
redirectUrl: {
type: String,
default: ''
},
errorMessage: {
type: String,
default: ''
},
t: {
type: Function,
required: true
}
});
</script>
<template>
<div class="faceai-feedback mt-4">
<p class="lead mb-2">{{ statusLabel }}</p>
<p v-if="activeSearch" class="mb-2">{{ t('matchesLabel') }}: {{ activeSearch.matchCount }}</p>
<p v-if="redirectUrl" class="mb-2">{{ t('redirectMessage') }}</p>
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
</div>
</template>
<style scoped>
.faceai-feedback {
border-radius: 24px;
padding: 1.25rem 1.5rem;
background: linear-gradient(180deg, #fffdf9, #f5efe5);
border: 1px solid rgba(212, 189, 154, 0.55);
}
@media (max-width: 991.98px) {
.faceai-feedback {
padding: 1.25rem;
}
}
</style>

View file

@ -0,0 +1,110 @@
<script setup>
defineProps({
session: {
type: Object,
default: null
},
currentLocale: {
type: String,
required: true
},
activeSearchStatusLabel: {
type: String,
required: true
},
t: {
type: Function,
required: true
}
});
</script>
<template>
<div class="faceai-hero card border-0 shadow-sm">
<div class="faceai-hero-body">
<p class="faceai-kicker mb-2">{{ t('pageTitle') }}</p>
<h1 class="faceai-title mb-3">{{ t('pageHeadline') }}</h1>
<p class="faceai-intro mb-4">{{ t('pageIntro') }}</p>
<div class="faceai-summary-grid">
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('raceLabel') }}</span>
<strong>{{ session ? session.race.name : t('raceFallback') }}</strong>
</div>
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('languageLabel') }}</span>
<strong>{{ session ? session.lang.toUpperCase() : currentLocale.toUpperCase() }}</strong>
</div>
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('statusLabel') }}</span>
<strong>{{ activeSearchStatusLabel }}</strong>
</div>
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('userLabel') }}</span>
<strong>{{ session ? session.user.displayName : t('userFallback') }}</strong>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.faceai-hero {
overflow: hidden;
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(244, 190, 92, 0.28), transparent 32%),
linear-gradient(135deg, #fffaf1 0%, #f3ebdc 100%);
}
.faceai-hero-body {
padding: 2rem;
}
.faceai-kicker {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #9a6a19;
}
.faceai-title {
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.05;
color: #2d241c;
}
.faceai-intro {
color: #665548;
}
.faceai-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.9rem;
}
.faceai-summary-pill {
padding: 0.95rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(191, 158, 117, 0.28);
}
.faceai-summary-label {
display: block;
margin-bottom: 0.2rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8b775f;
}
@media (max-width: 991.98px) {
.faceai-hero-body {
padding: 1.25rem;
}
}
</style>

View file

@ -0,0 +1,394 @@
<script setup>
defineProps({
loading: {
type: Boolean,
required: true
},
isWorking: {
type: Boolean,
required: true
},
isProcessingSearch: {
type: Boolean,
required: true
},
session: {
type: Object,
default: null
},
simulatorUrl: {
type: String,
required: true
},
busyLabel: {
type: String,
required: true
},
canPickFile: {
type: Boolean,
required: true
},
isDragging: {
type: Boolean,
required: true
},
selectedFile: {
type: Object,
default: null
},
selectedFileSizeLabel: {
type: String,
required: true
},
canStartSearch: {
type: Boolean,
required: true
},
fileInput: {
type: Object,
required: true
},
t: {
type: Function,
required: true
}
});
const emit = defineEmits([
'open-file-picker',
'file-change',
'drag-enter',
'drag-over',
'drag-leave',
'drop',
'clear-file',
'submit-search'
]);
</script>
<template>
<section class="faceai-panel shadow-sm">
<div v-if="loading" class="faceai-loading-state">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<div v-else-if="!session" class="faceai-empty-state">
<h2 class="faceai-section-title">{{ t('pageTitle') }}</h2>
<p class="mb-3">{{ t('handoffMissing') }}</p>
<a class="btn btn-warning" :href="simulatorUrl">{{ t('openSimulator') }}</a>
</div>
<template v-else>
<div class="faceai-panel-header">
<div>
<h2 class="faceai-section-title mb-2">{{ t('uploaderTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ t('uploaderHint') }}</p>
</div>
</div>
<div v-if="isWorking && busyLabel" class="faceai-busy-banner" aria-live="polite">
<span class="faceai-spinner" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<div
class="faceai-dropzone"
:class="{
'is-dragging': isDragging,
'is-disabled': !canPickFile,
'has-file': selectedFile,
'is-processing': isProcessingSearch
}"
@click="emit('open-file-picker')"
@dragenter="emit('drag-enter', $event)"
@dragover="emit('drag-over', $event)"
@dragleave="emit('drag-leave', $event)"
@drop="emit('drop', $event)"
>
<input
ref="fileInput"
class="d-none"
type="file"
accept="image/*"
:disabled="!canPickFile"
@change="emit('file-change', $event)"
/>
<div class="faceai-dropzone-inner">
<div class="faceai-dropzone-icon">
<i class="fa fa-cloud-upload" aria-hidden="true"></i>
</div>
<template v-if="selectedFile">
<p class="faceai-dropzone-title mb-2">{{ t('uploaderSelected') }}</p>
<strong class="faceai-file-name">{{ selectedFile.name }}</strong>
<p class="faceai-file-meta mb-0">{{ selectedFileSizeLabel }}</p>
</template>
<template v-else>
<p class="faceai-dropzone-title mb-2">
{{ isDragging ? t('uploaderDragActive') : t('uploaderDragIdle') }}
</p>
<p class="faceai-dropzone-copy mb-0">
{{ canPickFile ? t('uploaderFormats') : t('dropzoneDisabled') }}
</p>
</template>
</div>
<div class="faceai-dropzone-actions" @click.stop>
<button class="btn btn-outline-warning" type="button" :disabled="!canPickFile" @click="emit('open-file-picker')">
{{ selectedFile ? t('uploaderReplace') : t('uploaderBrowse') }}
</button>
<button v-if="selectedFile" class="btn btn-link" type="button" @click="emit('clear-file')">
{{ t('uploaderRemove') }}
</button>
</div>
<div v-if="isDragging" class="faceai-dropzone-overlay">
<span>{{ t('uploaderDragActive') }}</span>
</div>
<div v-if="isProcessingSearch" class="faceai-processing-overlay" aria-live="polite" aria-busy="true">
<span class="faceai-spinner faceai-spinner-lg" role="status" aria-hidden="true"></span>
<strong>{{ busyLabel }}</strong>
</div>
</div>
<div class="faceai-action-row">
<button v-if="selectedFile" class="btn btn-warning" type="button" :disabled="!canStartSearch" @click="emit('submit-search')">
{{ t('uploadButton') }}
</button>
<a class="btn btn-light" :href="session.returnUrl">{{ t('backButton') }}</a>
</div>
<div v-if="!selectedFile && canPickFile" class="faceai-subtle-note">
{{ t('noFileCta') }}
</div>
</template>
</section>
</template>
<style scoped>
.faceai-panel {
border-radius: 28px;
padding: 1.5rem;
background: #fffdf9;
border: 1px solid rgba(212, 189, 154, 0.55);
}
.faceai-panel-header {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.faceai-section-title {
margin: 0;
font-size: 1.45rem;
color: #30261e;
}
.faceai-panel-subtitle,
.faceai-dropzone-copy,
.faceai-subtle-note {
color: #665548;
}
.faceai-busy-banner {
display: inline-flex;
align-items: center;
gap: 0.65rem;
margin-bottom: 1rem;
padding: 0.8rem 1rem;
border-radius: 999px;
background: rgba(255, 248, 224, 0.95);
color: #744500;
border: 1px solid rgba(213, 138, 0, 0.24);
}
.faceai-spinner {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid rgba(191, 158, 117, 0.3);
border-top-color: #c87800;
animation: faceai-spin 0.75s linear infinite;
}
.faceai-spinner-lg {
width: 2.75rem;
height: 2.75rem;
border-width: 4px;
}
.faceai-loading-state,
.faceai-empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
padding: 1rem 0.25rem;
}
.faceai-dropzone {
position: relative;
border-radius: 24px;
border: 2px dashed rgba(187, 144, 72, 0.55);
background: linear-gradient(180deg, rgba(255, 248, 235, 0.95), rgba(252, 244, 230, 0.98));
min-height: 280px;
padding: 1.5rem;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.faceai-dropzone.is-processing {
overflow: hidden;
}
.faceai-dropzone:hover {
transform: translateY(-1px);
box-shadow: 0 18px 38px rgba(93, 72, 44, 0.08);
}
.faceai-dropzone.is-dragging {
border-color: #d58a00;
background: linear-gradient(180deg, #fff4d7, #ffe7a8);
box-shadow: 0 20px 45px rgba(213, 138, 0, 0.18);
}
.faceai-dropzone.is-disabled {
cursor: not-allowed;
opacity: 0.7;
}
.faceai-dropzone.has-file {
border-style: solid;
background: linear-gradient(180deg, #fffaf0, #f8efe0);
}
.faceai-dropzone-inner {
min-height: 190px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.faceai-dropzone-icon {
width: 88px;
height: 88px;
margin-bottom: 1rem;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.9);
color: #b87014;
font-size: 2rem;
box-shadow: inset 0 0 0 1px rgba(191, 158, 117, 0.24);
}
.faceai-dropzone-title {
font-size: 1.2rem;
font-weight: 700;
color: #2d241c;
}
.faceai-file-name {
display: block;
font-size: 1.05rem;
color: #2d241c;
word-break: break-word;
}
.faceai-file-meta {
color: #7b6857;
}
.faceai-dropzone-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
}
.faceai-dropzone-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 22px;
background: rgba(255, 236, 184, 0.84);
color: #764300;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.faceai-processing-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.9rem;
border-radius: 22px;
background: rgba(255, 249, 238, 0.9);
color: #5e3800;
text-align: center;
padding: 1.5rem;
backdrop-filter: blur(2px);
}
.faceai-action-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.faceai-subtle-note {
margin-top: 0.85rem;
font-size: 0.95rem;
}
@media (max-width: 991.98px) {
.faceai-panel {
padding: 1.25rem;
}
}
@media (max-width: 767.98px) {
.faceai-dropzone {
min-height: 240px;
padding: 1rem;
}
.faceai-action-row {
flex-direction: column;
}
.faceai-action-row .btn,
.faceai-empty-state .btn {
width: 100%;
}
.faceai-dropzone-actions {
flex-direction: column;
}
.faceai-dropzone-actions .btn {
width: 100%;
}
}
@keyframes faceai-spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,9 +1,19 @@
<script setup>
import { ref } from 'vue';
import { legacyAsset } from '../legacyAssets.js';
const logoUrl = legacyAsset('/images/layout/regalami-un-sorriso-ets-640.png');
const facebookUrl = legacyAsset('/images/FB-f-Logo__blue_29.png');
const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
const isMenuOpen = ref(false);
function toggleMenu() {
isMenuOpen.value = !isMenuOpen.value;
}
function closeMenu() {
isMenuOpen.value = false;
}
</script>
<template>
@ -14,25 +24,32 @@ const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
<a class="navbar-brand" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">
<img :src="logoUrl" alt="Regalami Un Sorriso ETS" width="100" />
</a>
<button class="navbar-toggler navbar-toggler-right" type="button">
<button
class="navbar-toggler navbar-toggler-right"
type="button"
aria-controls="navbarResponsive"
:aria-expanded="isMenuOpen ? 'true' : 'false'"
aria-label="Toggle navigation"
@click="toggleMenu"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse show" id="navbarResponsive">
<div :class="['collapse', 'navbar-collapse', { show: isMenuOpen }]" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="http://localhost:8080/index.jsp">Home</a>
<a class="nav-link" href="http://localhost:8080/index.jsp" @click="closeMenu">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="http://localhost:8080/associazione.jsp">Associazione</a>
<a class="nav-link" href="http://localhost:8080/associazione.jsp" @click="closeMenu">Associazione</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Foto</a>
<a class="nav-link active" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it" @click="closeMenu">Foto</a>
</li>
<li class="nav-item">
<a class="nav-link btn btn-sm btn-warning" href="http://localhost:8080/gallery2.php">Archivio</a>
<a class="nav-link btn btn-sm btn-warning" href="http://localhost:8080/gallery2.php" @click="closeMenu">Archivio</a>
</li>
<li class="nav-item">
<a href="http://localhost:8080/dettaglio_clienti-it.html">
<a href="http://localhost:8080/dettaglio_clienti-it.html" @click="closeMenu">
<img :src="donateUrl" border="0" alt="PayPal" />
</a>
</li>
@ -40,12 +57,12 @@ const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link active" href="#">
<a class="nav-link active" href="#" @click="closeMenu">
<i class="fa fa-user" aria-hidden="true"></i> Il mio account
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://it-it.facebook.com/pg/Regalami-un-sorriso-ETS-189377806523/community/">
<a class="nav-link" href="https://it-it.facebook.com/pg/Regalami-un-sorriso-ETS-189377806523/community/" @click="closeMenu">
<img :src="facebookUrl" class="img-fluid" alt="Facebook" />
</a>
</li>

View file

@ -0,0 +1,533 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
const copy = {
it: {
pageTitle: 'Face ID',
pageHeadline: 'Trova le tue foto con un selfie',
pageIntro: 'Carica una tua immagine recente e lascia che Face ID cerchi le corrispondenze solo nella gara corrente.',
userFallback: 'Sessione FaceAI',
raceFallback: 'Gara corrente',
statusReady: 'Pronto',
statusProcessing: 'In lavorazione',
statusCompleted: 'Completata',
statusFailed: 'Errore',
statusLabel: 'Stato',
userLabel: 'Utente',
raceLabel: 'Gara',
languageLabel: 'Lingua',
uploaderTitle: 'Carica il tuo selfie',
uploaderHint: 'Puoi trascinare un file immagine oppure selezionarlo dal dispositivo.',
uploaderDragIdle: 'Trascina qui il selfie',
uploaderDragActive: 'Rilascia limmagine per caricarla',
uploaderBrowse: 'Scegli immagine',
uploaderFormats: 'Formati supportati: JPG, PNG, WEBP',
uploaderSelected: 'File selezionato',
uploaderReplace: 'Sostituisci',
uploaderRemove: 'Rimuovi',
backButton: 'Torna alla pagina gara',
uploadButton: 'Avvia ricerca Face ID',
openSimulator: 'Apri il simulatore legacy',
handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.',
sessionLoading: 'Caricamento della sessione FaceAI…',
submitLoading: 'Invio del selfie e preparazione della ricerca…',
redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…',
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
unavailableDefault: 'FaceAI non è disponibile per questa gara.',
noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.',
readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.',
completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.',
failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.',
matchesLabel: 'Foto trovate',
redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…',
noFileCta: 'Seleziona unimmagine per sbloccare la ricerca.',
invalidImage: 'Seleziona un file immagine valido.',
pollError: 'Impossibile leggere lo stato della ricerca.',
searchFailed: 'La ricerca non è andata a buon fine.',
redirectError: 'Impossibile generare il link di ritorno.',
chooseSelfie: 'Seleziona un selfie prima di avviare la ricerca.',
raceDataUnavailable: 'I dati FaceAI non sono disponibili per questa gara.',
invalidRaceData: 'I dati della gara ricevuti non sono validi. Torna alla pagina gara e riapri Face ID dalla gara corretta.',
searchCreateError: 'Impossibile avviare la ricerca.',
faceAiAlt: 'FaceAI',
dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.'
},
en: {
pageTitle: 'Face ID',
pageHeadline: 'Find your photos with a selfie',
pageIntro: 'Upload a recent picture of yourself and Face ID will search only within the current race.',
userFallback: 'FaceAI session',
raceFallback: 'Current race',
statusReady: 'Ready',
statusProcessing: 'Processing',
statusCompleted: 'Completed',
statusFailed: 'Error',
statusLabel: 'Status',
userLabel: 'User',
raceLabel: 'Race',
languageLabel: 'Language',
uploaderTitle: 'Upload your selfie',
uploaderHint: 'Drag an image here or choose it from your device.',
uploaderDragIdle: 'Drag your selfie here',
uploaderDragActive: 'Drop the image to upload it',
uploaderBrowse: 'Choose image',
uploaderFormats: 'Supported formats: JPG, PNG, WEBP',
uploaderSelected: 'Selected file',
uploaderReplace: 'Replace',
uploaderRemove: 'Remove',
backButton: 'Back to the race page',
uploadButton: 'Start Face ID search',
openSimulator: 'Open the legacy simulator',
handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.',
sessionLoading: 'Loading the FaceAI session…',
submitLoading: 'Uploading the selfie and preparing the search…',
redirectLoading: 'Redirecting to the filtered legacy page…',
processingLoading: 'Biometric search in progress across all race photos…',
unavailableDefault: 'FaceAI is not available for this race.',
noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.',
readyMessage: 'Select a selfie to start a search limited to the current race.',
completedMessage: 'Search completed. Found {count} matching photos.',
failedMessage: 'The search did not complete. Check the message and try again.',
matchesLabel: 'Photos found',
redirectMessage: 'Redirecting to the filtered legacy page…',
noFileCta: 'Select an image to unlock the search action.',
invalidImage: 'Select a valid image file.',
pollError: 'Unable to read the search status.',
searchFailed: 'The search failed.',
redirectError: 'Unable to build the return link.',
chooseSelfie: 'Choose a selfie before starting the search.',
raceDataUnavailable: 'FaceAI data is not available for this race.',
invalidRaceData: 'The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.',
searchCreateError: 'Unable to start the search.',
faceAiAlt: 'FaceAI',
dropzoneDisabled: 'Upload is not available for this race.'
}
};
const knownServerMessages = {
'No training dataset available for this race.': 'raceDataUnavailable',
'FaceAI data is not available for this race.': 'raceDataUnavailable',
'FaceAI is not available for this race.': 'unavailableDefault',
'Unable to read search status.': 'pollError',
'The search failed.': 'searchFailed',
'Unable to build return URL.': 'redirectError',
'Unable to create the search.': 'searchCreateError',
'Choose a selfie before starting the search.': 'chooseSelfie'
};
const simulatorUrl = 'http://localhost:8080/faceai_simulator.php?raceId=101&lang=it';
const legacyHomeUrl = 'http://localhost:8080/index.jsp';
function isInvalidRaceAvailability(availability) {
return availability?.reasonCode === 'RACE_DIRECTORY_NOT_FOUND' || availability?.reasonCode === 'MISSING_RACE_STORAGE';
}
export function useFaceAiHome() {
const session = ref(null);
const loading = ref(true);
const errorMessage = ref('');
const selectedFile = ref(null);
const activeSearch = ref(null);
const redirectUrl = ref('');
const isSubmitting = ref(false);
const isRedirecting = ref(false);
const isDragging = ref(false);
const fileInput = ref(null);
let pollTimer = null;
let dragDepth = 0;
const currentLocale = computed(() => {
const language = (session.value?.lang || document.documentElement.lang || 'it').toLowerCase();
return language.startsWith('en') ? 'en' : 'it';
});
function t(key, params = {}) {
const message = copy[currentLocale.value][key] || copy.it[key] || key;
return Object.keys(params).reduce((text, paramKey) => text.replace(`{${paramKey}}`, String(params[paramKey])), message);
}
function localizeServerMessage(message, fallbackKey) {
if (!message) {
return t(fallbackKey);
}
if (currentLocale.value === 'en') {
return message;
}
const mappedKey = knownServerMessages[message];
if (mappedKey) {
return t(mappedKey);
}
return t(fallbackKey);
}
function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') {
if (isInvalidRaceAvailability(availability)) {
return t('invalidRaceData');
}
return localizeServerMessage(availability?.message, fallbackKey);
}
function shouldLogFaceAiDebug() {
return import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
}
function logFaceAiDebug(label, extra = null) {
if (!shouldLogFaceAiDebug()) {
return;
}
const payload = {
pageUrl: window.location.href,
session: session.value,
availability: raceAvailability.value,
activeSearch: activeSearch.value,
redirectUrl: redirectUrl.value,
extra
};
console.groupCollapsed(`[FaceAI] ${label}`);
console.log(payload);
console.groupEnd();
}
function reportInvalidRaceAvailability(availability) {
if (!isInvalidRaceAvailability(availability)) {
return;
}
const details = {
raceId: session.value?.race?.id || null,
raceName: session.value?.race?.name || null,
lang: session.value?.lang || currentLocale.value,
reasonCode: availability.reasonCode,
message: availability.message,
storage: availability.storage || null,
raceDir: availability.raceDir || null
};
console.error(`[FaceAI] Invalid race data: ${JSON.stringify(details)}`);
}
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing');
const raceAvailability = computed(() => session.value?.availability || null);
const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value);
const canStartSearch = computed(() => {
if (!session.value || !selectedFile.value) {
return false;
}
if (!session.value.access?.faceAiAllowed) {
return false;
}
return raceAvailability.value?.available === true && !isWorking.value;
});
const selectedFileSizeLabel = computed(() => {
if (!selectedFile.value?.size) {
return '';
}
const sizeInMb = selectedFile.value.size / (1024 * 1024);
if (sizeInMb >= 1) {
return `${sizeInMb.toFixed(1)} MB`;
}
return `${Math.max(1, Math.round(selectedFile.value.size / 1024))} KB`;
});
const activeSearchStatusLabel = computed(() => {
const status = activeSearch.value?.status;
if (status === 'processing') {
return t('statusProcessing');
}
if (status === 'completed') {
return t('statusCompleted');
}
if (status === 'failed') {
return t('statusFailed');
}
return t('statusReady');
});
const busyLabel = computed(() => {
if (loading.value) {
return t('sessionLoading');
}
if (isSubmitting.value) {
return t('submitLoading');
}
if (isRedirecting.value) {
return t('redirectLoading');
}
if (activeSearch.value?.status === 'processing') {
return t('processingLoading');
}
return '';
});
const statusLabel = computed(() => {
if (!activeSearch.value) {
if (session.value && raceAvailability.value && !raceAvailability.value.available) {
return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
}
return t('readyMessage');
}
if (activeSearch.value.status === 'completed') {
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
return t('noFacesFoundMessage');
}
return t('completedMessage', { count: activeSearch.value.matchCount ?? 0 });
}
if (activeSearch.value.status === 'failed') {
return localizeServerMessage(activeSearch.value.errorMessage, 'failedMessage');
}
return t('processingLoading');
});
function openFilePicker() {
if (!canPickFile.value) {
return;
}
fileInput.value?.click();
}
function setSelectedFile(file) {
if (!file) {
selectedFile.value = null;
return;
}
if (!file.type || !file.type.startsWith('image/')) {
selectedFile.value = null;
errorMessage.value = t('invalidImage');
if (fileInput.value) {
fileInput.value.value = '';
}
return;
}
selectedFile.value = file;
errorMessage.value = '';
}
function clearSelectedFile() {
selectedFile.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
}
function onFileChange(event) {
setSelectedFile(event.target.files?.[0] || null);
}
function onDragEnter(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
dragDepth += 1;
isDragging.value = true;
}
function onDragOver(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
isDragging.value = true;
}
function onDragLeave(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
isDragging.value = false;
}
}
function onDrop(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
dragDepth = 0;
isDragging.value = false;
setSelectedFile(event.dataTransfer?.files?.[0] || null);
}
async function loadSession() {
loading.value = true;
const response = await fetch('/api/session', { credentials: 'include' });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
loading.value = false;
logFaceAiDebug('Session load failed', { status: response.status, payload });
if (response.status === 401 || response.status === 403) {
window.location.replace(payload.redirectUrl || legacyHomeUrl);
}
return;
}
session.value = await response.json();
loading.value = false;
if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) {
errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData');
reportInvalidRaceAvailability(session.value.availability);
}
logFaceAiDebug('Session loaded');
}
async function pollSearch(searchId) {
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
if (!response.ok) {
errorMessage.value = t('pollError');
isSubmitting.value = false;
logFaceAiDebug('Search polling failed', { searchId, status: response.status });
return;
}
activeSearch.value = await response.json();
logFaceAiDebug('Search status updated', { searchId, status: activeSearch.value.status });
if (activeSearch.value.status === 'failed') {
isSubmitting.value = false;
errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed');
return;
}
if (activeSearch.value.status === 'completed') {
isSubmitting.value = false;
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
isRedirecting.value = false;
redirectUrl.value = '';
clearSelectedFile();
logFaceAiDebug('Search completed without detectable faces', { searchId });
return;
}
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
const payload = await redirectResponse.json();
if (!redirectResponse.ok) {
errorMessage.value = localizeServerMessage(payload.error, 'redirectError');
logFaceAiDebug('Redirect build failed', { searchId, payload });
return;
}
redirectUrl.value = payload.url;
isRedirecting.value = true;
logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url });
window.setTimeout(() => {
window.location.href = payload.url;
}, 1200);
return;
}
pollTimer = window.setTimeout(() => {
pollSearch(searchId);
}, 1500);
}
async function submitSearch() {
errorMessage.value = '';
redirectUrl.value = '';
isRedirecting.value = false;
if (!selectedFile.value) {
errorMessage.value = t('chooseSelfie');
return;
}
if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) {
errorMessage.value = getAvailabilityUserMessage(raceAvailability.value, 'raceDataUnavailable');
return;
}
isSubmitting.value = true;
const formData = new FormData();
formData.set('raceId', session.value.race.id);
formData.set('selfie', selectedFile.value);
const response = await fetch('/api/searches', {
method: 'POST',
credentials: 'include',
body: formData
});
const payload = await response.json();
if (!response.ok) {
errorMessage.value = localizeServerMessage(payload.error, 'searchCreateError');
isSubmitting.value = false;
logFaceAiDebug('Search creation failed', { status: response.status, payload });
return;
}
activeSearch.value = payload;
logFaceAiDebug('Search created', { payload });
pollSearch(payload.id);
}
onMounted(loadSession);
onBeforeUnmount(() => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
});
return {
activeSearch,
activeSearchStatusLabel,
busyLabel,
canPickFile,
canStartSearch,
clearSelectedFile,
currentLocale,
errorMessage,
fileInput,
isDragging,
isProcessingSearch,
isRedirecting,
isSubmitting,
isWorking,
loading,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onFileChange,
openFilePicker,
redirectUrl,
selectedFile,
selectedFileSizeLabel,
session,
simulatorUrl,
statusLabel,
submitSearch,
t
};
}

View file

@ -1,150 +1,39 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import LegacyHeader from '../components/LegacyHeader.vue';
import { legacyAsset } from '../legacyAssets.js';
import FaceAiFeedbackPanel from '../components/FaceAiFeedbackPanel.vue';
import FaceAiHeroCard from '../components/FaceAiHeroCard.vue';
import FaceAiUploadPanel from '../components/FaceAiUploadPanel.vue';
import { useFaceAiHome } from '../composables/useFaceAiHome.js';
const coverImageUrl = legacyAsset('/images/layout/Logo_RUS_ETS_tricolore_3-1.jpg');
const session = ref(null);
const loading = ref(true);
const errorMessage = ref('');
const selectedFile = ref(null);
const activeSearch = ref(null);
const redirectUrl = ref('');
const isSubmitting = ref(false);
const isRedirecting = ref(false);
let pollTimer = null;
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const busyLabel = computed(() => {
if (loading.value) {
return 'Caricamento sessione FaceAI...';
}
if (isSubmitting.value) {
return 'Invio del selfie e preparazione della ricerca...';
}
if (isRedirecting.value) {
return 'Reindirizzamento alla pagina legacy filtrata in corso...';
}
if (activeSearch.value?.status === 'processing') {
return 'Ricerca biometrica in corso su tutte le foto della gara...';
}
return '';
});
const statusLabel = computed(() => {
if (!activeSearch.value) {
return 'Carica un selfie per avviare una ricerca limitata alla gara corrente.';
}
if (activeSearch.value.status === 'completed') {
return `Ricerca completata. Trovate ${activeSearch.value.matchCount} foto corrispondenti.`;
}
if (activeSearch.value.status === 'failed') {
return 'La ricerca non e stata completata. Verifica il messaggio di errore e riprova.';
}
return 'Ricerca in corso. Il sistema aggiorna automaticamente lo stato finche il risultato non e pronto.';
});
async function loadSession() {
loading.value = true;
const response = await fetch('/api/session', { credentials: 'include' });
if (!response.ok) {
loading.value = false;
return;
}
session.value = await response.json();
loading.value = false;
}
function onFileChange(event) {
selectedFile.value = event.target.files?.[0] || null;
}
async function pollSearch(searchId) {
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
if (!response.ok) {
errorMessage.value = 'Unable to read search status.';
isSubmitting.value = false;
return;
}
activeSearch.value = await response.json();
if (activeSearch.value.status === 'failed') {
isSubmitting.value = false;
errorMessage.value = activeSearch.value.errorMessage || 'The search failed.';
return;
}
if (activeSearch.value.status === 'completed') {
isSubmitting.value = false;
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
const payload = await redirectResponse.json();
if (!redirectResponse.ok) {
errorMessage.value = payload.error || 'Unable to build return URL.';
return;
}
redirectUrl.value = payload.url;
isRedirecting.value = true;
window.setTimeout(() => {
window.location.href = payload.url;
}, 1200);
return;
}
pollTimer = window.setTimeout(() => {
pollSearch(searchId);
}, 1500);
}
async function submitSearch() {
errorMessage.value = '';
redirectUrl.value = '';
isRedirecting.value = false;
if (!selectedFile.value) {
errorMessage.value = 'Choose a selfie before starting the search.';
return;
}
isSubmitting.value = true;
const formData = new FormData();
formData.set('raceId', session.value.race.id);
formData.set('selfie', selectedFile.value);
const response = await fetch('/api/searches', {
method: 'POST',
credentials: 'include',
body: formData
});
const payload = await response.json();
if (!response.ok) {
errorMessage.value = payload.error || 'Unable to create the search.';
isSubmitting.value = false;
return;
}
activeSearch.value = payload;
pollSearch(payload.id);
}
onMounted(loadSession);
onBeforeUnmount(() => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
});
const {
activeSearch,
activeSearchStatusLabel,
busyLabel,
canPickFile,
canStartSearch,
clearSelectedFile,
currentLocale,
errorMessage,
fileInput,
isDragging,
isProcessingSearch,
isWorking,
loading,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onFileChange,
openFilePicker,
redirectUrl,
selectedFile,
selectedFileSizeLabel,
session,
simulatorUrl,
statusLabel,
submitSearch,
t
} = useFaceAiHome();
</script>
<template>
@ -152,92 +41,62 @@ onBeforeUnmount(() => {
<LegacyHeader />
<div class="container my-3 faceai-page">
<div class="row mb-5">
<div class="col-lg-12">
<h1 class="my-4">Face ID</h1>
</div>
<FaceAiHeroCard
:session="session"
:current-locale="currentLocale"
:active-search-status-label="activeSearchStatusLabel"
:t="t"
/>
<div class="col-md-2">
<img :src="coverImageUrl" class="img-fluid border border-warning" alt="FaceAI" />
</div>
<div class="col-md-10">
<div class="row riepilogo">
<div class="col-md-3">
<p><i class="fa fa-user fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.user.displayName : 'Sessione FaceAI' }}</p>
</div>
<div class="col-md-3">
<p><i class="fa fa-camera-retro fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.race.name : 'Upload selfie' }}</p>
</div>
<div class="col">
<p><i class="fa fa-refresh fa-lg text-warning" aria-hidden="true"></i> {{ activeSearch ? activeSearch.status : 'ready' }}</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="bg-light faceai-form-shell">
<div class="row">
<div class="form-group mx-3 pt-4 pb-1 mb-0 px-2 arrow_box">
<h2>Cerca le tue foto</h2>
</div>
<div v-if="loading" class="col-12 p-4">
<div class="faceai-spinner-block">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
</div>
<div v-else-if="!session" class="col-12 p-4">
<p>Apri prima il simulatore legacy per generare il token firmato di handoff.</p>
<a class="btn btn-warning" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Apri il simulatore legacy</a>
</div>
<template v-else>
<div class="form-group col-12 col-md-4 mt-3 ml-3">
<div class="input-group">
<label for="raceName" class="sr-only">Gara</label>
<input id="raceName" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.race.name" readonly />
</div>
</div>
<div class="form-group col-12 col-md-3 mt-3 ml-3">
<div class="input-group">
<label for="langView" class="sr-only">Lingua</label>
<input id="langView" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.lang" readonly />
</div>
</div>
<div class="form-group col-12 col-md-4 mt-3 ml-3">
<div class="input-group">
<label for="selfieUpload" class="sr-only">Selfie</label>
<input id="selfieUpload" class="form-control form-control-sm mb-2 mb-sm-0" type="file" accept="image/*" @change="onFileChange" />
</div>
</div>
<div class="form-group col-12 mt-2 ml-3 mr-3 faceai-action-row">
<button class="btn btn-warning" type="button" :disabled="isWorking" @click="submitSearch">Avvia ricerca Face ID</button>
<a class="btn btn-light" :href="session.returnUrl">Torna alla pagina gara</a>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="faceai-feedback mt-4">
<p class="lead mb-2">{{ statusLabel }}</p>
<div v-if="isWorking && busyLabel" class="faceai-spinner-block mb-3">
<span class="spinner-border spinner-border-sm text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<p v-if="activeSearch" class="mb-2">Match trovati: {{ activeSearch.matchCount }}</p>
<p v-if="redirectUrl" class="mb-2">Reindirizzamento alla pagina legacy filtrata in corso...</p>
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
</div>
<div class="row mt-4">
<div class="col-12">
<FaceAiUploadPanel
:loading="loading"
:is-working="isWorking"
:is-processing-search="isProcessingSearch"
:session="session"
:simulator-url="simulatorUrl"
:busy-label="busyLabel"
:can-pick-file="canPickFile"
:is-dragging="isDragging"
:selected-file="selectedFile"
:selected-file-size-label="selectedFileSizeLabel"
:can-start-search="canStartSearch"
:file-input="fileInput"
:t="t"
@open-file-picker="openFilePicker"
@file-change="onFileChange"
@drag-enter="onDragEnter"
@drag-over="onDragOver"
@drag-leave="onDragLeave"
@drop="onDrop"
@clear-file="clearSelectedFile"
@submit-search="submitSearch"
/>
</div>
</div>
<FaceAiFeedbackPanel
:status-label="statusLabel"
:is-working="isWorking"
:busy-label="busyLabel"
:active-search="activeSearch"
:redirect-url="redirectUrl"
:error-message="errorMessage"
:t="t"
/>
</div>
</main>
</template>
<style scoped>
.faceai-page {
padding-bottom: 2rem;
}
@media (max-width: 767.98px) {
.faceai-page {
padding-bottom: 1.25rem;
}
}
</style>

View file

@ -1,11 +1,13 @@
import path from 'node:path';
export const config = {
redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379',
queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches',
workerConcurrency: Number(process.env.FACEAI_WORKER_CONCURRENCY || 2),
workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000),
runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime',
logRoot: process.env.FACEAI_LOG_ROOT || path.join(process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', 'logs'),
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
fallbackPklRoot: process.env.FACEAI_TEST_PKL_ROOT || '/data/pkl/test',
matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher',
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),
resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60)

View file

@ -1,34 +1,22 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { resolveRacePklAvailability } from '../../backend/src/race-storage.js';
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function resolvePklPath({ raceId, raceStorage, pklRoot }) {
const availability = await resolveRacePklAvailability({
pklRoot,
race: {
id: raceId,
storage: raceStorage
}
});
export async function resolvePklPath({ raceId, pklRoot, fallbackPklRoot }) {
const preferred = path.join(pklRoot, String(raceId), 'face_encodings.pkl');
if (await fileExists(preferred)) {
return preferred;
if (!availability.available || !availability.pklPath) {
throw new Error(availability.message || `No PKL file available for race ${raceId}`);
}
const flatFile = path.join(pklRoot, `${raceId}.pkl`);
if (await fileExists(flatFile)) {
return flatFile;
}
const fallbackEntries = await fs.readdir(fallbackPklRoot).catch(() => []);
const fallbackFile = fallbackEntries.find((entry) => entry.toLowerCase().endsWith('.pkl'));
if (fallbackFile) {
return path.join(fallbackPklRoot, fallbackFile);
}
throw new Error(`No PKL file available for race ${raceId}`);
return availability.pklPath;
}
export async function runFaceMatcher({ matcherBinary, selfiePath, pklPath, csvPath, logPath, timeoutMs }) {

View file

@ -15,6 +15,55 @@ import { parseMatcherCsv, resolvePklPath, runFaceMatcher } from './worker-utils.
const connection = createRedisConnection(config.redisUrl);
function formatLogLine(message, details) {
const timestamp = new Date().toISOString();
if (details === undefined) {
return `[${timestamp}] ${message}\n`;
}
return `[${timestamp}] ${message} ${JSON.stringify(details)}\n`;
}
async function appendSearchLog(logPath, message, details) {
await fs.mkdir(path.dirname(logPath), { recursive: true });
await fs.appendFile(logPath, formatLogLine(message, details), 'utf8');
}
async function resolveCompletionCode(logPath, matchCount) {
if (matchCount > 0) {
return null;
}
const matcherLog = await fs.readFile(logPath, 'utf8').catch(() => '');
if (/nessun\s+volt|no\s+faces?|no\s+face|0\s+faces?/i.test(matcherLog)) {
return 'NO_FACES_FOUND';
}
return 'NO_FACES_FOUND';
}
async function completeSearch(search, searchId, searchLogPath, matchCount, matches, completionCode) {
const result = await storeResultRecord(connection, {
raceId: search.raceId,
raceName: search.raceName,
userId: search.userId,
returnUrl: search.returnUrl,
lang: search.lang,
matches
}, config.resultTtlSeconds);
await appendSearchLog(searchLogPath, 'Completed FaceAI search', {
resultId: result.id,
matchCount,
completionCode
});
await markSearchCompleted(connection, searchId, result.id, matchCount, config.searchTtlSeconds, {
completionCode
});
await releaseActiveSearchLock(connection, search.userId, searchId);
}
async function processJob(job) {
const searchId = String(job.data.searchId || '');
const search = await getSearchRecord(connection, searchId);
@ -25,40 +74,73 @@ async function processJob(job) {
await markSearchProcessing(connection, searchId, config.searchTtlSeconds);
const searchDir = path.join(config.runtimeRoot, 'searches', searchId);
const searchLogDir = path.join(config.logRoot, 'searches', searchId);
const searchLogPath = path.join(searchLogDir, 'worker.log');
await fs.mkdir(searchDir, { recursive: true });
await fs.mkdir(searchLogDir, { recursive: true });
await appendSearchLog(searchLogPath, 'Starting FaceAI search', {
searchId,
raceId: search.raceId,
userId: search.userId,
selfiePath: search.selfiePath,
runtimeRoot: config.runtimeRoot,
logRoot: config.logRoot,
queueName: config.queueName
});
try {
const pklPath = await resolvePklPath({
raceId: search.raceId,
pklRoot: config.pklRoot,
fallbackPklRoot: config.fallbackPklRoot
raceStorage: search.raceStorage,
pklRoot: config.pklRoot
});
await appendSearchLog(searchLogPath, 'Resolved PKL path', {
pklPath,
raceStorage: search.raceStorage
});
const csvPath = path.join(searchDir, 'result.csv');
const logPath = path.join(searchDir, 'matcher.log');
const logPath = path.join(searchLogDir, 'matcher.log');
await runFaceMatcher({
await appendSearchLog(searchLogPath, 'Running matcher', {
matcherBinary: config.matcherBinary,
selfiePath: search.selfiePath,
pklPath,
csvPath,
logPath,
matcherLogPath: logPath,
timeoutMs: config.workerTimeoutMs
});
const matches = await parseMatcherCsv(csvPath);
const result = await storeResultRecord(connection, {
raceId: search.raceId,
raceName: search.raceName,
userId: search.userId,
returnUrl: search.returnUrl,
lang: search.lang,
matches
}, config.resultTtlSeconds);
try {
await runFaceMatcher({
matcherBinary: config.matcherBinary,
selfiePath: search.selfiePath,
pklPath,
csvPath,
logPath,
timeoutMs: config.workerTimeoutMs
});
} catch (error) {
if (error.message === 'face_matcher exited with code 1') {
await appendSearchLog(searchLogPath, 'Matcher reported no detectable faces', {
matcherLogPath: logPath,
selfiePath: search.selfiePath
});
await completeSearch(search, searchId, searchLogPath, 0, [], 'NO_FACES_FOUND');
return;
}
await markSearchCompleted(connection, searchId, result.id, matches.length, config.searchTtlSeconds);
await releaseActiveSearchLock(connection, search.userId, searchId);
throw error;
}
const matches = await parseMatcherCsv(csvPath);
const completionCode = await resolveCompletionCode(logPath, matches.length);
await completeSearch(search, searchId, searchLogPath, matches.length, matches, completionCode);
} catch (error) {
await appendSearchLog(searchLogPath, 'FaceAI search failed', {
message: error.message,
stack: error.stack || null
});
await markSearchFailed(connection, searchId, 'PROCESSOR_ERROR', error.message, config.searchTtlSeconds);
await releaseActiveSearchLock(connection, search.userId, searchId);
throw error;
@ -76,7 +158,7 @@ worker.on('completed', (job) => {
worker.on('failed', (job, error) => {
const searchId = job?.data?.searchId || 'unknown';
console.error(`Failed FaceAI search ${searchId}: ${error.message}`);
console.error(`Failed FaceAI search ${searchId}:`, error);
});
console.log(`FaceAI processor listening on queue ${config.queueName} with concurrency ${config.workerConcurrency}`);

View file

@ -3,12 +3,13 @@ services:
image: node:20-alpine
container_name: regalami-faceai
working_dir: /app
command: sh -c "npm run start --workspace @regalami/faceai-backend"
command: sh -c "mkdir -p /data/logs && npm run start --workspace @regalami/faceai-backend >> /data/logs/backend.log 2>&1"
environment:
PORT: 3001
FACEAI_FRONTEND_URL: http://localhost:3001
FACEAI_PUBLIC_BASE_URL: http://localhost:3001
FACEAI_LEGACY_RETURN_URL: http://localhost:8080/faceai_return.php
FACEAI_PKL_ROOT: /data/pkl
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 1
FACEAI_LOCAL_LEGACY_STATIC_ROOT: /legacy-www
FACEAI_SHARED_SECRET: change-me
@ -16,9 +17,12 @@ services:
FACEAI_REDIS_URL: redis://redis:6379
FACEAI_RUNTIME_ROOT: /data/runtime
FACEAI_UPLOAD_ROOT: /data/runtime/uploads
FACEAI_LOG_ROOT: /data/logs
volumes:
- .:/app
- ./logs:/data/logs
- ../www:/legacy-www:ro
- ../test_pkl:/data/pkl:ro
- faceai-runtime:/data/runtime
ports:
- "3001:3001"
@ -26,22 +30,26 @@ services:
- redis
processor:
image: node:20-bookworm-slim
build:
context: .
dockerfile: docker/processor.Dockerfile
image: regalami-faceai-processor-local
container_name: regalami-faceai-processor
working_dir: /app
command: sh -c "npm run start --workspace @regalami/faceai-processor"
command: sh -c "mkdir -p /data/logs && npm run start --workspace @regalami/faceai-processor >> /data/logs/processor.log 2>&1"
environment:
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_TEST_PKL_ROOT: /data/pkl/test
FACEAI_WORKER_CONCURRENCY: 2
FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher
volumes:
- .:/app
- ./logs:/data/logs
- ../bin/Face_Recognition_Unix:/opt/face-recognition:ro
- ../test_pkl:/data/pkl/test:ro
- ../test_pkl:/data/pkl:ro
- faceai-runtime:/data/runtime
depends_on:
- redis
@ -55,6 +63,7 @@ services:
image: php:8.3-apache
container_name: regalami-legacy-php
environment:
FACEAI_FEATURE_ENABLED: 1
FACEAI_BACKEND_INTERNAL_URL: http://faceai:3001
FACEAI_FRONTEND_URL: http://localhost:3001
FACEAI_SHARED_SECRET: change-me

View file

@ -0,0 +1,8 @@
FROM node:22-trixie-slim
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends libxcb1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

View file

@ -10,7 +10,7 @@ Add an internal processor service that executes `face_matcher` jobs for the publ
- add a dedicated `processor` workspace and container scaffold
- replace in-memory search orchestration in the public backend
- preserve the existing frontend polling and legacy return flow
- support local PKL testing from `test_pkl/`
- support local PKL testing from `test_pkl/` mounted with the same directory shape used in hosted deployment
This slice does not yet implement production NAS mounting, persistent databases, or a final parser tailored to the real matcher CSV format.
@ -53,25 +53,34 @@ The lock is released only when the processor marks the search as terminal: `comp
## Race And PKL Resolution
The canonical race key is the legacy `id_gara`, already exposed as `raceId` in the existing handoff flow.
The canonical race key is still the legacy `id_gara`, but the worker no longer guesses the PKL path from `raceId` alone.
The processor resolves the PKL path using a race-based directory layout:
The legacy handoff must provide a `raceStorage` object with:
- `year`
- `monthFolder` like `04.APRILE`
- `raceFolder` like `PISA`
The processor resolves the PKL path using this mounted directory layout:
```text
/data/pkl/
101/
face_encodings.pkl
202/
face_encodings.pkl
2026/
04.APRILE/
PISA/
face_encodings_20260330_170210.pkl
LUCCA/
face_encodings_20260330_170155.pkl
```
The lookup rule is:
1. try `/data/pkl/{raceId}/face_encodings.pkl`
2. optionally fall back to `/data/pkl/{raceId}.pkl`
3. fail the job if neither exists
1. resolve `/data/pkl/{year}/{monthFolder}/{raceFolder}`
2. list files at that race root
3. take the first `.pkl` file found there, regardless of filename
4. fail the job if the directory does not exist or contains no `.pkl` file
For local development, `test_pkl/` is mounted into `/data/pkl/test` and the backend can fall back to the first `.pkl` file in that folder when no race-specific file exists yet.
For local development, `test_pkl/` is mounted directly into `/data/pkl` in both the public FaceAI container and the processor container, so the same rule is used in every environment.
## Shared Runtime Storage
@ -91,14 +100,15 @@ Both the public backend and the processor mount the same writable runtime direct
1. frontend uploads a selfie and calls `POST /api/searches`
2. backend validates session, rate limit, and active-user lock
3. backend stores the upload and creates a Redis search record with status `queued`
4. backend enqueues a BullMQ job
5. processor picks up the job and sets status `processing`
6. processor runs `face_matcher`
7. processor parses CSV output into matches
8. processor stores a result record and marks the search `completed`
9. frontend polling reads Redis-backed state through `GET /api/searches/:id`
10. existing redirect flow sends the user back to the legacy filtered page
3. backend verifies that the mounted race directory exists and already contains a `.pkl`; if not, it rejects the request before queueing
4. backend stores the upload and creates a Redis search record with status `queued`
5. backend enqueues a BullMQ job
6. processor picks up the job and sets status `processing`
7. processor runs `face_matcher`
8. processor parses CSV output into matches
9. processor stores a result record and marks the search `completed`
10. frontend polling reads Redis-backed state through `GET /api/searches/:id`
11. existing redirect flow sends the user back to the legacy filtered page
## Search Record Shape
@ -107,6 +117,11 @@ Both the public backend and the processor mount the same writable runtime direct
"id": "search_...",
"status": "queued",
"raceId": "101",
"raceStorage": {
"year": "2026",
"monthFolder": "04.APRILE",
"raceFolder": "PISA"
},
"userId": "legacy-user-1",
"returnUrl": "https://...",
"lang": "it",
@ -162,5 +177,4 @@ Both the public backend and the processor mount the same writable runtime direct
- confirm the real CSV columns emitted by `face_matcher`
- verify the Linux binary shared library requirements inside the processor image
- replace the PKL fallback with a strict NAS-backed race mapping once the final folder layout is agreed
- add cleanup jobs for expired runtime files

View file

@ -11,6 +11,7 @@
"apps/processor"
],
"devDependencies": {
"@playwright/test": "^1.59.1",
"concurrently": "^9.1.2"
}
},
@ -621,6 +622,22 @@
"win32"
]
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@regalami/faceai-backend": {
"resolved": "apps/backend",
"link": true
@ -2242,6 +2259,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",

View file

@ -13,9 +13,13 @@
"dev:processor": "npm run dev --workspace @regalami/faceai-processor",
"build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-backend",
"start": "npm run start --workspace @regalami/faceai-backend",
"start:processor": "npm run start --workspace @regalami/faceai-processor"
"start:processor": "npm run start --workspace @regalami/faceai-processor",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:install": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"concurrently": "^9.1.2"
}
}

View file

@ -0,0 +1,21 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests/e2e',
timeout: 10 * 60 * 1000,
expect: {
timeout: 30 * 1000
},
fullyParallel: false,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
globalSetup: require.resolve('./tests/e2e/global-setup.js'),
globalTeardown: require.resolve('./tests/e2e/global-teardown.js'),
use: {
browserName: 'chromium',
headless: true,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
});

View file

@ -0,0 +1,399 @@
const { test, expect } = require('@playwright/test');
const {
EXPECTED_MATCH_COUNT,
FACEAI_BASE_URL,
buildHandoffUrl,
buildSimulatorUrl,
getSearchArtifacts,
getSelfiePath,
readUtf8
} = require('./faceai-test-utils');
const FACEAI_HOME_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/?(?:\?.*)?$/;
const FACEAI_CALLBACK_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/auth\/callback\?token=/;
const FACEAI_RETURN_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):8080\/faceai_return\.php\?resultId=.*token=.*/;
const LEGACY_HOME_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):8080\/index\.jsp$/;
function buildLegacySimulatorReturnMatcher(raceId) {
return new RegExp(`http:\\/\\/(localhost|127\\.0\\.0\\.1):8080\\/faceai_simulator\\.php\\?raceId=${raceId}.*`);
}
function assertLogDoesNotContain(content, patterns, label) {
for (const pattern of patterns) {
expect(content, `${label} should not contain ${pattern}`).not.toMatch(pattern);
}
}
async function waitForFaceAiHome(page) {
await page.waitForURL((url) => FACEAI_CALLBACK_URL_RE.test(url.toString()) || FACEAI_HOME_URL_RE.test(url.toString()), {
timeout: 60 * 1000
});
await expect(page.getByRole('heading', { name: 'Trova le tue foto con un selfie' })).toBeVisible();
}
async function launchFromSimulator(page, options = {}) {
const simulatorUrl = buildSimulatorUrl(options);
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
await expect(page.locator('select#tipoPuntoFoto')).toHaveCount(0);
await page.locator('#faceaiLaunchButton').click();
await waitForFaceAiHome(page);
return simulatorUrl;
}
async function enterViaHandoff(page, options = {}) {
await page.goto(buildHandoffUrl(options), { waitUntil: 'domcontentloaded' });
await waitForFaceAiHome(page);
}
async function startSearch(page, selfieName) {
const createResponsePromise = page.waitForResponse((response) => {
return response.url().includes('/api/searches')
&& response.request().method() === 'POST'
&& response.status() === 201;
});
await page.locator('input[type="file"]').setInputFiles(getSelfiePath(selfieName));
await expect(page.getByText(selfieName)).toBeVisible();
await page.getByRole('button', { name: 'Avvia ricerca Face ID' }).click();
const createResponse = await createResponsePromise;
return createResponse.json();
}
async function fetchSearchStatus(page, searchId) {
return page.evaluate(async ({ searchId }) => {
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
const body = await response.json().catch(() => null);
return {
statusCode: response.status,
body
};
}, { searchId });
}
async function waitForSearchCondition(page, searchId, predicate, timeoutMs = 30 * 1000) {
const deadline = Date.now() + timeoutMs;
let lastPayload = null;
while (Date.now() < deadline) {
const payload = await fetchSearchStatus(page, searchId);
lastPayload = payload;
if (payload.statusCode === 200 && predicate(payload.body)) {
return payload.body;
}
await page.waitForTimeout(250);
}
throw new Error(`Timed out waiting for search ${searchId}. Last payload: ${JSON.stringify(lastPayload)}`);
}
async function waitForLegacyResult(page, expectedMatchCount = null) {
await page.waitForURL(FACEAI_RETURN_URL_RE, {
timeout: 6 * 60 * 1000
});
await expect(page.locator('.sim-banner')).toContainText('Vista filtrata da FaceAI');
if (expectedMatchCount === null) {
await expect(page.locator('.gallery-card').first()).toBeVisible();
return;
}
await expect(page.locator('.sim-banner')).toContainText(String(expectedMatchCount));
await expect(page.locator('.gallery-card')).toHaveCount(expectedMatchCount);
}
async function verifySearchLogs(searchId, { expectedMatchCount, expectedSelfieName }) {
const artifacts = getSearchArtifacts(searchId);
const [backendLog, processorLog, workerLog, matcherLog] = await Promise.all([
readUtf8(artifacts.backendLogPath),
readUtf8(artifacts.processorLogPath),
readUtf8(artifacts.workerLogPath),
readUtf8(artifacts.matcherLogPath)
]);
expect(workerLog).toContain('Completed FaceAI search');
if (expectedMatchCount !== undefined) {
expect(workerLog).toContain(`"matchCount":${expectedMatchCount}`);
}
if (expectedSelfieName) {
expect(matcherLog).toContain(expectedSelfieName);
}
assertLogDoesNotContain(backendLog, [/\bnpm error\b/i, /\berror:\b/i, /\bfailed\b/i], 'backend.log');
assertLogDoesNotContain(processorLog, [new RegExp(`Failed FaceAI search ${searchId}`, 'i'), /\bnpm error\b/i], 'processor.log');
assertLogDoesNotContain(workerLog, [/FaceAI search failed/i], 'worker.log');
assertLogDoesNotContain(matcherLog, [/\[ERROR\]/i, /Traceback/i], 'matcher.log');
return { backendLog, processorLog, workerLog, matcherLog };
}
async function closeContexts(contexts) {
await Promise.all(contexts.map((context) => context.close()));
}
test('runs the simulator flow through FaceAI and returns to the filtered legacy result', async ({ page }) => {
await launchFromSimulator(page, {
raceId: '202',
raceSlug: 'mezza-di-pisa',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
const search = await startSearch(page, 'DSC_1960.JPG');
await waitForLegacyResult(page, EXPECTED_MATCH_COUNT);
await expect(page.locator('.gallery-card').filter({ hasText: 'DSC_1960.JPG' }).first()).toBeVisible();
await verifySearchLogs(search.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
});
});
test('shows the unsupported-race message when the current race has no PKL data and lets the user go back', async ({ page }) => {
await launchFromSimulator(page, {
raceId: '404',
raceSlug: 'corsa-di-livorno',
raceName: 'Corsa di Livorno',
raceFolder: 'LIVORNO'
});
await expect(page.locator('.faceai-feedback')).toContainText('FaceAI non è disponibile per questa gara.');
await expect(page.locator('input[type="file"]')).toBeDisabled();
await expect(page.getByRole('button', { name: 'Scegli immagine' })).toBeDisabled();
await page.waitForTimeout(2000);
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
await page.getByRole('link', { name: 'Torna alla pagina gara' }).click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('404'));
});
test('shows a localized invalid-race error when session race data points to a missing folder', async ({ page }) => {
const consoleErrors = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
const simulatorUrl = buildSimulatorUrl({
raceId: '405',
lang: 'en',
raceSlug: 'ghost-race',
raceName: 'Ghost Race',
raceFolder: 'THIS RACE DOES NOT EXIST'
});
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
await page.locator('#faceaiLaunchButton').click();
await page.waitForURL(FACEAI_HOME_URL_RE, {
timeout: 60 * 1000
});
await expect(page.getByRole('heading', { name: 'Find your photos with a selfie' })).toBeVisible();
await expect(page.locator('.faceai-feedback')).toContainText('The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.');
await expect(page.locator('input[type="file"]')).toBeDisabled();
await expect(page.getByRole('button', { name: 'Choose image' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Start Face ID search' })).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Back to the race page' })).toBeVisible();
await expect.poll(() => {
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
}).toContain('RACE_DIRECTORY_NOT_FOUND');
await expect.poll(() => {
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
}).toContain('THIS RACE DOES NOT EXIST');
await page.getByRole('link', { name: 'Back to the race page' }).click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('405'));
});
test('rejects a not-logged-in user after clicking the Face ID button and sends them back to the original race page', async ({ page }) => {
const simulatorUrl = buildSimulatorUrl({
raceId: '202',
raceSlug: 'mezza-di-pisa',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
await page.evaluate(() => {
if (window.faceAiSimulator) {
delete window.faceAiSimulator.devUserId;
delete window.faceAiSimulator.devDisplayName;
delete window.faceAiSimulator.devEmail;
delete window.faceAiSimulator.devMembershipStatus;
}
});
await page.locator('#faceaiLaunchButton').click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
await expect(page).not.toHaveURL(FACEAI_HOME_URL_RE);
await expect(page.locator('#faceAiErrorModal')).toBeVisible();
await expect(page.locator('#faceAiErrorModalLabel')).toContainText('Face ID non disponibile');
await expect(page.locator('#faceAiErrorModalMessage')).toContainText('Il servizio Face ID non e al momento disponibile. Riprova piu tardi.');
});
test('shows the no-face message and allows the user to return to the race page', async ({ page }) => {
await launchFromSimulator(page, {
raceId: '202',
raceSlug: 'mezza-di-pisa',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
const search = await startSearch(page, 'DSC_1994.JPG');
await waitForSearchCondition(page, search.id, (payload) => {
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
}, 2 * 60 * 1000);
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
await page.waitForTimeout(2000);
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
await verifySearchLogs(search.id, {
expectedMatchCount: 0,
expectedSelfieName: 'DSC_1994.JPG'
});
await page.getByRole('link', { name: 'Torna alla pagina gara' }).click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
});
test('lets the user retry with a valid photo after a no-face upload and then returns to the filtered legacy result', async ({ page }) => {
await launchFromSimulator(page, {
raceId: '202',
raceSlug: 'mezza-di-pisa',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
const noFaceSearch = await startSearch(page, 'DSC_1994.JPG');
await waitForSearchCondition(page, noFaceSearch.id, (payload) => {
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
}, 2 * 60 * 1000);
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
await expect(page.locator('input[type="file"]')).toBeEnabled();
const retrySearch = await startSearch(page, 'DSC_1960.JPG');
await waitForLegacyResult(page, EXPECTED_MATCH_COUNT);
await verifySearchLogs(noFaceSearch.id, {
expectedMatchCount: 0,
expectedSelfieName: 'DSC_1994.JPG'
});
await verifySearchLogs(retrySearch.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
});
});
test('redirects direct-entry users without FaceAI session data back to the legacy site', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(`${FACEAI_BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await page.waitForURL(LEGACY_HOME_URL_RE, { timeout: 30 * 1000 });
} finally {
await context.close();
}
});
test('allows two users to process different photos at the same time', async ({ browser }) => {
const contexts = [await browser.newContext(), await browser.newContext()];
const pages = await Promise.all(contexts.map((context) => context.newPage()));
try {
await Promise.all([
enterViaHandoff(pages[0], { userId: 'concurrency-user-1' }),
enterViaHandoff(pages[1], { userId: 'concurrency-user-2' })
]);
const [searchOne, searchTwo] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[1], 'DSC_1987.JPG')
]);
await Promise.all([
waitForLegacyResult(pages[0]),
waitForLegacyResult(pages[1])
]);
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' })
]);
} finally {
await closeContexts(contexts);
}
});
test('queues the third user until a worker is free and then completes all three searches normally', async ({ browser }) => {
const contexts = [await browser.newContext(), await browser.newContext(), await browser.newContext()];
const pages = await Promise.all(contexts.map((context) => context.newPage()));
try {
await Promise.all([
enterViaHandoff(pages[0], { userId: 'queue-user-1' }),
enterViaHandoff(pages[1], { userId: 'queue-user-2' }),
enterViaHandoff(pages[2], { userId: 'queue-user-3' })
]);
const [searchOne, searchTwo, searchThree] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[1], 'DSC_1987.JPG'),
startSearch(pages[2], 'DSC_2058.JPG')
]);
const searchSessions = [
{ page: pages[0], searchId: searchOne.id },
{ page: pages[1], searchId: searchTwo.id },
{ page: pages[2], searchId: searchThree.id }
];
let queuedSearch = null;
const deadline = Date.now() + 30 * 1000;
while (Date.now() < deadline && !queuedSearch) {
const statuses = await Promise.all(searchSessions.map(async (session) => {
const payload = await fetchSearchStatus(session.page, session.searchId);
return {
...session,
search: payload.body
};
}));
const processingCount = statuses.filter((item) => item.search?.status === 'processing').length;
queuedSearch = processingCount >= 2
? statuses.find((item) => item.search?.status === 'queued') || null
: null;
if (!queuedSearch) {
await pages[0].waitForTimeout(250);
}
}
expect(queuedSearch, 'one search should remain queued while two worker slots are busy').toBeTruthy();
await waitForSearchCondition(queuedSearch.page, queuedSearch.searchId, (payload) => {
return payload.status === 'processing' || payload.status === 'completed';
}, 2 * 60 * 1000);
await Promise.all(pages.map((page) => waitForLegacyResult(page)));
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' }),
verifySearchLogs(searchThree.id, { expectedSelfieName: 'DSC_2058.JPG' })
]);
} finally {
await closeContexts(contexts);
}
});

View file

@ -0,0 +1,203 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT_DIR = path.resolve(__dirname, '..', '..');
const WORKSPACE_ROOT = path.resolve(ROOT_DIR, '..');
const LOG_ROOT = path.join(ROOT_DIR, 'logs');
const SEARCH_LOG_ROOT = path.join(LOG_ROOT, 'searches');
const FACEAI_BASE_URL = process.env.FACEAI_E2E_BASE_URL || 'http://127.0.0.1:3001';
const SIMULATOR_URL = process.env.FACEAI_E2E_SIMULATOR_URL || 'http://127.0.0.1:8080/faceai_simulator.php?raceId=202&lang=it';
const LEGACY_BASE_URL = process.env.FACEAI_E2E_LEGACY_BASE_URL || 'http://127.0.0.1:8080';
const LEGACY_HOME_URL = process.env.FACEAI_E2E_LEGACY_HOME_URL || `${LEGACY_BASE_URL}/index.jsp`;
const SELFIE_NAME = process.env.FACEAI_E2E_SELFIE || 'DSC_1960.JPG';
const EXPECTED_MATCH_COUNT = Number(process.env.FACEAI_E2E_EXPECTED_MATCH_COUNT || '6');
function quoteShellArg(value) {
if (!/[\s"]/u.test(value)) {
return value;
}
return `"${value.replace(/"/g, '\\"')}"`;
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function runCommand(command, args, options = {}) {
const { cwd = ROOT_DIR, allowFailure = false } = options;
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
const useShell = process.platform === 'win32';
return new Promise((resolve, reject) => {
const child = useShell
? spawn([executable, ...args].map(quoteShellArg).join(' '), {
cwd,
env: process.env,
shell: true,
stdio: ['ignore', 'pipe', 'pipe']
})
: spawn(executable, args, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
const result = { code, stdout, stderr };
if (code === 0 || allowFailure) {
resolve(result);
return;
}
const error = new Error(`Command failed: ${executable} ${args.join(' ')}`);
error.result = result;
reject(error);
});
});
}
function dockerCompose(args, options) {
return runCommand('docker', ['compose', ...args], options);
}
async function prepareHostState() {
await fs.rm(LOG_ROOT, { recursive: true, force: true });
await fs.mkdir(LOG_ROOT, { recursive: true });
}
async function waitForHttp(url, validate, timeoutMs = 3 * 60 * 1000) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
const bodyText = await response.text();
let parsedBody = null;
try {
parsedBody = JSON.parse(bodyText);
} catch {
parsedBody = null;
}
if (validate({ response, bodyText, parsedBody })) {
return;
}
lastError = new Error(`Readiness check did not pass for ${url}.`);
} catch (error) {
lastError = error;
}
await sleep(1000);
}
throw lastError || new Error(`Timed out waiting for ${url}`);
}
function getSelfiePath(fileName = SELFIE_NAME) {
return path.join(WORKSPACE_ROOT, 'test_pkl', 'test_images', fileName);
}
function buildSimulatorUrl({
raceId = '202',
lang = 'it',
raceSlug = 'mezza-di-pisa',
raceName = 'Mezza di Pisa',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'PISA'
} = {}) {
const url = new URL('/faceai_simulator.php', LEGACY_BASE_URL);
url.searchParams.set('raceId', raceId);
url.searchParams.set('lang', lang);
url.searchParams.set('raceSlug', raceSlug);
url.searchParams.set('raceName', raceName);
url.searchParams.set('raceYear', raceYear);
url.searchParams.set('raceMonthFolder', raceMonthFolder);
url.searchParams.set('raceFolder', raceFolder);
return url.toString();
}
function buildHandoffUrl({
raceId = '202',
lang = 'it',
raceSlug = 'mezza-di-pisa',
raceName = 'Mezza di Pisa',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'PISA',
userId = '1',
displayName = `Local Test User ${userId}`,
email = `local-test-${userId}@example.invalid`,
membershipStatus = 'active',
returnUrl = buildSimulatorUrl({ raceId, lang, raceSlug, raceName, raceYear, raceMonthFolder, raceFolder })
} = {}) {
const url = new URL('/faceai_handoff.php', LEGACY_BASE_URL);
url.searchParams.set('raceId', raceId);
url.searchParams.set('raceSlug', raceSlug);
url.searchParams.set('raceName', raceName);
url.searchParams.set('raceYear', raceYear);
url.searchParams.set('raceMonthFolder', raceMonthFolder);
url.searchParams.set('raceFolder', raceFolder);
url.searchParams.set('lang', lang);
url.searchParams.set('returnUrl', returnUrl);
url.searchParams.set('devUserId', userId);
url.searchParams.set('devDisplayName', displayName);
url.searchParams.set('devEmail', email);
url.searchParams.set('devMembershipStatus', membershipStatus);
return url.toString();
}
function getSearchArtifacts(searchId) {
const searchRoot = path.join(SEARCH_LOG_ROOT, searchId);
return {
searchRoot,
backendLogPath: path.join(LOG_ROOT, 'backend.log'),
processorLogPath: path.join(LOG_ROOT, 'processor.log'),
workerLogPath: path.join(searchRoot, 'worker.log'),
matcherLogPath: path.join(searchRoot, 'matcher.log')
};
}
async function readUtf8(filePath) {
return fs.readFile(filePath, 'utf8');
}
module.exports = {
ROOT_DIR,
LOG_ROOT,
SEARCH_LOG_ROOT,
FACEAI_BASE_URL,
LEGACY_BASE_URL,
LEGACY_HOME_URL,
SIMULATOR_URL,
SELFIE_NAME,
EXPECTED_MATCH_COUNT,
buildHandoffUrl,
buildSimulatorUrl,
dockerCompose,
getSearchArtifacts,
getSelfiePath,
prepareHostState,
readUtf8,
runCommand,
waitForHttp
};

View file

@ -0,0 +1,27 @@
const {
FACEAI_BASE_URL,
SIMULATOR_URL,
dockerCompose,
prepareHostState,
runCommand,
waitForHttp
} = require('./faceai-test-utils');
module.exports = async () => {
await prepareHostState();
await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true });
await runCommand('npm', ['run', 'build']);
await dockerCompose(['up', '--build', '-d']);
await waitForHttp(`${FACEAI_BASE_URL}/health`, ({ response, parsedBody }) => {
return response.ok && parsedBody && parsedBody.ok === true;
});
await waitForHttp(`${FACEAI_BASE_URL}/api/health/queue`, ({ response, parsedBody }) => {
return response.ok && parsedBody && parsedBody.ok === true;
});
await waitForHttp(SIMULATOR_URL, ({ response, bodyText }) => {
return response.ok && bodyText.includes('FaceAI Legacy Simulator');
});
};

View file

@ -0,0 +1,9 @@
const { dockerCompose } = require('./faceai-test-utils');
module.exports = async () => {
if (process.env.FACEAI_E2E_KEEP_STACK === '1') {
return;
}
await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true });
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

View file

@ -114,6 +114,19 @@ function getCurrentLangValue() {
return $("html").attr("lang") || "it";
}
function getFaceAiStorageValue(fieldId, simulatorKey) {
var field = $("#" + fieldId);
if (field.length && field.val()) {
return field.val();
}
if (window.faceAiSimulator && window.faceAiSimulator.raceStorage && window.faceAiSimulator.raceStorage[simulatorKey]) {
return window.faceAiSimulator.raceStorage[simulatorKey];
}
return "";
}
function faceAiFeatureEnabled() {
var config = window.faceAiConfig || {};
var simulatorConfig = window.faceAiSimulator || {};
@ -136,6 +149,57 @@ function faceAiEscapeHtml(value) {
.replace(/'/g, "&#39;");
}
function isFaceAiDebugEnabled() {
var hostname = window.location && window.location.hostname ? window.location.hostname : "";
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function getFaceAiDebugPayload() {
var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year");
var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder");
var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder");
var racePathBase = $("#faceAiRacePathBase").val() || "";
var raceStorageRelativeDir = $("#faceAiRaceStorageRelativeDir").val() || [raceYear, raceMonthFolder, raceFolder].filter(Boolean).join("/");
var simulatorConfig = window.faceAiSimulator || null;
return {
pageUrl: window.location.href,
race: {
id: $("#id_gara").val() || "",
slug: $("#garaDesc").val() || "",
name: $("h1.my-4").last().text().replace(/\s+/g, " ").trim(),
lang: getCurrentLangValue(),
storage: {
year: raceYear,
monthFolder: raceMonthFolder,
raceFolder: raceFolder,
pathBase: racePathBase,
relativeDir: raceStorageRelativeDir
}
},
simulator: simulatorConfig,
handoff: {
url: (simulatorConfig && simulatorConfig.handoffUrl) || "faceai_handoff.php",
returnUrl: (simulatorConfig && simulatorConfig.returnUrl) || window.location.href
}
};
}
function logFaceAiDebug(label, extraPayload) {
if (!isFaceAiDebugEnabled() || !window.console || typeof window.console.groupCollapsed !== "function") {
return;
}
var payload = getFaceAiDebugPayload();
if (extraPayload) {
payload.extra = extraPayload;
}
window.console.groupCollapsed("[FaceAI] " + label);
window.console.log(payload);
window.console.groupEnd();
}
function getFaceAiErrorState() {
if (typeof URLSearchParams === "undefined") {
return null;
@ -191,6 +255,9 @@ function buildFaceAiLaunchUrl() {
var raceId = $("#id_gara").val() || "";
var raceSlug = $("#garaDesc").val() || "";
var raceName = $("h1.my-4").last().text().replace(/\s+/g, " ").trim();
var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year");
var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder");
var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder");
var lang = getCurrentLangValue();
var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php";
var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href;
@ -198,6 +265,9 @@ function buildFaceAiLaunchUrl() {
"raceId=" + encodeURIComponent(raceId),
"raceSlug=" + encodeURIComponent(raceSlug),
"raceName=" + encodeURIComponent(raceName),
"raceYear=" + encodeURIComponent(raceYear),
"raceMonthFolder=" + encodeURIComponent(raceMonthFolder),
"raceFolder=" + encodeURIComponent(raceFolder),
"lang=" + encodeURIComponent(lang),
"returnUrl=" + encodeURIComponent(returnUrl)
];
@ -215,10 +285,18 @@ function buildFaceAiLaunchUrl() {
query.push("devMembershipStatus=" + encodeURIComponent(window.faceAiSimulator.devMembershipStatus));
}
logFaceAiDebug("Legacy launch payload prepared", {
query: query.slice(0),
raceId: raceId,
raceSlug: raceSlug,
raceName: raceName
});
return handoffUrl + "?" + query.join("&");
}
function launchFaceAi() {
logFaceAiDebug("Redirecting to FaceAI handoff");
$("body").addClass("loading");
window.location.href = buildFaceAiLaunchUrl();
return false;
@ -451,6 +529,7 @@ function goPage()
$(function() {
initFaceAiRaceSearchButton();
initFaceAiErrorModal();
logFaceAiDebug("Legacy race page ready");
});

View file

@ -8,6 +8,9 @@ try {
$raceId = faceai_request_value('raceId');
$raceSlug = faceai_request_value('raceSlug');
$raceName = faceai_request_value('raceName', $raceSlug !== '' ? $raceSlug : $raceId);
$raceYear = faceai_request_value('raceYear');
$raceMonthFolder = faceai_request_value('raceMonthFolder');
$raceFolder = faceai_request_value('raceFolder');
$lang = faceai_request_value('lang', 'it');
$returnUrl = faceai_request_value('returnUrl');
@ -36,6 +39,20 @@ try {
faceai_redirect_with_error($returnUrl, 'Il tuo account non e abilitato all uso di Face ID.');
}
$racePayload = array(
'id' => $raceId,
'slug' => $raceSlug !== '' ? $raceSlug : $raceId,
'name' => $raceName !== '' ? $raceName : $raceId
);
if ($raceYear !== '' && $raceMonthFolder !== '' && $raceFolder !== '') {
$racePayload['storage'] = array(
'year' => $raceYear,
'monthFolder' => $raceMonthFolder,
'raceFolder' => strtoupper(trim($raceFolder))
);
}
$payload = array(
'type' => 'handoff',
'user' => array(
@ -44,11 +61,7 @@ try {
'email' => $identity['email'],
'membershipStatus' => $identity['membershipStatus']
),
'race' => array(
'id' => $raceId,
'slug' => $raceSlug !== '' ? $raceSlug : $raceId,
'name' => $raceName !== '' ? $raceName : $raceId
),
'race' => $racePayload,
'lang' => $lang,
'returnUrl' => $returnUrl,
'expiresAt' => ((int) round(microtime(true) * 1000)) + (5 * 60 * 1000)

View file

@ -39,6 +39,19 @@ try {
'token' => $token
));
$result = faceai_fetch_json($bridgeUrl);
$matches = is_array($result['matches'] ?? null) ? $result['matches'] : array();
$photos = array_map(static function ($match) {
$photoId = (string) ($match['photoId'] ?? ($match['id'] ?? ''));
return array(
'id' => $photoId,
'photoId' => $photoId,
'label' => (string) ($match['label'] ?? $photoId),
'checkpoint' => (string) ($match['checkpoint'] ?? '-'),
'previewUrl' => (string) ($match['previewUrl'] ?? ''),
'score' => $match['score'] ?? null,
);
}, $matches);
faceai_sim_render_page(array(
'raceId' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
@ -46,9 +59,9 @@ try {
'raceSlug' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
'raceName' => (string) ($result['raceName'] ?? ('Race ' . ($payload['raceId'] ?? ''))),
'returnUrl' => (string) ($result['returnUrl'] ?? 'faceai_simulator.php'),
'banner' => 'Vista filtrata da FaceAI. Sono state trovate <strong>' . count($result['matches'] ?? array()) . '</strong> foto corrispondenti per l utente corrente.',
'totalLabel' => count($result['matches'] ?? array()) . ' foto da FaceAI',
'photos' => is_array($result['matches'] ?? null) ? $result['matches'] : array(),
'banner' => 'Vista filtrata da FaceAI. Sono state trovate <strong>' . count($photos) . '</strong> foto corrispondenti per l utente corrente.',
'totalLabel' => count($photos) . ' foto da FaceAI',
'photos' => $photos,
'showSimulatorBootstrap' => false
));
} catch (Throwable $error) {

View file

@ -1,11 +1,14 @@
<?php
require_once __DIR__ . '/faceai_simulator_view.php';
$raceId = isset($_GET['raceId']) ? trim((string) $_GET['raceId']) : '101';
$raceId = isset($_GET['raceId']) ? trim((string) $_GET['raceId']) : '202';
$lang = isset($_GET['lang']) ? trim((string) $_GET['lang']) : 'it';
$raceSlug = isset($_GET['raceSlug']) ? trim((string) $_GET['raceSlug']) : 'mezza-di-firenze';
$raceName = isset($_GET['raceName']) ? trim((string) $_GET['raceName']) : 'Mezza di Firenze';
$returnUrl = 'http://localhost:8080/faceai_simulator.php?raceId=' . rawurlencode($raceId) . '&lang=' . rawurlencode($lang) . '&raceSlug=' . rawurlencode($raceSlug) . '&raceName=' . rawurlencode($raceName);
$raceSlug = isset($_GET['raceSlug']) ? trim((string) $_GET['raceSlug']) : 'mezza-di-pisa';
$raceName = isset($_GET['raceName']) ? trim((string) $_GET['raceName']) : 'Mezza di Pisa';
$raceYear = isset($_GET['raceYear']) ? trim((string) $_GET['raceYear']) : '2026';
$raceMonthFolder = isset($_GET['raceMonthFolder']) ? trim((string) $_GET['raceMonthFolder']) : '04.APRILE';
$raceFolder = isset($_GET['raceFolder']) ? trim((string) $_GET['raceFolder']) : 'PISA';
$returnUrl = 'http://localhost:8080/faceai_simulator.php?raceId=' . rawurlencode($raceId) . '&lang=' . rawurlencode($lang) . '&raceSlug=' . rawurlencode($raceSlug) . '&raceName=' . rawurlencode($raceName) . '&raceYear=' . rawurlencode($raceYear) . '&raceMonthFolder=' . rawurlencode($raceMonthFolder) . '&raceFolder=' . rawurlencode($raceFolder);
$photos = array(
array('id' => 'f101-001', 'thumb' => 'thumb-arrivo-001.jpg', 'label' => 'Arrivo 001', 'checkpoint' => 'Arrivo'),
@ -27,6 +30,9 @@ faceai_sim_render_page(array(
'lang' => $lang,
'raceSlug' => $raceSlug,
'raceName' => $raceName,
'raceYear' => $raceYear,
'raceMonthFolder' => $raceMonthFolder,
'raceFolder' => $raceFolder,
'returnUrl' => $returnUrl,
'banner' => 'Questa pagina PHP simula il punto di ingresso del sito legacy. Il vecchio select con ID <strong>tipoPuntoFoto</strong> viene rimosso dal JavaScript originale e sostituito dal pulsante Face ID.',
'totalLabel' => count($photos) . ' foto demo',

View file

@ -11,6 +11,9 @@ function faceai_sim_render_page(array $options)
$lang = $options['lang'];
$raceSlug = $options['raceSlug'];
$raceName = $options['raceName'];
$raceYear = $options['raceYear'] ?? '';
$raceMonthFolder = $options['raceMonthFolder'] ?? '';
$raceFolder = $options['raceFolder'] ?? '';
$returnUrl = $options['returnUrl'];
$banner = $options['banner'];
$totalLabel = $options['totalLabel'];
@ -69,8 +72,8 @@ function faceai_sim_render_page(array $options)
<nav class="navbar fixed-top navbar-expand-lg navbar-light bg-white fixed-top">
<div class="container">
<a class="navbar-brand" href="faceai_simulator.php?raceId=<?php echo faceai_sim_html($raceId); ?>&lang=<?php echo faceai_sim_html($lang); ?>"><img src="images/layout/regalami-un-sorriso-ets-640.png" alt="Regalami Un Sorriso Ets" width="100"></a>
<button class="navbar-toggler navbar-toggler-right" type="button"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse show" id="navbarResponsive">
<button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="index.jsp">Home</a></li>
<li class="nav-item"><a class="nav-link" href="associazione.jsp">Associazione</a></li>
@ -105,6 +108,9 @@ function faceai_sim_render_page(array $options)
<input name="id_gara" id="id_gara" type="hidden" value="<?php echo faceai_sim_html($raceId); ?>">
<input name="id_foto" id="id_foto" type="hidden">
<input name="garaDesc" id="garaDesc" type="hidden" value="<?php echo faceai_sim_html($raceSlug); ?>">
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<?php echo faceai_sim_html($raceYear); ?>">
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<?php echo faceai_sim_html($raceMonthFolder); ?>">
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<?php echo faceai_sim_html($raceFolder); ?>">
<input name="lang" id="lang" type="hidden" value="<?php echo faceai_sim_html($lang); ?>">
<input name="pageNumber" id="pageNumber" type="hidden" value="1">
<input name="actionPage" id="actionPage" type="hidden" value="Foto.abl">
@ -147,17 +153,24 @@ function faceai_sim_render_page(array $options)
<div class="gallery-grid">
<?php foreach ($photos as $photo): ?>
<?php
$photoId = (string) ($photo['id'] ?? ($photo['photoId'] ?? ''));
$photoLabel = (string) ($photo['label'] ?? ($photoId !== '' ? $photoId : ($photo['thumb'] ?? 'Foto senza etichetta')));
$photoPreviewUrl = (string) ($photo['previewUrl'] ?? '');
$photoThumb = (string) ($photo['thumb'] ?? $photoId);
$photoCheckpoint = (string) ($photo['checkpoint'] ?? '-');
?>
<div class="gallery-card">
<div class="gallery-thumb">
<?php if (!empty($photo['previewUrl'])): ?>
<img src="<?php echo faceai_sim_html($photo['previewUrl']); ?>" alt="<?php echo faceai_sim_html($photo['label']); ?>">
<?php if ($photoPreviewUrl !== ''): ?>
<img src="<?php echo faceai_sim_html($photoPreviewUrl); ?>" alt="<?php echo faceai_sim_html($photoLabel); ?>">
<?php else: ?>
<?php echo faceai_sim_html($photo['thumb'] ?? $photo['id']); ?>
<?php echo faceai_sim_html($photoThumb); ?>
<?php endif; ?>
</div>
<strong><?php echo faceai_sim_html($photo['label'] ?? $photo['id']); ?></strong><br>
<small>ID foto: <?php echo faceai_sim_html($photo['id'] ?? ''); ?></small><br>
<small>Punto foto: <?php echo faceai_sim_html($photo['checkpoint'] ?? '-'); ?></small>
<strong><?php echo faceai_sim_html($photoLabel); ?></strong><br>
<small>ID foto: <?php echo faceai_sim_html($photoId); ?></small><br>
<small>Punto foto: <?php echo faceai_sim_html($photoCheckpoint); ?></small>
</div>
<?php endforeach; ?>
</div>
@ -169,14 +182,58 @@ window.faceAiSimulator = {
enabled: true,
handoffUrl: 'faceai_handoff.php',
returnUrl: <?php echo json_encode($returnUrl); ?>,
raceStorage: {
year: <?php echo json_encode($raceYear); ?>,
monthFolder: <?php echo json_encode($raceMonthFolder); ?>,
raceFolder: <?php echo json_encode($raceFolder); ?>
},
devUserId: '1',
devDisplayName: 'Mario Rossi',
devEmail: 'mario.rossi@example.test',
devMembershipStatus: 'active'
};
(function () {
var hostname = window.location && window.location.hostname ? window.location.hostname : '';
var isLocalDebug = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
if (!isLocalDebug || !window.console || typeof window.console.groupCollapsed !== 'function') {
return;
}
window.console.groupCollapsed('[FaceAI] Simulator bootstrap');
window.console.log({
pageUrl: window.location.href,
race: {
id: <?php echo json_encode($raceId); ?>,
slug: <?php echo json_encode($raceSlug); ?>,
name: <?php echo json_encode($raceName); ?>,
lang: <?php echo json_encode($lang); ?>,
storage: {
year: <?php echo json_encode($raceYear); ?>,
monthFolder: <?php echo json_encode($raceMonthFolder); ?>,
raceFolder: <?php echo json_encode($raceFolder); ?>,
relativeDir: <?php echo json_encode(implode('/', array_values(array_filter([$raceYear, $raceMonthFolder, $raceFolder], static function ($segment) {
return $segment !== null && $segment !== '';
})))); ?>
}
},
handoff: {
url: 'faceai_handoff.php',
returnUrl: <?php echo json_encode($returnUrl); ?>
},
devUser: {
id: '1',
displayName: 'Mario Rossi',
email: 'mario.rossi@example.test',
membershipStatus: 'active'
}
});
window.console.groupEnd();
}());
</script>
<?php endif; ?>
<script src="vendor/jquery/jquery.min.js"></script>
<script src="vendor/popper/popper.min.js"></script>
<script src="vendor/bootstrap/js/bootstrap.min.js"></script>
<script src="_js/rus-ecom-240621.js"></script>
</body>

View file

@ -56,6 +56,50 @@ if (faceAiFeatureEnabledValue == null) {
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
}
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
java.util.Date faceAiRaceDate = CR.getGara().getDataGaraInizio();
String faceAiRacePathBase = CR.getGara().getPathBase() != null ? CR.getGara().getPathBase().trim() : "";
String faceAiRaceYear = "";
String faceAiRaceMonthFolder = "";
String faceAiRaceFolder = "";
String faceAiRaceStorageRelativeDir = "";
if (!faceAiRacePathBase.isEmpty()) {
String[] faceAiPathSegments = faceAiRacePathBase.split("/");
java.util.ArrayList faceAiNormalizedSegments = new java.util.ArrayList();
for (int faceAiSegmentIndex = 0; faceAiSegmentIndex < faceAiPathSegments.length; faceAiSegmentIndex++) {
String faceAiSegment = faceAiPathSegments[faceAiSegmentIndex] != null ? faceAiPathSegments[faceAiSegmentIndex].trim() : "";
if (!faceAiSegment.isEmpty()) {
faceAiNormalizedSegments.add(faceAiSegment);
}
}
if (faceAiNormalizedSegments.size() > 0) {
faceAiRaceYear = (String) faceAiNormalizedSegments.get(0);
}
if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceMonthFolder = (String) faceAiNormalizedSegments.get(1);
}
if (faceAiNormalizedSegments.size() > 2) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(2);
} else if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(faceAiNormalizedSegments.size() - 1);
}
}
if (faceAiRaceYear.isEmpty() && faceAiRaceDate != null) {
java.util.Calendar faceAiCalendar = java.util.Calendar.getInstance();
faceAiCalendar.setTime(faceAiRaceDate);
faceAiRaceYear = String.valueOf(faceAiCalendar.get(java.util.Calendar.YEAR));
}
if (faceAiRaceFolder.isEmpty()) {
faceAiRaceFolder = String.valueOf(CR.getGara().getId_gara());
}
if (!faceAiRaceYear.isEmpty()) {
faceAiRaceStorageRelativeDir = faceAiRaceYear;
if (!faceAiRaceMonthFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceMonthFolder;
}
if (!faceAiRaceFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceFolder;
}
}
%>
<!-- InstanceEndEditable -->
<!-- InstanceBeginEditable name="doctitle" -->
@ -121,6 +165,11 @@ boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".e
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
<input name="id_foto" id="id_foto" type="hidden">
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<%= faceAiRaceYear %>">
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<%= faceAiRaceMonthFolder %>">
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<%= faceAiRaceFolder %>">
<input name="faceAiRacePathBase" id="faceAiRacePathBase" type="hidden" value="<%= faceAiRacePathBase %>">
<input name="faceAiRaceStorageRelativeDir" id="faceAiRaceStorageRelativeDir" type="hidden" value="<%= faceAiRaceStorageRelativeDir %>">
<div class="row">
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">

View file

@ -56,6 +56,50 @@ if (faceAiFeatureEnabledValue == null) {
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
}
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
java.util.Date faceAiRaceDate = CR.getGara().getDataGaraInizio();
String faceAiRacePathBase = CR.getGara().getPathBase() != null ? CR.getGara().getPathBase().trim() : "";
String faceAiRaceYear = "";
String faceAiRaceMonthFolder = "";
String faceAiRaceFolder = "";
String faceAiRaceStorageRelativeDir = "";
if (!faceAiRacePathBase.isEmpty()) {
String[] faceAiPathSegments = faceAiRacePathBase.split("/");
java.util.ArrayList faceAiNormalizedSegments = new java.util.ArrayList();
for (int faceAiSegmentIndex = 0; faceAiSegmentIndex < faceAiPathSegments.length; faceAiSegmentIndex++) {
String faceAiSegment = faceAiPathSegments[faceAiSegmentIndex] != null ? faceAiPathSegments[faceAiSegmentIndex].trim() : "";
if (!faceAiSegment.isEmpty()) {
faceAiNormalizedSegments.add(faceAiSegment);
}
}
if (faceAiNormalizedSegments.size() > 0) {
faceAiRaceYear = (String) faceAiNormalizedSegments.get(0);
}
if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceMonthFolder = (String) faceAiNormalizedSegments.get(1);
}
if (faceAiNormalizedSegments.size() > 2) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(2);
} else if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(faceAiNormalizedSegments.size() - 1);
}
}
if (faceAiRaceYear.isEmpty() && faceAiRaceDate != null) {
java.util.Calendar faceAiCalendar = java.util.Calendar.getInstance();
faceAiCalendar.setTime(faceAiRaceDate);
faceAiRaceYear = String.valueOf(faceAiCalendar.get(java.util.Calendar.YEAR));
}
if (faceAiRaceFolder.isEmpty()) {
faceAiRaceFolder = String.valueOf(CR.getGara().getId_gara());
}
if (!faceAiRaceYear.isEmpty()) {
faceAiRaceStorageRelativeDir = faceAiRaceYear;
if (!faceAiRaceMonthFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceMonthFolder;
}
if (!faceAiRaceFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceFolder;
}
}
%>
<!-- InstanceEndEditable -->
<!-- InstanceBeginEditable name="doctitle" -->
@ -121,6 +165,11 @@ boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".e
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
<input name="id_foto" id="id_foto" type="hidden">
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<%= faceAiRaceYear %>">
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<%= faceAiRaceMonthFolder %>">
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<%= faceAiRaceFolder %>">
<input name="faceAiRacePathBase" id="faceAiRacePathBase" type="hidden" value="<%= faceAiRacePathBase %>">
<input name="faceAiRaceStorageRelativeDir" id="faceAiRaceStorageRelativeDir" type="hidden" value="<%= faceAiRaceStorageRelativeDir %>">
<div class="row">
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">