- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
260 lines
9.4 KiB
TypeScript
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 });
|
|
}
|
|
}
|