- ChatThread per client/provider and channel; messages get files, internal notes, source (bot/mini app) and delivery errors; reply templates. Migration moves old concierge messages into threads. - Routing: subscribers write to the concierge, others to support; /support and /concierge in the client bot; staff can take, transfer, close and move threads. - Telegram: photos/documents from clients and providers, replies with the channel header, files sent from storage, notifications only to the assignee or the channel's staff, "Reply" into a specific thread. - New admin: /chats with list, conversation, notes, files, templates, client panel; "Message" button on the client card; dashboard counts threads awaiting reply. - Client mini app: in-app chat screen sharing the same conversation. - Old admin keeps working on the same data (notes hidden there). - Tests for routing, notes, assign/close/move, lists, recipients; separate test DB via TEST_DB_NAME; dev:chats-demo script. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
97 lines
4.5 KiB
TypeScript
97 lines
4.5 KiB
TypeScript
import path from 'node:path';
|
|
import { config } from 'dotenv';
|
|
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, fromYerevan, toYerevan, YEREVAN_DISTRICTS } from '@barev/core';
|
|
import { createDb, type Db } from '@barev/db';
|
|
import type { Ctx, DomainEvent } from '../src/context.ts';
|
|
|
|
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
|
|
|
|
function testUrl() {
|
|
const u = new URL(process.env.DATABASE_URL!);
|
|
u.pathname = `/${process.env.TEST_DB_NAME ?? 'barev_test'}`;
|
|
return u.toString();
|
|
}
|
|
|
|
export const db: Db = createDb(testUrl());
|
|
|
|
export function makeCtx(now: Date) {
|
|
const events: DomainEvent[] = [];
|
|
const clock = { now };
|
|
const ctx: Ctx = { db, notify: { emit: (e) => void events.push(e) }, now: () => clock.now };
|
|
return { ctx, events, clock };
|
|
}
|
|
|
|
export async function wipe() {
|
|
const tables = await db.$queryRaw<{ tablename: string }[]>`
|
|
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
|
|
await db.$executeRawUnsafe(`TRUNCATE ${tables.map((t) => `"public"."${t.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
|
|
}
|
|
|
|
/** Понедельник 2026-10-19, 10:00 по Еревану — «сейчас» в тестах */
|
|
export const NOW = fromYerevan(2026, 10, 19, 10 * 60);
|
|
/** Слот послезавтра (ср) в hh:00 — вне 24-часового порога */
|
|
export const slotAt = (dayOffset: number, hour: number) => {
|
|
const p = toYerevan(new Date(NOW.getTime() + dayOffset * 24 * 3600_000));
|
|
return fromYerevan(p.year, p.month, p.day, hour * 60);
|
|
};
|
|
|
|
let tgSeq = 1000;
|
|
|
|
export async function fixtures() {
|
|
// шаблоны переезда (в базе их создаёт миграция; тесты чистят все таблицы)
|
|
const steps = ['consult', 'airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'];
|
|
const docs = ['passport', 'diploma', 'criminalRecord'];
|
|
await wipe();
|
|
await db.relocationItem.createMany({
|
|
data: [
|
|
...steps.map((code, i) => ({ kind: 'STEP' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: code !== 'consult' })),
|
|
...docs.map((code, i) => ({ kind: 'DOC' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: false })),
|
|
],
|
|
});
|
|
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
|
|
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
|
|
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
|
|
const c = CATALOG.find((s) => s.code === 'cleaning')!;
|
|
const cleaning = await db.service.create({
|
|
data: {
|
|
code: c.code,
|
|
categoryId: home.id,
|
|
flow: c.flow,
|
|
providerRole: c.providerRole,
|
|
pricing: c.pricing,
|
|
paramsSchema: c.paramsSchema,
|
|
slotConfig: c.slotConfig,
|
|
lateCancelFeePct: 30,
|
|
checklist: { create: c.checklist.map((x, i) => ({ code: x.code, requiresPhoto: !!x.requiresPhoto, sortOrder: i })) },
|
|
},
|
|
});
|
|
const agency = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik', commissionPct: 10 } });
|
|
await db.agency.create({ data: { code: 'CLOSED', name: 'Closed', active: false } });
|
|
const schedule = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 }));
|
|
const mkProvider = async (name: string, languages: ('ru' | 'en' | 'hy')[], companyId?: string) => {
|
|
const user = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: name } });
|
|
return db.provider.create({
|
|
data: {
|
|
name,
|
|
userId: user.id,
|
|
role: 'CLEANER',
|
|
languages,
|
|
companyId,
|
|
inviteCode: `INV-${name}`,
|
|
onShift: true,
|
|
districts: { create: [{ districtId: kentron.id }] },
|
|
schedule: { create: schedule },
|
|
},
|
|
});
|
|
};
|
|
const maria = await mkProvider('Maria', ['ru', 'en']);
|
|
const ani = await mkProvider('Ani', ['hy']);
|
|
const company = await db.providerCompany.create({ data: { name: 'Chisto', roles: ['CLEANER'], inviteCode: 'CO-1', rateValue: 65 } });
|
|
const lusine = await mkProvider('Lusine', ['ru'], company.id);
|
|
const dispatcher = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: 'Karen' } });
|
|
await db.companyDispatcher.create({ data: { companyId: company.id, userId: dispatcher.id } });
|
|
return { kentron, cleaning, agency, maria, ani, company, lusine, dispatcher };
|
|
}
|
|
|
|
export const tgUser = (id: number, lang = 'ru') => ({ id, first_name: `User${id}`, language_code: lang });
|