godcrm/backend/routes/v3/chat/scheduledMessageController.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

230 lines
10 KiB
JavaScript

/**
* Scheduled Messages Controller (WP-17)
* CRUD for messages queued for future delivery.
*/
import {
dbRun, dbGet, dbAll, isPostgres, safeJsonParse, apiLogger,
success, created, error, badRequest, notFound, forbidden,
requireAuth,
} from './chatShared.js';
import { sendScheduledMessage } from '../../../services/ScheduledMessageWorker.js';
export default function registerScheduledMessageRoutes(router) {
// POST /conversations/:id/scheduled-messages — Schedule a message
router.post('/conversations/:id/scheduled-messages', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const { content, content_type = 'text', mentions = [], attachments = [], metadata = {}, scheduled_at } = req.body;
if (!content || !scheduled_at) return badRequest(res, 'content and scheduled_at are required');
const scheduledDate = new Date(scheduled_at);
if (isNaN(scheduledDate.getTime())) return badRequest(res, 'Invalid scheduled_at date');
if (scheduledDate.getTime() <= Date.now()) return badRequest(res, 'scheduled_at must be in the future');
// Verify conversation exists and user is participant
const participant = await dbGet(
isPostgres()
? `SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`
: `SELECT 1 FROM conversation_participants WHERE conversation_id = ? AND user_id = ?`,
[id, userId]
);
if (!participant) return notFound(res, 'Conversation not found');
const mentionsJson = JSON.stringify(mentions);
const attachmentsJson = JSON.stringify(attachments);
const metadataJson = JSON.stringify(metadata);
let result;
if (isPostgres()) {
result = await dbRun(
`INSERT INTO scheduled_messages (conversation_id, sender_id, content, content_type, mentions, attachments, metadata, scheduled_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8) RETURNING id`,
[id, userId, content, content_type, mentionsJson, attachmentsJson, metadataJson, scheduledDate.toISOString()]
);
} else {
result = await dbRun(
`INSERT INTO scheduled_messages (conversation_id, sender_id, content, content_type, mentions, attachments, metadata, scheduled_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, userId, content, content_type, mentionsJson, attachmentsJson, metadataJson, scheduledDate.toISOString()]
);
}
const row = await dbGet(
isPostgres() ? `SELECT * FROM scheduled_messages WHERE id = $1` : `SELECT * FROM scheduled_messages WHERE id = ?`,
[result.lastInsertRowid]
);
apiLogger.info({ conversationId: id, scheduledMessageId: row.id, scheduledAt: scheduled_at }, 'WP-17: Scheduled message created');
return created(res, formatRow(row));
} catch (err) {
apiLogger.error({ err }, 'Error creating scheduled message');
return error(res, 'SCHEDULE_MESSAGE_ERROR', err.message, 500);
}
});
// GET /conversations/:id/scheduled-messages — List pending scheduled messages
router.get('/conversations/:id/scheduled-messages', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const rows = await dbAll(
isPostgres()
? `SELECT sm.*, u.name as sender_name, u.avatar as sender_avatar FROM scheduled_messages sm LEFT JOIN users u ON sm.sender_id = u.id WHERE sm.conversation_id = $1 AND sm.status = 'pending' ORDER BY sm.scheduled_at ASC`
: `SELECT sm.*, u.name as sender_name, u.avatar as sender_avatar FROM scheduled_messages sm LEFT JOIN users u ON sm.sender_id = u.id WHERE sm.conversation_id = ? AND sm.status = 'pending' ORDER BY sm.scheduled_at ASC`,
[id]
);
return success(res, { scheduled_messages: rows.map(formatRow) });
} catch (err) {
apiLogger.error({ err }, 'Error listing scheduled messages');
return error(res, 'LIST_SCHEDULED_ERROR', err.message, 500);
}
});
// PUT /scheduled-messages/:smId — Edit a pending scheduled message
router.put('/scheduled-messages/:smId', requireAuth, async (req, res) => {
try {
const { smId } = req.params;
const userId = req.user.userId;
const existing = await dbGet(
isPostgres() ? `SELECT * FROM scheduled_messages WHERE id = $1` : `SELECT * FROM scheduled_messages WHERE id = ?`,
[smId]
);
if (!existing) return notFound(res, 'Scheduled message not found');
if (existing.status !== 'pending') return badRequest(res, 'Can only edit pending messages');
if (Number(existing.sender_id) !== Number(userId)) return forbidden(res, 'Can only edit your own scheduled messages');
const { content, scheduled_at, mentions, attachments, metadata } = req.body;
const updates = [];
const params = [];
let paramIdx = 1;
if (content !== undefined) {
updates.push(isPostgres() ? `content = $${paramIdx++}` : `content = ?`);
params.push(content);
}
if (scheduled_at !== undefined) {
const d = new Date(scheduled_at);
if (isNaN(d.getTime())) return badRequest(res, 'Invalid scheduled_at');
if (d.getTime() <= Date.now()) return badRequest(res, 'scheduled_at must be in the future');
updates.push(isPostgres() ? `scheduled_at = $${paramIdx++}` : `scheduled_at = ?`);
params.push(d.toISOString());
}
if (mentions !== undefined) {
updates.push(isPostgres() ? `mentions = $${paramIdx++}::jsonb` : `mentions = ?`);
params.push(JSON.stringify(mentions));
}
if (attachments !== undefined) {
updates.push(isPostgres() ? `attachments = $${paramIdx++}::jsonb` : `attachments = ?`);
params.push(JSON.stringify(attachments));
}
if (metadata !== undefined) {
updates.push(isPostgres() ? `metadata = $${paramIdx++}::jsonb` : `metadata = ?`);
params.push(JSON.stringify(metadata));
}
if (updates.length === 0) return badRequest(res, 'No fields to update');
updates.push(isPostgres() ? `updated_at = NOW()` : `updated_at = datetime('now')`);
params.push(smId);
await dbRun(
`UPDATE scheduled_messages SET ${updates.join(', ')} WHERE id = ${isPostgres() ? `$${paramIdx}` : '?'}`,
params
);
const updated = await dbGet(
isPostgres() ? `SELECT * FROM scheduled_messages WHERE id = $1` : `SELECT * FROM scheduled_messages WHERE id = ?`,
[smId]
);
apiLogger.info({ scheduledMessageId: smId }, 'WP-17: Scheduled message updated');
return success(res, formatRow(updated));
} catch (err) {
apiLogger.error({ err }, 'Error updating scheduled message');
return error(res, 'UPDATE_SCHEDULED_ERROR', err.message, 500);
}
});
// POST /scheduled-messages/:smId/send-now — Force immediate delivery of a pending scheduled message
router.post('/scheduled-messages/:smId/send-now', requireAuth, async (req, res) => {
try {
const { smId } = req.params;
const userId = req.user.userId;
const existing = await dbGet(
isPostgres() ? `SELECT * FROM scheduled_messages WHERE id = $1` : `SELECT * FROM scheduled_messages WHERE id = ?`,
[smId]
);
if (!existing) return notFound(res, 'Scheduled message not found');
if (Number(existing.sender_id) !== Number(userId)) return forbidden(res, 'Can only send your own scheduled messages');
if (existing.status !== 'pending') {
return error(res, 'already_processed', `Scheduled message already processed (status=${existing.status})`, 409);
}
// Reuse worker delivery path (insert message, parse mentions, trigger agents, mark sent)
await sendScheduledMessage(existing);
// Re-fetch to grab sent_message_id written by sendScheduledMessage
const sent = await dbGet(
isPostgres() ? `SELECT sent_message_id FROM scheduled_messages WHERE id = $1` : `SELECT sent_message_id FROM scheduled_messages WHERE id = ?`,
[smId]
);
apiLogger.info({ scheduledMessageId: smId, messageId: sent?.sent_message_id }, 'WP-17: Scheduled message force-sent via send-now');
return success(res, {
sent: true,
message_id: sent?.sent_message_id ? Number(sent.sent_message_id) : null,
scheduled_message_id: Number(smId),
});
} catch (err) {
apiLogger.error({ err, scheduledMessageId: req.params.smId }, 'Error force-sending scheduled message');
// Worker has its own try/catch around sendScheduledMessage in tick(), but here we propagate
return error(res, 'SEND_NOW_ERROR', err.message, 500);
}
});
// DELETE /scheduled-messages/:smId — Cancel a pending scheduled message
router.delete('/scheduled-messages/:smId', requireAuth, async (req, res) => {
try {
const { smId } = req.params;
const userId = req.user.userId;
const existing = await dbGet(
isPostgres() ? `SELECT * FROM scheduled_messages WHERE id = $1` : `SELECT * FROM scheduled_messages WHERE id = ?`,
[smId]
);
if (!existing) return notFound(res, 'Scheduled message not found');
if (existing.status !== 'pending') return badRequest(res, 'Can only cancel pending messages');
if (Number(existing.sender_id) !== Number(userId)) return forbidden(res, 'Can only cancel your own scheduled messages');
await dbRun(
isPostgres()
? `UPDATE scheduled_messages SET status = 'cancelled', updated_at = NOW() WHERE id = $1`
: `UPDATE scheduled_messages SET status = 'cancelled', updated_at = datetime('now') WHERE id = ?`,
[smId]
);
apiLogger.info({ scheduledMessageId: smId }, 'WP-17: Scheduled message cancelled');
return success(res, { cancelled: true, id: Number(smId) });
} catch (err) {
apiLogger.error({ err }, 'Error cancelling scheduled message');
return error(res, 'CANCEL_SCHEDULED_ERROR', err.message, 500);
}
});
}
function formatRow(row) {
return {
...row,
id: Number(row.id),
conversation_id: Number(row.conversation_id),
sender_id: Number(row.sender_id),
mentions: safeJsonParse(row.mentions) || [],
attachments: safeJsonParse(row.attachments) || [],
metadata: safeJsonParse(row.metadata) || {},
};
}