diff --git a/src/app.js b/src/app.js index a5f3dae..248256b 100644 --- a/src/app.js +++ b/src/app.js @@ -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); diff --git a/src/controllers/eventPhoto.controller.js b/src/controllers/eventPhoto.controller.js new file mode 100644 index 0000000..0dfe5e8 --- /dev/null +++ b/src/controllers/eventPhoto.controller.js @@ -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} 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, +}; + diff --git a/src/lambda/README.md b/src/lambda/README.md new file mode 100644 index 0000000..0972b37 --- /dev/null +++ b/src/lambda/README.md @@ -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. diff --git a/src/lambda/eventPhotoIndex.js b/src/lambda/eventPhotoIndex.js new file mode 100644 index 0000000..c9d8e6d --- /dev/null +++ b/src/lambda/eventPhotoIndex.js @@ -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 }; +}; + diff --git a/src/models/EventPhoto.js b/src/models/EventPhoto.js new file mode 100644 index 0000000..57d9ae9 --- /dev/null +++ b/src/models/EventPhoto.js @@ -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); + diff --git a/src/routes/eventPhoto.routes.js b/src/routes/eventPhoto.routes.js new file mode 100644 index 0000000..9a40664 --- /dev/null +++ b/src/routes/eventPhoto.routes.js @@ -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, + }, + }, +]; + diff --git a/src/services/rekognitionService.js b/src/services/rekognitionService.js new file mode 100644 index 0000000..66c16f1 --- /dev/null +++ b/src/services/rekognitionService.js @@ -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} 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, +}; + diff --git a/src/utils/s3EventPhotos.js b/src/utils/s3EventPhotos.js new file mode 100644 index 0000000..a21f376 --- /dev/null +++ b/src/utils/s3EventPhotos.js @@ -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, +}; +