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

177 lines
7 KiB
JavaScript

// ADR-0068 WP-E — Pinned messages.
//
// Two idempotent endpoints for the PinnedBanner (📌 N закреплённых ▾) and
// the ⋮-menu pin toggle in MessageBubble. Both share one permission gate:
//
// - group / channel chat (conversations.type IN ('group','channel')):
// any conversation participant may pin/unpin.
// - direct chat / DM (conversations.type = 'direct'):
// only the conversation owner (conversations.created_by) may pin/unpin.
// This mirrors the locked acceptance contract in T-159793.
//
// Soft cap: at most 50 pinned messages per conversation. The 51st POST
// returns 409 `{ error: 'pin_cap_reached', cap: 50 }`. Cap is checked AFTER
// idempotency — re-pinning an already-pinned message never trips the cap.
import {
dbRun, dbGet, isPostgres, apiLogger,
success, error, badRequest, notFound, forbidden,
requireAuth,
} from './chatShared.js';
const PIN_CAP_PER_CONVERSATION = 50;
// Group/channel: participant gate. Direct: owner gate.
// Returns `{ ok: true, conversationId }` on success, or `{ ok: false, status, code, message }`
// on a 4xx. Centralizes the gate so POST and DELETE share it verbatim.
async function checkPinPermission({ messageId, userId }) {
const message = await dbGet(
isPostgres()
? `SELECT id, conversation_id, pinned_at FROM messages WHERE id = $1 AND (is_deleted IS NULL OR is_deleted = 0)`
: `SELECT id, conversation_id, pinned_at FROM messages WHERE id = ? AND (is_deleted IS NULL OR is_deleted = 0)`,
[messageId]
);
if (!message) {
return { ok: false, status: 404, code: 'MESSAGE_NOT_FOUND', message: 'Message not found' };
}
const conversation = await dbGet(
isPostgres()
? `SELECT id, type, created_by FROM conversations WHERE id = $1`
: `SELECT id, type, created_by FROM conversations WHERE id = ?`,
[message.conversation_id]
);
if (!conversation) {
return { ok: false, status: 404, code: 'CONVERSATION_NOT_FOUND', message: 'Conversation not found' };
}
const isDirect = conversation.type === 'direct';
if (isDirect) {
if (Number(conversation.created_by) !== Number(userId)) {
return { ok: false, status: 403, code: 'FORBIDDEN', message: 'Only the chat owner can pin in a direct chat' };
}
} else {
const participant = await dbGet(
isPostgres()
? `SELECT user_id FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`
: `SELECT user_id FROM conversation_participants WHERE conversation_id = ? AND user_id = ?`,
[conversation.id, userId]
);
if (!participant) {
return { ok: false, status: 403, code: 'FORBIDDEN', message: 'No access to this conversation' };
}
}
return { ok: true, message, conversation };
}
export default function registerPinRoutes(router) {
// POST /messages/:id/pin — idempotent pin.
// (Mounted under /api/v3/chat by index.js, so effective URL is
// /api/v3/chat/messages/:id/pin.)
//
// Already-pinned message → 200 with the current pinned_at (no UPDATE).
// Otherwise → cap check, then UPDATE pinned_at = NOW().
router.post('/messages/:id/pin', requireAuth, async (req, res) => {
try {
const messageId = Number(req.params.id);
if (!Number.isFinite(messageId) || messageId <= 0) {
return badRequest(res, 'Invalid message id');
}
const userId = req.user.userId;
const gate = await checkPinPermission({ messageId, userId });
if (!gate.ok) {
if (gate.status === 404) return notFound(res, gate.message);
if (gate.status === 403) return forbidden(res, gate.message);
return error(res, gate.code, gate.message, gate.status);
}
// Idempotent: already pinned → return current pinned_at unchanged.
if (gate.message.pinned_at) {
return success(res, { id: gate.message.id, pinned_at: gate.message.pinned_at });
}
// Soft cap: count existing pinned messages in this conversation.
const capRow = await dbGet(
isPostgres()
? `SELECT COUNT(*)::int AS n FROM messages WHERE conversation_id = $1 AND pinned_at IS NOT NULL`
: `SELECT COUNT(*) AS n FROM messages WHERE conversation_id = ? AND pinned_at IS NOT NULL`,
[gate.conversation.id]
);
const currentCount = Number(capRow?.n || 0);
if (currentCount >= PIN_CAP_PER_CONVERSATION) {
return res.status(409).json({
success: false,
error: 'pin_cap_reached',
cap: PIN_CAP_PER_CONVERSATION,
message: `Cannot pin more than ${PIN_CAP_PER_CONVERSATION} messages per conversation`,
});
}
const updated = await dbGet(
isPostgres()
? `UPDATE messages SET pinned_at = NOW() WHERE id = $1 RETURNING id, pinned_at`
: `UPDATE messages SET pinned_at = datetime('now') WHERE id = ?`,
[messageId]
);
// SQLite has no RETURNING — re-read.
const result = updated && updated.pinned_at
? updated
: await dbGet(
isPostgres()
? `SELECT id, pinned_at FROM messages WHERE id = $1`
: `SELECT id, pinned_at FROM messages WHERE id = ?`,
[messageId]
);
apiLogger.info({ messageId, userId, conversationId: gate.conversation.id }, 'WP-E pinned message');
return success(res, { id: result.id, pinned_at: result.pinned_at });
} catch (err) {
apiLogger.error({ err }, 'Error pinning message');
return error(res, 'PIN_MESSAGE_ERROR', err.message, 500);
}
});
// DELETE /messages/:id/pin — idempotent unpin.
// (Mounted under /api/v3/chat by index.js → /api/v3/chat/messages/:id/pin.)
//
// Already-unpinned message → 200 with pinned_at: null.
// Otherwise → UPDATE pinned_at = NULL.
router.delete('/messages/:id/pin', requireAuth, async (req, res) => {
try {
const messageId = Number(req.params.id);
if (!Number.isFinite(messageId) || messageId <= 0) {
return badRequest(res, 'Invalid message id');
}
const userId = req.user.userId;
const gate = await checkPinPermission({ messageId, userId });
if (!gate.ok) {
if (gate.status === 404) return notFound(res, gate.message);
if (gate.status === 403) return forbidden(res, gate.message);
return error(res, gate.code, gate.message, gate.status);
}
// Idempotent: already unpinned → no UPDATE.
if (!gate.message.pinned_at) {
return success(res, { id: gate.message.id, pinned_at: null });
}
await dbRun(
isPostgres()
? `UPDATE messages SET pinned_at = NULL WHERE id = $1`
: `UPDATE messages SET pinned_at = NULL WHERE id = ?`,
[messageId]
);
apiLogger.info({ messageId, userId, conversationId: gate.conversation.id }, 'WP-E unpinned message');
return success(res, { id: gate.message.id, pinned_at: null });
} catch (err) {
apiLogger.error({ err }, 'Error unpinning message');
return error(res, 'UNPIN_MESSAGE_ERROR', err.message, 500);
}
});
}