Merge branch 'main' of http://192.168.1.200:3000/Stratawiz/EventBuzz_Backend
This commit is contained in:
commit
8cc36b2674
20
src/app.js
20
src/app.js
|
|
@ -48,6 +48,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). */
|
||||
|
|
@ -317,6 +318,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);
|
||||
|
|
@ -410,6 +412,24 @@ const app = {
|
|||
},
|
||||
{ timezone: process.env.CRON_TZ || undefined },
|
||||
);
|
||||
// Automatic photo retention cleanup — runs daily at 02:00.
|
||||
const { runPhotoCleanup } = require('./services/photoCleanupService');
|
||||
cron.schedule(
|
||||
'0 2 * * *',
|
||||
async () => {
|
||||
try {
|
||||
const r = await runPhotoCleanup();
|
||||
if (r.deleted > 0 || r.failed > 0) {
|
||||
console.log(
|
||||
`[cron] photo retention cleanup: processed=${r.processed} deleted=${r.deleted} failed=${r.failed} retentionMonths=${r.retentionMonths}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[cron] photo retention cleanup failed', err);
|
||||
}
|
||||
},
|
||||
{ timezone: process.env.CRON_TZ || undefined },
|
||||
);
|
||||
const { startEmbeddedQueueWorkers } = require('./utils/queueWorkerKick');
|
||||
startEmbeddedQueueWorkers();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* stores `photoRetentionMonths`, which controls how long event photos are kept
|
||||
* before the automatic photo-retention cleanup deletes them.
|
||||
*
|
||||
* A single document (with a fixed, constant `_id`) is used so reads are cheap
|
||||
* and there is never more than one settings row.
|
||||
*/
|
||||
|
||||
const SYSTEM_SETTINGS_ID = new mongoose.Types.ObjectId('000000000000000000000001');
|
||||
|
||||
const SystemSettingsSchema = new mongoose.Schema(
|
||||
{
|
||||
// How many months an event photo is retained before automatic cleanup.
|
||||
photoRetentionMonths: { type: Number, default: 0, min: 0 },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
const SystemSettings = mongoose.model('SystemSettings', SystemSettingsSchema);
|
||||
|
||||
/**
|
||||
* Ensure the singleton settings document exists and return it.
|
||||
*
|
||||
* Lazily creates the document with defaults on first access so the retention
|
||||
* cleanup always has a value to read even before any settings are saved.
|
||||
*
|
||||
* @returns {Promise<import('mongoose').Document>} the singleton settings doc
|
||||
*/
|
||||
async function getSettings() {
|
||||
let settings = await SystemSettings.findById(SYSTEM_SETTINGS_ID);
|
||||
if (!settings) {
|
||||
settings = await SystemSettings.create({ _id: SYSTEM_SETTINGS_ID, photoRetentionMonths: 3 });
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the configured photo retention period in months.
|
||||
*
|
||||
* @returns {Promise<number>} retention in months (defaults to 3)
|
||||
*/
|
||||
async function getPhotoRetentionMonths() {
|
||||
const settings = await getSettings();
|
||||
const n = Number(settings.photoRetentionMonths);
|
||||
if (!Number.isFinite(n) || n < 0) return 3;
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = SystemSettings;
|
||||
module.exports.getSettings = getSettings;
|
||||
module.exports.getPhotoRetentionMonths = getPhotoRetentionMonths;
|
||||
module.exports.SYSTEM_SETTINGS_ID = SYSTEM_SETTINGS_ID;
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
*
|
||||
* Deletes event photos that are older than the configured retention period
|
||||
* (`SystemSettings.photoRetentionMonths`, default 3). For each expired photo it:
|
||||
* 1. Deletes the Rekognition FaceIds from the event's collection (DeleteFaces).
|
||||
* 2. Deletes the S3 object via the existing `deleteEventPhotoFromS3()` helper.
|
||||
* 3. Deletes the MongoDB EventPhoto document.
|
||||
*
|
||||
* The Rekognition collection itself is intentionally NOT deleted — the event may
|
||||
* receive more photos later.
|
||||
*/
|
||||
|
||||
const EventPhoto = require('../models/EventPhoto');
|
||||
const { getPhotoRetentionMonths } = require('../models/SystemSettings');
|
||||
const rekognitionService = require('./rekognitionService');
|
||||
const { deleteEventPhotoFromS3 } = require('../utils/s3EventPhotos');
|
||||
|
||||
/**
|
||||
* Compute the cutoff `Date` before which photos are considered expired.
|
||||
*
|
||||
* @param {number} retentionMonths - retention in months
|
||||
* @returns {Date} cutoff date
|
||||
*/
|
||||
function computeRetentionCutoff(retentionMonths) {
|
||||
const months = Number(retentionMonths);
|
||||
if (!Number.isFinite(months) || months < 0) return new Date(0);
|
||||
if (months === 0) return new Date();
|
||||
const now = new Date();
|
||||
now.setUTCMonth(now.getUTCMonth() - months);
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single expired photo's Rekognition faces, S3 object, and Mongo doc.
|
||||
*
|
||||
* Individual failures do not abort the whole run — each step is attempted and
|
||||
* failures are counted so they can be logged/retried on the next run.
|
||||
*
|
||||
* @param {object} photo - lean EventPhoto document with _id, s3Key, collectionId, faces
|
||||
* @returns {Promise<{ok: boolean, errors: string[]}>}
|
||||
*/
|
||||
async function cleanupPhoto(photo) {
|
||||
const photoId = String(photo._id);
|
||||
const errors = [];
|
||||
const faceIds = (photo.faces || []).map((f) => f && f.faceId).filter(Boolean);
|
||||
|
||||
let canDeleteMongo = true;
|
||||
|
||||
// 1. Delete Rekognition FaceIds
|
||||
if (photo.collectionId && faceIds.length > 0) {
|
||||
try {
|
||||
await rekognitionService.deleteFaces(photo.collectionId, faceIds);
|
||||
} catch (err) {
|
||||
errors.push(`rekognition: ${err?.message || String(err)}`);
|
||||
canDeleteMongo = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete S3 object
|
||||
if (photo.s3Key) {
|
||||
try {
|
||||
await deleteEventPhotoFromS3({ s3Key: photo.s3Key });
|
||||
} catch (err) {
|
||||
errors.push(`s3: ${err?.message || String(err)}`);
|
||||
canDeleteMongo = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete MongoDB document only if previous steps succeeded
|
||||
if (canDeleteMongo) {
|
||||
try {
|
||||
await EventPhoto.deleteOne({ _id: photo._id });
|
||||
} catch (err) {
|
||||
errors.push(`mongo: ${err?.message || String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and clean up all expired event photos.
|
||||
*
|
||||
* @returns {Promise<{deleted: number, failed: number, processed: number, retentionMonths: number}>}
|
||||
*/
|
||||
async function runPhotoCleanup() {
|
||||
const retentionMonths = await getPhotoRetentionMonths();
|
||||
const cutoff = computeRetentionCutoff(retentionMonths);
|
||||
|
||||
const expired = await EventPhoto.find({
|
||||
createdAt: { $lt: cutoff },
|
||||
}).lean();
|
||||
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const photo of expired) {
|
||||
try {
|
||||
const result = await cleanupPhoto(photo);
|
||||
if (result.ok) {
|
||||
deleted += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
console.error(
|
||||
`[photoCleanup] Failed to clean up photo ${photo._id} (${photo.s3Key}): ${result.errors.join('; ')}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(`[photoCleanup] Unexpected error cleaning photo ${photo._id}:`, err && err.message ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[photoCleanup] Retention run complete — retentionMonths=${retentionMonths} cutoff=${cutoff.toISOString()} ` +
|
||||
`processed=${expired.length} deleted=${deleted} failed=${failed}`,
|
||||
);
|
||||
|
||||
return { deleted, failed, processed: expired.length, retentionMonths };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runPhotoCleanup,
|
||||
computeRetentionCutoff,
|
||||
cleanupPhoto,
|
||||
};
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const {
|
||||
RekognitionClient,
|
||||
IndexFacesCommand,
|
||||
CreateCollectionCommand,
|
||||
DescribeCollectionCommand,
|
||||
SearchFacesByImageCommand,
|
||||
DeleteFacesCommand,
|
||||
} = 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific face ids from a Rekognition collection.
|
||||
*
|
||||
* Used by the photo-retention cleanup to remove indexed faces of photos that are
|
||||
* being deleted. Only the given face ids are removed — the collection itself is
|
||||
* intentionally NOT deleted because the event may receive more photos later.
|
||||
*
|
||||
* If there are no face ids to delete, this is a no-op.
|
||||
*
|
||||
* @param {string} collectionId - the Rekognition CollectionId (the event id)
|
||||
* @param {string[]} [faceIds] - array of face ids to delete
|
||||
* @returns {Promise<{deleted: string[], failed: string[]}>}
|
||||
*/
|
||||
async function deleteFaces(collectionId, faceIds = []) {
|
||||
const ids = Array.isArray(faceIds) ? faceIds.filter(Boolean) : [];
|
||||
if (!collectionId || ids.length === 0) {
|
||||
return { deleted: [], failed: [] };
|
||||
}
|
||||
|
||||
const response = await getRekognitionClient().send(
|
||||
new DeleteFacesCommand({
|
||||
CollectionId: String(collectionId),
|
||||
FaceIds: ids,
|
||||
}),
|
||||
);
|
||||
|
||||
const deleted = (response.DeletedFaces || []).filter(Boolean);
|
||||
const failed = ids.filter((id) => !deleted.includes(id));
|
||||
|
||||
console.log(
|
||||
`[rekognitionService] Deleted faces collection=${collectionId} requested=${ids.length} deleted=${deleted.length} failed=${failed.length}`,
|
||||
);
|
||||
|
||||
return { deleted, failed };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRekognitionClient,
|
||||
ensureCollection,
|
||||
indexPhoto,
|
||||
searchPhoto,
|
||||
deleteFaces,
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue