Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "priceBreakdown" JSONB;
-- AlterTable
ALTER TABLE "Provider" ADD COLUMN "payoutPresetId" TEXT;
-- CreateTable
CREATE TABLE "PayoutPreset" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "ProviderRole" NOT NULL,
"rules" JSONB NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PayoutPreset_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Provider" ADD CONSTRAINT "Provider_payoutPresetId_fkey" FOREIGN KEY ("payoutPresetId") REFERENCES "PayoutPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,54 @@
-- CreateEnum
CREATE TYPE "WebRequestStatus" AS ENUM ('NEW', 'LINKED', 'ORDERED', 'CANCELLED');
-- CreateTable
CREATE TABLE "WebRequest" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"params" JSONB NOT NULL,
"preferredAt" TIMESTAMP(3) NOT NULL,
"durationMinutes" INTEGER NOT NULL,
"districtId" TEXT NOT NULL,
"addressText" TEXT NOT NULL,
"note" TEXT,
"phone" TEXT NOT NULL,
"name" TEXT,
"locale" "Locale" NOT NULL,
"totalPrice" INTEGER NOT NULL,
"consentAt" TIMESTAMP(3) NOT NULL,
"consentVersion" TEXT NOT NULL,
"status" "WebRequestStatus" NOT NULL DEFAULT 'NEW',
"clientId" TEXT,
"orderId" TEXT,
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WebRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_number_key" ON "WebRequest"("number");
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_token_key" ON "WebRequest"("token");
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_orderId_key" ON "WebRequest"("orderId");
-- CreateIndex
CREATE INDEX "WebRequest_status_createdAt_idx" ON "WebRequest"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "LeadStatus" AS ENUM ('NEW', 'IN_WORK', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "LeadContactType" AS ENUM ('PHONE', 'TELEGRAM');
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"contact" TEXT NOT NULL,
"contactType" "LeadContactType" NOT NULL,
"locale" "Locale" NOT NULL,
"agency" TEXT,
"utm" JSONB,
"moved" BOOLEAN,
"moveWhen" TEXT,
"moveDate" TIMESTAMP(3),
"package" TEXT,
"options" JSONB,
"submittedAt" TIMESTAMP(3),
"consentAt" TIMESTAMP(3) NOT NULL,
"consentVersion" TEXT NOT NULL,
"status" "LeadStatus" NOT NULL DEFAULT 'NEW',
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Lead_number_key" ON "Lead"("number");
-- CreateIndex
CREATE INDEX "Lead_status_createdAt_idx" ON "Lead"("status", "createdAt");
@@ -0,0 +1,106 @@
-- CreateEnum
CREATE TYPE "ClientSegment" AS ENUM ('MOVING', 'LIVING');
-- CreateEnum
CREATE TYPE "LeadStepStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED');
-- CreateEnum
CREATE TYPE "ClientRequestKind" AS ENUM ('SUBSCRIPTION', 'SERVICE');
-- CreateEnum
CREATE TYPE "ClientRequestStatus" AS ENUM ('NEW', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ConciergeAuthor" AS ENUM ('CLIENT', 'OPERATOR');
-- AlterEnum
ALTER TYPE "SubscriptionPlan" ADD VALUE 'ELITE';
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "eliteDiscountPct" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "segment" "ClientSegment";
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "checklist" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "clientId" TEXT;
-- CreateTable
CREATE TABLE "LeadStep" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"status" "LeadStepStatus" NOT NULL DEFAULT 'TODO',
"note" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeadStep_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ClientRequest" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"clientId" TEXT NOT NULL,
"kind" "ClientRequestKind" NOT NULL,
"plan" "SubscriptionPlan",
"serviceCode" TEXT,
"comment" TEXT,
"status" "ClientRequestStatus" NOT NULL DEFAULT 'NEW',
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ClientRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConciergeMessage" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"author" "ConciergeAuthor" NOT NULL,
"adminUserId" TEXT,
"text" TEXT NOT NULL,
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConciergeMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LeadStep_leadId_code_key" ON "LeadStep"("leadId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "ClientRequest_number_key" ON "ClientRequest"("number");
-- CreateIndex
CREATE INDEX "ClientRequest_status_createdAt_idx" ON "ClientRequest"("status", "createdAt");
-- CreateIndex
CREATE INDEX "ClientRequest_clientId_idx" ON "ClientRequest"("clientId");
-- CreateIndex
CREATE INDEX "ConciergeMessage_clientId_createdAt_idx" ON "ConciergeMessage"("clientId", "createdAt");
-- CreateIndex
CREATE INDEX "ConciergeMessage_author_readAt_idx" ON "ConciergeMessage"("author", "readAt");
-- CreateIndex
CREATE INDEX "Lead_clientId_idx" ON "Lead"("clientId");
-- AddForeignKey
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeadStep" ADD CONSTRAINT "LeadStep_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ClientRequest" ADD CONSTRAINT "ClientRequest_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Elite: скидки по направлениям как на главной («до −35%»)
UPDATE "Category" SET "eliteDiscountPct" = 35 WHERE "code" IN ('HOME', 'DOCS', 'LEARNING');
@@ -0,0 +1,94 @@
-- CreateEnum
CREATE TYPE "RelocationItemKind" AS ENUM ('STEP', 'DOC');
-- AlterEnum
ALTER TYPE "ConciergeAuthor" ADD VALUE 'PROVIDER';
-- DropForeignKey
ALTER TABLE "ConciergeMessage" DROP CONSTRAINT "ConciergeMessage_clientId_fkey";
-- AlterTable
ALTER TABLE "AdminUser" ADD COLUMN "telegramId" BIGINT,
ADD COLUMN "tgLinkToken" TEXT,
ADD COLUMN "tgNotify" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "ConciergeMessage" ADD COLUMN "providerId" TEXT,
ALTER COLUMN "clientId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Lead" DROP COLUMN "checklist";
-- AlterTable
ALTER TABLE "LeadStep" ADD COLUMN "title" TEXT;
-- CreateTable
CREATE TABLE "RelocationItem" (
"id" TEXT NOT NULL,
"kind" "RelocationItemKind" NOT NULL,
"code" TEXT NOT NULL,
"name" JSONB NOT NULL,
"hint" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"inTurnkey" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "RelocationItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeadDoc" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"title" TEXT,
"checked" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL,
CONSTRAINT "LeadDoc_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RelocationItem_kind_code_key" ON "RelocationItem"("kind", "code");
-- CreateIndex
CREATE UNIQUE INDEX "LeadDoc_leadId_code_key" ON "LeadDoc"("leadId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_telegramId_key" ON "AdminUser"("telegramId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_tgLinkToken_key" ON "AdminUser"("tgLinkToken");
-- CreateIndex
CREATE INDEX "ConciergeMessage_providerId_createdAt_idx" ON "ConciergeMessage"("providerId", "createdAt");
-- AddForeignKey
ALTER TABLE "LeadDoc" ADD CONSTRAINT "LeadDoc_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- стартовые шаблоны: шаги переезда и чек-лист документов (дальше правятся в админке)
INSERT INTO "RelocationItem" ("id", "kind", "code", "name", "hint", "sortOrder", "active", "inTurnkey") VALUES
(gen_random_uuid()::text, 'STEP', 'consult', '{"ru": "Консультация по переезду", "en": "Relocation consultation", "zh": "搬家咨询", "hy": "Տեղափոխության խորհրդատվություն", "es": "Consulta de mudanza"}'::jsonb, NULL, 0, true, false),
(gen_random_uuid()::text, 'STEP', 'airport', '{"ru": "Встреча в аэропорту", "en": "Airport pickup", "zh": "机场接机", "hy": "Դիմավորում օդանավակայանում", "es": "Recogida en el aeropuerto"}'::jsonb, NULL, 1, true, true),
(gen_random_uuid()::text, 'STEP', 'stay', '{"ru": "Жильё на первое время", "en": "A place for the first days", "zh": "初期住宿", "hy": "Բնակություն առաջին օրերի համար", "es": "Alojamiento para empezar"}'::jsonb, NULL, 2, true, true),
(gen_random_uuid()::text, 'STEP', 'flat', '{"ru": "Поиск квартиры", "en": "Flat search", "zh": "找房", "hy": "Բնակարանի որոնում", "es": "Búsqueda de piso"}'::jsonb, NULL, 3, true, true),
(gen_random_uuid()::text, 'STEP', 'registration', '{"ru": "Регистрация", "en": "Registration", "zh": "登记", "hy": "Գրանցում", "es": "Empadronamiento"}'::jsonb, NULL, 4, true, true),
(gen_random_uuid()::text, 'STEP', 'socialCard', '{"ru": "Соцкарта", "en": "Social card", "zh": "社保卡", "hy": "Սոցքարտ", "es": "Tarjeta social"}'::jsonb, NULL, 5, true, true),
(gen_random_uuid()::text, 'STEP', 'bankCard', '{"ru": "Банковская карта", "en": "Bank card", "zh": "银行卡", "hy": "Բանկային քարտ", "es": "Tarjeta bancaria"}'::jsonb, NULL, 6, true, true),
(gen_random_uuid()::text, 'STEP', 'residence', '{"ru": "ВНЖ", "en": "Residence permit", "zh": "居留许可", "hy": "Կացության քարտ", "es": "Permiso de residencia"}'::jsonb, NULL, 7, true, true),
(gen_random_uuid()::text, 'DOC', 'passport', '{"ru": "Загранпаспорт", "en": "International passport", "zh": "护照", "hy": "Արտասահմանյան անձնագիր", "es": "Pasaporte"}'::jsonb, '{"ru": "Действует ещё минимум 6 месяцев после переезда", "en": "Valid for at least 6 months after the move", "zh": "搬家后至少还有 6 个月有效期", "hy": "Վավեր տեղափոխվելուց հետո առնվազն 6 ամիս", "es": "Vigente al menos 6 meses después de la mudanza"}'::jsonb, 0, true, false),
(gen_random_uuid()::text, 'DOC', 'passportCopies', '{"ru": "Копии паспорта", "en": "Passport copies", "zh": "护照复印件", "hy": "Անձնագրի պատճեններ", "es": "Copias del pasaporte"}'::jsonb, '{"ru": "2–3 копии страницы с фото", "en": "2–3 copies of the photo page", "zh": "照片页复印 2–3 份", "hy": "Լուսանկարով էջի 2–3 պատճեն", "es": "2–3 copias de la página con foto"}'::jsonb, 1, true, false),
(gen_random_uuid()::text, 'DOC', 'photos', '{"ru": "Фото 3×4", "en": "3×4 photos", "zh": "3×4 照片", "hy": "3×4 լուսանկարներ", "es": "Fotos 3×4"}'::jsonb, '{"ru": "4–6 штук для ВНЖ и соцкарты", "en": "4–6 for the residence permit and social card", "zh": "4–6 张,用于居留许可和社保卡", "hy": "4–6 հատ՝ կացության քարտի և սոցքարտի համար", "es": "4–6 para la residencia y la tarjeta social"}'::jsonb, 2, true, false),
(gen_random_uuid()::text, 'DOC', 'birthCertificate', '{"ru": "Свидетельство о рождении", "en": "Birth certificate", "zh": "出生证明", "hy": "Ծննդյան վկայական", "es": "Partida de nacimiento"}'::jsonb, '{"ru": "С апостилем — нужно для ВНЖ", "en": "Apostilled — needed for the residence permit", "zh": "需海牙认证,办理居留许可时使用", "hy": "Ապոստիլով՝ պետք է կացության քարտի համար", "es": "Apostillada: se necesita para la residencia"}'::jsonb, 3, true, false),
(gen_random_uuid()::text, 'DOC', 'marriageCertificate', '{"ru": "Свидетельство о браке", "en": "Marriage certificate", "zh": "结婚证", "hy": "Ամուսնության վկայական", "es": "Certificado de matrimonio"}'::jsonb, '{"ru": "С апостилем, если переезжаете семьёй", "en": "Apostilled, if moving as a family", "zh": "需海牙认证,全家搬迁时需要", "hy": "Ապոստիլով, եթե տեղափոխվում եք ընտանիքով", "es": "Apostillado, si os mudáis en familia"}'::jsonb, 4, true, false),
(gen_random_uuid()::text, 'DOC', 'diploma', '{"ru": "Диплом", "en": "Diploma", "zh": "学历证书", "hy": "Դիպլոմ", "es": "Título universitario"}'::jsonb, '{"ru": "С апостилем — пригодится для работы и ВНЖ", "en": "Apostilled — useful for work and the residence permit", "zh": "需海牙认证,工作和居留许可时有用", "hy": "Ապոստիլով՝ պետք կգա աշխատանքի և կացության քարտի համար", "es": "Apostillado: útil para trabajar y para la residencia"}'::jsonb, 5, true, false),
(gen_random_uuid()::text, 'DOC', 'criminalRecord', '{"ru": "Справка о несудимости", "en": "Criminal record certificate", "zh": "无犯罪记录证明", "hy": "Դատվածության բացակայության տեղեկանք", "es": "Certificado de antecedentes penales"}'::jsonb, '{"ru": "С апостилем, действует 3 месяца", "en": "Apostilled, valid for 3 months", "zh": "需海牙认证,有效期 3 个月", "hy": "Ապոստիլով, վավեր է 3 ամիս", "es": "Apostillado, válido 3 meses"}'::jsonb, 6, true, false),
(gen_random_uuid()::text, 'DOC', 'insurance', '{"ru": "Медицинская страховка", "en": "Health insurance", "zh": "医疗保险", "hy": "Բժշկական ապահովագրություն", "es": "Seguro médico"}'::jsonb, '{"ru": "На первые месяцы, пока нет ВНЖ", "en": "For the first months before the residence permit", "zh": "覆盖办下居留许可前的头几个月", "hy": "Առաջին ամիսների համար, մինչև կացության քարտը", "es": "Para los primeros meses, hasta tener la residencia"}'::jsonb, 7, true, false)
ON CONFLICT ("kind", "code") DO NOTHING;
@@ -0,0 +1,81 @@
-- CreateEnum
CREATE TYPE "OfferLimit" AS ENUM ('NONE', 'ONCE', 'WEEK', 'MONTH');
-- CreateEnum
CREATE TYPE "PartnerRole" AS ENUM ('OWNER', 'STAFF');
-- AlterTable
ALTER TABLE "CardUsage" ADD COLUMN "level" TEXT,
ADD COLUMN "memberUserId" TEXT,
ADD COLUMN "offerId" TEXT;
-- AlterTable
ALTER TABLE "Partner" ADD COLUMN "ownerInviteCode" TEXT;
-- CreateTable
CREATE TABLE "PartnerOffer" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"title" JSONB NOT NULL,
"minLevel" "SubscriptionPlan",
"limit" "OfferLimit" NOT NULL DEFAULT 'NONE',
"limitCount" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PartnerOffer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnerMember" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "PartnerRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnerInvite" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PartnerMember_partnerId_userId_key" ON "PartnerMember"("partnerId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "PartnerInvite_code_key" ON "PartnerInvite"("code");
-- CreateIndex
CREATE INDEX "CardUsage_clientId_offerId_createdAt_idx" ON "CardUsage"("clientId", "offerId", "createdAt");
-- CreateIndex
CREATE INDEX "CardUsage_partnerId_createdAt_idx" ON "CardUsage"("partnerId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Partner_ownerInviteCode_key" ON "Partner"("ownerInviteCode");
-- AddForeignKey
ALTER TABLE "PartnerOffer" ADD CONSTRAINT "PartnerOffer_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerMember" ADD CONSTRAINT "PartnerMember_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerMember" ADD CONSTRAINT "PartnerMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerInvite" ADD CONSTRAINT "PartnerInvite_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CardUsage" ADD CONSTRAINT "CardUsage_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "PartnerOffer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,64 @@
-- CreateEnum
CREATE TYPE "StaffRole" AS ENUM ('SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES');
-- AlterTable
ALTER TABLE "AdminUser" ADD COLUMN "roles" "StaffRole"[] DEFAULT ARRAY[]::"StaffRole"[],
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "adminNote" TEXT,
ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "agencyId" TEXT,
ADD COLUMN "amount" INTEGER,
ADD COLUMN "cancelledAt" TIMESTAMP(3),
ADD COLUMN "grantedByAdminId" TEXT,
ADD COLUMN "note" TEXT,
ADD COLUMN "payMethod" TEXT,
ADD COLUMN "refundAmount" INTEGER,
ADD COLUMN "refundedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "AdminLoginRequest" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"pollKey" TEXT NOT NULL,
"adminUserId" TEXT,
"approvedAt" TIMESTAMP(3),
"consumedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminLoginRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SavedSegment" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"filters" JSONB NOT NULL,
"adminUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedSegment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AdminLoginRequest_code_key" ON "AdminLoginRequest"("code");
-- CreateIndex
CREATE INDEX "Subscription_createdAt_idx" ON "Subscription"("createdAt");
-- AddForeignKey
ALTER TABLE "AdminLoginRequest" ADD CONSTRAINT "AdminLoginRequest_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedSegment" ADD CONSTRAINT "SavedSegment_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- существующие админы получают роли новой админки
UPDATE "AdminUser" SET "roles" = ARRAY['SUPERADMIN']::"StaffRole"[] WHERE "role" = 'OWNER';
UPDATE "AdminUser" SET "roles" = ARRAY['SUPPORT','CONCIERGE']::"StaffRole"[] WHERE "role" = 'OPERATOR';
-- подарки агентств: запоминаем агентство
UPDATE "Subscription" s SET "agencyId" = c."agencyId" FROM "Client" c WHERE s."clientId" = c.id AND s.source = 'AGENCY_GIFT';
+398 -14
View File
@@ -107,8 +107,17 @@ enum AdminRole {
OPERATOR
}
/// Роли новой админки (apps/backoffice). У одного человека может быть несколько.
enum StaffRole {
SUPERADMIN
SUPPORT
CONCIERGE
SALES
}
enum SubscriptionPlan {
BAREV_PLUS
ELITE
}
enum SubscriptionSource {
@@ -147,6 +156,15 @@ enum TaskStatus {
DONE
}
/// Заявка с сайта: NEW — человек ещё не открыл бота; LINKED — открыл, но заказ не создан (нужен звонок оператора);
/// ORDERED — стала заказом; CANCELLED — отменена оператором
enum WebRequestStatus {
NEW
LINKED
ORDERED
CANCELLED
}
enum PayoutStatus {
DRAFT
APPROVED
@@ -170,6 +188,7 @@ model User {
client Client?
provider Provider?
dispatcher CompanyDispatcher[]
partnerMembers PartnerMember[]
messages Message[]
}
@@ -189,6 +208,12 @@ model Client {
giftUsedAt DateTime?
/// короткий код клубной карты (Этап 3)
cardCode String @unique
/// сценарий приложения: переезжает или уже живёт в Армении; null — ещё не выбрал
segment ClientSegment?
/// метки команды: VIP, проблемный…
tags String[] @default([])
/// заметка команды о клиенте (клиент не видит)
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -197,6 +222,10 @@ model Client {
ratings Rating[]
cardUsages CardUsage[]
promoUses PromoCodeUse[]
webRequests WebRequest[]
leads Lead[]
requests ClientRequest[]
concierge ConciergeMessage[]
@@index([agencyId])
}
@@ -254,6 +283,9 @@ model Provider {
onShift Boolean @default(false)
active Boolean @default(true)
inviteCode String @unique
/// пресет ставок (джун / мидл / прайват); если задан — заменяет rateType/rateValue
payoutPresetId String?
payoutPreset PayoutPreset? @relation(fields: [payoutPresetId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -265,14 +297,30 @@ model Provider {
payouts Payout[]
@@index([role, active])
concierge ConciergeMessage[]
}
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts)
model PayoutPreset {
id String @id @default(cuid())
name String
role ProviderRole
rules Json
sortOrder Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
providers Provider[]
}
model District {
id String @id @default(cuid())
code String @unique
sortOrder Int @default(0)
providers ProviderDistrict[]
orders Order[]
providers ProviderDistrict[]
orders Order[]
webRequests WebRequest[]
}
model ProviderDistrict {
@@ -304,6 +352,8 @@ model Category {
sortOrder Int @default(0)
/// скидка Barev+ на направление, %
barevPlusDiscountPct Int @default(0)
/// скидка Barev Elite на направление, %
eliteDiscountPct Int @default(0)
active Boolean @default(true)
services Service[]
}
@@ -330,8 +380,9 @@ model Service {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
checklist ChecklistItem[]
orders Order[]
checklist ChecklistItem[]
orders Order[]
webRequests WebRequest[]
}
/// Шаблон пункта чек-листа услуги
@@ -379,6 +430,8 @@ model Order {
/// язык клиента на момент заказа
locale Locale
basePrice Int
/// разбивка цены на момент заказа (основа, комнаты, санузлы, доп. услуги) — для расчёта выплат
priceBreakdown Json?
discountPct Int @default(0)
discountAmount Int @default(0)
totalPrice Int
@@ -413,6 +466,7 @@ model Order {
payments Payment[]
commission AgencyCommission?
tasks OperatorTask[]
webRequest WebRequest?
@@index([status])
@@index([scheduledAt])
@@ -644,9 +698,22 @@ model Subscription {
startsAt DateTime
endsAt DateTime
remindedAt DateTime?
/// оплачено клиентом, AMD (PAID); для подарков пусто
amount Int?
/// cash | transfer | card | link
payMethod String?
/// кто из команды начислил / продал
grantedByAdminId String?
/// агентство, подарившее подписку (AGENCY_GIFT)
agencyId String?
note String?
cancelledAt DateTime?
refundAmount Int?
refundedAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, endsAt])
@@index([createdAt])
}
// ───────────── Партнёры и клубная карта (Этап 3) ─────────────
@@ -661,18 +728,90 @@ model Partner {
terms Json?
address String?
active Boolean @default(true)
/// одноразовая ссылка для собственника: t.me/<рабочий бот>?start=po-<код> (выдаёт админка)
ownerInviteCode String? @unique
createdAt DateTime @default(now())
usages CardUsage[]
offers PartnerOffer[]
members PartnerMember[]
invites PartnerInvite[]
}
enum OfferLimit {
/// без ограничений
NONE
/// только первое посещение
ONCE
/// N раз в календарную неделю
WEEK
/// N раз в календарный месяц
MONTH
}
/// Предложение партнёра: что положено клиенту с определённым уровнем и как часто
model PartnerOffer {
id String @id @default(cuid())
partnerId String
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
/// { ru, en, zh, hy, es }
title Json
/// минимальный уровень: пусто — любой член клуба, BAREV_PLUS — Barev+ и Elite, ELITE — только Elite
minLevel SubscriptionPlan?
limit OfferLimit @default(NONE)
limitCount Int @default(1)
active Boolean @default(true)
sortOrder Int @default(0)
usages CardUsage[]
}
enum PartnerRole {
OWNER
STAFF
}
/// Собственник или сотрудник партнёра — работает в рабочем боте: проверяет QR клиентов
model PartnerMember {
id String @id @default(cuid())
partnerId String
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
role PartnerRole
createdAt DateTime @default(now())
@@unique([partnerId, userId])
}
/// Приглашение сотрудника: собственник показывает QR, сотрудник сканирует — t.me/<рабочий бот>?start=pi-<код>
model PartnerInvite {
id String @id @default(cuid())
code String @unique
partnerId String
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
}
/// Проверка клубной карты у партнёра (offerId пусто) или списание лимитного предложения
model CardUsage {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
createdAt DateTime @default(now())
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
offerId String?
offer PartnerOffer? @relation(fields: [offerId], references: [id], onDelete: SetNull)
/// кто из сотрудников партнёра проверил
memberUserId String?
/// уровень клиента на момент проверки
level String?
createdAt DateTime @default(now())
@@index([clientId, offerId, createdAt])
@@index([partnerId, createdAt])
}
// ───────────── Финансы ─────────────
@@ -694,17 +833,53 @@ model Payout {
model AdminUser {
id String @id @default(cuid())
email String @unique
/// вход по паролю — только старая админка; в новой вход через Telegram
email String? @unique
name String
passwordHash String
passwordHash String?
role AdminRole @default(OPERATOR)
/// роли в новой админке
roles StaffRole[] @default([])
totpSecret String?
totpEnabled Boolean @default(false)
active Boolean @default(true)
/// Telegram оператора — уведомления приходят через рабочий бот
telegramId BigInt? @unique
/// одноразовый код ссылки t.me/<рабочий бот>?start=adm-<код>
tgLinkToken String? @unique
/// присылать уведомления в Telegram
tgNotify Boolean @default(true)
createdAt DateTime @default(now())
sessions AdminSession[]
audit AuditLog[]
sessions AdminSession[]
audit AuditLog[]
loginRequests AdminLoginRequest[]
segments SavedSegment[]
}
/// Вход в новую админку через Telegram: страница показывает ссылку t.me/<рабочий бот>?start=lg-<code>,
/// бот подтверждает по telegramId, страница опрашивает статус и получает сессию
model AdminLoginRequest {
id String @id @default(cuid())
code String @unique
/// секрет браузера — по нему страница забирает сессию (code виден в ссылке)
pollKey String
adminUserId String?
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
approvedAt DateTime?
consumedAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
}
/// Сохранённая выборка клиентов (фильтры списка)
model SavedSegment {
id String @id @default(cuid())
name String
filters Json
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
model AdminSession {
@@ -747,3 +922,212 @@ model StartToken {
utm Json?
createdAt DateTime @default(now())
}
/// Заявка на услугу с сайта (barevclub.com/clean). Попадает в админку сразу; когда человек открывает бота
/// по ссылке с token, заявка привязывается к клиенту и, если выбранное время свободно, становится заказом.
model WebRequest {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
/// id StartToken — payload ссылки в бота
token String @unique
serviceId String
service Service @relation(fields: [serviceId], references: [id])
params Json
/// желаемое время, оператор подтверждает
preferredAt DateTime
durationMinutes Int
districtId String
district District @relation(fields: [districtId], references: [id])
addressText String
note String?
phone String
name String?
locale Locale
/// цена на момент заявки, без скидки Barev+
totalPrice Int
consentAt DateTime
consentVersion String
status WebRequestStatus @default(NEW)
clientId String?
client Client? @relation(fields: [clientId], references: [id])
orderId String? @unique
order Order? @relation(fields: [orderId], references: [id])
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
}
enum LeadStatus {
NEW
IN_WORK
DONE
CANCELLED
}
enum LeadContactType {
PHONE
TELEGRAM
}
/// Заявка из квиза на главной («Переезжаю в Армению»). Создаётся сразу после шага с контактом,
/// дальше дополняется ответами; submittedAt — человек дошёл до конца.
model Lead {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
/// секрет для дозаписи ответов из браузера (id видят в URL логов, ключ — нет)
key String
name String
contact String
contactType LeadContactType
locale Locale
agency String?
utm Json?
/// уже живёт в Армении — квиз отправил на /live
moved Boolean?
/// month | quarter | later | date
moveWhen String?
moveDate DateTime?
/// CONSULT | TURNKEY | CUSTOM
package String?
/// конструктор: { airport, stay, stayBudgetFrom, stayBudgetTo, flat, socialCard, bankCard, registration, residence }
options Json?
submittedAt DateTime?
consentAt DateTime
consentVersion String
status LeadStatus @default(NEW)
adminNote String?
/// клиент в приложении — заявка из приложения или найдена по @username из квиза
clientId String?
client Client? @relation(fields: [clientId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
steps LeadStep[]
docs LeadDoc[]
@@index([status, createdAt])
@@index([clientId])
}
enum ClientSegment {
MOVING
LIVING
}
enum LeadStepStatus {
TODO
IN_PROGRESS
DONE
SKIPPED
}
/// Шаг плана переезда (аэропорт, жильё, соцкарта…) — статус двигает оператор, клиент видит прогресс
model LeadStep {
id String @id @default(cuid())
leadId String
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
/// код из шаблона (RelocationItem) или custom-… для шага, добавленного только этому клиенту
code String
/// своё название для этого клиента; пусто — название из шаблона
title String?
sortOrder Int
status LeadStepStatus @default(TODO)
/// комментарий оператора для клиента: «просмотры в среду в 15:00»
note String?
updatedAt DateTime @updatedAt
@@unique([leadId, code])
}
enum RelocationItemKind {
STEP
DOC
}
/// Общий список шагов переезда и документов — редактируется в админке; у клиента копия, которую можно менять отдельно
model RelocationItem {
id String @id @default(cuid())
kind RelocationItemKind
code String
/// { ru, en, zh, hy, es }
name Json
/// подсказка под названием (для документов)
hint Json?
sortOrder Int @default(0)
active Boolean @default(true)
/// шаг входит в пакет «под ключ»
inTurnkey Boolean @default(true)
@@unique([kind, code])
}
/// Документ в чек-листе конкретного клиента
model LeadDoc {
id String @id @default(cuid())
leadId String
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
code String
title String?
checked Boolean @default(false)
sortOrder Int
@@unique([leadId, code])
}
enum ClientRequestKind {
SUBSCRIPTION
SERVICE
}
enum ClientRequestStatus {
NEW
DONE
CANCELLED
}
/// Заявка из приложения оператору: «хочу Elite», «перезвоните по ремонту»
model ClientRequest {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
clientId String
client Client @relation(fields: [clientId], references: [id])
kind ClientRequestKind
plan SubscriptionPlan?
serviceCode String?
comment String?
status ClientRequestStatus @default(NEW)
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
@@index([clientId])
}
enum ConciergeAuthor {
CLIENT
PROVIDER
OPERATOR
}
/// Переписка из админки: с клиентом (клиентский бот) или с исполнителем (рабочий бот); ответ приходит в тот же чат с ботом
model ConciergeMessage {
id String @id @default(cuid())
clientId String?
client Client? @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
author ConciergeAuthor
/// кто из операторов ответил
adminUserId String?
text String
/// оператор прочитал сообщение клиента
readAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, createdAt])
@@index([providerId, createdAt])
@@index([author, readAt])
}