chore: rename service folders to lowercase
This commit is contained in:
101
frontend/server/utils/auth.ts
Normal file
101
frontend/server/utils/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { H3Event } from "h3";
|
||||
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
|
||||
import { prisma } from "./prisma";
|
||||
import { hashPassword } from "./password";
|
||||
|
||||
export type AuthContext = {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
const COOKIE_USER = "cf_user";
|
||||
const COOKIE_TEAM = "cf_team";
|
||||
const COOKIE_CONV = "cf_conv";
|
||||
|
||||
function cookieOpts() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAuthSession(event: H3Event) {
|
||||
deleteCookie(event, COOKIE_USER, { path: "/" });
|
||||
deleteCookie(event, COOKIE_TEAM, { path: "/" });
|
||||
deleteCookie(event, COOKIE_CONV, { path: "/" });
|
||||
}
|
||||
|
||||
export function setSession(event: H3Event, ctx: AuthContext) {
|
||||
setCookie(event, COOKIE_USER, ctx.userId, cookieOpts());
|
||||
setCookie(event, COOKIE_TEAM, ctx.teamId, cookieOpts());
|
||||
setCookie(event, COOKIE_CONV, ctx.conversationId, cookieOpts());
|
||||
}
|
||||
|
||||
export async function getAuthContext(event: H3Event): Promise<AuthContext> {
|
||||
const cookieUser = getCookie(event, COOKIE_USER)?.trim();
|
||||
const cookieTeam = getCookie(event, COOKIE_TEAM)?.trim();
|
||||
const cookieConv = getCookie(event, COOKIE_CONV)?.trim();
|
||||
|
||||
// Temporary compatibility: allow passing via headers for debugging/dev tools.
|
||||
const hdrTeam = getHeader(event, "x-team-id")?.trim();
|
||||
const hdrUser = getHeader(event, "x-user-id")?.trim();
|
||||
const hdrConv = getHeader(event, "x-conversation-id")?.trim();
|
||||
|
||||
const hasAnySession = Boolean(cookieUser || cookieTeam || cookieConv || hdrTeam || hdrUser || hdrConv);
|
||||
if (!hasAnySession) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const userId = cookieUser || hdrUser;
|
||||
const teamId = cookieTeam || hdrTeam;
|
||||
const conversationId = cookieConv || hdrConv;
|
||||
|
||||
if (!userId || !teamId || !conversationId) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
const team = await prisma.team.findUnique({ where: { id: teamId } });
|
||||
|
||||
if (!user || !team) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const conv = await prisma.chatConversation.findFirst({
|
||||
where: { id: conversationId, teamId: team.id, createdByUserId: user.id },
|
||||
});
|
||||
|
||||
if (!conv) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
return { teamId: team.id, userId: user.id, conversationId: conv.id };
|
||||
}
|
||||
|
||||
export async function ensureDemoAuth() {
|
||||
const demoPasswordHash = hashPassword("DemoPass123!");
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: "demo-user" },
|
||||
update: { phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||
create: { id: "demo-user", phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||
});
|
||||
const team = await prisma.team.upsert({
|
||||
where: { id: "demo-team" },
|
||||
update: { name: "Demo Team" },
|
||||
create: { id: "demo-team", name: "Demo Team" },
|
||||
});
|
||||
await prisma.teamMember.upsert({
|
||||
where: { teamId_userId: { teamId: team.id, userId: user.id } },
|
||||
update: {},
|
||||
create: { teamId: team.id, userId: user.id, role: "OWNER" },
|
||||
});
|
||||
const conv = await prisma.chatConversation.upsert({
|
||||
where: { id: `pilot-${team.id}` },
|
||||
update: {},
|
||||
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Pilot" },
|
||||
});
|
||||
return { teamId: team.id, userId: user.id, conversationId: conv.id };
|
||||
}
|
||||
426
frontend/server/utils/changeSet.ts
Normal file
426
frontend/server/utils/changeSet.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
type CalendarSnapshotRow = {
|
||||
id: string;
|
||||
teamId: string;
|
||||
contactId: string | null;
|
||||
title: string;
|
||||
startsAt: string;
|
||||
endsAt: string | null;
|
||||
note: string | null;
|
||||
isArchived: boolean;
|
||||
archiveNote: string | null;
|
||||
archivedAt: string | null;
|
||||
};
|
||||
|
||||
type ContactNoteSnapshotRow = {
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MessageSnapshotRow = {
|
||||
id: string;
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
kind: string;
|
||||
direction: string;
|
||||
channel: string;
|
||||
content: string;
|
||||
durationSec: number | null;
|
||||
occurredAt: string;
|
||||
};
|
||||
|
||||
type DealSnapshotRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
contactName: string;
|
||||
stage: string;
|
||||
nextStep: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
export type SnapshotState = {
|
||||
calendarById: Map<string, CalendarSnapshotRow>;
|
||||
noteByContactId: Map<string, ContactNoteSnapshotRow>;
|
||||
messageById: Map<string, MessageSnapshotRow>;
|
||||
dealById: Map<string, DealSnapshotRow>;
|
||||
};
|
||||
|
||||
export type ChangeItem = {
|
||||
entity: "calendar_event" | "contact_note" | "message" | "deal";
|
||||
action: "created" | "updated" | "deleted";
|
||||
title: string;
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
|
||||
type UndoOp =
|
||||
| { kind: "delete_calendar_event"; id: string }
|
||||
| { kind: "restore_calendar_event"; data: CalendarSnapshotRow }
|
||||
| { kind: "delete_contact_message"; id: string }
|
||||
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
|
||||
| { kind: "restore_contact_note"; contactId: string; content: string | null }
|
||||
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null };
|
||||
|
||||
export type ChangeSet = {
|
||||
id: string;
|
||||
status: "pending" | "confirmed" | "rolled_back";
|
||||
createdAt: string;
|
||||
summary: string;
|
||||
items: ChangeItem[];
|
||||
undo: UndoOp[];
|
||||
};
|
||||
|
||||
function fmt(val: string | null | undefined) {
|
||||
return (val ?? "").trim();
|
||||
}
|
||||
|
||||
function toCalendarText(row: CalendarSnapshotRow) {
|
||||
const when = new Date(row.startsAt).toLocaleString("ru-RU");
|
||||
return `${row.title} · ${when}${row.note ? ` · ${row.note}` : ""}`;
|
||||
}
|
||||
|
||||
function toMessageText(row: MessageSnapshotRow) {
|
||||
const when = new Date(row.occurredAt).toLocaleString("ru-RU");
|
||||
return `${row.contactName} · ${row.channel} · ${row.kind.toLowerCase()} · ${when} · ${row.content}`;
|
||||
}
|
||||
|
||||
function toDealText(row: DealSnapshotRow) {
|
||||
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
|
||||
}
|
||||
|
||||
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
|
||||
const [calendar, notes, messages, deals] = await Promise.all([
|
||||
prisma.calendarEvent.findMany({
|
||||
where: { teamId },
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
contactId: true,
|
||||
title: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
note: true,
|
||||
isArchived: true,
|
||||
archiveNote: true,
|
||||
archivedAt: true,
|
||||
},
|
||||
take: 4000,
|
||||
}),
|
||||
prisma.contactNote.findMany({
|
||||
where: { contact: { teamId } },
|
||||
select: { contactId: true, content: true, contact: { select: { name: true } } },
|
||||
take: 4000,
|
||||
}),
|
||||
prisma.contactMessage.findMany({
|
||||
where: { contact: { teamId } },
|
||||
include: { contact: { select: { name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 6000,
|
||||
}),
|
||||
prisma.deal.findMany({
|
||||
where: { teamId },
|
||||
include: { contact: { select: { name: true } } },
|
||||
take: 4000,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
calendarById: new Map(
|
||||
calendar.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId ?? null,
|
||||
title: row.title,
|
||||
startsAt: row.startsAt.toISOString(),
|
||||
endsAt: row.endsAt?.toISOString() ?? null,
|
||||
note: row.note ?? null,
|
||||
isArchived: Boolean(row.isArchived),
|
||||
archiveNote: row.archiveNote ?? null,
|
||||
archivedAt: row.archivedAt?.toISOString() ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
noteByContactId: new Map(
|
||||
notes.map((row) => [
|
||||
row.contactId,
|
||||
{
|
||||
contactId: row.contactId,
|
||||
contactName: row.contact.name,
|
||||
content: row.content,
|
||||
},
|
||||
]),
|
||||
),
|
||||
messageById: new Map(
|
||||
messages.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
contactId: row.contactId,
|
||||
contactName: row.contact.name,
|
||||
kind: row.kind,
|
||||
direction: row.direction,
|
||||
channel: row.channel,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec ?? null,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
},
|
||||
]),
|
||||
),
|
||||
dealById: new Map(
|
||||
deals.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
contactName: row.contact.name,
|
||||
stage: row.stage,
|
||||
nextStep: row.nextStep ?? null,
|
||||
summary: row.summary ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChangeSet(before: SnapshotState, after: SnapshotState): ChangeSet | null {
|
||||
const items: ChangeItem[] = [];
|
||||
const undo: UndoOp[] = [];
|
||||
|
||||
for (const [id, row] of after.calendarById) {
|
||||
const prev = before.calendarById.get(id);
|
||||
if (!prev) {
|
||||
items.push({
|
||||
entity: "calendar_event",
|
||||
action: "created",
|
||||
title: `Event created: ${row.title}`,
|
||||
before: "",
|
||||
after: toCalendarText(row),
|
||||
});
|
||||
undo.push({ kind: "delete_calendar_event", id });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
prev.title !== row.title ||
|
||||
prev.startsAt !== row.startsAt ||
|
||||
prev.endsAt !== row.endsAt ||
|
||||
fmt(prev.note) !== fmt(row.note) ||
|
||||
prev.isArchived !== row.isArchived ||
|
||||
fmt(prev.archiveNote) !== fmt(row.archiveNote) ||
|
||||
fmt(prev.archivedAt) !== fmt(row.archivedAt) ||
|
||||
prev.contactId !== row.contactId
|
||||
) {
|
||||
items.push({
|
||||
entity: "calendar_event",
|
||||
action: "updated",
|
||||
title: `Event updated: ${row.title}`,
|
||||
before: toCalendarText(prev),
|
||||
after: toCalendarText(row),
|
||||
});
|
||||
undo.push({ kind: "restore_calendar_event", data: prev });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, row] of before.calendarById) {
|
||||
if (after.calendarById.has(id)) continue;
|
||||
items.push({
|
||||
entity: "calendar_event",
|
||||
action: "deleted",
|
||||
title: `Event archived: ${row.title}`,
|
||||
before: toCalendarText(row),
|
||||
after: "",
|
||||
});
|
||||
undo.push({ kind: "restore_calendar_event", data: row });
|
||||
}
|
||||
|
||||
for (const [contactId, row] of after.noteByContactId) {
|
||||
const prev = before.noteByContactId.get(contactId);
|
||||
if (!prev) {
|
||||
items.push({
|
||||
entity: "contact_note",
|
||||
action: "created",
|
||||
title: `Summary added: ${row.contactName}`,
|
||||
before: "",
|
||||
after: row.content,
|
||||
});
|
||||
undo.push({ kind: "restore_contact_note", contactId, content: null });
|
||||
continue;
|
||||
}
|
||||
if (prev.content !== row.content) {
|
||||
items.push({
|
||||
entity: "contact_note",
|
||||
action: "updated",
|
||||
title: `Summary updated: ${row.contactName}`,
|
||||
before: prev.content,
|
||||
after: row.content,
|
||||
});
|
||||
undo.push({ kind: "restore_contact_note", contactId, content: prev.content });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [contactId, row] of before.noteByContactId) {
|
||||
if (after.noteByContactId.has(contactId)) continue;
|
||||
items.push({
|
||||
entity: "contact_note",
|
||||
action: "deleted",
|
||||
title: `Summary cleared: ${row.contactName}`,
|
||||
before: row.content,
|
||||
after: "",
|
||||
});
|
||||
undo.push({ kind: "restore_contact_note", contactId, content: row.content });
|
||||
}
|
||||
|
||||
for (const [id, row] of after.messageById) {
|
||||
if (before.messageById.has(id)) continue;
|
||||
items.push({
|
||||
entity: "message",
|
||||
action: "created",
|
||||
title: `Message created: ${row.contactName}`,
|
||||
before: "",
|
||||
after: toMessageText(row),
|
||||
});
|
||||
undo.push({ kind: "delete_contact_message", id });
|
||||
}
|
||||
|
||||
for (const [id, row] of after.dealById) {
|
||||
const prev = before.dealById.get(id);
|
||||
if (!prev) continue;
|
||||
if (prev.stage !== row.stage || fmt(prev.nextStep) !== fmt(row.nextStep) || fmt(prev.summary) !== fmt(row.summary)) {
|
||||
items.push({
|
||||
entity: "deal",
|
||||
action: "updated",
|
||||
title: `Deal updated: ${row.title}`,
|
||||
before: toDealText(prev),
|
||||
after: toDealText(row),
|
||||
});
|
||||
undo.push({
|
||||
kind: "restore_deal",
|
||||
id,
|
||||
stage: prev.stage,
|
||||
nextStep: prev.nextStep,
|
||||
summary: prev.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const created = items.filter((x) => x.action === "created").length;
|
||||
const updated = items.filter((x) => x.action === "updated").length;
|
||||
const deleted = items.filter((x) => x.action === "deleted").length;
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
summary: `Created: ${created}, Updated: ${updated}, Archived: ${deleted}`,
|
||||
items,
|
||||
undo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, changeSet: ChangeSet) {
|
||||
const ops = [...changeSet.undo].reverse();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const op of ops) {
|
||||
if (op.kind === "delete_calendar_event") {
|
||||
await tx.calendarEvent.deleteMany({ where: { id: op.id, teamId } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_calendar_event") {
|
||||
const row = op.data;
|
||||
await tx.calendarEvent.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId,
|
||||
title: row.title,
|
||||
startsAt: new Date(row.startsAt),
|
||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||
note: row.note,
|
||||
isArchived: row.isArchived,
|
||||
archiveNote: row.archiveNote,
|
||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId,
|
||||
title: row.title,
|
||||
startsAt: new Date(row.startsAt),
|
||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||
note: row.note,
|
||||
isArchived: row.isArchived,
|
||||
archiveNote: row.archiveNote,
|
||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "delete_contact_message") {
|
||||
await tx.contactMessage.deleteMany({ where: { id: op.id } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_contact_message") {
|
||||
const row = op.data;
|
||||
await tx.contactMessage.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
contactId: row.contactId,
|
||||
kind: row.kind as any,
|
||||
direction: row.direction as any,
|
||||
channel: row.channel as any,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec,
|
||||
occurredAt: new Date(row.occurredAt),
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
contactId: row.contactId,
|
||||
kind: row.kind as any,
|
||||
direction: row.direction as any,
|
||||
channel: row.channel as any,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec,
|
||||
occurredAt: new Date(row.occurredAt),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_contact_note") {
|
||||
const contact = await tx.contact.findFirst({ where: { id: op.contactId, teamId }, select: { id: true } });
|
||||
if (!contact) continue;
|
||||
if (op.content === null) {
|
||||
await tx.contactNote.deleteMany({ where: { contactId: op.contactId } });
|
||||
} else {
|
||||
await tx.contactNote.upsert({
|
||||
where: { contactId: op.contactId },
|
||||
update: { content: op.content },
|
||||
create: { contactId: op.contactId, content: op.content },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_deal") {
|
||||
await tx.deal.updateMany({
|
||||
where: { id: op.id, teamId },
|
||||
data: {
|
||||
stage: op.stage,
|
||||
nextStep: op.nextStep,
|
||||
summary: op.summary,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
29
frontend/server/utils/langfuse.ts
Normal file
29
frontend/server/utils/langfuse.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Langfuse } from "langfuse";
|
||||
|
||||
let client: Langfuse | null = null;
|
||||
|
||||
function isTruthy(value: string | undefined) {
|
||||
const v = (value ?? "").trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes" || v === "on";
|
||||
}
|
||||
|
||||
export function isLangfuseEnabled() {
|
||||
const enabledRaw = process.env.LANGFUSE_ENABLED;
|
||||
if (enabledRaw && !isTruthy(enabledRaw)) return false;
|
||||
return Boolean((process.env.LANGFUSE_PUBLIC_KEY ?? "").trim() && (process.env.LANGFUSE_SECRET_KEY ?? "").trim());
|
||||
}
|
||||
|
||||
export function getLangfuseClient() {
|
||||
if (!isLangfuseEnabled()) return null;
|
||||
if (client) return client;
|
||||
|
||||
client = new Langfuse({
|
||||
publicKey: (process.env.LANGFUSE_PUBLIC_KEY ?? "").trim(),
|
||||
secretKey: (process.env.LANGFUSE_SECRET_KEY ?? "").trim(),
|
||||
baseUrl: (process.env.LANGFUSE_BASE_URL ?? "http://langfuse-web:3000").trim(),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
29
frontend/server/utils/password.ts
Normal file
29
frontend/server/utils/password.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const SCRYPT_KEY_LENGTH = 64;
|
||||
|
||||
export function normalizePhone(raw: string) {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const hasPlus = trimmed.startsWith("+");
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
if (!digits) return "";
|
||||
return `${hasPlus ? "+" : ""}${digits}`;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
const salt = randomBytes(16).toString("base64url");
|
||||
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
|
||||
return `scrypt$${salt}$${digest}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, encodedHash: string) {
|
||||
const [algo, salt, digest] = (encodedHash ?? "").split("$");
|
||||
if (algo !== "scrypt" || !salt || !digest) return false;
|
||||
|
||||
const actual = scryptSync(password, salt, SCRYPT_KEY_LENGTH);
|
||||
const expected = Buffer.from(digest, "base64url");
|
||||
if (actual.byteLength !== expected.byteLength) return false;
|
||||
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
17
frontend/server/utils/prisma.ts
Normal file
17
frontend/server/utils/prisma.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalThis.__prisma ??
|
||||
new PrismaClient({
|
||||
log: ["error", "warn"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__prisma = prisma;
|
||||
}
|
||||
|
||||
22
frontend/server/utils/redis.ts
Normal file
22
frontend/server/utils/redis.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __redis: Redis | undefined;
|
||||
}
|
||||
|
||||
export function getRedis() {
|
||||
if (globalThis.__redis) return globalThis.__redis;
|
||||
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
const client = new Redis(url, {
|
||||
maxRetriesPerRequest: null, // recommended for BullMQ
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__redis = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
29
frontend/server/utils/telegram.ts
Normal file
29
frontend/server/utils/telegram.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type TelegramUpdate = Record<string, any>;
|
||||
|
||||
export function telegramApiBase() {
|
||||
return process.env.TELEGRAM_API_BASE || "https://api.telegram.org";
|
||||
}
|
||||
|
||||
export function requireTelegramBotToken() {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required");
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function telegramBotApi<T>(method: string, body: unknown): Promise<T> {
|
||||
const token = requireTelegramBotToken();
|
||||
const res = await fetch(`${telegramApiBase()}/bot${token}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok || !json?.ok) {
|
||||
const desc = json?.description || `HTTP ${res.status}`;
|
||||
throw new Error(`Telegram API ${method} failed: ${desc}`);
|
||||
}
|
||||
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
53
frontend/server/utils/whisper.ts
Normal file
53
frontend/server/utils/whisper.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
type WhisperTranscribeInput = {
|
||||
samples: Float32Array;
|
||||
sampleRate: number;
|
||||
language?: string;
|
||||
};
|
||||
let whisperPipelinePromise: Promise<any> | null = null;
|
||||
let transformersPromise: Promise<any> | null = null;
|
||||
|
||||
function getWhisperModelId() {
|
||||
return (process.env.CF_WHISPER_MODEL ?? "Xenova/whisper-small").trim() || "Xenova/whisper-small";
|
||||
}
|
||||
|
||||
function getWhisperLanguage() {
|
||||
const value = (process.env.CF_WHISPER_LANGUAGE ?? "ru").trim();
|
||||
return value || "ru";
|
||||
}
|
||||
|
||||
async function getWhisperPipeline() {
|
||||
if (!transformersPromise) {
|
||||
transformersPromise = import("@xenova/transformers");
|
||||
}
|
||||
|
||||
const { env, pipeline } = await transformersPromise;
|
||||
|
||||
if (!whisperPipelinePromise) {
|
||||
env.allowRemoteModels = true;
|
||||
env.allowLocalModels = true;
|
||||
env.cacheDir = "/app/.data/transformers";
|
||||
|
||||
const modelId = getWhisperModelId();
|
||||
whisperPipelinePromise = pipeline("automatic-speech-recognition", modelId);
|
||||
}
|
||||
|
||||
return whisperPipelinePromise;
|
||||
}
|
||||
|
||||
export async function transcribeWithWhisper(input: WhisperTranscribeInput) {
|
||||
const transcriber = (await getWhisperPipeline()) as any;
|
||||
const result = await transcriber(
|
||||
input.samples,
|
||||
{
|
||||
sampling_rate: input.sampleRate,
|
||||
language: (input.language ?? getWhisperLanguage()) || "ru",
|
||||
task: "transcribe",
|
||||
chunk_length_s: 20,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: false,
|
||||
},
|
||||
);
|
||||
|
||||
const text = String((result as any)?.text ?? "").trim();
|
||||
return text;
|
||||
}
|
||||
Reference in New Issue
Block a user