feat: Add FaceAI integration with handoff and return functionality
- Introduced a new workspace for FaceAI in package.json. - Implemented FaceAI handoff logic in faceai_handoff.php, including identity verification and token signing. - Created faceai_return.php to handle return requests from FaceAI, validating tokens and forwarding results. - Developed faceai_simulator.php and faceai_simulator_view.php for simulating the FaceAI interface with demo photos. - Enhanced rus-ecom-240621.js to support new FaceAI features, including dynamic URL building and button integration. - Added faceai_config.php for configuration management, including environment variable handling and utility functions. - Updated HTML structure and styles in simulator view for better user experience.
This commit is contained in:
parent
f65a85dcc9
commit
da362c201f
31 changed files with 4511 additions and 60 deletions
40
faceai/apps/backend/src/auth.js
Normal file
40
faceai/apps/backend/src/auth.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
function base64UrlEncode(input) {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
function base64UrlDecode(input) {
|
||||
return Buffer.from(input, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
export function signPayload(payload, secret) {
|
||||
const body = base64UrlEncode(JSON.stringify(payload));
|
||||
const signature = crypto.createHmac('sha256', secret).update(body).digest('base64url');
|
||||
return `${body}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifySignedPayload(token, secret) {
|
||||
if (!token || !token.includes('.')) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
const [body, signature] = token.split('.');
|
||||
const expected = crypto.createHmac('sha256', secret).update(body).digest('base64url');
|
||||
|
||||
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
||||
throw new Error('Invalid token signature');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(base64UrlDecode(body));
|
||||
|
||||
if (payload.expiresAt && Date.now() > payload.expiresAt) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function randomId(prefix) {
|
||||
return `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
18
faceai/apps/backend/src/config.js
Normal file
18
faceai/apps/backend/src/config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const defaultLocalLegacyRoot = path.resolve(__dirname, '../../../../www');
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT || 3001),
|
||||
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',
|
||||
enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC
|
||||
? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1'
|
||||
: process.env.NODE_ENV !== 'production',
|
||||
localLegacyStaticRoot: process.env.FACEAI_LOCAL_LEGACY_STATIC_ROOT || defaultLocalLegacyRoot,
|
||||
sharedSecret: process.env.FACEAI_SHARED_SECRET || 'change-me',
|
||||
sessionCookieName: process.env.FACEAI_SESSION_COOKIE || 'rus_faceai_session'
|
||||
};
|
||||
356
faceai/apps/backend/src/server.js
Normal file
356
faceai/apps/backend/src/server.js
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config } from './config.js';
|
||||
import { signPayload, verifySignedPayload } from './auth.js';
|
||||
import { createSession, createSearch, completeSearch, getResult, getSearch, getSession, mockCatalog } from './store.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const frontendDist = path.resolve(__dirname, '../../frontend/dist');
|
||||
const app = express();
|
||||
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
if (config.enableLocalLegacyStatic && fs.existsSync(config.localLegacyStaticRoot)) {
|
||||
app.use('/legacy-static', express.static(config.localLegacyStaticRoot));
|
||||
} else {
|
||||
app.use('/legacy-static', (req, res) => {
|
||||
res.status(404).type('text/plain').send('Legacy static assets are not configured in this environment.');
|
||||
});
|
||||
}
|
||||
app.use(cors({
|
||||
origin: config.frontendUrl,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
function getFaceAiSession(req) {
|
||||
const sessionId = req.cookies[config.sessionCookieName];
|
||||
return sessionId ? getSession(sessionId) : null;
|
||||
}
|
||||
|
||||
function requireSession(req, res, next) {
|
||||
const session = getFaceAiSession(req);
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Not authenticated with FaceAI' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.faceaiSession = session;
|
||||
next();
|
||||
}
|
||||
|
||||
function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) {
|
||||
const race = mockCatalog[raceId] || { id: raceId, slug: raceSlug || `race-${raceId}`, name: raceSlug || `Race ${raceId}` };
|
||||
|
||||
return signPayload({
|
||||
type: 'handoff',
|
||||
user: {
|
||||
id: 'legacy-user-1',
|
||||
displayName: 'Mario Rossi',
|
||||
email: 'mario.rossi@example.test',
|
||||
membershipStatus: 'active'
|
||||
},
|
||||
race: {
|
||||
id: race.id,
|
||||
slug: race.slug,
|
||||
name: race.name
|
||||
},
|
||||
lang: lang || 'it',
|
||||
returnUrl,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000
|
||||
}, config.sharedSecret);
|
||||
}
|
||||
|
||||
function issueReturnToken(result) {
|
||||
return signPayload({
|
||||
type: 'return',
|
||||
resultId: result.id,
|
||||
raceId: result.raceId,
|
||||
userId: result.userId,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000
|
||||
}, config.sharedSecret);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderLegacyRacePage({ raceId, lang = 'it', result = null }) {
|
||||
const race = mockCatalog[raceId] || { id: raceId, name: `Race ${raceId}`, slug: `race-${raceId}`, photos: [] };
|
||||
const returnUrl = `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(race.id)}&lang=${encodeURIComponent(lang)}`;
|
||||
const photos = result ? result.matches : race.photos;
|
||||
const banner = result
|
||||
? `<div class="legacy-banner">Vista filtrata da FaceAI. Trovate ${photos.length} foto per l'utente corrente.</div>`
|
||||
: '<div class="legacy-banner legacy-banner-neutral">Pagina gara simulata per il test locale del handoff FaceAI.</div>';
|
||||
|
||||
const photoList = photos.length
|
||||
? photos.map((photo) => `
|
||||
<li class="legacy-card">
|
||||
<div class="legacy-thumb">${escapeHtml(photo.thumb || photo.id)}</div>
|
||||
<div class="legacy-meta">
|
||||
<strong>${escapeHtml(photo.label)}</strong>
|
||||
<span>ID foto: ${escapeHtml(photo.id)}</span>
|
||||
<span>Punto foto: ${escapeHtml(photo.checkpoint || '-')}</span>
|
||||
</div>
|
||||
</li>
|
||||
`).join('')
|
||||
: '<li class="legacy-empty">Nessuna foto disponibile.</li>';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Legacy Race Simulator</title>
|
||||
<style>
|
||||
body { font-family: Georgia, serif; margin: 0; background: #f7f1e8; color: #2c241b; }
|
||||
.topbar { background: #fff; border-bottom: 1px solid #d9c7aa; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.brand { font-size: 22px; font-weight: 700; }
|
||||
.page { max-width: 1120px; margin: 0 auto; padding: 24px; }
|
||||
.toolbar { background: #fff; border: 1px solid #dccab2; padding: 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.toolbar label { font-size: 14px; color: #6a5845; }
|
||||
.toolbar select { padding: 10px 12px; min-width: 220px; }
|
||||
.toolbar button { background: #8d1f1f; color: #fff; border: 0; padding: 11px 18px; font-weight: 700; cursor: pointer; }
|
||||
.legacy-banner { margin: 20px 0; background: #f6d9b6; border: 1px solid #c69257; padding: 14px 16px; }
|
||||
.legacy-banner-neutral { background: #e9efe8; border-color: #8fa18b; }
|
||||
.summary { margin: 12px 0 24px; }
|
||||
.gallery { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
||||
.legacy-card { background: #fff; border: 1px solid #dccab2; padding: 16px; display: grid; gap: 12px; }
|
||||
.legacy-thumb { background: #efe2d1; border: 1px dashed #b79a77; min-height: 120px; display: grid; place-items: center; color: #6f5b47; font-size: 13px; }
|
||||
.legacy-meta { display: grid; gap: 4px; font-size: 14px; }
|
||||
.legacy-empty { background: #fff; border: 1px solid #dccab2; padding: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">Regalami un Sorriso ETS</div>
|
||||
<div>Utente simulato: Mario Rossi</div>
|
||||
</header>
|
||||
<main class="page">
|
||||
<h1>${escapeHtml(race.name)}</h1>
|
||||
<div class="toolbar">
|
||||
<label>Punti Foto</label>
|
||||
<select>
|
||||
<option>-- Punti Foto --</option>
|
||||
<option>Arrivo</option>
|
||||
<option>Centro</option>
|
||||
<option>Ponte</option>
|
||||
</select>
|
||||
<button id="faceai-launch">Face ID</button>
|
||||
</div>
|
||||
${banner}
|
||||
<p class="summary">${result ? 'La pagina mostra solo le foto restituite da FaceAI.' : 'In questa simulazione il vecchio select tipoPuntoFoto è sostituito dal pulsante Face ID.'}</p>
|
||||
<ul class="gallery">${photoList}</ul>
|
||||
</main>
|
||||
<script>
|
||||
const launchButton = document.getElementById('faceai-launch');
|
||||
launchButton.addEventListener('click', () => {
|
||||
const returnUrl = ${JSON.stringify(returnUrl)};
|
||||
const launchUrl = new URL('/dev/legacy/launch', ${JSON.stringify(config.publicBaseUrl)});
|
||||
launchUrl.searchParams.set('raceId', ${JSON.stringify(race.id)});
|
||||
launchUrl.searchParams.set('raceSlug', ${JSON.stringify(race.slug)});
|
||||
launchUrl.searchParams.set('lang', ${JSON.stringify(lang)});
|
||||
launchUrl.searchParams.set('returnUrl', returnUrl);
|
||||
window.location.href = launchUrl.toString();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/dev/legacy/race', (req, res) => {
|
||||
const raceId = String(req.query.raceId || '101');
|
||||
const lang = String(req.query.lang || 'it');
|
||||
res.type('html').send(renderLegacyRacePage({ raceId, lang }));
|
||||
});
|
||||
|
||||
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 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 });
|
||||
res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`);
|
||||
});
|
||||
|
||||
app.get('/dev/legacy/return', (req, res) => {
|
||||
try {
|
||||
const token = String(req.query.token || '');
|
||||
const payload = verifySignedPayload(token, config.sharedSecret);
|
||||
if (payload.type !== 'return') {
|
||||
throw new Error('Wrong token type');
|
||||
}
|
||||
|
||||
const result = getResult(String(req.query.resultId || payload.resultId));
|
||||
if (!result || result.userId !== payload.userId) {
|
||||
throw new Error('Result not found');
|
||||
}
|
||||
|
||||
res.type('html').send(renderLegacyRacePage({ raceId: result.raceId, lang: result.lang || 'it', result }));
|
||||
} catch (error) {
|
||||
res.status(400).type('html').send(`<h1>Return handoff failed</h1><p>${escapeHtml(error.message)}</p>`);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/exchange', (req, res) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
const payload = verifySignedPayload(token, config.sharedSecret);
|
||||
if (payload.type !== 'handoff') {
|
||||
throw new Error('Wrong token type');
|
||||
}
|
||||
|
||||
const sessionId = createSession({
|
||||
user: payload.user,
|
||||
race: payload.race,
|
||||
lang: payload.lang,
|
||||
returnUrl: payload.returnUrl,
|
||||
access: {
|
||||
faceAiAllowed: payload.user.membershipStatus === 'active'
|
||||
}
|
||||
});
|
||||
|
||||
res.cookie(config.sessionCookieName, sessionId, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: false,
|
||||
path: '/'
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: payload.user,
|
||||
race: payload.race,
|
||||
lang: payload.lang,
|
||||
returnUrl: payload.returnUrl,
|
||||
access: {
|
||||
faceAiAllowed: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/session', requireSession, (req, res) => {
|
||||
res.json(req.faceaiSession);
|
||||
});
|
||||
|
||||
app.post('/api/searches', requireSession, (req, res) => {
|
||||
const raceId = String(req.body.raceId || req.faceaiSession.race.id);
|
||||
const selfieName = String(req.body.selfieName || 'selfie.jpg');
|
||||
|
||||
const search = createSearch({
|
||||
raceId,
|
||||
selfieName,
|
||||
user: req.faceaiSession.user,
|
||||
returnUrl: req.faceaiSession.returnUrl,
|
||||
lang: req.faceaiSession.lang
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
completeSearch(search.id);
|
||||
}, 3500);
|
||||
|
||||
res.status(201).json({
|
||||
id: search.id,
|
||||
status: search.status,
|
||||
raceId: search.raceId,
|
||||
selfieName: search.selfieName
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/searches/:id', requireSession, (req, res) => {
|
||||
const search = getSearch(req.params.id);
|
||||
if (!search || search.user.id !== req.faceaiSession.user.id) {
|
||||
res.status(404).json({ error: 'Search not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: search.id,
|
||||
status: search.status,
|
||||
raceId: search.raceId,
|
||||
resultId: search.resultId,
|
||||
createdAt: search.createdAt,
|
||||
completedAt: search.completedAt,
|
||||
matchCount: search.matches.length
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/searches/:id/redirect', requireSession, (req, res) => {
|
||||
const search = getSearch(req.params.id);
|
||||
if (!search || search.user.id !== req.faceaiSession.user.id) {
|
||||
res.status(404).json({ error: 'Search not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (search.status !== 'completed' || !search.resultId) {
|
||||
res.status(409).json({ error: 'Search not completed yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = getResult(search.resultId);
|
||||
const token = issueReturnToken(result);
|
||||
|
||||
res.json({
|
||||
url: `${config.legacyReturnUrl}?resultId=${encodeURIComponent(result.id)}&token=${encodeURIComponent(token)}`
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/bridge/results/:id', (req, res) => {
|
||||
try {
|
||||
const token = String(req.query.token || '');
|
||||
const payload = verifySignedPayload(token, config.sharedSecret);
|
||||
if (payload.type !== 'return') {
|
||||
throw new Error('Wrong token type');
|
||||
}
|
||||
|
||||
if (String(payload.resultId || '') !== String(req.params.id)) {
|
||||
throw new Error('Result id mismatch');
|
||||
}
|
||||
|
||||
const result = getResult(req.params.id);
|
||||
if (!result || result.userId !== payload.userId) {
|
||||
throw new Error('Result not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: result.id,
|
||||
raceId: result.raceId,
|
||||
raceName: result.raceName,
|
||||
userId: result.userId,
|
||||
returnUrl: result.returnUrl,
|
||||
lang: result.lang,
|
||||
matches: result.matches
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
if (fs.existsSync(frontendDist)) {
|
||||
app.use(express.static(frontendDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/dev/')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.sendFile(path.join(frontendDist, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`FaceAI backend listening on http://localhost:${config.port}`);
|
||||
});
|
||||
110
faceai/apps/backend/src/store.js
Normal file
110
faceai/apps/backend/src/store.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { randomId } from './auth.js';
|
||||
|
||||
export const mockCatalog = {
|
||||
'101': {
|
||||
id: '101',
|
||||
slug: 'mezza-di-firenze',
|
||||
name: 'Mezza di Firenze',
|
||||
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' },
|
||||
{ id: 'f101-003', label: 'Ponte 003', bib: '245', checkpoint: 'Ponte', thumb: 'thumb-ponte-003.jpg' },
|
||||
{ id: 'f101-004', label: 'Centro 004', bib: '245', checkpoint: 'Centro', thumb: 'thumb-centro-004.jpg' },
|
||||
{ id: 'f101-005', label: 'Centro 005', bib: '812', checkpoint: 'Centro', thumb: 'thumb-centro-005.jpg' },
|
||||
{ id: 'f101-006', label: 'Arrivo 006', bib: '812', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-006.jpg' },
|
||||
{ id: 'f101-007', label: 'Ponte 007', bib: '391', checkpoint: 'Ponte', thumb: 'thumb-ponte-007.jpg' },
|
||||
{ id: 'f101-008', label: 'Centro 008', bib: '391', checkpoint: 'Centro', thumb: 'thumb-centro-008.jpg' },
|
||||
{ id: 'f101-009', label: 'Arrivo 009', bib: '128', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-009.jpg' },
|
||||
{ id: 'f101-010', label: 'Lungarno 010', bib: '128', checkpoint: 'Lungarno', thumb: 'thumb-lungarno-010.jpg' },
|
||||
{ id: 'f101-011', label: 'Piazza 011', bib: '560', checkpoint: 'Piazza', thumb: 'thumb-piazza-011.jpg' },
|
||||
{ id: 'f101-012', label: 'Arrivo 012', bib: '560', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-012.jpg' }
|
||||
]
|
||||
},
|
||||
'202': {
|
||||
id: '202',
|
||||
slug: 'trail-del-chianti',
|
||||
name: 'Trail del Chianti',
|
||||
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' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const sessions = new Map();
|
||||
const searches = new Map();
|
||||
const results = new Map();
|
||||
|
||||
export function createSession(session) {
|
||||
const sessionId = randomId('sess');
|
||||
sessions.set(sessionId, {
|
||||
...session,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function getSession(sessionId) {
|
||||
return sessions.get(sessionId) || null;
|
||||
}
|
||||
|
||||
export function createSearch({ raceId, user, selfieName, returnUrl, lang }) {
|
||||
const searchId = randomId('search');
|
||||
searches.set(searchId, {
|
||||
id: searchId,
|
||||
raceId,
|
||||
user,
|
||||
selfieName,
|
||||
returnUrl,
|
||||
lang,
|
||||
status: 'processing',
|
||||
createdAt: Date.now(),
|
||||
completedAt: null,
|
||||
resultId: null,
|
||||
matches: []
|
||||
});
|
||||
return searches.get(searchId);
|
||||
}
|
||||
|
||||
export function getSearch(searchId) {
|
||||
return searches.get(searchId) || null;
|
||||
}
|
||||
|
||||
export function completeSearch(searchId) {
|
||||
const search = searches.get(searchId);
|
||||
if (!search) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const race = mockCatalog[search.raceId];
|
||||
const matches = (race?.photos || []).slice(0, Math.min(4, race?.photos?.length || 0));
|
||||
const resultId = randomId('result');
|
||||
|
||||
results.set(resultId, {
|
||||
id: resultId,
|
||||
raceId: search.raceId,
|
||||
raceName: race?.name || search.raceId,
|
||||
userId: search.user.id,
|
||||
returnUrl: search.returnUrl,
|
||||
lang: search.lang,
|
||||
matches,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
const completed = {
|
||||
...search,
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
resultId,
|
||||
matches
|
||||
};
|
||||
|
||||
searches.set(searchId, completed);
|
||||
return completed;
|
||||
}
|
||||
|
||||
export function getResult(resultId) {
|
||||
return results.get(resultId) || null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue