Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
240 lines
9.6 KiB
JavaScript
240 lines
9.6 KiB
JavaScript
// ADR-0031 P5 / ADR-133 WP-20 — Move-with-stub
|
|
//
|
|
// Move a batch of messages from source conversation to target conversation,
|
|
// leaving a stub (`content_type='moved'`) in the source pointing forward.
|
|
// Atomic: all source updates + target inserts in a single transaction.
|
|
//
|
|
// Source row after move:
|
|
// content_type='moved'
|
|
// content='Moved to chat #<target> at <ts>'
|
|
// metadata.moved_to = { conversation_id, message_ids, batch_id, moved_by }
|
|
//
|
|
// Target row (new):
|
|
// content_type / content / role / sender preserved from source
|
|
// metadata.moved_from = { conversation_id, message_id, original_time, batch_id, moved_by }
|
|
//
|
|
// `moved_by` snapshots the actor at move time: { user_id, name, avatar }.
|
|
// avatar is null when missing OR when >2KB (mobile OOM guard, matches summary
|
|
// endpoint's AVATAR_MAX_BYTES). Frontend ChatLinkCard renders the mover's
|
|
// avatar+name as the card header.
|
|
//
|
|
// ADR-0031 §Z / WP-24: every move call generates one UUID v4 `batch_id`. Both
|
|
// the stub (source) and the new rows (target) carry it so the frontend
|
|
// ChatLinkCard can group all messages from the same move into a single card.
|
|
//
|
|
// Auth: caller must be a participant of BOTH source and target conversations,
|
|
// unless `actorIsChatOwner=true` (the route layer sets this for the chat owner
|
|
// per `conversations.created_by`, or for the system admin override). The
|
|
// chat-owner gate itself is enforced at the route layer (ADR-0031 WP-24).
|
|
|
|
import { randomUUID } from 'crypto';
|
|
import { dbGet, withTransactionAsync } from '../database/connection.js';
|
|
import { apiLogger } from '../utils/logger.js';
|
|
|
|
export class MoveValidationError extends Error {
|
|
constructor(message) { super(message); this.name = 'MoveValidationError'; this.code = 'VALIDATION'; }
|
|
}
|
|
export class MoveAuthError extends Error {
|
|
constructor(message) { super(message); this.name = 'MoveAuthError'; this.code = 'AUTH'; }
|
|
}
|
|
|
|
function asJsonString(val) {
|
|
if (val == null) return null;
|
|
if (typeof val === 'string') return val;
|
|
return JSON.stringify(val);
|
|
}
|
|
|
|
function asObject(val, fallback = {}) {
|
|
if (val == null) return fallback;
|
|
if (typeof val === 'object') return val;
|
|
if (typeof val === 'string') {
|
|
try { return JSON.parse(val); } catch { return fallback; }
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* Move messages from one conversation to another, leaving stubs in the source.
|
|
*
|
|
* @param {Object} args
|
|
* @param {number} args.sourceConversationId
|
|
* @param {number} args.targetConversationId
|
|
* @param {number[]} args.messageIds — must all belong to the source conversation, not deleted, not already moved.
|
|
* @param {number} args.userId — caller; must be a participant of both conversations (unless actorIsChatOwner=true).
|
|
* @param {boolean} [args.actorIsChatOwner=false] — caller is the chat owner (or system admin override); skip the participant check. The chat-owner gate itself is enforced at the route layer (ADR-0031 WP-24).
|
|
* @returns {Promise<{moved_count, source_message_ids, target_message_ids}>}
|
|
*/
|
|
export async function moveMessages({
|
|
sourceConversationId,
|
|
targetConversationId,
|
|
messageIds,
|
|
userId,
|
|
actorIsChatOwner = false,
|
|
}) {
|
|
const srcId = Number(sourceConversationId);
|
|
const tgtId = Number(targetConversationId);
|
|
const uid = Number(userId);
|
|
|
|
if (!Number.isFinite(srcId) || srcId <= 0) throw new MoveValidationError('invalid source conversation id');
|
|
if (!Number.isFinite(tgtId) || tgtId <= 0) throw new MoveValidationError('invalid target conversation id');
|
|
if (!Number.isFinite(uid) || uid <= 0) throw new MoveValidationError('invalid user id');
|
|
if (srcId === tgtId) throw new MoveValidationError('source and target conversation must differ');
|
|
if (!Array.isArray(messageIds) || messageIds.length === 0) {
|
|
throw new MoveValidationError('message_ids must be a non-empty array');
|
|
}
|
|
const ids = messageIds.map(Number);
|
|
if (ids.some(n => !Number.isFinite(n) || n <= 0)) {
|
|
throw new MoveValidationError('message_ids must contain positive integers');
|
|
}
|
|
|
|
// Auth: caller is participant of both conversations (skipped when the route layer
|
|
// has already verified the caller is the chat owner or system admin override).
|
|
if (!actorIsChatOwner) {
|
|
const srcPart = await dbGet(
|
|
`SELECT user_id FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
|
[srcId, uid]
|
|
);
|
|
if (!srcPart) throw new MoveAuthError('not a participant of source conversation');
|
|
|
|
const tgtPart = await dbGet(
|
|
`SELECT user_id FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
|
[tgtId, uid]
|
|
);
|
|
if (!tgtPart) throw new MoveAuthError('not a participant of target conversation');
|
|
}
|
|
|
|
// ADR-0031 §Z / WP-24: one UUID v4 per move operation, written to every
|
|
// stub (moved_to.batch_id) and every target message (moved_from.batch_id).
|
|
const batchId = randomUUID();
|
|
|
|
// Snapshot the actor (avatar capped to 2KB to keep messages JSONB small —
|
|
// see project_users_avatar_base64_mine memory). Frontend renders mover's
|
|
// avatar+name in the card header.
|
|
const AVATAR_MAX_BYTES = 2048;
|
|
const actorRow = await dbGet(
|
|
`SELECT id, name, avatar FROM users WHERE id = $1`,
|
|
[uid]
|
|
);
|
|
const movedBy = actorRow ? {
|
|
user_id: actorRow.id,
|
|
name: actorRow.name || null,
|
|
avatar: typeof actorRow.avatar === 'string' && actorRow.avatar.length > 0 && actorRow.avatar.length <= AVATAR_MAX_BYTES
|
|
? actorRow.avatar
|
|
: null,
|
|
} : { user_id: uid, name: null, avatar: null };
|
|
|
|
return await withTransactionAsync(async (trx) => {
|
|
// Fetch source messages — must all belong to source conversation, not deleted, not already moved.
|
|
const sourceMsgs = await trx.all(
|
|
`SELECT id, conversation_id, sender_id, sender_type, role, content, content_type,
|
|
agent_id, model_used, mentions, attachments, tool_results, metadata, created_at
|
|
FROM messages
|
|
WHERE id = ANY($1::int[])
|
|
AND conversation_id = $2
|
|
AND (is_deleted = 0 OR is_deleted IS NULL)
|
|
ORDER BY id ASC`,
|
|
[ids, srcId]
|
|
);
|
|
|
|
if (sourceMsgs.length !== ids.length) {
|
|
const found = new Set(sourceMsgs.map(m => m.id));
|
|
const missing = ids.filter(i => !found.has(i));
|
|
throw new MoveValidationError(`messages not found in source conversation: ${missing.join(', ')}`);
|
|
}
|
|
const alreadyMoved = sourceMsgs.filter(m => m.content_type === 'moved');
|
|
if (alreadyMoved.length > 0) {
|
|
throw new MoveValidationError(`messages already moved: ${alreadyMoved.map(m => m.id).join(', ')}`);
|
|
}
|
|
|
|
// Insert into target — collect new ids 1:1 with sourceMsgs ordering.
|
|
const targetIds = [];
|
|
for (const src of sourceMsgs) {
|
|
const baseMeta = asObject(src.metadata, {});
|
|
const targetMeta = {
|
|
...baseMeta,
|
|
moved_from: {
|
|
conversation_id: srcId,
|
|
message_id: src.id,
|
|
original_time: src.created_at instanceof Date
|
|
? src.created_at.toISOString()
|
|
: String(src.created_at),
|
|
batch_id: batchId,
|
|
moved_by: movedBy,
|
|
},
|
|
};
|
|
|
|
// ADR-0031 P5 acceptance: preserve original created_at on target rows
|
|
// (so they appear in chronological position in the target conversation).
|
|
const inserted = await trx.run(
|
|
`INSERT INTO messages (
|
|
conversation_id, sender_id, sender_type, role, content, content_type,
|
|
agent_id, model_used, mentions, attachments, tool_results, metadata, created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6,
|
|
$7, $8, $9::jsonb, $10::jsonb, $11, $12::jsonb, $13
|
|
) RETURNING id`,
|
|
[
|
|
tgtId,
|
|
src.sender_id,
|
|
src.sender_type || 'human',
|
|
src.role,
|
|
src.content,
|
|
src.content_type,
|
|
src.agent_id,
|
|
src.model_used,
|
|
asJsonString(src.mentions) ?? '[]',
|
|
asJsonString(src.attachments) ?? '[]',
|
|
src.tool_results == null ? null : asJsonString(src.tool_results),
|
|
JSON.stringify(targetMeta),
|
|
src.created_at,
|
|
]
|
|
);
|
|
targetIds.push(Number(inserted.lastInsertRowid));
|
|
}
|
|
|
|
// Stub each source row. Each stub carries:
|
|
// - message_id: own 1:1 counterpart in the target chat
|
|
// - message_ids: full batch (so frontend's "[Открыть →]" can scroll to the
|
|
// first moved message regardless of which stub was clicked)
|
|
const stubContent = `Moved to chat #${tgtId}`;
|
|
for (let i = 0; i < sourceMsgs.length; i++) {
|
|
const src = sourceMsgs[i];
|
|
const newTargetId = targetIds[i];
|
|
const baseMeta = asObject(src.metadata, {});
|
|
const stubMeta = {
|
|
...baseMeta,
|
|
moved_to: {
|
|
conversation_id: tgtId,
|
|
message_id: newTargetId,
|
|
message_ids: targetIds,
|
|
batch_id: batchId,
|
|
moved_by: movedBy,
|
|
},
|
|
};
|
|
await trx.run(
|
|
`UPDATE messages
|
|
SET content_type = 'moved',
|
|
content = $1,
|
|
metadata = $2::jsonb,
|
|
updated_at = NOW()
|
|
WHERE id = $3`,
|
|
[stubContent, JSON.stringify(stubMeta), src.id]
|
|
);
|
|
}
|
|
|
|
await trx.run(`UPDATE conversations SET updated_at = NOW() WHERE id = $1`, [srcId]);
|
|
await trx.run(`UPDATE conversations SET updated_at = NOW() WHERE id = $1`, [tgtId]);
|
|
|
|
apiLogger.info(
|
|
{ srcId, tgtId, moved: sourceMsgs.length, sourceIds: sourceMsgs.map(m => m.id), targetIds, batchId, userId: uid },
|
|
'ADR-0031 P5: messages moved with stub'
|
|
);
|
|
|
|
return {
|
|
moved_count: sourceMsgs.length,
|
|
source_message_ids: sourceMsgs.map(m => m.id),
|
|
target_message_ids: targetIds,
|
|
batch_id: batchId,
|
|
};
|
|
});
|
|
}
|