TGICS/backend/src/services/rapport.js

310 lines
9.3 KiB
JavaScript

require('dotenv').config();
const API_BASE = (process.env.RAPPORT_API_BASE || 'https://rapportapi.click/').replace(/\/?$/, '/');
const ACCESS_TOKEN = process.env.RAPPORT_ACCESS_TOKEN || '';
const PARTITION = process.env.RAPPORT_PARTITION || 'rapport-lifemankind';
/**
* Fetch a contact from Rapport contact/get_one.
* Returns a normalized doctor object or null.
*/
async function getContactById(contactId) {
if (!contactId) return null;
if (!ACCESS_TOKEN) {
const err = new Error('RAPPORT_ACCESS_TOKEN is not configured');
err.statusCode = 503;
throw err;
}
const url = new URL('contact/contact/get_one', API_BASE);
url.searchParams.set('id', contactId);
url.searchParams.set('application', 'rapport');
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: ACCESS_TOKEN,
Accept: 'application/json',
},
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const err = new Error(body.message || `Rapport contact lookup failed (${response.status})`);
err.statusCode = response.status === 401 ? 503 : 502;
throw err;
}
const item = body?.data?.item || body?.item || null;
if (!item || !item.id) {
return null;
}
// Optional partition check when present on the contact
if (item.partition && item.partition !== PARTITION) {
return null;
}
return item;
}
function isTgicsEnrolled(contact) {
const flag = contact?.tgics_enrolled_doctor;
if (flag === undefined || flag === null) return false;
return String(flag).toLowerCase() === 'yes' || flag === true || flag === 1 || flag === '1';
}
function toDoctorPayload(contact, userId) {
return {
contact_id: contact.id,
user_id: userId || null,
name: contact.name || '',
wfsa_code: contact.msl_code || '',
phone: contact.phone || '',
email: contact.email || '',
age: contact.age || '',
years_of_practice: contact.years_of_practice || '',
type_of_practice: contact.type_of_practice || '',
city: contact.city || contact.city_town || '',
state: contact.state || '',
qualification: contact.qualification || '',
tgics_enrolled_doctor: contact.tgics_enrolled_doctor || '',
};
}
async function resolveTgicsDoctor(userId, contactId) {
const contact = await getContactById(contactId);
if (!contact) {
return { ok: false, statusCode: 404, message: 'Doctor not found.' };
}
if (!isTgicsEnrolled(contact)) {
return {
ok: false,
statusCode: 403,
message: 'Doctor is not enrolled for TGICS.',
};
}
return { ok: true, doctor: toDoctorPayload(contact, userId), contact };
}
function normalizePhone(phone) {
const digits = String(phone || '').replace(/\D+/g, '');
if (digits.length >= 10) {
return digits.slice(-10);
}
return digits;
}
async function searchContactsByKeyword(keyword) {
if (!ACCESS_TOKEN) {
const err = new Error('RAPPORT_ACCESS_TOKEN is not configured');
err.statusCode = 503;
throw err;
}
const url = new URL('contact/contact/get_all', API_BASE);
url.searchParams.set('keyword', keyword);
url.searchParams.set('start', '0');
url.searchParams.set('length', '100');
url.searchParams.set('search', JSON.stringify({}));
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: ACCESS_TOKEN,
Accept: 'application/json',
},
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const err = new Error(body.message || `Rapport contact search failed (${response.status})`);
err.statusCode = response.status === 401 ? 503 : 502;
throw err;
}
return Array.isArray(body?.data?.items) ? body.data.items : [];
}
/**
* Resolve TGICS doctor by MR userId + WhatsApp phone (last 10 digits).
* Prefers contacts owned by that user_id.
*/
async function resolveTgicsDoctorByPhone(userId, phone) {
const needle = normalizePhone(phone);
if (needle.length < 10) {
return {
ok: false,
statusCode: 400,
message: 'Please enter a valid 10-digit mobile number.',
};
}
const items = await searchContactsByKeyword(needle);
const matches = items.filter((contact) => {
if (!contact || !contact.id) return false;
if (!isTgicsEnrolled(contact)) return false;
if (normalizePhone(contact.phone) !== needle) return false;
if (contact.partition && contact.partition !== PARTITION) return false;
return true;
});
if (matches.length === 0) {
return {
ok: false,
statusCode: 404,
message: 'No enrolled doctor found for this mobile number.',
};
}
const forUser = matches.filter((c) => String(c.user_id || '') === String(userId));
const unscoped = matches.filter((c) => !c.user_id);
const contact = forUser[0] || unscoped[0];
if (!contact) {
return {
ok: false,
statusCode: 404,
message: 'No enrolled doctor found for this mobile number under this link.',
};
}
return { ok: true, doctor: toDoctorPayload(contact, userId), contact };
}
async function rapportGet(service, path, params = {}) {
if (!ACCESS_TOKEN) {
const err = new Error('RAPPORT_ACCESS_TOKEN is not configured');
err.statusCode = 503;
throw err;
}
const url = new URL(`${service}/${path}`, API_BASE);
url.searchParams.set('application', 'rapport');
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: ACCESS_TOKEN,
Accept: 'application/json',
},
});
const body = await response.json().catch(() => ({}));
const apiStatus = Number(body?.statusCode || response.status);
if (!response.ok || apiStatus >= 400) {
const err = new Error(body.message || `Rapport ${service}/${path} failed (${apiStatus})`);
err.statusCode = apiStatus === 401 ? 503 : 502;
throw err;
}
return body?.data || {};
}
const CACHE_TTL_MS = Number(process.env.RAPPORT_CACHE_TTL_MS || 10 * 60 * 1000);
const cache = new Map();
async function cached(key, loader, refresh = false) {
const hit = cache.get(key);
if (!refresh && hit && hit.expiresAt > Date.now()) {
return hit.value;
}
const value = await loader();
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
return value;
}
/**
* Active users holding any of the given role ids (ums/users/dropdown, de-duplicated by id).
*/
async function getUsersByRoles(roleIds, { refresh = false } = {}) {
const ids = [...new Set((roleIds || []).map((r) => String(r).trim()).filter(Boolean))];
return cached(`users:${ids.join(',')}`, async () => {
const byId = new Map();
for (const roleId of ids) {
const data = await rapportGet('ums', 'users/dropdown', { filter: { roles: roleId } });
for (const user of Array.isArray(data.items) ? data.items : []) {
if (user?.id && !byId.has(user.id)) {
byId.set(user.id, user);
}
}
}
return [...byId.values()];
}, refresh);
}
/**
* Map of owner user id -> position details (ums/hierarchy/get_all with positions + export).
*/
async function getPositionsByOwner({ refresh = false } = {}) {
return cached('positions', async () => {
const data = await rapportGet('ums', 'hierarchy/get_all', { positions: 1, export: 1 });
const rows = Array.isArray(data.hierarchys) ? data.hierarchys : Array.isArray(data.items) ? data.items : [];
const byOwner = new Map();
for (const row of rows) {
const owner = row?.owner;
if (!owner || owner === 'vacant' || byOwner.has(owner)) continue;
const info = row.owner_info || {};
byOwner.set(owner, {
position_id: row.id || '',
position_name: row.name || '',
position_code: row.code || '',
designation: row.designation || '',
name: `${info.firstname || ''} ${info.lastname || ''}`.trim(),
employee_code: info.employee_code || '',
});
}
return byOwner;
}, refresh);
}
const ENROLLED_VALUES = ['Yes', 'yes', 'YES'];
const ES_PAGE_SIZE = 1000;
const ES_MAX_WINDOW = 10000;
/**
* All TGICS-enrolled contacts (full get_all items).
* The ES term filter is case-sensitive, so each spelling of "yes" is queried and merged.
*/
async function getEnrolledContacts({ refresh = false } = {}) {
return cached('enrolled-contacts', async () => {
const byId = new Map();
for (const value of ENROLLED_VALUES) {
for (let start = 0; start < ES_MAX_WINDOW; start += ES_PAGE_SIZE) {
const data = await rapportGet('contact', 'contact/get_all', {
start,
length: ES_PAGE_SIZE,
keyword: '',
search: { attribute_tgics_enrolled_doctor: value },
});
const items = Array.isArray(data.items) ? data.items : [];
for (const item of items) {
if (item?.id && !byId.has(item.id)) {
byId.set(item.id, item);
}
}
const total = Number(data.count) || 0;
if (items.length < ES_PAGE_SIZE || start + ES_PAGE_SIZE >= total) break;
}
}
return [...byId.values()];
}, refresh);
}
module.exports = {
rapportGet,
getUsersByRoles,
getPositionsByOwner,
getEnrolledContacts,
getContactById,
isTgicsEnrolled,
toDoctorPayload,
resolveTgicsDoctor,
resolveTgicsDoctorByPhone,
normalizePhone,
PARTITION,
};