Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Modules/AIProjectGenerator/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
35 changes: 31 additions & 4 deletions Modules/AIProjectGenerator/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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}`);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: `<b>${historyActor.Employee_Name || 'AlianHub AI'}</b> has created new <b>${d.TaskName}</b> ${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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1051,4 +1077,5 @@ module.exports = {
blocksToText,
wrapDescriptionBlock,
normalizePriority,
createTasksForSprint,
};
79 changes: 76 additions & 3 deletions Modules/EstimatedTime/aiTaskEstimator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: [
Expand All @@ -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
? `<b>${actorName}</b> has updated total estimated time from <b>${taskHistoryHelper.convertToDisplayFormat(previousMinutes)}</b> to <b>${toText}</b> using AI.`
: `<b>${actorName}</b> has set the total estimated time to <b>${toText}</b> 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`.
Expand All @@ -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' };
Expand All @@ -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}`);
Expand Down
11 changes: 11 additions & 0 deletions Modules/EstimatedTime/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading