diff --git a/Modules/AIProjectGenerator/controller.js b/Modules/AIProjectGenerator/controller.js index c74eba3..2e3d311 100644 --- a/Modules/AIProjectGenerator/controller.js +++ b/Modules/AIProjectGenerator/controller.js @@ -492,17 +492,27 @@ exports.execute = async (req, res) => { plan = normalizePlanColors(reCheck.data); // Re-sanitize assignee ids against the current company membership in - // case roster changed since /plan was called. + // case roster changed since /plan was called. While the roster is + // loaded, resolve the current user's display name from `uid` so every + // AI-generated activity-log entry (project / task / estimate) is + // attributed to the real person who triggered the run — matching the + // manual flow — instead of a generic label. + let currentUserName = ''; try { const members = await loadActiveMembers(companyId); const allowed = new Set(members.map((m) => String(m.id))); + const me = members.find((m) => String(m.id) === String(uid)); + if (me && me.name) currentUserName = me.name; const sanitized = sanitizeMemberIds(plan, allowed); plan = sanitized.plan; } catch (_e) { /* leave as-is */ } const userData = { id: String(uid), - Employee_Name: (req.body && req.body.userName) || 'AlianHub AI', + // Prefer the server-resolved roster name (authoritative current + // user); fall back to a client-supplied name, then a generic label + // only if the user genuinely can't be resolved. + Employee_Name: currentUserName || (req.body && req.body.userName) || 'AlianHub AI', companyOwnerId: companyId, }; diff --git a/Modules/AIProjectGenerator/orchestrator.js b/Modules/AIProjectGenerator/orchestrator.js index bbda08c..397edc3 100644 --- a/Modules/AIProjectGenerator/orchestrator.js +++ b/Modules/AIProjectGenerator/orchestrator.js @@ -54,7 +54,7 @@ const sseEmitter = require('./sseEmitter'); // project generates dozens of tasks at once. const ESTIMATE_CONCURRENCY = 3; -function fireTaskEstimatesInBackground(companyId, docs) { +function fireTaskEstimatesInBackground(companyId, docs, userData) { if (!Array.isArray(docs) || docs.length === 0) return; let cursor = 0; const runOne = async () => { @@ -67,6 +67,9 @@ function fireTaskEstimatesInBackground(companyId, docs) { companyId, taskId: String(d._id), task: d, + // Pass the run's actor so the estimate's activity-log entry + // is attributed consistently (defaults to "AlianHub AI"). + userData, }); } catch (e) { logger.error(`AI task estimate error (orchestrator): ${e && e.message ? e.message : e}`); @@ -735,7 +738,7 @@ async function reserveTaskKeyRange({ companyId, projectId, count, taskTypeIncrem return { newLastTaskId: lastTaskId, startKey: lastTaskId - count + 1 }; } -async function createTasksForSprint({ companyId, projectDoc, sprintDoc, tasks, statusByName, taskTypeByKey, creatorUid }) { +async function createTasksForSprint({ companyId, projectDoc, sprintDoc, tasks, statusByName, taskTypeByKey, creatorUid, userData }) { if (!tasks || tasks.length === 0) return []; const docs = tasks.map((t) => buildTaskDoc({ @@ -779,10 +782,32 @@ async function createTasksForSprint({ companyId, projectDoc, sprintDoc, tasks, s try { socketEmitter.emit('insert', { type: 'insert', data: d, module: 'task' }); } catch (_e) { /* ignore */ } } + // Activity-log entry per created task — mirrors the manual task-create + // history (key "Task_Created") so each AI-generated task shows a + // "created" row in its Activity tab, just like the project does. We log + // ONLY task-level history (not project-level) so the project's Activity + // feed keeps its single "created project with N sprints and M tasks" + // summary instead of being flooded with one row per task. Best-effort: + // failures are logged and never block task creation. + const historyActor = userData || { id: String(creatorUid || ''), Employee_Name: 'AlianHub AI' }; + for (const d of docs) { + const taskTypeLabel = String(d.TaskType || 'task').replace(/_/g, '-'); + const historyObj = { + message: `${historyActor.Employee_Name || 'AlianHub AI'} has created new ${d.TaskName} ${taskTypeLabel}.`, + key: 'Task_Created', + sprintId: String(sprintDoc._id), + }; + HandleHistory('task', companyId, String(projectDoc._id), String(d._id), historyObj, historyActor) + .catch((e) => { + logger.error(`AI task-create history error: ${e && e.message ? e.message : e}`); + }); + } + // Estimates run in the background so the orchestrator's progress stream // is not blocked by the per-task LLM calls. Each estimate persists its - // own `totalEstimatedTime` update and emits a follow-up socket event. - fireTaskEstimatesInBackground(companyId, docs); + // own `totalEstimatedTime` update, emits a follow-up socket event, and + // logs its own estimate activity-log entry. + fireTaskEstimatesInBackground(companyId, docs, userData); return docs; } @@ -975,6 +1000,7 @@ async function executePlan({ plan, companyId, uid, userData, jobId }) { statusByName: taskStatusByName, taskTypeByKey, creatorUid: uid, + userData, }); for (const t of created) tracker.tasks.push(t._id.toString()); completed += created.length; @@ -1051,4 +1077,5 @@ module.exports = { blocksToText, wrapDescriptionBlock, normalizePriority, + createTasksForSprint, }; diff --git a/Modules/EstimatedTime/aiTaskEstimator.js b/Modules/EstimatedTime/aiTaskEstimator.js index 13be56a..ee295e2 100644 --- a/Modules/EstimatedTime/aiTaskEstimator.js +++ b/Modules/EstimatedTime/aiTaskEstimator.js @@ -29,6 +29,22 @@ try { providerFactory = null; } +// Task activity-log + minute-formatting helpers, loaded defensively (same +// pattern as providerFactory above) so a missing/renamed module can never +// break the estimator — the activity-log entry just silently no-ops. +// HandleHistory writes the same HISTORY doc the manual estimate edit does, +// so the AI estimate shows up in a task's Activity tab identically. +let taskHistoryHelper = null; +try { + taskHistoryHelper = require('../Tasks/helpers/helper'); +} catch (_e) { + taskHistoryHelper = null; +} + +// Default actor for AI-generated estimates when no explicit user is passed. +// Matches the AIProjectGenerator flow's author name (see controller.js). +const AI_ACTOR_NAME = 'AlianHub AI'; + // Bounds chosen so a malformed LLM response can't write nonsense. // 5 minutes — smallest meaningful "AI does the task end-to-end" unit. // 7 days — anything larger should be broken into subtasks. @@ -231,7 +247,8 @@ async function callProvider(task) { return parseMinutes(result.content); } -async function persistEstimate(companyId, taskId, minutes) { +async function persistEstimate(companyId, taskId, minutes, opts = {}) { + const { userData, previousMinutes } = opts; const updateQuery = { type: SCHEMA_TYPE.TASKS, data: [ @@ -250,10 +267,57 @@ async function persistEstimate(companyId, taskId, minutes) { module: 'task', }); } catch (_e) { /* socket emit best-effort */ } + + // Activity-log entry — mirrors the manual estimate-edit history + // (key "task_total_estimate") so the AI estimate shows in the task's + // Activity tab. Best-effort: never throws, never blocks the estimate. + logEstimateHistory({ companyId, result, taskId, minutes, userData, previousMinutes }); } return result; } +/** + * Write a task activity-log row for an AI-generated estimate. Resolved from + * the canonical updated task doc (so ProjectId / sprintId are always right) + * and the shared HandleHistory helper used everywhere else. Best-effort: + * any failure is logged and swallowed so it can't affect the estimate flow. + */ +function logEstimateHistory({ companyId, result, taskId, minutes, userData, previousMinutes }) { + try { + if (!taskHistoryHelper + || typeof taskHistoryHelper.HandleHistory !== 'function' + || typeof taskHistoryHelper.convertToDisplayFormat !== 'function') { + return; + } + const projectId = result.ProjectID ? String(result.ProjectID) : null; + if (!projectId) return; + + const actorName = (userData && userData.Employee_Name) || AI_ACTOR_NAME; + const actorId = (userData && userData.id) ? String(userData.id) : ''; + // HISTORY.UserId is a required String — Mongoose rejects an empty + // value, so a blank actor id would make the save fail validation and + // get silently swallowed by the .catch below (no activity-log row). + // Skip rather than attempt a doomed write; callers that want the log + // pass a real user (orchestrator -> creator, manual trigger -> clicker). + if (!actorId) return; + const toText = taskHistoryHelper.convertToDisplayFormat(minutes); + const hasPrev = typeof previousMinutes === 'number' && previousMinutes > 0; + const message = hasPrev + ? `${actorName} has updated total estimated time from ${taskHistoryHelper.convertToDisplayFormat(previousMinutes)} to ${toText} using AI.` + : `${actorName} has set the total estimated time to ${toText} using AI.`; + + taskHistoryHelper.HandleHistory('task', companyId, projectId, taskId, { + key: 'task_total_estimate', + message, + sprintId: (result.sprintArray && result.sprintArray.id) || result.sprintId || '', + }, { id: actorId, Employee_Name: actorName }).catch((e) => { + logger.error(`AI estimate history error for task ${taskId}: ${e && e.message ? e.message : e}`); + }); + } catch (e) { + logger.error(`AI estimate history build error for task ${taskId}: ${e && e.message ? e.message : e}`); + } +} + /** * Estimate task completion time and persist to `totalEstimatedTime` (minutes). * Never throws; safe to invoke without `await`. @@ -265,9 +329,12 @@ async function persistEstimate(companyId, taskId, minutes) { * @param {boolean} [params.force] When true, bypass the "estimate already set" * guard. Used by the manual sidebar button * which is an explicit recalculation request. + * @param {object} [params.userData] Optional actor for the activity-log entry + * ({ id, Employee_Name }). Defaults to the + * "AlianHub AI" actor when omitted. * @returns {Promise<{status: boolean, minutes?: number, reason?: string}>} */ -async function estimateAndPersist({ companyId, taskId, task, force = false } = {}) { +async function estimateAndPersist({ companyId, taskId, task, force = false, userData } = {}) { try { if (!companyId || !taskId || !task) { return { status: false, reason: 'missing required input' }; @@ -284,11 +351,17 @@ async function estimateAndPersist({ companyId, taskId, task, force = false } = { && task.totalEstimatedTime > 0) { return { status: false, reason: 'estimate already set' }; } + // Capture the prior value BEFORE the update so the activity log can + // render a "from X to Y" message on a re-estimate (and "set to Y" on + // a first estimate, where there is no prior value). + const previousMinutes = (typeof task.totalEstimatedTime === 'number') + ? task.totalEstimatedTime + : null; const minutes = await callProvider(task); if (minutes == null) { return { status: false, reason: 'no estimate returned' }; } - await persistEstimate(companyId, taskId, minutes); + await persistEstimate(companyId, taskId, minutes, { userData, previousMinutes }); return { status: true, minutes }; } catch (error) { logger.error(`AI task estimator failed for task ${taskId}: ${error && error.message ? error.message : error}`); diff --git a/Modules/EstimatedTime/controller.js b/Modules/EstimatedTime/controller.js index 918fb1b..4c1b0a6 100644 --- a/Modules/EstimatedTime/controller.js +++ b/Modules/EstimatedTime/controller.js @@ -105,11 +105,22 @@ exports.generateAiEstimate = async (req, res) => { return res.status(404).json({ status: false, statusText: 'task not found' }); } + // Actor for the activity-log entry the estimator writes. The client + // sends the logged-in user so the "updated estimated time" history row + // is attributed to whoever clicked the AI trigger (and so HISTORY.UserId + // — a required String — is never blank). Falls back to undefined when + // absent, in which case the estimator skips the log rather than failing. + const { userName, userId } = req.body || {}; + const userData = userId + ? { id: String(userId), Employee_Name: userName || 'AlianHub AI' } + : undefined; + const result = await estimateTaskTimeWithAI({ companyId, taskId, task: taskDoc, force: true, + userData, }); if (!result.status) { diff --git a/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue b/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue index a40dc87..c2dc263 100644 --- a/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue +++ b/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue @@ -715,7 +715,14 @@ const generateAiEstimate = async () => { } isAiEstimateLoading.value = true; try { - const response = await apiRequest('post', `${env.ESTIMATED_TIME}/ai/${taskId}`, {}); + // Send the logged-in user so the estimator can attribute the + // "updated estimated time" activity-log entry to whoever clicked + // (and so the required HISTORY.UserId is never blank). + const userData = getUserData(); + const response = await apiRequest('post', `${env.ESTIMATED_TIME}/ai/${taskId}`, { + userName: userData.Employee_Name, + userId: userData.id, + }); if (response && response.data && response.data.status) { $toast.success('Estimate generated', { position: 'top-right' }); } else {