diff --git a/.env.example b/.env.example index e886fe7..c7db438 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,19 @@ LLM_MAX_TOKENS_CLARIFY=2000 # cap for follow-up Q&A # DEEPSEEK_TIMEOUT_MS=600000 # optional: raise DeepSeek axios timeout (default: 10 min for reasoner, 4 min for chat) # DEEPSEEK_BASE_URL="https://api.deepseek.com" # optional: override DeepSeek base URL (e.g. for a compatible proxy) +# ───────────────────────────────────────── +# PROPOSAL REVIEW — Managed Agent (platform.claude.com) +# ───────────────────────────────────────── +# Standalone from LLM_PROVIDER above: the proposal-review feature calls a +# platform.claude.com Managed Agent ("Upwork-bid-reviewer") whose system prompt, +# judgment rules, output schema, and skill checklist live in the Console — not +# here. We only send the per-card data (job + comment thread). +# +# Auth reuses ANTHROPIC_API_KEY, but it MUST belong to the workspace that owns +# the agent. Leave PROPOSAL_REVIEW_AGENT_ID blank to disable the feature. +PROPOSAL_REVIEW_AGENT_ID="" # e.g. agent_01JQVKcN58n2qGvwkm1k15uP +PROPOSAL_REVIEW_ENV_ID="" # e.g. env_01LY9fhnRrGohJMGsXD7UQtv — create in Console: Managed Agents → Environments + # ───────────────────────────────────────── # MISC # ───────────────────────────────────────── diff --git a/Modules/ProposalReview/applyVerdict.js b/Modules/ProposalReview/applyVerdict.js new file mode 100644 index 0000000..46fb13d --- /dev/null +++ b/Modules/ProposalReview/applyVerdict.js @@ -0,0 +1,252 @@ +/** + * Write layer for proposal review. + * + * Takes the output of evaluator.evaluateOne() and turns it into the real + * mutations on the task: + * - verdict APPROVE → move to "Approved" + * - verdict BACKLOG → move to "Backlog" + post a system + * comment with the agent's reason + * - verdict found:false → move to "Backlog" + "no real proposal" comment + * - skipReason no_token → move to "Backlog" + "no Upwork link" comment + * - skipReason no_comments → move to "Backlog" + "no proposal comment yet" comment + * - skipReason job_not_found → move to "Backlog" + "job not scraped yet" comment + * + * The status move goes through `taskMongo.updateStatus` — the SAME path a + * human click uses — so socket broadcasts, activity history, and + * notifications all fire the same as a manual move. The reason comment is + * saved via the standard `comments` save path and a matching socket "insert" + * is emitted so it shows up live in the task chat. + * + * Status keys are resolved by NAME from each project's `taskStatusData` + * (status keys are per-project and dynamic — they are NEVER hardcoded). + * + * Dry-run mode (`dryRun: true`) returns the planned action without touching + * anything — useful for verifying the wiring against a real task before + * flipping the live switch. + */ +'use strict'; + +const mongoose = require('mongoose'); +const logger = require('../../Config/loggerConfig'); +const { dbCollections } = require('../../Config/collections'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const { ALIANHUB_BOT_USER } = require('../../utils/commonFunctions'); +const { taskMongo } = require('../Tasks/helpers/task_class_Mongo'); +const socketEmitter = require('../../event/socketEventEmitter'); + +// Status NAMES expected on every proposal-review project. Status KEYS are +// per-project and resolved dynamically. +const SOURCE_STATUS_NAME = 'In Review - TL'; +const APPROVE_STATUS_NAME = 'Approved'; +const BACKLOG_STATUS_NAME = 'Backlog'; + +// Human-readable reason text for each "can't review" outcome. The LLM's own +// reason is used verbatim when the verdict is BACKLOG (a real quality call). +const SKIP_REASON_TEXT = { + no_token: 'No Upwork job link (~token) was found in the task name, so the proposal could not be reviewed automatically.', + no_comments: 'No proposal comment was found on this task to review.', + job_not_found: 'The linked Upwork job was not found in the jobs database, so the proposal could not be reviewed automatically.', + no_proposal_in_thread: 'No actual freelancer proposal was found in the task comments.', + error: 'An error occurred while reviewing this proposal automatically.', +}; + +function getProject(companyId, projectId) { + return MongoDbCrudOpration(companyId, { + type: dbCollections.PROJECTS, + data: [ + { _id: new mongoose.Types.ObjectId(projectId) }, + { ProjectName: 1, ProjectCode: 1, lastTaskId: 1, taskStatusData: 1, CompanyId: 1, apps: 1 }, + ], + }, 'findOne'); +} + +function resolveStatus(project, name) { + const arr = Array.isArray(project && project.taskStatusData) ? project.taskStatusData : []; + return arr.find((s) => s && String(s.name).toLowerCase() === String(name).toLowerCase()) || null; +} + +// Resolve the user to attribute the auto-action to. Uses the global static +// AlianHub AI Bot user defined in utils/commonFunctions.js — NOT a real DB user. +// Every auto-move and reason-comment is therefore attributed to "AlianHub AI Bot" +// in the activity history and task chat, regardless of company / project. +// +// Kept async so the call-site signature doesn't change. If you later want a +// real DB-backed bot account (for proper avatar resolution, etc.), swap this +// body back to a `MongoDbCrudOpration('global', { type: USERS, ... })` lookup +// — every consumer still just reads { id, Employee_Name, companyOwnerId }. +async function getActorUserData() { + return { + id: ALIANHUB_BOT_USER.id, + Employee_Name: ALIANHUB_BOT_USER.Employee_Name, + companyOwnerId: ALIANHUB_BOT_USER.companyOwnerId, + }; +} + +async function applyStatusMove({ companyId, project, fullTask, targetStatus, prevStatus, userData }) { + const prev = { + backColor: prevStatus && prevStatus.bgColor, + color: prevStatus && prevStatus.textColor, + statusName: prevStatus && prevStatus.name, + taskName: fullTask.TaskName, + bgColor: targetStatus.bgColor, + textColor: targetStatus.textColor, + taskId: String(fullTask._id), + updatedTaskName: targetStatus.name, + }; + const next = { + status: { text: targetStatus.name, key: targetStatus.key, type: targetStatus.type }, + statusType: targetStatus.type, + statusKey: targetStatus.key, + }; + const projectData = { + _id: String(project._id), + CompanyId: companyId, + lastTaskId: project.lastTaskId, + ProjectName: project.ProjectName, + ProjectCode: project.ProjectCode, + }; + const taskForUpdate = { + _id: String(fullTask._id), + sprintId: String(fullTask.sprintId || ''), + folderObjId: fullTask.folderObjId || '', + TaskName: fullTask.TaskName, + statusKey: fullTask.statusKey, + }; + await taskMongo.updateStatus({ + newStatus: next, prevStatus: prev, projectData, task: taskForUpdate, userData, isUpdateTask: true, + }); +} + +async function postReasonComment({ companyId, project, fullTask, reason, userData, statusName }) { + try { + const reasonText = (reason && String(reason).trim()) || 'The proposal did not sufficiently match the job requirements.'; + const message = `Moved to "${statusName}" by proposal review.\nReason: ${reasonText}`; + const data = { + message, + type: 'text', + userId: String((userData && userData.id) || ''), + project: false, + taskId: new mongoose.Types.ObjectId(String(fullTask._id)), + sprintId: new mongoose.Types.ObjectId(String(fullTask.sprintId)), + projectId: new mongoose.Types.ObjectId(String(project._id)), + mentionIds: [], + }; + if (fullTask.folderObjId) data.folderId = new mongoose.Types.ObjectId(String(fullTask.folderObjId)); + const saved = await MongoDbCrudOpration(companyId, { type: dbCollections.COMMENTS, data }, 'save'); + // Mirror the broadcast the default comment-save controller emits so + // the team sees the reason in the live task chat. + try { socketEmitter.emit('insert', { type: 'insert', data: saved, updatedFields: {}, module: 'comments' }); } catch (_e) { /* socket optional */ } + return saved; + } catch (e) { + logger.error(`ProposalReview postReasonComment error (task ${fullTask && fullTask._id}): ${e && e.message ? e.message : e}`); + return null; + } +} + +/** + * Apply the result of an evaluateOne() to the task. + * + * @param {Object} input + * @param {string} input.companyId + * @param {Object} input.evaluation Full output of evaluateOne(). + * @param {boolean} [input.dryRun=false] If true, returns the plan; mutates nothing. + * + * @returns {Promise<{ + * plan: { action: string, targetStatus: object|null, comment: string|null, userData: object|null, reason: string|null }, + * applied: boolean, + * error?: string + * }>} + */ +async function applyVerdict({ companyId, evaluation, dryRun = false }) { + const plan = { action: null, targetStatus: null, comment: null, userData: null, reason: null }; + + if (!evaluation || !evaluation.task) { + plan.action = 'noop'; plan.reason = 'no task in evaluation'; + return { plan, applied: false, error: 'no_task' }; + } + + const project = await getProject(companyId, evaluation.task.ProjectID); + if (!project) { + plan.action = 'noop'; plan.reason = 'project not found'; + return { plan, applied: false, error: 'project_not_found' }; + } + + const sourceStatus = resolveStatus(project, SOURCE_STATUS_NAME); + const approveStatus = resolveStatus(project, APPROVE_STATUS_NAME); + const backlogStatus = resolveStatus(project, BACKLOG_STATUS_NAME); + if (!sourceStatus || !approveStatus || !backlogStatus) { + plan.action = 'noop'; + plan.reason = `project missing one of: "${SOURCE_STATUS_NAME}", "${APPROVE_STATUS_NAME}", "${BACKLOG_STATUS_NAME}"`; + return { plan, applied: false, error: 'missing_status' }; + } + + // Decide the action. + const v = evaluation.verdict; + if (v && v.verdict === 'APPROVE') { + plan.action = 'approve'; + plan.targetStatus = { name: approveStatus.name, key: approveStatus.key }; + } else if (v && v.verdict === 'BACKLOG') { + plan.action = 'backlog_rejected'; + plan.targetStatus = { name: backlogStatus.name, key: backlogStatus.key }; + plan.comment = (v.reason || '').trim() || 'The proposal did not sufficiently match the job requirements.'; + } else if (v && v.found === false) { + plan.action = 'backlog_no_proposal_in_thread'; + plan.targetStatus = { name: backlogStatus.name, key: backlogStatus.key }; + plan.comment = SKIP_REASON_TEXT.no_proposal_in_thread; + } else if (evaluation.skipReason) { + plan.action = `backlog_${evaluation.skipReason}`; + plan.targetStatus = { name: backlogStatus.name, key: backlogStatus.key }; + plan.comment = SKIP_REASON_TEXT[evaluation.skipReason] || SKIP_REASON_TEXT.error; + } else { + plan.action = 'noop'; + plan.reason = 'no verdict and no skipReason'; + return { plan, applied: false, error: 'no_action_inferable' }; + } + + const userData = await getActorUserData(); + plan.userData = userData ? { id: userData.id, Employee_Name: userData.Employee_Name } : null; + + if (dryRun) { + return { plan, applied: false }; + } + + if (!userData) { + plan.reason = 'PROPOSAL_REVIEW_USER_ID not set'; + return { plan, applied: false, error: 'no_actor' }; + } + + try { + // Re-read the full task — we need fields like statusKey for the move. + const fullTask = await MongoDbCrudOpration(companyId, { + type: dbCollections.TASKS, + data: [{ _id: new mongoose.Types.ObjectId(evaluation.task._id) }], + }, 'findOne'); + if (!fullTask) { + return { plan, applied: false, error: 'task_disappeared' }; + } + // If the task has already left "In Review - TL" (e.g. a human moved it + // while the agent was thinking), don't override that human decision. + if (fullTask.statusKey !== sourceStatus.key) { + plan.reason = `task is no longer in "${SOURCE_STATUS_NAME}" (statusKey=${fullTask.statusKey})`; + plan.action = 'noop'; + return { plan, applied: false, error: 'status_changed_during_review' }; + } + + const targetStatusObj = plan.action === 'approve' ? approveStatus : backlogStatus; + await applyStatusMove({ + companyId, project, fullTask, targetStatus: targetStatusObj, prevStatus: sourceStatus, userData, + }); + + if (plan.action !== 'approve') { + await postReasonComment({ + companyId, project, fullTask, reason: plan.comment, userData, statusName: backlogStatus.name, + }); + } + return { plan, applied: true }; + } catch (e) { + logger.error(`ProposalReview applyVerdict error (task ${evaluation.task && evaluation.task._id}): ${e && e.message ? e.message : e}`); + return { plan, applied: false, error: (e && e.message) || 'apply_failed' }; + } +} + +module.exports = { applyVerdict, SOURCE_STATUS_NAME, APPROVE_STATUS_NAME, BACKLOG_STATUS_NAME }; diff --git a/Modules/ProposalReview/evaluator.js b/Modules/ProposalReview/evaluator.js new file mode 100644 index 0000000..5d1317b --- /dev/null +++ b/Modules/ProposalReview/evaluator.js @@ -0,0 +1,137 @@ +/** + * Read-only evaluation pipeline for ONE task. + * + * Given (companyId, taskId), this: + * 1. Reads the task from Mongo + * 2. Extracts the Upwork ~token from TaskName + * 3. Reads the recent comment thread (oldest → newest) + * 4. Looks up the matching job in Postgres + * 5. Calls the Managed Agent for a verdict + * + * Returns a diagnostics object. DOES NOT mutate any data — no status moves, + * no comments posted, no Postgres writes. Each early-return sets `skipReason` + * so the caller knows exactly why this task can't be (auto-)judged: + * task_not_found | no_token | no_comments | job_not_found + * + * This is the smallest piece the eventual cron will call once per task. + */ +'use strict'; + +const mongoose = require('mongoose'); +const { dbCollections } = require('../../Config/collections'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const pg = require('./pgClient'); +const { reviewProposal } = require('./managedAgentClient'); + +const MAX_COMMENTS = 8; +const MAX_COMMENT_CHARS = 2500; + +// Comments are stored HTML-escaped; the agent should see the raw text. +function decodeEntities(s) { + if (typeof s !== 'string') return ''; + return s + .replace(/�?39;/g, "'").replace(/'/g, "'") + .replace(/"/g, '"').replace(/&/g, '&') + .replace(/</g, '<').replace(/>/g, '>') + .replace(/ /g, ' '); +} + +async function getTask(companyId, taskId) { + const oid = new mongoose.Types.ObjectId(taskId); + return MongoDbCrudOpration(companyId, { + type: dbCollections.TASKS, + data: [ + { _id: oid }, + { TaskName: 1, statusKey: 1, sprintId: 1, folderObjId: 1, ProjectID: 1 }, + ], + }, 'findOne'); +} + +// Don't filter by `type`: a proposal containing a URL is sometimes stored as +// "link" rather than "text"; the text always lives in `message`. We also don't +// assume the proposal is the LAST comment — threads can mix proposal versions, +// status notes, review remarks, and chit-chat. The agent itself is responsible +// for picking the actual proposal out of the thread. +async function getRecentComments(companyId, taskId) { + const oid = new mongoose.Types.ObjectId(taskId); + const list = await MongoDbCrudOpration(companyId, { + type: dbCollections.COMMENTS, + data: [ + { taskId: { $in: [oid, String(taskId)] }, isDeleted: { $ne: true }, message: { $nin: ['', null] } }, + { message: 1, userId: 1, createdAt: 1 }, + ], + }, 'find').catch(() => []); + const sorted = (Array.isArray(list) ? list : []) + .filter((c) => c && typeof c.message === 'string' && c.message.trim()) + .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + return sorted.slice(-MAX_COMMENTS).map((c) => ({ + userId: String(c.userId || ''), + text: decodeEntities(c.message).trim().slice(0, MAX_COMMENT_CHARS), + })); +} + +/** + * @param {string} companyId Multi-tenant Mongo database name. + * @param {string} taskId Mongo _id of the task to evaluate. + * @returns {Promise<{ + * companyId: string, taskId: string, + * task: object|null, token: string|null, + * commentsCount: number, commentsPreview: object[], + * job: object|null, + * verdict: object|null, + * skipReason: 'task_not_found'|'no_token'|'no_comments'|'job_not_found'|null + * }>} + */ +async function evaluateOne(companyId, taskId) { + const result = { + companyId, + taskId, + task: null, + token: null, + commentsCount: 0, + commentsPreview: [], + job: null, + verdict: null, + skipReason: null, + }; + + const task = await getTask(companyId, taskId); + if (!task) { result.skipReason = 'task_not_found'; return result; } + result.task = { + _id: String(task._id), + TaskName: task.TaskName, + statusKey: task.statusKey, + sprintId: String(task.sprintId || ''), + ProjectID: String(task.ProjectID || ''), + }; + + const token = pg.extractToken(task.TaskName); + result.token = token; + if (!token) { result.skipReason = 'no_token'; return result; } + + const comments = await getRecentComments(companyId, task._id); + result.commentsCount = comments.length; + result.commentsPreview = comments.map((c) => ({ + userId: c.userId, + text: c.text.length > 140 ? c.text.slice(0, 140) + '… [truncated]' : c.text, + })); + if (!comments.length) { result.skipReason = 'no_comments'; return result; } + + const job = await pg.findJobByToken(token); + if (!job) { result.skipReason = 'job_not_found'; return result; } + result.job = { + jobId: job.jobId, + title: job.title, + descriptionLen: (job.description || '').length, + questionsCount: Array.isArray(job.questions) ? job.questions.length : 0, + }; + + result.verdict = await reviewProposal({ + job: { title: job.title, description: job.description, questions: job.questions }, + thread: comments, + }); + + return result; +} + +module.exports = { evaluateOne }; diff --git a/Modules/ProposalReview/init.js b/Modules/ProposalReview/init.js new file mode 100644 index 0000000..fbb0d6d --- /dev/null +++ b/Modules/ProposalReview/init.js @@ -0,0 +1,24 @@ +/** + * Proposal Review module init — registers the event listener at app startup. + * + * Called from index.js after the standard module inits. Does nothing if the + * feature isn't fully configured (missing API key, agent id, env id, PG url, + * or actor user id) — the rest of the app keeps working as before. + */ +'use strict'; + +const logger = require('../../Config/loggerConfig'); +const trigger = require('./trigger'); + +exports.init = (_app) => { + if (!trigger.isConfigured()) { + logger.warn('[ProposalReview] not fully configured — listener NOT registered. ' + + 'Set ANTHROPIC_API_KEY, PROPOSAL_REVIEW_AGENT_ID, PROPOSAL_REVIEW_ENV_ID, ' + + 'and PROPOSAL_PG_URL to enable. (Actor is the static AlianHub AI Bot — no user-id env var needed.)'); + return; + } + const ok = trigger.register(); + if (ok) { + logger.info('[ProposalReview] event listener registered — will auto-review tasks moved into "In Review - TL" on projects with the proposalReview app enabled.'); + } +}; diff --git a/Modules/ProposalReview/managedAgentClient.js b/Modules/ProposalReview/managedAgentClient.js new file mode 100644 index 0000000..1888e34 --- /dev/null +++ b/Modules/ProposalReview/managedAgentClient.js @@ -0,0 +1,176 @@ +/** + * Thin client for the Upwork-bid-reviewer Managed Agent on platform.claude.com. + * + * Single responsibility: given a job + the task's recent comment thread, ask + * the agent for a verdict and return its raw JSON output as + * { found: boolean, verdict?: "APPROVE"|"BACKLOG"|null, reason?: string }. + * + * Connection only — does NOT move statuses, post comments, or know about + * Mongo/Postgres. Whatever wraps this layer wires it into the wider workflow. + * + * Self-contained: does NOT touch Modules/AIProjectGenerator/llmProvider/, so + * the existing OpenAI/Anthropic/DeepSeek selection for the AI project + * generator is unaffected. Uses its own env vars: + * - ANTHROPIC_API_KEY (must belong to the workspace that owns the agent) + * - PROPOSAL_REVIEW_AGENT_ID (the platform.claude.com agent id, e.g. agent_01J...) + * - PROPOSAL_REVIEW_ENV_ID (the Managed Agent environment id, e.g. env_01L...) + * + * The agent's system prompt, judgment rules, output schema, and per-category + * skill checklist are all configured on platform.claude.com — this client + * sends ONLY the per-call data (job + thread) and returns the parsed verdict. + */ +'use strict'; + +let AnthropicSdk; +try { + AnthropicSdk = require('@anthropic-ai/sdk'); +} catch (_e) { + AnthropicSdk = null; +} + +const logger = require('../../Config/loggerConfig'); + +const MAX_JOB_DESCRIPTION_CHARS = 3000; +const MAX_COMMENT_CHARS = 2500; +const MAX_COMMENTS = 8; + +function isConfigured() { + return Boolean( + AnthropicSdk + && process.env.ANTHROPIC_API_KEY + && process.env.PROPOSAL_REVIEW_AGENT_ID + && process.env.PROPOSAL_REVIEW_ENV_ID, + ); +} + +function getClient() { + const Anthropic = AnthropicSdk.default || AnthropicSdk.Anthropic || AnthropicSdk; + return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); +} + +function buildUserMessage(job, thread) { + const title = (job && job.title) || ''; + const description = String((job && job.description) || '').slice(0, MAX_JOB_DESCRIPTION_CHARS); + const questions = (job && job.questions) || []; + const comments = Array.isArray(thread) ? thread.slice(-MAX_COMMENTS) : []; + const threadText = comments + .map((c, i) => { + const text = String((c && c.text) || '').slice(0, MAX_COMMENT_CHARS); + const user = (c && c.userId) || 'unknown'; + return `--- Comment ${i + 1} (user ${user}) ---\n${text}`; + }) + .join('\n\n'); + return [ + `JOB TITLE: ${title}`, + '', + 'JOB DESCRIPTION:', + description, + '', + `SCREENING QUESTIONS: ${JSON.stringify(questions)}`, + '', + 'COMMENT THREAD (oldest first):', + threadText || '(no comments)', + ].join('\n'); +} + +function parseVerdict(text) { + let parsed; + try { parsed = JSON.parse(text); } catch (_e) { return { found: false, raw: text, parseError: true }; } + if (parsed && parsed.found === false) return { found: false }; + const v = String((parsed && parsed.verdict) || '').toUpperCase(); + return { + found: parsed.found === true, + verdict: v === 'APPROVE' ? 'APPROVE' : (v === 'BACKLOG' ? 'BACKLOG' : null), + reason: (parsed && parsed.reason) || '', + }; +} + +/** + * Send one (job, thread) pair to the Managed Agent and return its verdict. + * + * @param {Object} input + * @param {{ title: string, description: string, questions?: any }} input.job + * @param {Array<{ userId?: string, text: string }>} input.thread Oldest first. + * @returns {Promise<{ + * found: boolean, + * verdict?: 'APPROVE'|'BACKLOG'|null, + * reason?: string, + * raw?: string, + * parseError?: boolean + * }>} + */ +async function reviewProposal({ job, thread }) { + if (!isConfigured()) { + throw new Error( + 'ProposalReview Managed Agent client not configured: install @anthropic-ai/sdk ' + + 'and set ANTHROPIC_API_KEY + PROPOSAL_REVIEW_AGENT_ID + PROPOSAL_REVIEW_ENV_ID', + ); + } + const client = getClient(); + const agentId = process.env.PROPOSAL_REVIEW_AGENT_ID; + const envId = process.env.PROPOSAL_REVIEW_ENV_ID; + const userText = buildUserMessage(job, thread); + + try { + const session = await client.beta.sessions.create({ agent: agentId, environment_id: envId }); + const sessionId = session && (session.id || session.session_id); + if (!sessionId) throw new Error('Managed Agent: session.create returned no id'); + + // Open the event stream BEFORE sending the user message so we never miss + // the agent.message event on a fast-responding session. + const stream = await client.beta.sessions.events.stream(sessionId); + + await client.beta.sessions.events.send(sessionId, { + events: [{ type: 'user.message', content: [{ type: 'text', text: userText }] }], + }); + + let assistantText = ''; + for await (const evt of stream) { + if (!evt || typeof evt !== 'object') continue; + if (evt.type === 'agent.message' && Array.isArray(evt.content)) { + for (const block of evt.content) { + if (block && block.type === 'text' && typeof block.text === 'string') { + assistantText += block.text; + } + } + } else if (evt.type === 'session.status_idle') { + const reason = evt.stop_reason && evt.stop_reason.type; + if (reason === 'retries_exhausted') throw new Error('Managed Agent: retries exhausted'); + if (reason === 'requires_action') throw new Error('Managed Agent: requires user action (unexpected for this no-tool reviewer agent)'); + // end_turn (or any other idle reason) → the agent finished its turn. + break; + } else if (evt.type === 'session.error') { + const msg = (evt.error && (evt.error.message || evt.error.type)) || 'unknown error'; + throw new Error(`Managed Agent error: ${msg}`); + } + } + + return parseVerdict(assistantText); + } catch (err) { + const status = err && err.status; + if (status === 401 || status === 403) { + const e = new Error('Managed Agent: invalid or unauthorized ANTHROPIC_API_KEY for this workspace'); + e.code = 'MA_AUTH_FAILED'; + throw e; + } + if (status === 404) { + const e = new Error(`Managed Agent: agent "${agentId}" not found (check PROPOSAL_REVIEW_AGENT_ID and workspace)`); + e.code = 'MA_NOT_FOUND'; + throw e; + } + if (status === 429) { + const e = new Error('Managed Agent: rate-limited or out of credits (HTTP 429)'); + e.code = 'MA_RATE_LIMITED'; + throw e; + } + logger.error(`ProposalReview managedAgentClient error: ${err && err.message ? err.message : err}`); + throw err; + } +} + +module.exports = { + isConfigured, + reviewProposal, + buildUserMessage, + parseVerdict, +}; diff --git a/Modules/ProposalReview/pgClient.js b/Modules/ProposalReview/pgClient.js new file mode 100644 index 0000000..e8f637e --- /dev/null +++ b/Modules/ProposalReview/pgClient.js @@ -0,0 +1,122 @@ +/** + * Read-only Postgres client for the Proposal Review feature. + * + * Only purpose: given an Upwork ~token extracted from a task name, return the + * matching row from the "Jobs" table so the Managed Agent can evaluate the + * proposal against the real job requirements. + * + * HARD GUARANTEES: + * - Only SELECT is exposed. No insert/update/delete/exec helpers. + * - All values are passed as bound parameters ($1, $2…). No string-built SQL. + * - The pool is created lazily on first use and reused thereafter. + * + * Env vars (read at first call): + * - PROPOSAL_PG_URL required, e.g. postgresql://user:pass@host:5432/portfolios + * - PROPOSAL_PG_POOL_MAX optional, default 4 + */ +'use strict'; + +let Pool; +try { + ({ Pool } = require('pg')); +} catch (_e) { + Pool = null; +} + +const logger = require('../../Config/loggerConfig'); + +let pool = null; + +function isConfigured() { + return Boolean(Pool && process.env.PROPOSAL_PG_URL); +} + +function getPool() { + if (pool) return pool; + if (!isConfigured()) { + throw new Error('ProposalReview Postgres client not configured: install `pg` and set PROPOSAL_PG_URL'); + } + const max = Number(process.env.PROPOSAL_PG_POOL_MAX) || 4; + pool = new Pool({ + connectionString: process.env.PROPOSAL_PG_URL, + max, + // Keep idle connections short — this feature runs a small burst every + // 5 minutes, not steady traffic. No reason to hold sockets open. + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 10_000, + }); + pool.on('error', (err) => { + logger.error(`ProposalReview pgClient pool error: ${err && err.message ? err.message : err}`); + }); + return pool; +} + +/** + * Extract the Upwork job token from a task name. Tasks usually contain a URL + * like `https://www.upwork.com/jobs/~022061197431416346310`; we want the digits + * after the `~`. Returns the raw token string, or null if no token present. + * + * "Build a Shopify checkout (https://...~022061197431416346310)" → "022061197431416346310" + * "Plain task name with no link" → null + */ +function extractToken(taskName) { + if (typeof taskName !== 'string') return null; + const m = taskName.match(/~([0-9a-zA-Z]+)/); + return m ? m[1] : null; +} + +/** + * Look up a job by the ~token extracted from a task name. The `jobId` column + * sometimes stores the token with a leading "02" prefix and sometimes without, + * so we try both candidates in one round-trip. + * + * Returns the matching row (object) or null if not found. + * + * @param {string} token Raw digits after `~`, e.g. "022061197431416346310". + */ +async function findJobByToken(token) { + if (!token || typeof token !== 'string') return null; + const candidates = [token]; + if (token.startsWith('02') && token.length > 2) { + candidates.push(token.slice(2)); + } else { + candidates.push(`02${token}`); + } + const sql = 'SELECT * FROM "Jobs" WHERE "jobId" = ANY($1::text[]) LIMIT 1'; + const client = await getPool().connect(); + try { + const result = await client.query(sql, [candidates]); + return (result.rows && result.rows[0]) || null; + } finally { + client.release(); + } +} + +/** + * Smoke helper: verify the connection itself works without touching the Jobs + * table. Useful for startup diagnostics. + */ +async function ping() { + const client = await getPool().connect(); + try { + const r = await client.query('SELECT 1 AS ok'); + return r.rows[0] && r.rows[0].ok === 1; + } finally { + client.release(); + } +} + +async function close() { + if (pool) { + await pool.end(); + pool = null; + } +} + +module.exports = { + isConfigured, + extractToken, + findJobByToken, + ping, + close, +}; diff --git a/Modules/ProposalReview/trigger.js b/Modules/ProposalReview/trigger.js new file mode 100644 index 0000000..dfed99d --- /dev/null +++ b/Modules/ProposalReview/trigger.js @@ -0,0 +1,241 @@ +/** + * Event-driven trigger: when a task moves INTO "In Review - TL" on a project + * that has the `proposalReview` app enabled, run the Managed Agent and apply + * its verdict (move to Approved or Backlog). + * + * Wired via the existing socketEventEmitter — no polling, no cron. The handler + * is fire-and-forget so the originating status-update call returns immediately; + * the agent's ~30s of work happens in the background. + * + * Safety: + * - Skips when `proposalReview` is NOT in `project.apps` + * - Skips when the NEW status name (resolved dynamically from + * `project.taskStatusData` by key) is not "In Review - TL" + * - Skips when the task somehow has no projectId / no taskId + * - applyVerdict re-reads the task before mutating and bails if a human + * moved it out of "In Review - TL" while the agent was thinking + * + * No status keys are hardcoded — keys are looked up by NAME on each project. + */ +'use strict'; + +const mongoose = require('mongoose'); +const logger = require('../../Config/loggerConfig'); +const { dbCollections } = require('../../Config/collections'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const socketEmitter = require('../../event/socketEventEmitter'); +const { evaluateOne } = require('./evaluator'); +const { applyVerdict, SOURCE_STATUS_NAME } = require('./applyVerdict'); +const { isConfigured: agentConfigured } = require('./managedAgentClient'); +const pg = require('./pgClient'); + +const APP_KEY = 'proposalReview'; + +// In multi-tenant AlianHub each company has its OWN MongoDB database, and +// the database NAME equals the companyId. A Mongoose document returned by +// `findOneAndUpdate` (the `data` field in the socket emit payload) carries +// its connection reference, so `doc.db.name` resolves to the companyId +// without us having to touch the emit site itself. +// +// Falls back to an explicit `payload.companyId` when present (used by the +// smoke test, which emits synthetic events on plain objects). +function extractCompanyId(payload) { + if (payload && typeof payload.companyId === 'string' && payload.companyId) { + return payload.companyId; + } + const doc = payload && payload.data; + if (doc && doc.db && typeof doc.db.name === 'string' && doc.db.name) { + return doc.db.name; + } + return null; +} + +// Bulk task updates (Modules/Tasks/helpers/taskMongo/bulk.js) convert the +// task via `toObject()` before emitting, which strips Mongoose's connection +// reference — so `data.db.name` is undefined and `extractCompanyId` returns +// null. This fallback scans every open per-tenant Mongoose connection and +// finds the one whose `tasks` collection contains the given _id. The result +// is cached so subsequent events for the same task (the typical pattern when +// the bulk path emits N events in a row) are O(1). +const companyIdCacheByTaskId = new Map(); +const COMPANY_ID_CACHE_MAX = 2000; + +async function findCompanyIdFromTaskId(taskId) { + if (!taskId) return null; + if (companyIdCacheByTaskId.has(taskId)) return companyIdCacheByTaskId.get(taskId); + let oid; + try { oid = new mongoose.Types.ObjectId(String(taskId)); } catch (_e) { return null; } + for (const conn of mongoose.connections) { + const name = conn && conn.name; + if (!name || name === 'global' || conn.readyState !== 1) continue; + try { + const found = await conn.collection('tasks').findOne({ _id: oid }, { projection: { _id: 1 } }); + if (found) { + if (companyIdCacheByTaskId.size >= COMPANY_ID_CACHE_MAX) { + // Cheap FIFO eviction — keeps memory bounded. + const firstKey = companyIdCacheByTaskId.keys().next().value; + companyIdCacheByTaskId.delete(firstKey); + } + companyIdCacheByTaskId.set(String(taskId), name); + return name; + } + } catch (_e) { /* ignore: connection may be closing or DB missing */ } + } + return null; +} + +function hasProposalReviewApp(project) { + const apps = Array.isArray(project && project.apps) ? project.apps : []; + return apps.some((a) => { + if (typeof a === 'string') return a === APP_KEY; + return a && typeof a === 'object' && a.key === APP_KEY; + }); +} + +function resolveStatusName(project, statusKey) { + const arr = Array.isArray(project && project.taskStatusData) ? project.taskStatusData : []; + const found = arr.find((s) => s && s.key === statusKey); + return found ? found.name : null; +} + +/** + * Emit a synthetic task:update event that flips a transient + * `proposalReviewProcessing` flag on the task. Carried by the existing + * task-event pipeline (taskSocket → frontend Vuex), so the frontend sees the + * change reactively and can render a spinner. NOT persisted to Mongo — the + * flag lives only in the in-memory frontend store and is wiped on refresh. + * + * Our own listener (`handleTaskUpdate`) ignores these because they have no + * `updatedFields.statusKey` — so there's no recursion. + */ +function emitSpinnerEvent(taskDataForSpinner, processing) { + if (!taskDataForSpinner || !taskDataForSpinner._id) return; + try { + // Send MINIMAL payload: only the routing fields taskSocket reads + // (ProjectID, sprintId, ParentTaskId, AssigneeUserId, _id) plus the + // flag. Sending the whole task here would let Vuex's merge re-write + // dozens of fields that didn't actually change, blowing up Task.vue's + // JSON.stringify watcher and causing a full row re-render (visible as + // a "blink"). With a minimal payload the merge only flips this one + // field, so reactivity stays narrow and the row doesn't blink. + const minimal = { + _id: taskDataForSpinner._id, + ProjectID: taskDataForSpinner.ProjectID, + sprintId: taskDataForSpinner.sprintId, + ParentTaskId: taskDataForSpinner.ParentTaskId || null, + AssigneeUserId: Array.isArray(taskDataForSpinner.AssigneeUserId) + ? taskDataForSpinner.AssigneeUserId : [], + proposalReviewProcessing: processing, + }; + socketEmitter.emit('update', { + type: 'update', + data: minimal, + updatedFields: { proposalReviewProcessing: processing }, + module: 'task', + }); + logger.info(`[ProposalReview] spinner emit task=${minimal._id} processing=${processing} ts=${Date.now()}`); + } catch (e) { + logger.error(`[ProposalReview] emitSpinnerEvent error: ${e && e.message ? e.message : e}`); + } +} + +/** + * Run the full review pipeline for one task. Fire-and-forget. + * @param {string} companyId + * @param {string} taskId + * @param {object} [taskDataForSpinner] Optional task doc used purely to emit + * the transient spinner flag to the frontend. When provided, a `processing + * = true` event fires before the agent call and a matching `false` event + * fires in `finally` (covers success AND error). + */ +async function runProposalReview(companyId, taskId, taskDataForSpinner) { + const startedAt = Date.now(); + emitSpinnerEvent(taskDataForSpinner, true); + try { + const evaluation = await evaluateOne(companyId, taskId); + const v = evaluation.verdict; + logger.info( + `[ProposalReview] task=${taskId} company=${companyId} ` + + `verdict=${v ? v.verdict || (v.found === false ? 'NO_PROPOSAL' : 'INVALID') : 'NONE'} ` + + `skip=${evaluation.skipReason || 'none'} eval_ms=${Date.now() - startedAt}`, + ); + const applyRes = await applyVerdict({ companyId, evaluation, dryRun: false }); + logger.info( + `[ProposalReview] task=${taskId} applied=${applyRes.applied} ` + + `action=${applyRes.plan && applyRes.plan.action} error=${applyRes.error || 'none'} ` + + `total_ms=${Date.now() - startedAt}`, + ); + } catch (e) { + logger.error(`[ProposalReview] task=${taskId} pipeline error: ${e && e.message ? e.message : e}`); + } finally { + emitSpinnerEvent(taskDataForSpinner, false); + } +} + +/** + * Listener for the namespaced `task:update` event. Filters quickly and + * dispatches matching events to `runProposalReview` without awaiting. + */ +async function handleTaskUpdate(payload) { + try { + const newKey = payload && payload.updatedFields && payload.updatedFields.statusKey; + if (newKey === undefined || newKey === null) return; // not a status change + + const data = payload.data; + if (!data || !data._id || !data.ProjectID) return; + + let companyId = extractCompanyId(payload); + if (!companyId) { + // Bulk path (toObject'd payload) — scan open connections for the task. + companyId = await findCompanyIdFromTaskId(String(data._id)); + } + if (!companyId) { + logger.warn(`[ProposalReview] task=${data._id} status changed but companyId could not be resolved; skipping`); + return; + } + + const project = await MongoDbCrudOpration(companyId, { + type: dbCollections.PROJECTS, + data: [ + { _id: new mongoose.Types.ObjectId(String(data.ProjectID)) }, + { taskStatusData: 1, apps: 1, ProjectName: 1 }, + ], + }, 'findOne').catch(() => null); + if (!project) return; + + if (!hasProposalReviewApp(project)) return; + + const newName = resolveStatusName(project, newKey); + if (!newName || String(newName).toLowerCase() !== SOURCE_STATUS_NAME.toLowerCase()) return; + + // Fire-and-forget so the originating updateStatus call returns fast. + // Pass the task doc so the spinner event can fan out to the right rooms + // (taskSocket routes by ProjectID + sprintId on the payload's `data`). + runProposalReview(companyId, String(data._id), data) + .catch((e) => logger.error(`[ProposalReview] background pipeline rejected: ${e && e.message ? e.message : e}`)); + } catch (e) { + logger.error(`[ProposalReview] trigger handler error: ${e && e.message ? e.message : e}`); + } +} + +let registered = false; +function register() { + if (registered) return false; + socketEmitter.on('task:update', handleTaskUpdate); + registered = true; + return true; +} + +function isConfigured() { + // The actor user is now a static in-code constant + // (utils/commonFunctions.js → ALIANHUB_BOT_USER), so no env var check + // is needed for attribution anymore. Only the agent + PG need to be set. + return Boolean(agentConfigured() && pg.isConfigured()); +} + +module.exports = { + register, + isConfigured, + runProposalReview, // exported for smoke tests + handleTaskUpdate, // exported for smoke tests +}; diff --git a/frontend/src/assets/images/svg/project_apps_active_icons/apps_proposal_review_active.svg b/frontend/src/assets/images/svg/project_apps_active_icons/apps_proposal_review_active.svg new file mode 100644 index 0000000..30baf4b --- /dev/null +++ b/frontend/src/assets/images/svg/project_apps_active_icons/apps_proposal_review_active.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/images/svg/project_apps_inactive_icons/apps_proposal_review_inactive.svg b/frontend/src/assets/images/svg/project_apps_inactive_icons/apps_proposal_review_inactive.svg new file mode 100644 index 0000000..7f5839d --- /dev/null +++ b/frontend/src/assets/images/svg/project_apps_inactive_icons/apps_proposal_review_inactive.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/organisms/Comment/Comment.vue b/frontend/src/components/organisms/Comment/Comment.vue index 6953c5d..563d371 100644 --- a/frontend/src/components/organisms/Comment/Comment.vue +++ b/frontend/src/components/organisms/Comment/Comment.vue @@ -278,6 +278,10 @@ async function processUrl(message) { if (message.type !== "video" || message.type !== "audio" || message.type !== "image") { let properUrl = message.downloadURL || message.mediaURL; + // Text/link comments carry no media URL; skip cleanly instead of + // throwing `Cannot read properties of undefined (reading 'includes')`. + if (!properUrl) return; + if (!properUrl.includes("http")) { try { diff --git a/frontend/src/components/organisms/Task/Task.vue b/frontend/src/components/organisms/Task/Task.vue index 652b2c1..02af615 100644 --- a/frontend/src/components/organisms/Task/Task.vue +++ b/frontend/src/components/organisms/Task/Task.vue @@ -61,6 +61,11 @@
+ + + + + {{ task.TaskName }} diff --git a/frontend/src/components/organisms/Task/style.css b/frontend/src/components/organisms/Task/style.css index a91b91b..237c120 100644 --- a/frontend/src/components/organisms/Task/style.css +++ b/frontend/src/components/organisms/Task/style.css @@ -116,4 +116,21 @@ div.task-options div:last-child div { max-width: 45%; position: absolute; } -} \ No newline at end of file +} +/* Proposal Review — small inline SVG spinner shown next to TaskName while the agent is reviewing. + Two concentric circles in the SVG: a low-contrast gray ring (always visible) and a blue arc + (rotated by CSS). The SVG guarantees a clean round shape at any size — the previous pure-CSS + border approach rendered as a barely-visible C-shape on white because the gray was too faint. */ +.proposal-review-spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 6px; + vertical-align: middle; + flex-shrink: 0; + transform-origin: 50% 50%; + animation: proposalReviewSpin 0.8s linear infinite; +} +@keyframes proposalReviewSpin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/composable/commonFunction.js b/frontend/src/composable/commonFunction.js index d9c566c..4e55355 100644 --- a/frontend/src/composable/commonFunction.js +++ b/frontend/src/composable/commonFunction.js @@ -221,6 +221,11 @@ export const projectAppsIcons = (key) => { key: "AI", beforeIcon: require("@/assets/images/svg/project_apps_inactive_icons/apps_ai_inactive.svg"), afterIcon: require("@/assets/images/svg/project_apps_active_icons/apps_ai_active.svg") + }, + { + key: "proposalReview", + beforeIcon: require("@/assets/images/svg/project_apps_inactive_icons/apps_proposal_review_inactive.svg"), + afterIcon: require("@/assets/images/svg/project_apps_active_icons/apps_proposal_review_active.svg") } ]; diff --git a/frontend/src/composable/index.js b/frontend/src/composable/index.js index e500a30..bbf55f2 100644 --- a/frontend/src/composable/index.js +++ b/frontend/src/composable/index.js @@ -550,6 +550,29 @@ export function useGetterFunctions() { * @returns user object */ function getUser(id,type = null) { + // Static AlianHub AI sBot user — defined server-side in + // utils/commonFunctions.js → ALIANHUB_BOT_USER. The bot has no real + // entry in the `users` collection, so without this short-circuit it + // would render as "Ghost User" on every automated comment / activity + // entry. The id below MUST match ALIANHUB_BOT_USER.id on the backend. + if (id === '000000000000000000000b07') { + return { + id, + _id: id, + cuid: "", + Employee_Name: "AlianHub AI Bot", + Employee_profileImage: defaultUserAvatar, + Employee_profileImageURL: defaultUserAvatar, + isOnline: false, + timeFormat: "", + companyOwnerId: "", + Time_Zone: "", + assigneeCompany: [], + Employee_Email: "bot@alianhub.local", + ghostUser: false, + isVesionUpdate: false, + }; + } const obj = ref({ id: id, _id: id, diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index b783d77..817b00d 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -621,6 +621,7 @@ export default { CustomFields: "Custom Fields", TimeTracking: "Time Tracking", AI: "AI", + proposalReview: "Proposal Review", }, Tags: { no_tags_found: "No Tags Found", diff --git a/frontend/src/locales/gu.js b/frontend/src/locales/gu.js index 269ac61..0525436 100644 --- a/frontend/src/locales/gu.js +++ b/frontend/src/locales/gu.js @@ -592,7 +592,8 @@ export default { "MultipleAssignees": "એકથી વધુ સોંપનાર", "CustomFields": "કસ્ટમ ફીલ્ડ્સ", "TimeTracking": "સમય ટ્રેકિંગ", - "AI": "એઆઈ" + "AI": "એઆઈ", + "proposalReview": "પ્રસ્તાવ સમીક્ષા" }, "Tags": { "no_tags_found": "કોઈ ટૅગ્સ મળ્યા નથી", diff --git a/index.js b/index.js index af05cdd..44b33df 100644 --- a/index.js +++ b/index.js @@ -201,6 +201,7 @@ function initializeControllers() { require(`./Modules/storage/${currentDirectory}/init`).init(app); require('./Modules/AI/init').init(app); require('./Modules/AIProjectGenerator/init').init(app); + require('./Modules/ProposalReview/init').init(app); require('./Modules/Users/init').init(app); require('./Modules/Project/init').init(app); require('./Modules/Teams/init').init(app); diff --git a/package-lock.json b/package-lock.json index 27d9a91..ca18a4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "node-schedule": "2.1.1", "nodemailer": "6.9.16", "pdf-parse": "^2.4.5", + "pg": "^8.21.0", "sharp": "^0.34.5", "socket.io": "4.8.1", "swagger-jsdoc": "6.2.8", @@ -10228,6 +10229,96 @@ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10271,6 +10362,45 @@ "node": ">=8" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-format": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", @@ -11082,6 +11212,15 @@ "memory-pager": "^1.0.2" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -12257,6 +12396,15 @@ "node": ">=4.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 2f93c33..c743897 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "node-schedule": "2.1.1", "nodemailer": "6.9.16", "pdf-parse": "^2.4.5", + "pg": "^8.21.0", "sharp": "^0.34.5", "socket.io": "4.8.1", "swagger-jsdoc": "6.2.8", diff --git a/utils/commonFunctions.js b/utils/commonFunctions.js index 2e5b648..fc70529 100644 --- a/utils/commonFunctions.js +++ b/utils/commonFunctions.js @@ -20,4 +20,31 @@ exports.removeCache = (cacheKey,isKeyWithPrefix) => { } else { myCache.del(cacheKey); } -} \ No newline at end of file +} + +/* ------------- STATIC ALIANHUB AI BOT USER ------------- + * Global, in-code stand-in user for automated actions taken by the system + * (proposal-review auto-moves, future bot features). NOT persisted in the + * `users` collection — purely an attribution object that consumers can read + * via `userData.id` / `userData.Employee_Name` to label activity history, + * notifications, and comments as authored by "AlianHub AI Bot". + * + * The `_id` is a fictional 24-char hex string that intentionally does NOT + * collide with any real user ObjectId. Anywhere downstream that tries to + * resolve this id against the real `users` collection will simply not find + * it — which is fine: the Employee_Name is what shows in history messages. + * + * Frozen so callers can't accidentally mutate the shared instance. + */ +exports.ALIANHUB_BOT_USER = Object.freeze({ + id: '000000000000000000000b07', // 21x'0' + 'b07' (~= "bot") — clearly a placeholder + _id: '000000000000000000000b07', + Employee_Name: 'AlianHub AI Bot', + Employee_FName: 'AlianHub', + Employee_LName: 'Bot', + Employee_Email: 'bot@alianhub.local', + companyOwnerId: '', + isActive: true, + isOnline: false, + isEmailVerified: true, +}); \ No newline at end of file