Add event photo search

This commit is contained in:
Tanuja Pansare 2026-08-04 17:15:26 +05:30
parent 655cf98579
commit f74ac6913f
8 changed files with 1412 additions and 0 deletions

View File

@ -46,6 +46,7 @@ const conciergeBotRoutes = require('./routes/conciergeBot.routes');
const publicConciergeRoutes = require('./routes/publicConcierge.routes');
const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes');
const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
const eventPhotoRoutes = require('./routes/eventPhoto.routes');
const createServer = async () => {
/** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */
@ -313,6 +314,7 @@ const createServer = async () => {
server.route(eventTicketRoutes);
server.route(ticketManualRoutes);
server.route(eventTicketingManualRoutes);
server.route(eventPhotoRoutes);
server.route(conciergeBotRoutes);
server.route(attendanceRoutes);
server.route(checkinsRoutes);

View File

@ -0,0 +1,449 @@
const mongoose = require('mongoose');
const EventPhoto = require('../models/EventPhoto');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const {
uploadEventPhotoToS3,
deleteEventPhotoFromS3,
} = require('../utils/s3EventPhotos');
const rekognitionService = require('../services/rekognitionService');
const { userOrgId } = require('../services/acl.service');
function toOid(v) {
if (!v) return null;
if (v instanceof mongoose.Types.ObjectId) return v;
if (!mongoose.Types.ObjectId.isValid(String(v))) return null;
return new mongoose.Types.ObjectId(String(v));
}
/**
* Extract all uploaded files from the Hapi multipart payload.
* Hapi can deliver either a single file object or an array of file objects
* for the same field name ('files').
*/
// function extractUploadFiles(payload) {
// const raw = payload.files;
// if (!raw) return [];
// // Normalize to array
// const items = Array.isArray(raw) ? raw : [raw];
// function getMime(f) {
// if (f.hapi?.headers?.['content-type']) return f.hapi.headers['content-type'];
// if (f.headers?.['content-type']) return f.headers['content-type'];
// if (payload.mimeType) return payload.mimeType;
// return 'application/octet-stream';
// }
// function getOrigName(f) {
// if (f.hapi?.filename) return f.hapi.filename;
// if (f.filename) return f.filename;
// return 'photo';
// }
// const results = [];
// for (const file of items) {
// let buffer = null;
// if (Buffer.isBuffer(file)) {
// buffer = file;async function streamToBuffer(stream) {
// const chunks = [];
// for await (const chunk of stream) {
// chunks.push(chunk);
// }
// return Buffer.concat(chunks);
// }
// } else if (file && file.payload && Buffer.isBuffer(file.payload)) {
// buffer = file.payload;
// } else if (file && file._data && Buffer.isBuffer(file._data)) {
// buffer = file._data;
// }
// if (buffer) {
// console.log("file:", file);
// console.log("file.hapi:", file.hapi);
// console.log("file.headers:", file.headers);
// console.log("Detected MIME:", getMime(file));
// console.log("Original Name:", getOrigName(file));
// results.push({
// buffer,
// originalName: getOrigName(file),
// mimeType: getMime(file),
// });
// }
// const buffer = await streamToBuffer(file);
// results.push({
// buffer,
// originalName: file.hapi.filename,
// mimeType: file.hapi.headers['content-type'],
// });
// }
// return results;
// }
async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function extractUploadFiles(payload) {
const raw = payload.files;
if (!raw) return [];
const items = Array.isArray(raw) ? raw : [raw];
const results = [];
for (const file of items) {
const buffer = await streamToBuffer(file);
results.push({
buffer,
originalName: file.hapi.filename,
mimeType: file.hapi.headers["content-type"],
});
}
return results;
}
async function assertEventOrgAccess(eventId, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return false;
return event.organization?.toString() === oid?.toString();
}
/**
* Run Rekognition IndexFaces for a freshly created EventPhoto (fire-and-forget).
*
* Invoked after the S3 upload and MongoDB document creation succeed, so the
* upload API response stays fast and unchanged. Failures are logged and the
* document keeps embedding_generated = false so indexing can be retried later.
* The uploaded image is deliberately NOT deleted on Rekognition errors.
*
* @param {import('mongoose').Model} photo - the created EventPhoto document
* @returns {Promise<void>} never rejects
*/
async function runRekognitionIndexing(photo) {
try {
await rekognitionService.indexPhoto(photo);
} catch (err) {
console.error(
`[eventPhoto] Rekognition indexing failed for photo ${photo?._id} ` +
`(event=${photo?.event}, s3Key=${photo?.s3Key}). Image kept; ` +
'embedding_generated stays false; indexing can be retried later.',
err?.name,
err?.message,
);
}
}
const listEventPhotos = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId } = request.params;
if (!user) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const orgAccess = await assertEventOrgAccess(eventId, user);
if (!orgAccess) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const photos = await EventPhoto.find({ event: eventId })
.populate('uploadedBy', 'firstName lastName')
.sort({ createdAt: -1 })
.lean();
const out = photos.map((p) => ({
_id: String(p._id),
event: String(p.event),
organization: String(p.organization),
uploadedBy: p.uploadedBy
? (p.uploadedBy.firstName || p.uploadedBy.lastName
? `${p.uploadedBy.firstName || ''} ${p.uploadedBy.lastName || ''}`.trim()
: String(p.uploadedBy._id || p.uploadedBy))
: null,
fileName: p.fileName,
originalFileName: p.originalFileName,
url: p.url,
s3Key: p.s3Key,
mimeType: p.mimeType,
size: p.size,
embedding_generated: p.embedding_generated,
uploadedAt: p.createdAt,
}));
return h.response(success({ photos: out }));
};
const uploadEventPhotos = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId } = request.params;
const payload = request.payload || {};
if (!user) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const orgAccess = await assertEventOrgAccess(eventId, user);
if (!orgAccess) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const files = await extractUploadFiles(payload);
if (files.length === 0) {
return h
.response(error('VALIDATION_ERROR', 'No files received. Use multipart/form-data with a binary field named "files".'))
.code(400);
}
const allowedImageTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/gif', 'image/webp', 'image/bmp'];
const orgId = user.organization?._id || user.organization;
const uploadPromises = files.map(async (extracted) => {
const mime = extracted.mimeType || 'application/octet-stream';
if (mime !== 'application/octet-stream' && !allowedImageTypes.includes(mime)) {
return null; // skip unsupported types
}
const originalFileName = extracted.originalName;
const sanitizedOriginal = String(originalFileName || 'photo').replace(/[^a-zA-Z0-9._-]/g, '_');
const storedFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${sanitizedOriginal}`;
const uploaded = await uploadEventPhotoToS3({
buffer: extracted.buffer,
contentType: mime,
eventId,
filename: storedFilename,
});
const size = extracted.buffer.length;
const photo = await EventPhoto.create({
event: eventId,
organization: orgId,
uploadedBy: user._id,
fileName: storedFilename,
originalFileName,
url: uploaded.url,
s3Key: uploaded.s3Key,
mimeType: mime,
size,
embedding_generated: false,
});
// Direct backend integration: index the photo with Amazon Rekognition
// immediately after the S3 upload and doc creation succeed. It runs without
// blocking the HTTP response so the API contract stays unchanged.
runRekognitionIndexing(photo);
return { ...photo.toObject(), _id: String(photo._id) };
});
const results = await Promise.all(uploadPromises);
console.log(results);
const created = results.filter(Boolean);
return h.response(success({ photos: created }, `${created.length} photo(s) uploaded`, 201)).code(201);
};
/**
* Search event photos by a query image using Amazon Rekognition.
*
* Flow:
* 1. Validate auth + org access.
* 2. Extract the query image from the multipart payload.
* 3. Upload it to a temporary S3 key.
* 4. Call Rekognition SearchFacesByImage (CollectionId = eventId).
* 5. Resolve matched faceIds to EventPhoto documents (deduped by photo).
* 6. Delete the temporary query image from S3 (always, in a finally block).
*
* Response:
* { matchCount, matches: [{ ...photo, similarity, matchedFace }] }
*/
const searchEventPhotos = async (request, h) => {
console.log("===== SEARCH API CALLED =====");
const user = request.auth.credentials?.user;
const { eventId } = request.params;
if (!user) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const orgAccess = await assertEventOrgAccess(eventId, user);
if (!orgAccess) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
if (!mongoose.Types.ObjectId.isValid(String(eventId))) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
const files = await extractUploadFiles(request.payload || {});
if (files.length === 0) {
return h
.response(error('VALIDATION_ERROR', 'No query image received. Use multipart/form-data with a binary field named "files".'))
.code(400);
}
const query = files[0];
const mime = query.mimeType || 'application/octet-stream';
const allowedImageTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/gif', 'image/webp', 'image/bmp'];
if (mime !== 'application/octet-stream' && !allowedImageTypes.includes(mime)) {
return h.response(error('VALIDATION_ERROR', 'Unsupported query image type')).code(400);
}
// Temporary query image key (namespaced so it never collides with real photos).
const tempFilename = `search-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let tempS3Key = null;
try {
const uploaded = await uploadEventPhotoToS3({
buffer: query.buffer,
contentType: mime,
eventId: `search-tmp/${eventId}`,
filename: tempFilename,
});
tempS3Key = uploaded.s3Key;
const { collectionExists, matches } = await rekognitionService.searchPhoto(eventId, {
s3Key: tempS3Key,
bucket: undefined,
faceMatchThreshold: 50,
maxFaces: 100,
});
if (!collectionExists) {
return h.response(success({ collectionExists: false, matchCount: 0, matches: [] }, 'No collection exists for this event yet')).code(200);
}
if (!matches || matches.length === 0) {
return h.response(success({ collectionExists: true, matchCount: 0, matches: [] }, 'No matches found')).code(200);
}
const faceIds = matches.map((m) => m.faceId).filter(Boolean);
const photos = await EventPhoto.find({
event: eventId,
'faces.faceId': { $in: faceIds },
}).lean();
console.log("FaceIds:", faceIds);
console.log("Photos found:", photos.length);
// Build a faceId -> photo lookup for deduplication.
const photoByFaceId = new Map();
for (const photo of photos) {
for (const face of photo.faces || []) {
if (face && face.faceId) {
photoByFaceId.set(face.faceId, photo);
}
}
}
// Dedupe: keep the highest similarity per distinct photo.
const bestByPhoto = new Map();
for (const match of matches) {
const photo = photoByFaceId.get(match.faceId);
if (!photo) continue;
const existing = bestByPhoto.get(String(photo._id));
if (!existing || match.similarity > existing.similarity) {
bestByPhoto.set(String(photo._id), {
photo,
similarity: match.similarity,
matchedFace: match,
});
}
}
const results = Array.from(bestByPhoto.values()).map(({ photo, similarity, matchedFace }) => ({
_id: String(photo._id),
event: String(photo.event),
organization: String(photo.organization),
uploadedBy: photo.uploadedBy ? String(photo.uploadedBy) : null,
fileName: photo.fileName,
originalFileName: photo.originalFileName,
url: photo.url,
s3Key: photo.s3Key,
mimeType: photo.mimeType,
size: photo.size,
embedding_generated: photo.embedding_generated,
uploadedAt: photo.createdAt,
similarity,
matchedFace: {
faceId: matchedFace.faceId,
similarity: matchedFace.similarity,
boundingBox: matchedFace.boundingBox,
},
})).sort((a, b) => b.similarity - a.similarity);
return h.response(success({ collectionExists: true, matchCount: results.length, matches: results }, `${results.length} photo(s) matched`)).code(200);
} catch (err) {
console.error('[eventPhoto] Find photos search failed:', err?.name, err?.message);
if (err && (err.name === 'ResourceNotFoundException' || err.code === 'ResourceNotFoundException')) {
return h.response(success({ collectionExists: false, matchCount: 0, matches: [] }, 'No collection exists for this event yet')).code(200);
}
return h.response(error('REKOGNITION_ERROR', 'Photo search failed. Please try again later.')).code(500);
} finally {
// Always clean up the temporary query image, even on error.
if (tempS3Key) {
try {
await deleteEventPhotoFromS3({ s3Key: tempS3Key });
} catch (cleanupErr) {
console.error(`[eventPhoto] Failed to delete temp query image ${tempS3Key}:`, cleanupErr?.message);
}
}
}
};
const deleteEventPhoto = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId, photoId } = request.params;
if (!user) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const orgAccess = await assertEventOrgAccess(eventId, user);
if (!orgAccess) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
if (!mongoose.Types.ObjectId.isValid(String(photoId))) {
return h.response(error('NOT_FOUND', 'Photo not found')).code(404);
}
const photo = await EventPhoto.findOne({
_id: photoId,
event: eventId,
}).lean();
if (!photo) {
return h.response(error('NOT_FOUND', 'Photo not found')).code(404);
}
if (photo.s3Key) {
await deleteEventPhotoFromS3({ s3Key: photo.s3Key });
}
await EventPhoto.deleteOne({ _id: photoId });
return h.response(success(null, 'Photo deleted', 200));
};
module.exports = {
listEventPhotos,
uploadEventPhotos,
searchEventPhotos,
deleteEventPhoto,
};

132
src/lambda/README.md Normal file
View File

@ -0,0 +1,132 @@
# Event Photo Rekognition Indexer (AWS Lambda)
File: `backend/src/lambda/eventPhotoIndex.js`
This is a **standalone AWS Lambda** function that is triggered by S3 `ObjectCreated`
events on the event-photos bucket. It indexes detected faces with AWS Rekognition
`IndexFaces` and stores the results on the matching `EventPhoto` MongoDB document.
> The Hapi backend does **not** run Rekognition. It only uploads the image to S3 and
> creates the initial `EventPhoto` document (`embedding_generated: false`). The Lambda
> runs out-of-band and flips `embedding_generated` to `true` once processed.
## Flow
1. S3 `ObjectCreated` event fires for `eventbuzz-pictures`.
2. Lambda parses the S3 key → `{eventId}/{filename}` → extracts `eventId`.
3. Lambda finds the `EventPhoto` doc by `{ event: eventId, s3Key: key }`.
4. If `embedding_generated === true`, it skips (already processed).
5. Ensures a Rekognition collection exists (`CollectionId = eventId`), creating it if needed.
6. Calls `IndexFaces` on the S3 object, storing `{ faceId, confidence, boundingBox }`
per detected face into the photo's `faces` array.
7. Sets `embedding_generated: true`, `collectionId`, `indexedAt`.
Because the Rekognition collection id **is the event id**, face search is scoped per
event (e.g. use `SearchFacesByImage` with `CollectionId = eventId`).
## EventPhoto schema additions
- `collectionId` (String) — the Rekognition collection the photo's faces were indexed into.
- `indexedAt` (Date) — last successful (or deterministically skipped) index time.
- `faces` (Array of `{ faceId, confidence, boundingBox: { width, height, left, top } }`).
`embedding_generated` (Boolean) is reused as the "processed" flag:
- `true` + faces populated → faces were detected and indexed.
- `true` + `faces: []` → no faces found, or image was not indexable (marked processed so it
is never reprocessed).
## Deployment
The handler must be a standalone Node.js bundle that includes the backend source and the
required `node_modules` (at minimum `mongoose` and `@aws-sdk/client-rekognition`). The
easiest approach is to zip the `backend` directory (or build a Lambda package from it).
```
cd backend
zip -r ../eventPhotoIndex-lambda.zip . -x "node_modules/.cache/*"
```
Runtime: **Node.js 20.x** (matches `engines.node` in `backend/package.json`).
### Required environment variables
| Variable | Purpose |
|----------|----------|
| `MONGODB_URI` | MongoDB connection string (same as the backend). |
| `AWS_EVENT_PICTURES_BUCKET` | S3 bucket (default `eventbuzz-pictures`). |
| `AWS_DEFAULT_REGION` | Region (default `ap-south-1`). |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Optional if using a Lambda execution role. |
> Prefer assigning an IAM **execution role** to the Lambda over static keys.
### IAM role permissions
The Lambda's execution role needs at least:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::eventbuzz-pictures/*"
},
{
"Effect": "Allow",
"Action": [
"rekognition:IndexFaces",
"rekognition:CreateCollection",
"rekognition:DescribeCollection"
],
"Resource": "*"
}
]
}
```
### S3 trigger
Create an S3 bucket notification on **`eventbuzz-pictures`**:
- Event type: `s3:ObjectCreated:*`
- Destination: the Lambda function.
## Local testing
The handler is a normal Node module. To sanity-check it loads:
```bash
cd backend
node -e "const m=require('./src/lambda/eventPhotoIndex.js'); console.log(typeof m.handler)"
```
To invoke locally with a mock S3 event, construct an event like:
```js
{
Records: [
{
s3: {
bucket: { name: 'eventbuzz-pictures' },
object: { key: '641f1a2b3c4d5e6f7a8b9c0d/1720000000000-abcd-photo.jpg' },
},
},
],
}
```
and call `handler(event, { callbackWaitsForEmptyEventLoop: false })`.
## Retry / error handling
- **Transient errors** (Rekognition throttling, Mongo timeouts, etc.) are rethrown so
Lambda returns an error and S3 retries the invocation.
- **Deterministic errors** (`InvalidImageFormatException`, `InvalidParameterException`,
`ImageTooLargeException`, `InvalidS3ObjectException`, `AccessDeniedException`) are
swallowed and the photo is marked `embedding_generated: true` with `faces: []` so it
is never retried.

View File

@ -0,0 +1,305 @@
/**
* EventBuzz Event Photo Rekognition Indexer (AWS Lambda)
*
* Trigger: S3 ObjectCreated (e.g. s3:ObjectCreated:*)
* Bucket: AWS_EVENT_PICTURES_BUCKET (default: eventbuzz-pictures)
*
* Responsibilities:
* 1. Parse the S3 event -> bucket + key.
* 2. Extract the eventId from the photo key. Keys are built by
* backend/src/utils/s3EventPhotos.js using the pattern {eventId}/{filename}.
* 3. Look up the EventPhoto doc in MongoDB by s3Key.
* 4. Skip images already processed (embedding_generated === true).
* 5. Ensure a Rekognition collection exists (CollectionId = eventId).
* 6. Run IndexFaces on the S3 object and store detected faces
* ({ faceId, confidence, boundingBox }) on the EventPhoto doc.
* 7. Set embedding_generated = true (both when faces are detected AND when
* no faces are found or the image is unsupported, so it is never reprocessed).
*
* Deployment note:
* This handler must be bundled together with the backend source and the
* backend `node_modules` (at minimum: mongoose and @aws-sdk/client-rekognition).
* See README.md in this directory for IAM/S3-trigger setup.
*
* Runtime: Node.js 20.x (use the same AWS SDK v3 "@aws-sdk/*" packages as backend).
*/
const mongoose = require('mongoose');
const {
RekognitionClient,
IndexFacesCommand,
CreateCollectionCommand,
DescribeCollectionCommand,
} = require('@aws-sdk/client-rekognition');
const EventPhoto = require('../models/EventPhoto');
// ---------------------------------------------------------------------------
// Env helpers (kept consistent with backend/src/utils/s3EventPhotos.js)
// ---------------------------------------------------------------------------
function getEnv(name, fallback) {
const v = process.env[name];
if (v == null || String(v).trim() === '') return fallback;
return String(v).trim();
}
function getMongoUri() {
const uri = getEnv('MONGODB_URI');
if (!uri) throw new Error('MONGODB_URI is not configured');
return uri;
}
let connectPromise = null;
function connectDB() {
if (mongoose.connection.readyState === 1) {
return Promise.resolve(mongoose.connection);
}
if (connectPromise) {
return connectPromise;
}
connectPromise = mongoose
.connect(getMongoUri(), {
maxPoolSize: 2,
serverSelectionTimeoutMS: 15000,
socketTimeoutMS: 30000,
})
.then((conn) => {
console.log(`[eventPhotoIndex] MongoDB connected: ${conn.connection.host}`);
return conn;
})
.catch((err) => {
connectPromise = null;
throw err;
});
return connectPromise;
}
let cachedRekognitionClient = null;
function getRekognitionClient() {
if (cachedRekognitionClient) return cachedRekognitionClient;
const region = getEnv('AWS_DEFAULT_REGION', 'ap-south-1');
const accessKeyId = getEnv('AWS_ACCESS_KEY_ID');
const secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY');
cachedRekognitionClient = new RekognitionClient({
region,
credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined,
maxAttempts: 4,
});
return cachedRekognitionClient;
}
// ---------------------------------------------------------------------------
// Rekognition helpers
// ---------------------------------------------------------------------------
/**
* Collection id is the event id (extracted from the S3 key). Rekognition
* collection ids accept [a-zA-Z0-9_.\-], so a Mongo ObjectId is safe.
*/
function ensureCollection(collectionId) {
const client = getRekognitionClient();
return client
.send(new DescribeCollectionCommand({ CollectionId: collectionId }))
.then(() => collectionId)
.catch(async (err) => {
if (err && (err.name === 'ResourceNotFoundException' || err.code === 'ResourceNotFoundException')) {
console.log(`[eventPhotoIndex] Creating collection ${collectionId}`);
await client.send(new CreateCollectionCommand({ CollectionId: collectionId }));
return collectionId;
}
throw err;
});
}
/**
* Map a Rekognition IndexFaces response into the shape stored on EventPhoto:
* [{ faceId, confidence, boundingBox: { width, height, left, top } }]
*/
function mapFaceRecords(faceRecords) {
if (!Array.isArray(faceRecords)) return [];
return faceRecords
.filter((face) => face && face.Face && face.Face.FaceId)
.map((face) => {
const b = (face.Face.BoundingBox || {});
return {
faceId: face.Face.FaceId,
confidence: face.Face.Confidence != null ? face.Face.Confidence : null,
boundingBox: {
width: b.Width != null ? b.Width : null,
height: b.Height != null ? b.Height : null,
left: b.Left != null ? b.Left : null,
top: b.Top != null ? b.Top : null,
},
};
});
}
/**
* Errors that are deterministic (image will never be indexable). Mark the photo
* as processed so the S3 event never retries it.
*/
function isDeterministicIndexError(err) {
if (!err || !err.name) return false;
return [
'InvalidImageFormatException',
'InvalidParameterException',
'ImageTooLargeException',
'AccessDeniedException',
'InvalidS3ObjectException',
].includes(err.name);
}
// ---------------------------------------------------------------------------
// S3 event parsing
// ---------------------------------------------------------------------------
function extractS3Records(event) {
const records = [];
const raw = event && event.Records;
if (!Array.isArray(raw)) return records;
for (const record of raw) {
const s3 = record && record.s3;
const bucket = s3 && s3.bucket && s3.bucket.name;
const key = s3 && s3.object && s3.object.key;
if (bucket && key) {
records.push({
bucket,
key: decodeURIComponent(key.replace(/\+/g, ' ')),
});
}
}
return records;
}
function extractEventIdFromKey(key) {
// Pattern: {eventId}/{filename}
const parts = String(key || '').split('/');
return parts[0] || null;
}
// ---------------------------------------------------------------------------
// Core indexing logic
// ---------------------------------------------------------------------------
async function indexPhoto({ bucket, key }) {
const eventId = extractEventIdFromKey(key);
if (!eventId || !mongoose.Types.ObjectId.isValid(eventId)) {
console.log(`[eventPhotoIndex] Skipping key "${key}" — no valid event id / not an event photo key.`);
return { skipped: true, reason: 'invalid_key' };
}
await connectDB();
const photo = await EventPhoto.findOne({ event: eventId, s3Key: key }).lean();
if (!photo) {
console.log(`[eventPhotoIndex] No EventPhoto doc found for s3Key "${key}". Skipping.`);
return { skipped: true, reason: 'no_document' };
}
if (photo.embedding_generated === true) {
console.log(`[eventPhotoIndex] Photo ${photo._id} already processed. Skipping.`);
return { skipped: true, reason: 'already_processed' };
}
const collectionId = eventId;
await ensureCollection(collectionId);
const externalImageId = String(photo._id); // Mongo ObjectId: valid ExternalImageId (hex)
const response = await getRekognitionClient().send(
new IndexFacesCommand({
CollectionId: collectionId,
Image: {
S3Object: {
Bucket: bucket,
Name: key,
},
},
ExternalImageId: externalImageId,
MaxFaces: 100,
QualityFilter: 'AUTO',
DetectionAttributes: [],
}),
);
const faces = mapFaceRecords(response.FaceRecords || []);
// IndexFaces registers the faces in the collection, and the collection id is
// the event id, so face matching (SearchFacesByImage) is scoped per event.
await EventPhoto.findByIdAndUpdate(
photo._id,
{
$set: {
embedding_generated: true,
collectionId,
faces,
// record when the index was last completed
indexedAt: new Date(),
},
},
{ new: true },
);
console.log(
`[eventPhotoIndex] Indexed photo ${photo._id} (faces: ${faces.length}) collection=${collectionId}`,
);
return { indexed: true, photoId: String(photo._id), facesFound: faces.length };
}
// ---------------------------------------------------------------------------
// Lambda handler
// ---------------------------------------------------------------------------
exports.handler = async (event, context) => {
// Give the handler enough time to finish when processing a single photo
// before Lambda freezes the runtime.
if (context && typeof context.callbackWaitsForEmptyEventLoop === 'boolean') {
context.callbackWaitsForEmptyEventLoop = false;
}
console.log('[eventPhotoIndex] event', JSON.stringify({ records: event && event.Records ? event.Records.length : 0 }));
const records = extractS3Records(event);
if (records.length === 0) {
console.log('[eventPhotoIndex] No valid S3 records. Skipping.');
return { processed: 0 };
}
const results = [];
for (const record of records) {
try {
results.push(await indexPhoto(record));
} catch (err) {
if (isDeterministicIndexError(err)) {
console.error(`[eventPhotoIndex] Deterministic error for ${record.key}:`, err.name, err.message);
// Mark as processed so S3 never retries an unprocessable image.
try {
await connectDB();
const eventId = extractEventIdFromKey(record.key);
if (eventId && mongoose.Types.ObjectId.isValid(eventId)) {
await EventPhoto.updateOne(
{ event: eventId, s3Key: record.key, embedding_generated: { $ne: true } },
{ $set: { embedding_generated: true, faces: [], indexedAt: new Date() } },
);
}
} catch (dbErr) {
console.error('[eventPhotoIndex] Failed to mark deterministic error as processed:', dbErr.message);
}
results.push({ skipped: true, reason: 'deterministic_error', key: record.key });
continue;
}
// Transient error (throttling, Mongo timeout, etc.) -> rethrow so Lambda
// returns an error and the S3 trigger retries the invocation.
console.error(`[eventPhotoIndex] Transient error for ${record.key}:`, err.name, err.message);
throw err;
}
}
return { processed: results.length, results };
};

47
src/models/EventPhoto.js Normal file
View File

@ -0,0 +1,47 @@
const mongoose = require('mongoose');
const EventPhotoSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true },
uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false },
fileName: { type: String, required: true },
originalFileName: { type: String, required: true },
url: { type: String, required: true },
s3Key: { type: String, required: true },
mimeType: { type: String, required: false },
size: { type: Number, required: false },
embedding_generated: { type: Boolean, default: false },
// Rekognition collection id (the event id) that this photo's faces were indexed into.
collectionId: { type: String, required: false },
// Timestamp of the last successful (or deterministically skipped) index attempt.
indexedAt: { type: Date, required: false },
// Detected faces stored after Rekognition IndexFaces runs (via the Lambda).
faces: {
type: [
{
faceId: { type: String, required: false },
confidence: { type: Number, required: false },
boundingBox: {
type: {
width: { type: Number, required: false },
height: { type: Number, required: false },
left: { type: Number, required: false },
top: { type: Number, required: false },
},
default: undefined,
},
},
],
default: [],
},
},
{
timestamps: true,
},
);
EventPhotoSchema.index({ event: 1, createdAt: -1 });
module.exports = mongoose.model('EventPhoto', EventPhotoSchema);

View File

@ -0,0 +1,67 @@
const Joi = require('joi');
const controller = require('../controllers/eventPhoto.controller');
const searchPayload = Joi.object({
files: Joi.any().required(),
}).required();
const uploadPayload = Joi.object({
files: Joi.any().required(),
mimeType: Joi.string().allow('', null),
}).required();
module.exports = [
{
method: 'GET',
path: '/api/events/{eventId}/photos',
options: {
auth: 'jwt',
handler: controller.listEventPhotos,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/photos',
options: {
auth: 'jwt',
validate: {
payload: uploadPayload,
},
payload: {
maxBytes: 100 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: true,
allow: 'multipart/form-data',
},
handler: controller.uploadEventPhotos,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/photos/search',
options: {
auth: 'jwt',
validate: {
payload: searchPayload,
},
payload: {
maxBytes: 100 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: true,
allow: 'multipart/form-data',
},
handler: controller.searchEventPhotos,
},
},
{
method: 'DELETE',
path: '/api/events/{eventId}/photos/{photoId}',
options: {
auth: 'jwt',
handler: controller.deleteEventPhoto,
},
},
];

View File

@ -0,0 +1,299 @@
/**
* EventBuzz — Amazon Rekognition face-indexing service for event photos.
*
* Replaces the standalone AWS Lambda path (backend/src/lambda/eventPhotoIndex.js).
* The Hapi backend now calls Rekognition directly, immediately after an event
* photo is uploaded to S3 and persisted in MongoDB.
*
* Collection strategy:
* - CollectionId = eventId (the Mongo ObjectId of the Event).
* - Faces are scoped per event, so later face matching (e.g. SearchFacesByImage
* with CollectionId = eventId) is naturally scoped to a single event.
*
* Environment variables (kept consistent with backend/src/utils/s3EventPhotos.js):
* - AWS_EVENT_PICTURES_BUCKET S3 bucket (default: eventbuzz-pictures)
* - AWS_DEFAULT_REGION AWS region (default: ap-south-1)
* - AWS_ACCESS_KEY_ID optional, assumes an IAM role/permissions in prod
* - AWS_SECRET_ACCESS_KEY optional, assumes an IAM role/permissions in prod
* - AWS_SESSION_TOKEN optional, for temporary credentials
*/
const {
RekognitionClient,
IndexFacesCommand,
CreateCollectionCommand,
DescribeCollectionCommand,
SearchFacesByImageCommand,
} = require('@aws-sdk/client-rekognition');
const EventPhoto = require('../models/EventPhoto');
const DEFAULT_BUCKET = 'eventbuzz-pictures';
const DEFAULT_REGION = 'ap-south-1';
function getEnv(name, fallback) {
const v = process.env[name];
if (v == null || String(v).trim() === '') return fallback;
return String(v).trim();
}
let cachedRekognitionClient = null;
/**
* Lazily build (and cache) the AWS SDK v3 Rekognition client.
*
* @returns {RekognitionClient}
*/
function getRekognitionClient() {
if (cachedRekognitionClient) return cachedRekognitionClient;
const region = getEnv('AWS_DEFAULT_REGION', DEFAULT_REGION);
const accessKeyId = getEnv('AWS_ACCESS_KEY_ID');
const secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY');
const sessionToken = getEnv('AWS_SESSION_TOKEN');
cachedRekognitionClient = new RekognitionClient({
region,
credentials:
accessKeyId && secretAccessKey
? { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }
: undefined,
maxAttempts: 4,
});
return cachedRekognitionClient;
}
/**
* Map a Rekognition IndexFaces response into the shape persisted on EventPhoto:
* [{ faceId, confidence, boundingBox: { width, height, left, top } }]
*/
function mapFaceRecords(faceRecords) {
if (!Array.isArray(faceRecords)) return [];
return faceRecords
.filter((face) => face && face.Face && face.Face.FaceId)
.map((face) => {
const b = face.Face.BoundingBox || {};
return {
faceId: face.Face.FaceId,
confidence: face.Face.Confidence != null ? face.Face.Confidence : null,
boundingBox: {
width: b.Width != null ? b.Width : null,
height: b.Height != null ? b.Height : null,
left: b.Left != null ? b.Left : null,
top: b.Top != null ? b.Top : null,
},
};
});
}
/**
* Ensure a Rekognition face collection exists for an event.
*
* If the collection already exists it is reused — it is NOT recreated.
*
* @param {string} eventId - Mongo ObjectId of the Event (used as CollectionId)
* @returns {Promise<string>} the collection id
*/
async function ensureCollection(eventId) {
const collectionId = String(eventId || '').trim();
if (!collectionId) throw new Error('eventId is required to ensure a Rekognition collection');
const client = getRekognitionClient();
try {
await client.send(new DescribeCollectionCommand({ CollectionId: collectionId }));
} catch (err) {
if (err && (err.name === 'ResourceNotFoundException' || err.code === 'ResourceNotFoundException')) {
console.log(`[rekognitionService] Rekognition collection ${collectionId} not found — creating it`);
await client.send(new CreateCollectionCommand({ CollectionId: collectionId }));
} else {
throw err;
}
}
return collectionId;
}
/**
* Index a freshly uploaded event photo with Amazon Rekognition IndexFaces and
* persist the results on the corresponding EventPhoto MongoDB document.
*
* On success the document is updated with:
* - embedding_generated = true
* - collectionId = event id
* - indexedAt = current timestamp
* - faces = [{ faceId, confidence, boundingBox }] ([] when no faces detected)
*
* On error this function THROWS. It does NOT delete the uploaded image and does
* NOT flip embedding_generated, so the photo's document remains available for a
* later re-index retry.
*
* @param {import('mongoose').Model} eventPhotoDocument - created EventPhoto doc (requires _id, event, s3Key)
* @param {object} [options]
* @param {string} [options.bucket] - S3 bucket override (defaults to AWS_EVENT_PICTURES_BUCKET)
* @returns {Promise<{indexed: boolean, photoId: string, facesFound: number, document: object}>}
*/
async function indexPhoto(eventPhotoDocument, options = {}) {
if (!eventPhotoDocument || !eventPhotoDocument._id) {
throw new Error('indexPhoto requires an EventPhoto document with an _id');
}
const photoId = String(eventPhotoDocument._id);
const eventId = String(eventPhotoDocument.event || '').trim();
const s3Key = eventPhotoDocument.s3Key;
if (!eventId) throw new Error(`EventPhoto ${photoId} has no event id; cannot index`);
if (!s3Key) throw new Error(`EventPhoto ${photoId} has no s3Key; cannot index`);
const bucket = options.bucket || getEnv('AWS_EVENT_PICTURES_BUCKET', DEFAULT_BUCKET);
const collectionId = await ensureCollection(eventId);
const response = await getRekognitionClient().send(
new IndexFacesCommand({
CollectionId: collectionId,
Image: {
S3Object: {
Bucket: bucket,
Name: s3Key,
},
},
ExternalImageId: photoId,
MaxFaces: 100,
QualityFilter: 'AUTO',
DetectionAttributes: [],
}),
);
const faces = mapFaceRecords(response.FaceRecords || []);
const updated = await EventPhoto.findByIdAndUpdate(
photoId,
{
$set: {
embedding_generated: true,
collectionId,
faces,
indexedAt: new Date(),
},
},
{ new: true },
);
console.log(
`[rekognitionService] Indexed photo ${photoId} (faces: ${faces.length}) collection=${collectionId} bucket=${bucket}`,
);
return { indexed: true, photoId, facesFound: faces.length, document: updated };
}
/**
* Map a Rekognition SearchFacesByImage response into a normalized list:
* [{ faceId, similarity, boundingBox: { width, height, left, top } }]
*/
function mapFaceMatches(faceMatches) {
if (!Array.isArray(faceMatches)) return [];
return faceMatches
.filter((match) => match && match.Face && match.Face.FaceId)
.map((match) => {
const b = match.Face.BoundingBox || {};
return {
faceId: match.Face.FaceId,
similarity: match.Similarity != null ? match.Similarity : null,
boundingBox: {
width: b.Width != null ? b.Width : null,
height: b.Height != null ? b.Height : null,
left: b.Left != null ? b.Left : null,
top: b.Top != null ? b.Top : null,
},
};
});
}
/**
* Search an event's Rekognition collection for faces matching a query image.
*
* Returns the raw matched face ids + similarity scores + bounding boxes. The
* caller is responsible for resolving those face ids to EventPhoto documents
* and for deleting the temporary query image from S3.
*
* Collection handling:
* - If the collection does not exist, this returns `{ collectionExists: false,
* matches: [] }` rather than throwing, so the caller can respond gracefully.
* - If the query image contains no faces, Rekognition throws
* InvalidParameterException; we normalize that to an empty match set.
*
* @param {string} eventId - Mongo ObjectId of the Event (used as CollectionId)
* @param {object} options
* @param {string} options.s3Key - S3 key of the uploaded query image
* @param {string} [options.bucket] - S3 bucket override (defaults to AWS_EVENT_PICTURES_BUCKET)
* @param {number} [options.faceMatchThreshold] - similarity threshold (default 80)
* @param {number} [options.maxFaces] - max faces to return (default 100)
* @returns {Promise<{collectionExists: boolean, matches: Array}>}
*/
async function searchPhoto(eventId, options = {}) {
const collectionId = String(eventId || '').trim();
const s3Key = options.s3Key;
if (!collectionId) throw new Error('eventId is required to search a Rekognition collection');
if (!s3Key) throw new Error('s3Key is required to search a Rekognition collection');
const bucket = options.bucket || getEnv('AWS_EVENT_PICTURES_BUCKET', DEFAULT_BUCKET);
const faceMatchThreshold = options.faceMatchThreshold != null ? options.faceMatchThreshold : 80;
const maxFaces = options.maxFaces != null ? options.maxFaces : 100;
const client = getRekognitionClient();
// Verify the collection exists first. Do NOT auto-create on search.
try {
await client.send(new DescribeCollectionCommand({ CollectionId: collectionId }));
} catch (err) {
if (err && (err.name === 'ResourceNotFoundException' || err.code === 'ResourceNotFoundException')) {
console.log(`[rekognitionService] Collection ${collectionId} does not exist for search`);
return { collectionExists: false, matches: [] };
}
throw err;
}
let response;
try {
response = await client.send(
new SearchFacesByImageCommand({
CollectionId: collectionId,
Image: {
S3Object: {
Bucket: bucket,
Name: s3Key,
},
},
FaceMatchThreshold: faceMatchThreshold,
MaxFaces: maxFaces,
}),
);
} catch (err) {
// Rekognition reports "no face detected in the image" as InvalidParameterException.
if (
err &&
(err.name === 'InvalidParameterException' ||
err.code === 'InvalidParameterException' ||
/no face|face.*detect/i.test(String(err.message || '')))
) {
console.log(`[rekognitionService] No face detected in query image for collection ${collectionId}`);
return { collectionExists: true, matches: [] };
}
throw err;
}
const matches = mapFaceMatches(response.FaceMatches || []);
console.log("Face matches:", JSON.stringify(matches, null, 2));
console.log(JSON.stringify(response.FaceMatches, null, 2));
console.log(`[rekognitionService] SearchFacesByImage collection=${collectionId} matches=${matches.length}`);
return { collectionExists: true, matches };
}
module.exports = {
getRekognitionClient,
ensureCollection,
indexPhoto,
searchPhoto,
};

111
src/utils/s3EventPhotos.js Normal file
View File

@ -0,0 +1,111 @@
const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
function getEnv(name, fallback) {
const v = process.env[name];
if (v == null || String(v).trim() === '') return fallback;
return String(v).trim();
}
function sanitizeKeySegment(segment) {
const s = String(segment).trim();
return s.replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* Build S3 key for event photos under eventbuzz-pictures bucket.
* Pattern: {eventId}/{filename}
*/
function buildEventPhotoKey({ eventId, filename }) {
const safeEventId = sanitizeKeySegment(eventId || 'unknown-event');
const safeFilename = sanitizeKeySegment(filename || 'file');
return `${safeEventId}/${safeFilename}`;
}
function cloudfrontUrlForKey(key) {
const base = getEnv('AWS_URL_PICTURES', '').replace(/\/+$/, '');
if (!base) return key;
return `${base}/${key}`;
}
let cachedS3Client = null;
function getS3Client() {
if (cachedS3Client) return cachedS3Client;
const region = getEnv('AWS_DEFAULT_REGION', 'ap-south-1');
const accessKeyId = getEnv('AWS_ACCESS_KEY_ID');
const secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY');
cachedS3Client = new S3Client({
region,
credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined,
});
return cachedS3Client;
}
/**
* Upload a photo buffer to the eventbuzz-pictures S3 bucket.
* Returns { s3Key, url }.
*/
async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename }) {
const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured');
const key = buildEventPhotoKey({ eventId, filename });
await getS3Client().send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType || 'application/octet-stream',
}),
);
return { s3Key: key, url: cloudfrontUrlForKey(key) };
}
/**
* Delete a photo from eventbuzz-pictures S3 bucket by key.
*/
async function deleteEventPhotoFromS3({ s3Key }) {
const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
if (!bucket) return;
if (!s3Key) return;
await getS3Client().send(
new DeleteObjectCommand({
Bucket: bucket,
Key: s3Key,
}),
);
}
/**
* Get a photo buffer from eventbuzz-pictures S3 bucket by key.
*/
async function getEventPhotoBufferFromS3(s3Key) {
const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured');
if (!s3Key) throw new Error('s3Key required');
const response = await getS3Client().send(
new GetObjectCommand({
Bucket: bucket,
Key: s3Key,
}),
);
const chunks = [];
for await (const chunk of response.Body) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
module.exports = {
uploadEventPhotoToS3,
deleteEventPhotoFromS3,
getEventPhotoBufferFromS3,
buildEventPhotoKey,
};