Files
iistwin/server/routes/reaction-helpers.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

260 lines
9.4 KiB
TypeScript

import { db } from "../db";
import {
messageReactions, polls, pollVotes,
taskMessages, tasks, conversationMessages, conversationMembers, users,
} from "@shared/schema";
import { eq, and, inArray } from "drizzle-orm";
import { eventBus } from "./shared";
// ── Types ──────────────────────────────────────────────────────────────────────
export interface ReactionGroup {
emoji: string;
count: number;
userIds: number[];
users: { id: number; firstName: string; middleName: string; lastName: string }[];
hasReacted: boolean;
}
export interface PollData {
id: number;
question: string;
options: string[];
isMultiple: boolean;
isClosed: boolean;
createdBy: number;
totalVotes: number;
myVote: number[] | null;
optionVoteCounts: number[];
optionVoters: { id: number; firstName: string; middleName: string; lastName: string }[][];
}
export interface ReactionRow {
id: number;
taskMessageId: number | null;
convMessageId: number | null;
userId: number;
emoji: string;
firstName: string;
middleName: string | null;
lastName: string;
}
export interface PollVoteRow {
userId: number;
optionIndexes: number[];
firstName: string;
middleName: string;
lastName: string;
}
// ── Auth helpers ───────────────────────────────────────────────────────────────
export async function getTaskMsgContext(msgId: number, orgId: number): Promise<{ taskId: number; formId: number } | null> {
const [row] = await db
.select({ taskId: taskMessages.taskId, formId: taskMessages.formId })
.from(taskMessages)
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.where(and(eq(taskMessages.id, msgId), eq(tasks.organizationId, orgId)));
return row ?? null;
}
export async function getConvMsgContext(
msgId: number, orgId: number, userId: number
): Promise<{ conversationId: number } | null> {
const [row] = await db
.select({ conversationId: conversationMessages.conversationId })
.from(conversationMessages)
.where(eq(conversationMessages.id, msgId));
if (!row) return null;
const [member] = await db
.select({ id: conversationMembers.id })
.from(conversationMembers)
.where(and(
eq(conversationMembers.conversationId, row.conversationId),
eq(conversationMembers.userId, userId),
));
return member ? row : null;
}
// ── Reaction helpers ───────────────────────────────────────────────────────────
export async function buildReactionGroups(
rows: ReactionRow[],
msgIdFn: (r: ReactionRow) => number | null,
currentUserId: number,
): Promise<Record<number, ReactionGroup[]>> {
const byMsg: Record<number, Record<string, ReactionGroup>> = {};
for (const r of rows) {
const msgId = msgIdFn(r);
if (msgId == null) continue;
if (!byMsg[msgId]) byMsg[msgId] = {};
if (!byMsg[msgId][r.emoji]) {
byMsg[msgId][r.emoji] = {
emoji: r.emoji, count: 0, userIds: [], users: [], hasReacted: false,
};
}
const grp = byMsg[msgId][r.emoji];
grp.count++;
grp.userIds.push(r.userId);
grp.users.push({ id: r.userId, firstName: r.firstName, middleName: r.middleName ?? '', lastName: r.lastName });
if (r.userId === currentUserId) grp.hasReacted = true;
}
const result: Record<number, ReactionGroup[]> = {};
for (const [msgId, emojiMap] of Object.entries(byMsg)) {
result[Number(msgId)] = Object.values(emojiMap);
}
return result;
}
export async function getReactionsForTaskMsgs(
taskMsgIds: number[],
currentUserId: number,
): Promise<Record<number, ReactionGroup[]>> {
if (!taskMsgIds.length) return {};
const rows = await db
.select({
id: messageReactions.id,
taskMessageId: messageReactions.taskMessageId,
convMessageId: messageReactions.convMessageId,
userId: messageReactions.userId,
emoji: messageReactions.emoji,
firstName: users.firstName,
middleName: users.middleName,
lastName: users.lastName,
})
.from(messageReactions)
.innerJoin(users, eq(messageReactions.userId, users.id))
.where(inArray(messageReactions.taskMessageId, taskMsgIds));
return buildReactionGroups(rows, r => r.taskMessageId, currentUserId);
}
export async function getReactionsForConvMsgs(
convMsgIds: number[],
currentUserId: number,
): Promise<Record<number, ReactionGroup[]>> {
if (!convMsgIds.length) return {};
const rows = await db
.select({
id: messageReactions.id,
taskMessageId: messageReactions.taskMessageId,
convMessageId: messageReactions.convMessageId,
userId: messageReactions.userId,
emoji: messageReactions.emoji,
firstName: users.firstName,
middleName: users.middleName,
lastName: users.lastName,
})
.from(messageReactions)
.innerJoin(users, eq(messageReactions.userId, users.id))
.where(inArray(messageReactions.convMessageId, convMsgIds));
return buildReactionGroups(rows, r => r.convMessageId, currentUserId);
}
export async function getReactionGroupsForMsg(
taskMessageId: number | null,
convMessageId: number | null,
currentUserId: number,
): Promise<ReactionGroup[]> {
const condition = taskMessageId != null
? eq(messageReactions.taskMessageId, taskMessageId)
: eq(messageReactions.convMessageId, convMessageId!);
const rows = await db
.select({
id: messageReactions.id,
taskMessageId: messageReactions.taskMessageId,
convMessageId: messageReactions.convMessageId,
userId: messageReactions.userId,
emoji: messageReactions.emoji,
firstName: users.firstName,
middleName: users.middleName,
lastName: users.lastName,
})
.from(messageReactions)
.innerJoin(users, eq(messageReactions.userId, users.id))
.where(condition);
const map: Record<string, ReactionGroup> = {};
for (const r of rows) {
if (!map[r.emoji]) map[r.emoji] = { emoji: r.emoji, count: 0, userIds: [], users: [], hasReacted: false };
const g = map[r.emoji];
g.count++;
g.userIds.push(r.userId);
g.users.push({ id: r.userId, firstName: r.firstName, middleName: r.middleName ?? '', lastName: r.lastName });
if (r.userId === currentUserId) g.hasReacted = true;
}
return Object.values(map);
}
// ── Poll helpers ───────────────────────────────────────────────────────────────
export async function getPollDataById(pollId: number, currentUserId: number): Promise<PollData | null> {
const [p] = await db.select().from(polls).where(eq(polls.id, pollId));
if (!p) return null;
const voteRows = await db
.select({
userId: pollVotes.userId,
optionIndexes: pollVotes.optionIndexes,
firstName: users.firstName,
middleName: users.middleName,
lastName: users.lastName,
})
.from(pollVotes)
.innerJoin(users, eq(pollVotes.userId, users.id))
.where(eq(pollVotes.pollId, pollId));
const optCount = (p.options as string[]).length;
const voteCounts = new Array<number>(optCount).fill(0);
const voters: { id: number; firstName: string; middleName: string; lastName: string }[][] = Array.from({ length: optCount }, () => []);
let myVote: number[] | null = null;
for (const v of voteRows) {
if (v.userId === currentUserId) myVote = v.optionIndexes;
for (const idx of v.optionIndexes) {
if (idx >= 0 && idx < optCount) {
voteCounts[idx]++;
voters[idx].push({ id: v.userId, firstName: v.firstName, middleName: v.middleName ?? '', lastName: v.lastName });
}
}
}
return {
id: p.id,
question: p.question,
options: p.options as string[],
isMultiple: p.isMultiple ?? false,
isClosed: p.isClosed ?? false,
createdBy: p.createdBy,
totalVotes: new Set(voteRows.map(v => v.userId)).size,
myVote,
optionVoteCounts: voteCounts,
optionVoters: voters,
};
}
// ── SSE emitters ───────────────────────────────────────────────────────────────
export function emitTaskReaction(orgId: number, data: object) {
eventBus.publishEvent({ type: 'reaction_updated', data, organizationId: orgId });
}
export function emitTaskPoll(orgId: number, data: object) {
eventBus.publishEvent({ type: 'poll_updated', data, organizationId: orgId });
}
export async function emitConvReaction(convId: number, orgId: number, data: object) {
const members = await db
.select({ userId: conversationMembers.userId })
.from(conversationMembers)
.where(eq(conversationMembers.conversationId, convId));
for (const m of members) {
eventBus.publishEvent({ type: 'reaction_updated', data, organizationId: orgId, userId: m.userId });
}
}
export async function emitConvPoll(convId: number, orgId: number, data: object) {
const members = await db
.select({ userId: conversationMembers.userId })
.from(conversationMembers)
.where(eq(conversationMembers.conversationId, convId));
for (const m of members) {
eventBus.publishEvent({ type: 'poll_updated', data, organizationId: orgId, userId: m.userId });
}
}