godcrm/backend/routes/v3/life-pipeline.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

627 lines
19 KiB
JavaScript

// backend/routes/v3/life-pipeline.js
// GOD CRM Life Pipeline - Morning Briefing & Evening Check-in
// Generates structured daily briefings and sends via Telegram
import { Router } from 'express';
import { dbAll, dbGet, safeJsonParse } from '../../database/connection.js';
import { sendMessage, sendToTopic } from '../../services/TelegramService.js';
import { apiLogger } from '../../utils/logger.js';
import { success, error } from '../../utils/response.js';
const router = Router();
const log = apiLogger.child({ module: 'life-pipeline' });
// =============================================================================
// CONSTANTS
// =============================================================================
const TELEGRAM_CHAT_ID = '423753027';
// Table IDs in GOD CRM
const TABLE_IDS = {
CALENDAR_EVENTS: 2671, // Google Calendar events (synced)
SPRINT_TASKS: 2649, // Sprint / Kanban tasks
HEALTH_METRICS: 2643, // Health tracking data
MEDICATIONS: 2658, // Medications & supplements
};
const TIMEZONE = 'Europe/Moscow';
// Priority display mapping
const PRIORITY_ICONS = {
p1: '\u{1F534}', // red circle
p0: '\u{1F534}',
high: '\u{1F534}',
urgent: '\u{1F534}',
p2: '\u{1F7E1}', // yellow circle
medium: '\u{1F7E1}',
normal: '\u{1F7E1}',
p3: '\u{1F7E2}', // green circle
low: '\u{1F7E2}',
p4: '\u26AA', // white circle
};
const DONE_STATES = ['Done', 'Archive', 'done', 'archive', 'completed', 'Completed'];
// =============================================================================
// HELPERS
// =============================================================================
/**
* Get today's date boundaries in Europe/Moscow timezone
* @returns {{ todayStart: string, todayEnd: string, todayLabel: string }}
*/
function getTodayBounds() {
const now = new Date();
// Format in target timezone
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const todayStr = formatter.format(now); // YYYY-MM-DD
// Human-readable label
const labelFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: TIMEZONE,
weekday: 'long',
day: '2-digit',
month: 'short',
year: 'numeric',
});
const todayLabel = labelFormatter.format(now);
return {
todayStr,
todayStart: `${todayStr}T00:00:00`,
todayEnd: `${todayStr}T23:59:59`,
todayLabel,
};
}
/**
* Extract time string (HH:MM) from a datetime string
* @param {string} datetime - ISO datetime or similar
* @returns {string} - "HH:MM" or "all-day"
*/
function extractTime(datetime) {
if (!datetime) return 'all-day';
// All-day events are just dates: YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(datetime)) {
return 'all-day';
}
try {
const d = new Date(datetime);
if (isNaN(d.getTime())) return 'all-day';
const timeFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE,
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return timeFormatter.format(d);
} catch {
return 'all-day';
}
}
/**
* Get priority icon for a task
* @param {string} priority
* @returns {string}
*/
function getPriorityIcon(priority) {
if (!priority) return '\u26AA';
const key = String(priority).toLowerCase().trim();
return PRIORITY_ICONS[key] || '\u26AA';
}
/**
* Escape Telegram Markdown v1 special characters
* @param {string} text
* @returns {string}
*/
function escTg(text) {
if (!text) return '';
return String(text).replace(/([_*`\[])/g, '\\$1');
}
// =============================================================================
// DATA FETCHERS
// =============================================================================
/**
* Fetch today's calendar events from table_rows
*/
async function fetchTodayEvents(todayStr) {
try {
// Query events where start_datetime contains today's date
const rows = await dbAll(
`SELECT id, data, created_at
FROM table_rows
WHERE table_id = ?
ORDER BY created_at DESC`,
[TABLE_IDS.CALENDAR_EVENTS]
);
const events = [];
for (const row of rows) {
const data = safeJsonParse(row.data) || {};
const startDt = data.start_datetime || '';
// Check if this event starts today
if (startDt.startsWith(todayStr)) {
events.push({
id: row.id,
title: data.title || data.summary || 'Untitled',
start_datetime: startDt,
end_datetime: data.end_datetime || '',
location: data.location || '',
time: extractTime(startDt),
});
}
}
// Sort by start time
events.sort((a, b) => a.start_datetime.localeCompare(b.start_datetime));
return events;
} catch (err) {
log.error({ err }, 'Failed to fetch calendar events');
return [];
}
}
/**
* Fetch active sprint tasks (not Done/Archive)
*/
async function fetchSprintTasks() {
try {
const rows = await dbAll(
`SELECT id, data
FROM table_rows
WHERE table_id = ?
ORDER BY created_at DESC`,
[TABLE_IDS.SPRINT_TASKS]
);
const tasks = [];
for (const row of rows) {
const data = safeJsonParse(row.data) || {};
const state = data.state || data.status || '';
if (!DONE_STATES.includes(state)) {
tasks.push({
id: row.id,
title: data.title || data.name || 'Untitled',
state,
priority: data.priority || '',
assigned_to: data.assigned_to || '',
});
}
}
// Sort by priority: P1/high first
const priorityOrder = { p0: 0, p1: 1, urgent: 1, high: 1, p2: 2, medium: 2, normal: 2, p3: 3, low: 3, p4: 4 };
tasks.sort((a, b) => {
const pa = priorityOrder[String(a.priority).toLowerCase()] ?? 5;
const pb = priorityOrder[String(b.priority).toLowerCase()] ?? 5;
return pa - pb;
});
return tasks;
} catch (err) {
log.error({ err }, 'Failed to fetch sprint tasks');
return [];
}
}
/**
* Fetch latest health metrics
*/
async function fetchHealthMetrics() {
try {
const row = await dbGet(
`SELECT id, data, created_at
FROM table_rows
WHERE table_id = ?
ORDER BY created_at DESC
LIMIT 1`,
[TABLE_IDS.HEALTH_METRICS]
);
if (!row) return null;
return { id: row.id, ...safeJsonParse(row.data), _recorded_at: row.created_at };
} catch (err) {
log.error({ err }, 'Failed to fetch health metrics');
return null;
}
}
/**
* Fetch medications/supplements
*/
async function fetchMedications() {
try {
const rows = await dbAll(
`SELECT id, data
FROM table_rows
WHERE table_id = ?
ORDER BY created_at ASC`,
[TABLE_IDS.MEDICATIONS]
);
return rows.map(row => {
const data = safeJsonParse(row.data) || {};
return {
id: row.id,
name: data.name || data.title || 'Unknown',
dosage: data.dosage || '',
time_of_day: data.time_of_day || data.schedule || data.time || 'any',
notes: data.notes || '',
};
});
} catch (err) {
log.error({ err }, 'Failed to fetch medications');
return [];
}
}
/**
* Fetch tasks completed today (for evening check-in)
*/
async function fetchCompletedToday(todayStr) {
try {
const rows = await dbAll(
`SELECT id, data, updated_at
FROM table_rows
WHERE table_id = ?
ORDER BY updated_at DESC`,
[TABLE_IDS.SPRINT_TASKS]
);
const completed = [];
for (const row of rows) {
const data = safeJsonParse(row.data) || {};
const state = data.state || data.status || '';
const updatedAt = row.updated_at || '';
if (DONE_STATES.includes(state) && updatedAt.startsWith(todayStr)) {
completed.push({
id: row.id,
title: data.title || data.name || 'Untitled',
state,
priority: data.priority || '',
});
}
}
return completed;
} catch (err) {
log.error({ err }, 'Failed to fetch completed tasks');
return [];
}
}
// =============================================================================
// MESSAGE FORMATTERS
// =============================================================================
/**
* Format morning briefing message for Telegram (Markdown v1)
*/
function formatMorningBriefing({ todayLabel, events, tasks, health, medications }) {
const lines = [];
// Header
lines.push('\u2600\uFE0F *\u0414\u043E\u0431\u0440\u043E\u0435 \u0443\u0442\u0440\u043E, \u0413\u0435\u0440\u0430!*');
lines.push('');
lines.push(`\u{1F4C5} *\u0421\u0415\u0413\u041E\u0414\u041D\u042F* (${escTg(todayLabel)}):`);
lines.push('');
// Calendar events
lines.push('\u{1F4C6} *\u041A\u0410\u041B\u0415\u041D\u0414\u0410\u0420\u042C:*');
if (events.length === 0) {
lines.push(' \u041D\u0435\u0442 \u0441\u043E\u0431\u044B\u0442\u0438\u0439 \u043D\u0430 \u0441\u0435\u0433\u043E\u0434\u043D\u044F');
} else {
for (const event of events) {
const timeStr = event.time === 'all-day' ? '\u{1F30D} all-day' : event.time;
const locationStr = event.location ? ` (\u{1F4CD}${escTg(event.location)})` : '';
lines.push(` ${timeStr} - ${escTg(event.title)}${locationStr}`);
}
}
lines.push('');
// Sprint tasks
lines.push('\u{1F4CB} *\u0417\u0410\u0414\u0410\u0427\u0418 (Sprint):*');
if (tasks.length === 0) {
lines.push(' \u0412\u0441\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u044B\u043F\u043E\u043B\u043D\u0435\u043D\u044B! \u{1F389}');
} else {
const shown = tasks.slice(0, 15); // Limit to avoid Telegram message size overflow
for (const task of shown) {
const icon = getPriorityIcon(task.priority);
const pLabel = task.priority ? String(task.priority).toUpperCase() : '';
const stateStr = task.state ? ` [${escTg(task.state)}]` : '';
lines.push(` ${icon} ${pLabel ? pLabel + ': ' : ''}${escTg(task.title)}${stateStr}`);
}
if (tasks.length > 15) {
lines.push(` ... \u0438 \u0435\u0449\u0451 ${tasks.length - 15} \u0437\u0430\u0434\u0430\u0447`);
}
}
lines.push('');
// Medications
lines.push('\u{1F48A} *\u041B\u0415\u041A\u0410\u0420\u0421\u0422\u0412\u0410/\u0414\u041E\u0411\u0410\u0412\u041A\u0418:*');
if (medications.length === 0) {
lines.push(' \u041D\u0435\u0442 \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0439');
} else {
// Group by time_of_day
const groups = {};
for (const med of medications) {
const time = med.time_of_day || 'any';
if (!groups[time]) groups[time] = [];
const dosageStr = med.dosage ? ` (${escTg(med.dosage)})` : '';
groups[time].push(`${escTg(med.name)}${dosageStr}`);
}
const timeLabels = {
morning: '\u0423\u0442\u0440\u043E',
afternoon: '\u0414\u0435\u043D\u044C',
evening: '\u0412\u0435\u0447\u0435\u0440',
night: '\u041D\u043E\u0447\u044C',
any: '\u041B\u044E\u0431\u043E\u0435 \u0432\u0440\u0435\u043C\u044F',
};
for (const [time, meds] of Object.entries(groups)) {
const label = timeLabels[time.toLowerCase()] || escTg(time);
lines.push(` ${label}: ${meds.join(', ')}`);
}
}
lines.push('');
// Health metrics
lines.push('\u{1F4CA} *\u0417\u0414\u041E\u0420\u041E\u0412\u042C\u0415:*');
if (!health) {
lines.push(' \u041D\u0435\u0442 \u0434\u0430\u043D\u043D\u044B\u0445');
} else {
if (health.streak !== undefined && health.streak !== null) {
lines.push(` Streak: ${health.streak} \u0434\u043D\u0435\u0439`);
}
if (health.weight) {
lines.push(` \u0412\u0435\u0441: ${health.weight} kg`);
}
if (health.steps) {
lines.push(` \u0428\u0430\u0433\u0438: ${health.steps}`);
}
if (health.sleep_hours) {
lines.push(` \u0421\u043E\u043D: ${health.sleep_hours}h`);
}
if (health.mood) {
lines.push(` \u041D\u0430\u0441\u0442\u0440\u043E\u0435\u043D\u0438\u0435: ${escTg(health.mood)}`);
}
if (health.energy) {
lines.push(` \u042D\u043D\u0435\u0440\u0433\u0438\u044F: ${health.energy}/10`);
}
// If nothing specific was printed, show recorded_at
const hasSpecificMetrics = health.streak || health.weight || health.steps || health.sleep_hours || health.mood || health.energy;
if (!hasSpecificMetrics) {
lines.push(` \u041F\u043E\u0441\u043B\u0435\u0434\u043D\u044F\u044F \u0437\u0430\u043F\u0438\u0441\u044C: ${health._recorded_at || 'N/A'}`);
}
}
lines.push('');
// Footer
lines.push('\u0425\u043E\u0440\u043E\u0448\u0435\u0433\u043E \u0434\u043D\u044F! \u{1F4AA}');
return lines.join('\n');
}
/**
* Format evening check-in message for Telegram (Markdown v1)
*/
function formatEveningCheckin({ todayLabel, completedTasks, remainingTasks, medications, health }) {
const lines = [];
// Header
lines.push('\u{1F319} *\u0412\u0435\u0447\u0435\u0440\u043D\u0438\u0439 \u0447\u0435\u043A-\u0438\u043D*');
lines.push('');
lines.push(`\u{1F4C5} *\u0418\u0422\u041E\u0413\u0418 \u0414\u041D\u042F* (${escTg(todayLabel)}):`);
lines.push('');
// Completed tasks
lines.push('\u2705 *\u0421\u0414\u0415\u041B\u0410\u041D\u041E:*');
if (completedTasks.length === 0) {
lines.push(' \u041D\u0435\u0442 \u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043D\u043D\u044B\u0445 \u0437\u0430\u0434\u0430\u0447 \u0441\u0435\u0433\u043E\u0434\u043D\u044F');
} else {
for (const task of completedTasks) {
const icon = getPriorityIcon(task.priority);
lines.push(` ${icon} ${escTg(task.title)}`);
}
}
lines.push('');
// Remaining tasks
lines.push('\u{1F6A7} *\u041D\u0415 \u0417\u0410\u0412\u0415\u0420\u0428\u0415\u041D\u041E:*');
if (remainingTasks.length === 0) {
lines.push(' \u0412\u0441\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u044B\u043F\u043E\u043B\u043D\u0435\u043D\u044B! \u{1F3C6}');
} else {
const shown = remainingTasks.slice(0, 10);
for (const task of shown) {
const icon = getPriorityIcon(task.priority);
const stateStr = task.state ? ` [${escTg(task.state)}]` : '';
lines.push(` ${icon} ${escTg(task.title)}${stateStr}`);
}
if (remainingTasks.length > 10) {
lines.push(` ... \u0438 \u0435\u0449\u0451 ${remainingTasks.length - 10} \u0437\u0430\u0434\u0430\u0447`);
}
}
lines.push('');
// Evening medications reminder
lines.push('\u{1F48A} *\u0412\u0415\u0427\u0415\u0420\u041D\u0418\u0415 \u041B\u0415\u041A\u0410\u0420\u0421\u0422\u0412\u0410:*');
const eveningMeds = medications.filter(m => {
const t = (m.time_of_day || '').toLowerCase();
return t === 'evening' || t === 'night' || t === 'any';
});
if (eveningMeds.length === 0) {
lines.push(' \u041D\u0435\u0442 \u0432\u0435\u0447\u0435\u0440\u043D\u0438\u0445 \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0439');
} else {
const medNames = eveningMeds.map(m => {
const dosageStr = m.dosage ? ` (${escTg(m.dosage)})` : '';
return `${escTg(m.name)}${dosageStr}`;
});
lines.push(` ${medNames.join(', ')}`);
}
lines.push('');
// Health / streak
lines.push('\u{1F4CA} *\u0417\u0414\u041E\u0420\u041E\u0412\u042C\u0415:*');
if (!health) {
lines.push(' \u041D\u0435\u0442 \u0434\u0430\u043D\u043D\u044B\u0445');
} else {
if (health.streak !== undefined && health.streak !== null) {
lines.push(` Streak: ${health.streak} \u0434\u043D\u0435\u0439 \u{1F525}`);
}
if (health.mood) {
lines.push(` \u041D\u0430\u0441\u0442\u0440\u043E\u0435\u043D\u0438\u0435: ${escTg(health.mood)}`);
}
if (health.energy) {
lines.push(` \u042D\u043D\u0435\u0440\u0433\u0438\u044F: ${health.energy}/10`);
}
}
lines.push('');
// Summary stats
const total = completedTasks.length + remainingTasks.length;
const pct = total > 0 ? Math.round((completedTasks.length / total) * 100) : 0;
lines.push(`\u{1F4C8} *\u041F\u0440\u043E\u0434\u0443\u043A\u0442\u0438\u0432\u043D\u043E\u0441\u0442\u044C:* ${completedTasks.length}/${total} \u0437\u0430\u0434\u0430\u0447 (${pct}%)`);
lines.push('');
// Footer
lines.push('\u0421\u043F\u043E\u043A\u043E\u0439\u043D\u043E\u0439 \u043D\u043E\u0447\u0438! \u{1F303}');
return lines.join('\n');
}
// =============================================================================
// ROUTES
// =============================================================================
/**
* GET /api/v3/integrations/morning-briefing
* Generate and send a morning briefing via Telegram
*/
router.get('/morning-briefing', async (req, res) => {
try {
log.info('Generating morning briefing');
const { todayStr, todayLabel } = getTodayBounds();
// Fetch all data in parallel
const [events, tasks, health, medications] = await Promise.all([
fetchTodayEvents(todayStr),
fetchSprintTasks(),
fetchHealthMetrics(),
fetchMedications(),
]);
// Format the message
const message = formatMorningBriefing({
todayLabel,
events,
tasks,
health,
medications,
});
// Send to forum topic (not DM) — schedule topic for morning briefing
const tgResult = await sendToTopic('schedule', message);
log.info({
eventsCount: events.length,
tasksCount: tasks.length,
hasMedications: medications.length > 0,
hasHealth: !!health,
telegramSuccess: tgResult.success,
topic: 'schedule',
}, 'Morning briefing sent to topic');
return success(res, {
message,
telegram: tgResult,
stats: {
events: events.length,
active_tasks: tasks.length,
medications: medications.length,
has_health_data: !!health,
},
date: todayStr,
});
} catch (err) {
log.error({ err }, 'Failed to generate morning briefing');
return error(res, 'BRIEFING_FAILED', `Failed to generate morning briefing: ${err.message}`, 500);
}
});
/**
* GET /api/v3/integrations/evening-checkin
* Generate and send an evening check-in via Telegram
*/
router.get('/evening-checkin', async (req, res) => {
try {
log.info('Generating evening check-in');
const { todayStr, todayLabel } = getTodayBounds();
// Fetch all data in parallel
const [completedTasks, remainingTasks, health, medications] = await Promise.all([
fetchCompletedToday(todayStr),
fetchSprintTasks(), // These are still active (not done)
fetchHealthMetrics(),
fetchMedications(),
]);
// Format the message
const message = formatEveningCheckin({
todayLabel,
completedTasks,
remainingTasks,
medications,
health,
});
// Send to forum topic (not DM) — tasks topic for evening check-in
const tgResult = await sendToTopic('tasks', message);
log.info({
completedCount: completedTasks.length,
remainingCount: remainingTasks.length,
telegramSuccess: tgResult.success,
topic: 'tasks',
}, 'Evening check-in sent to topic');
return success(res, {
message,
telegram: tgResult,
stats: {
completed_today: completedTasks.length,
remaining: remainingTasks.length,
productivity_pct: completedTasks.length + remainingTasks.length > 0
? Math.round((completedTasks.length / (completedTasks.length + remainingTasks.length)) * 100)
: 0,
medications: medications.length,
has_health_data: !!health,
},
date: todayStr,
});
} catch (err) {
log.error({ err }, 'Failed to generate evening check-in');
return error(res, 'CHECKIN_FAILED', `Failed to generate evening check-in: ${err.message}`, 500);
}
});
export default router;