Eventbuzz_Concierge/src/services/identity.service.js

274 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const crypto = require('crypto');
const Participant = require('../models/Participant');
const { sendConciergeOtpEmail } = require('../utils/mail');
const OTP_TTL_MS = 10 * 60 * 1000;
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
function extractEmail(value) {
const match = String(value || '').match(EMAIL_RE);
return match ? match[0].trim().toLowerCase() : '';
}
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase();
}
function isFixedOtpMode() {
return String(process.env.CONCIERGE_OTP_MODE || 'dynamic').toLowerCase() === 'fixed';
}
function generateOtp() {
if (isFixedOtpMode()) {
const fixed = String(process.env.CONCIERGE_FIXED_OTP || '123123').replace(/\D/g, '');
return fixed.padStart(6, '0').slice(-6);
}
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
}
function hashOtp(otp, eventId, email) {
const pepper = process.env.OTP_PEPPER || 'eventbuzz-concierge-otp';
return crypto
.createHash('sha256')
.update(`${pepper}|${otp}|${eventId}|${email}`)
.digest('hex');
}
function isVerified(session) {
return Boolean(session?.participant && session?.identityVerifiedAt);
}
function clearAuthChallenge(session) {
if (typeof session.$unset === 'function') {
session.$unset('authState');
} else {
session.authState = undefined;
}
session.authEmail = null;
session.otpHash = null;
session.otpExpiresAt = null;
}
function clearPending(session) {
session.pendingIntent = null;
session.pendingMessage = null;
session.pendingEntities = null;
}
function clearIdentity(session) {
session.participant = null;
session.identityVerifiedAt = null;
session.identityMethod = null;
clearAuthChallenge(session);
clearPending(session);
}
function membershipForEvent(participant, eventId) {
const eid = String(eventId);
const rows = Array.isArray(participant?.eventMemberships) ? participant.eventMemberships : [];
return rows.find((m) => String(m.event) === eid) || null;
}
const AUTH_CANCEL_ONLY_RE =
/^(please\s+)?(ignore|cancel|skip|stop)(\s+(that|this|it))?([.!]*)?$|^(never mind|nevermind|forget it|not now|no thanks|no thank you|later)([.!]*)?$/i;
function isAuthCancelMessage(text) {
const t = String(text || '').trim();
if (!t || extractEmail(t)) return false;
if (AUTH_CANCEL_ONLY_RE.test(t)) return true;
return /\b(ignore|cancel|skip|never mind|nevermind|forget it|not now|stop asking)\b/i.test(t);
}
function isAuthCancelOnly(text) {
return AUTH_CANCEL_ONLY_RE.test(String(text || '').trim());
}
function looksLikeNewQuestionDuringAuth(text) {
const t = String(text || '').trim();
if (!t || extractEmail(t)) return false;
if (/^\d{4,8}$/.test(t.replace(/\s/g, ''))) return false;
if (t.includes('?')) return true;
return t.split(/\s+/).filter(Boolean).length >= 4;
}
async function beginEmailAuth(session, { intent, message, eventName, entities = null }) {
session.pendingIntent = intent || null;
session.pendingMessage = message || null;
session.pendingEntities = entities && typeof entities === 'object' ? entities : null;
clearAuthChallengeFieldsKeepPending(session);
session.authState = 'awaiting_email';
return {
reply: 'Send the email you registered with, or say cancel.',
classification: null,
usage: { inputTokens: 0, outputTokens: 0 },
modelId: null,
resumed: false,
};
}
function clearAuthChallengeFieldsKeepPending(session) {
session.authEmail = null;
session.otpHash = null;
session.otpExpiresAt = null;
}
async function sendOtpForEmail(session, { email, eventId, eventName }) {
const em = normalizeEmail(email);
const participant = await Participant.findOne({
tenantOrganization: session.organization,
email: em,
deletedAt: null,
}).lean();
if (!participant || !membershipForEvent(participant, eventId)) {
session.authState = 'awaiting_email';
clearAuthChallengeFieldsKeepPending(session);
return {
reply:
`I couldn’t find a registration for ${em} on this event. `
+ `Please use the email you registered with, or contact the organizers if you need help.`,
verified: false,
};
}
const otp = generateOtp();
session.authState = 'awaiting_otp';
session.authEmail = em;
session.otpHash = hashOtp(otp, String(eventId), em);
session.otpExpiresAt = new Date(Date.now() + OTP_TTL_MS);
if (isFixedOtpMode()) {
console.log(`[concierge OTP — fixed mode] to=${em} code=${otp}`);
await sendConciergeOtpEmail({ to: em, otp, eventName });
return {
reply: `I found your registration for ${em}. Reply with ${otp} to continue.`,
verified: false,
};
}
const sent = await sendConciergeOtpEmail({ to: em, otp, eventName });
if (!sent) {
return {
reply: `I found your registration, but couldn’t send the verification email right now. Please try again in a moment.`,
verified: false,
};
}
return {
reply: `I sent a 6-digit code to ${em}. Please reply with that code to continue.`,
verified: false,
};
}
async function verifyOtpAndLink(session, { code, eventId }) {
const em = normalizeEmail(session.authEmail);
const digits = String(code || '').replace(/\D/g, '').slice(0, 6);
if (!em || digits.length !== 6) {
return {
reply: 'Please reply with the 6-digit verification code from your email.',
verified: false,
};
}
if (!session.otpHash || !session.otpExpiresAt || new Date(session.otpExpiresAt).getTime() < Date.now()) {
session.authState = 'awaiting_email';
clearAuthChallengeFieldsKeepPending(session);
return {
reply: 'That code has expired. Please send your registration email again so I can send a new code.',
verified: false,
};
}
const expected = hashOtp(digits, String(eventId), em);
if (expected !== session.otpHash) {
return {
reply: 'That code is incorrect. Please try again, or send your email again to get a new code.',
verified: false,
};
}
const participant = await Participant.findOne({
tenantOrganization: session.organization,
email: em,
deletedAt: null,
});
if (!participant || !membershipForEvent(participant, eventId)) {
session.authState = 'awaiting_email';
clearAuthChallengeFieldsKeepPending(session);
return {
reply: 'I couldn’t verify that registration anymore. Please send your registration email again.',
verified: false,
};
}
session.participant = participant._id;
session.identityVerifiedAt = new Date();
session.identityMethod = 'email';
clearAuthChallenge(session);
return { reply: null, verified: true, participant };
}
/**
* Handle an in-progress auth challenge turn.
* @returns {{ reply: string|null, verified: boolean, shouldResume: boolean }}
*/
async function handleAuthTurn(session, { message, eventId, eventName }) {
const text = String(message || '').trim();
const state = session.authState;
const cancelAuth = () => ({
reply: null,
verified: false,
shouldResume: false,
cancelled: true,
cancelOnly: isAuthCancelOnly(text),
});
if (state === 'awaiting_email') {
if (isAuthCancelMessage(text) || looksLikeNewQuestionDuringAuth(text)) {
return cancelAuth();
}
const email = extractEmail(text);
if (!email) {
return {
reply: 'Send the email you registered with, or say cancel.',
verified: false,
shouldResume: false,
};
}
const result = await sendOtpForEmail(session, { email, eventId, eventName });
return { ...result, shouldResume: false };
}
if (state === 'awaiting_otp') {
if (isAuthCancelMessage(text) || looksLikeNewQuestionDuringAuth(text)) {
return cancelAuth();
}
// Allow restarting with a new email
const maybeEmail = extractEmail(text);
if (maybeEmail && !/^\d{4,8}$/.test(text.replace(/\s/g, ''))) {
const result = await sendOtpForEmail(session, { email: maybeEmail, eventId, eventName });
return { ...result, shouldResume: false };
}
const result = await verifyOtpAndLink(session, { code: text, eventId });
if (!result.verified) {
return { ...result, shouldResume: false };
}
return { reply: null, verified: true, shouldResume: true };
}
return { reply: null, verified: isVerified(session), shouldResume: false };
}
module.exports = {
extractEmail,
isVerified,
isAuthCancelMessage,
beginEmailAuth,
handleAuthTurn,
clearIdentity,
clearAuthChallenge,
clearPending,
};