Backoffice stage 5: products, broadcasts, activity log, settings

- Products: services with full setup — content (name, description, extras names
  in 5 languages via text overrides), price tiers and extras, booking window,
  late-cancel fee, on/off, checklist; category discounts by membership level;
  membership plan prices; promo codes; relocation steps and documents.
- Text overrides: TextOverride table on top of the i18n files; API, bots and the
  worker reload them every minute, mini apps fetch public.texts at start.
- Promo codes applied to orders (admin new-order form and client API): not
  stacked with membership discount — the larger wins; once per client; validity,
  limit and categories.
- Broadcasts to the client bot by any client filter, per-language texts with
  Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after
  worker restart, history with progress and stop.
- Activity log (who/when/what, filters, details) and Settings page with all
  owner settings grouped, plus payment methods.
- Tests for promo codes and text overrides.

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 03:17:56 +04:00
1 parent ac786cc5cc
commit 0edc6432b7
34 files changed
+2704 -26

No files matched your search

@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "BroadcastStatus" AS ENUM ('SENDING', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "BroadcastRecipientStatus" AS ENUM ('PENDING', 'SENT', 'BLOCKED', 'FAILED');
-- AlterTable
ALTER TABLE "PromoCode" ADD COLUMN "categories" "CategoryCode"[] DEFAULT ARRAY[]::"CategoryCode"[],
ADD COLUMN "note" TEXT;
-- CreateTable
CREATE TABLE "TextOverride" (
"key" TEXT NOT NULL,
"locale" "Locale" NOT NULL,
"value" TEXT NOT NULL,
"updatedById" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TextOverride_pkey" PRIMARY KEY ("key","locale")
);
-- CreateTable
CREATE TABLE "Broadcast" (
"id" TEXT NOT NULL,
"texts" JSONB NOT NULL,
"appButton" BOOLEAN NOT NULL DEFAULT true,
"filters" JSONB NOT NULL,
"status" "BroadcastStatus" NOT NULL DEFAULT 'SENDING',
"total" INTEGER NOT NULL DEFAULT 0,
"sent" INTEGER NOT NULL DEFAULT 0,
"blocked" INTEGER NOT NULL DEFAULT 0,
"failed" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
CONSTRAINT "Broadcast_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BroadcastRecipient" (
"id" TEXT NOT NULL,
"broadcastId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"status" "BroadcastRecipientStatus" NOT NULL DEFAULT 'PENDING',
"error" TEXT,
"sentAt" TIMESTAMP(3),
CONSTRAINT "BroadcastRecipient_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BroadcastRecipient_broadcastId_status_idx" ON "BroadcastRecipient"("broadcastId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "BroadcastRecipient_broadcastId_clientId_key" ON "BroadcastRecipient"("broadcastId", "clientId");
-- AddForeignKey
ALTER TABLE "BroadcastRecipient" ADD CONSTRAINT "BroadcastRecipient_broadcastId_fkey" FOREIGN KEY ("broadcastId") REFERENCES "Broadcast"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+66
View File
@@ -706,6 +706,10 @@ model PromoCode {
validFrom DateTime?
validUntil DateTime?
active Boolean @default(true)
/// для команды: откуда код (рассылка, блогер, партнёр)
note String?
/// на какие направления действует; пусто — на все
categories CategoryCode[] @default([])
createdAt DateTime @default(now())
uses PromoCodeUse[]
@@ -1369,3 +1373,65 @@ model SalesTask {
@@index([assigneeId, doneAt, dueAt])
}
// ───────────── Продукты, рассылки (этап 5) ─────────────
/// Текст, изменённый в админке поверх переводов из packages/i18n (название и описание услуги, пункт чек-листа…).
/// Пустой — не храним: действует стандартный перевод
model TextOverride {
key String
locale Locale
value String
updatedById String?
updatedAt DateTime @updatedAt
@@id([key, locale])
}
enum BroadcastStatus {
SENDING
DONE
CANCELLED
}
enum BroadcastRecipientStatus {
PENDING
SENT
/// заблокировал бота или ни разу его не открывал
BLOCKED
FAILED
}
/// Рассылка клиентам в бот по сегменту (фильтры списка клиентов). Текст — на языке клиента, фолбэк — русский
model Broadcast {
id String @id @default(cuid())
/// { ru, en, zh, hy, es }
texts Json
/// кнопка «Открыть приложение» под сообщением
appButton Boolean @default(true)
/// фильтры списка клиентов, по которым собрали получателей
filters Json
status BroadcastStatus @default(SENDING)
total Int @default(0)
sent Int @default(0)
blocked Int @default(0)
failed Int @default(0)
createdById String?
createdAt DateTime @default(now())
finishedAt DateTime?
recipients BroadcastRecipient[]
}
model BroadcastRecipient {
id String @id @default(cuid())
broadcastId String
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
clientId String
status BroadcastRecipientStatus @default(PENDING)
error String?
sentAt DateTime?
@@unique([broadcastId, clientId])
@@index([broadcastId, status])
}