diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index 3f2adf40e3..230453e1d9 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -2602,6 +2602,11 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "minigames.deleteMany", "path": "/v1/minigames/bulk-delete", }, + { + "method": "POST", + "operationId": "minigames.duplicate", + "path": "/v1/minigames/{id}/duplicate", + }, { "method": "GET", "operationId": "minigames.get", diff --git a/apps/builder/__tests__/api-parity-manifest.json b/apps/builder/__tests__/api-parity-manifest.json index 8ae2eea95b..abd311a3b3 100644 --- a/apps/builder/__tests__/api-parity-manifest.json +++ b/apps/builder/__tests__/api-parity-manifest.json @@ -375,6 +375,7 @@ "mini-apps/update-mini-app.action:updateMiniAppAction": "covered:mini-apps", "minigames/create-minigame.action:createMinigameAction": "covered:minigames", "minigames/delete-minigames.action:deleteMinigamesAction": "covered:minigames", + "minigames/duplicate-minigame.action:duplicateMinigameAction": "covered:minigames", "minigames/enable-minigame.action:enableMinigameAction": "covered:minigames", "minigames/find-contact-conversation.action:findContactConversationAction": "covered:minigames", "minigames/get-minigame-plays.action:getMinigamePlaysAction": "covered:minigames", diff --git a/apps/builder/messages/ar.json b/apps/builder/messages/ar.json index 4f16b2af16..521a9a7aad 100644 --- a/apps/builder/messages/ar.json +++ b/apps/builder/messages/ar.json @@ -9384,6 +9384,11 @@ "lose": "بدون جائزة", "empty": "لا توجد سجلات لعب بعد" } + }, + "duplicateDialog": { + "title": "تكرار اللعبة المصغرة", + "description": "سيتم إنشاء نسخة من اللعبة المصغرة بجميع إعداداتها. لا يتم نسخ الجوائز — أضفها إلى النسخة بعد ذلك.", + "success": "تم تكرار اللعبة المصغرة بنجاح" } }, "mediaLibrary": { diff --git a/apps/builder/messages/az.json b/apps/builder/messages/az.json index 3903fd585c..b480b41b1c 100644 --- a/apps/builder/messages/az.json +++ b/apps/builder/messages/az.json @@ -9426,6 +9426,11 @@ "lose": "Mükafat yoxdur", "empty": "Hələ oyun qeydi yoxdur" } + }, + "duplicateDialog": { + "title": "Mini oyunu dublikat et", + "description": "Bütün ayarları ilə mini oyunun surəti yaradılacaq. Mükafatlar köçürülmür — onları sonra surətə əlavə edin.", + "success": "Mini oyun uğurla dublikat edildi" } }, "mediaLibrary": { diff --git a/apps/builder/messages/da.json b/apps/builder/messages/da.json index b40ee22111..6b31214a26 100644 --- a/apps/builder/messages/da.json +++ b/apps/builder/messages/da.json @@ -9384,6 +9384,11 @@ "lose": "Ingen præmie", "empty": "Ingen spiloptegnelser endnu" } + }, + "duplicateDialog": { + "title": "Dupliker minispil", + "description": "Dette opretter en kopi af minispillet med alle dets indstillinger. Præmier kopieres ikke — tilføj dem til kopien bagefter.", + "success": "Minispillet blev duplikeret" } }, "mediaLibrary": { diff --git a/apps/builder/messages/de.json b/apps/builder/messages/de.json index 5e61beaf86..6f9c66b72a 100644 --- a/apps/builder/messages/de.json +++ b/apps/builder/messages/de.json @@ -9384,6 +9384,11 @@ "lose": "Kein Gewinn", "empty": "Noch keine Spielprotokolle" } + }, + "duplicateDialog": { + "title": "Minispiel duplizieren", + "description": "Dadurch wird eine Kopie des Minispiels mit allen Einstellungen erstellt. Preise werden nicht kopiert – füge sie anschließend zur Kopie hinzu.", + "success": "Minispiel erfolgreich dupliziert" } }, "mediaLibrary": { diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json index 3c98e80bb5..617816b7d2 100644 --- a/apps/builder/messages/en.json +++ b/apps/builder/messages/en.json @@ -9426,6 +9426,11 @@ "lose": "No prize", "empty": "No play records yet" } + }, + "duplicateDialog": { + "title": "Duplicate minigame", + "description": "This creates a copy of the minigame with all its settings. Prizes are not copied — add them to the copy afterwards.", + "success": "Minigame duplicated successfully" } }, "mediaLibrary": { diff --git a/apps/builder/messages/es.json b/apps/builder/messages/es.json index 2a0125c6a6..c023be8897 100644 --- a/apps/builder/messages/es.json +++ b/apps/builder/messages/es.json @@ -9384,6 +9384,11 @@ "lose": "Sin premio", "empty": "Aún no hay registros de jugadas" } + }, + "duplicateDialog": { + "title": "Duplicar minijuego", + "description": "Se creará una copia del minijuego con toda su configuración. Los premios no se copian: añádelos a la copia después.", + "success": "Minijuego duplicado correctamente" } }, "mediaLibrary": { diff --git a/apps/builder/messages/fi.json b/apps/builder/messages/fi.json index 1536627f08..14da4e99bc 100644 --- a/apps/builder/messages/fi.json +++ b/apps/builder/messages/fi.json @@ -9384,6 +9384,11 @@ "lose": "Ei palkintoa", "empty": "Ei pelitapahtumia vielä" } + }, + "duplicateDialog": { + "title": "Monista minipeli", + "description": "Tämä luo minipelistä kopion kaikkine asetuksineen. Palkintoja ei kopioida – lisää ne kopioon jälkikäteen.", + "success": "Minipeli monistettu" } }, "mediaLibrary": { diff --git a/apps/builder/messages/fr.json b/apps/builder/messages/fr.json index 0667a41e52..00df70707c 100644 --- a/apps/builder/messages/fr.json +++ b/apps/builder/messages/fr.json @@ -9384,6 +9384,11 @@ "lose": "Pas de lot", "empty": "Aucun registre de parties pour le moment" } + }, + "duplicateDialog": { + "title": "Dupliquer le mini-jeu", + "description": "Une copie du mini-jeu sera créée avec tous ses paramètres. Les lots ne sont pas copiés : ajoutez-les ensuite à la copie.", + "success": "Mini-jeu dupliqué avec succès" } }, "mediaLibrary": { diff --git a/apps/builder/messages/he.json b/apps/builder/messages/he.json index 53bcb957cb..d7e3ea9dc1 100644 --- a/apps/builder/messages/he.json +++ b/apps/builder/messages/he.json @@ -9384,6 +9384,11 @@ "lose": "ללא פרס", "empty": "אין עדיין רשומות משחק" } + }, + "duplicateDialog": { + "title": "שכפול מיני-משחק", + "description": "תיווצר העתקה של המיני-משחק עם כל ההגדרות שלו. הפרסים לא מועתקים — הוסיפו אותם להעתק לאחר מכן.", + "success": "המיני-משחק שוכפל בהצלחה" } }, "mediaLibrary": { diff --git a/apps/builder/messages/id.json b/apps/builder/messages/id.json index 192fdc2c1d..e5c85b2226 100644 --- a/apps/builder/messages/id.json +++ b/apps/builder/messages/id.json @@ -9384,6 +9384,11 @@ "lose": "Tidak menang", "empty": "Belum ada catatan permainan" } + }, + "duplicateDialog": { + "title": "Duplikat minigame", + "description": "Ini akan membuat salinan minigame dengan semua pengaturannya. Hadiah tidak disalin — tambahkan ke salinan setelahnya.", + "success": "Minigame berhasil diduplikat" } }, "mediaLibrary": { diff --git a/apps/builder/messages/it.json b/apps/builder/messages/it.json index e51d1bfe21..92b643d624 100644 --- a/apps/builder/messages/it.json +++ b/apps/builder/messages/it.json @@ -9384,6 +9384,11 @@ "lose": "Nessun premio", "empty": "Ancora nessun registro di partite" } + }, + "duplicateDialog": { + "title": "Duplica minigioco", + "description": "Verrà creata una copia del minigioco con tutte le impostazioni. I premi non vengono copiati: aggiungili alla copia in seguito.", + "success": "Minigioco duplicato correttamente" } }, "mediaLibrary": { diff --git a/apps/builder/messages/ja.json b/apps/builder/messages/ja.json index b3ce827fe7..9af65964c1 100644 --- a/apps/builder/messages/ja.json +++ b/apps/builder/messages/ja.json @@ -9384,6 +9384,11 @@ "lose": "ハズレ", "empty": "プレイ記録はまだありません" } + }, + "duplicateDialog": { + "title": "ミニゲームを複製", + "description": "すべての設定を含むミニゲームのコピーを作成します。景品はコピーされません。後でコピーに追加してください。", + "success": "ミニゲームを複製しました" } }, "mediaLibrary": { diff --git a/apps/builder/messages/nl.json b/apps/builder/messages/nl.json index dc9a75ab2a..f508d1d192 100644 --- a/apps/builder/messages/nl.json +++ b/apps/builder/messages/nl.json @@ -9384,6 +9384,11 @@ "lose": "Geen prijs", "empty": "Nog geen speelrecords" } + }, + "duplicateDialog": { + "title": "Minigame dupliceren", + "description": "Hiermee maak je een kopie van de minigame met alle instellingen. Prijzen worden niet gekopieerd — voeg ze daarna toe aan de kopie.", + "success": "Minigame succesvol gedupliceerd" } }, "mediaLibrary": { diff --git a/apps/builder/messages/pt-BR.json b/apps/builder/messages/pt-BR.json index ffa6d0fa66..127cb0d909 100644 --- a/apps/builder/messages/pt-BR.json +++ b/apps/builder/messages/pt-BR.json @@ -9384,6 +9384,11 @@ "lose": "Sem prêmio", "empty": "Ainda não há registros de jogadas" } + }, + "duplicateDialog": { + "title": "Duplicar minigame", + "description": "Isso cria uma cópia do minigame com todas as configurações. Os prêmios não são copiados — adicione-os à cópia depois.", + "success": "Minigame duplicado com sucesso" } }, "mediaLibrary": { diff --git a/apps/builder/messages/pt-PT.json b/apps/builder/messages/pt-PT.json index 8e73a74568..5737c4fe1e 100644 --- a/apps/builder/messages/pt-PT.json +++ b/apps/builder/messages/pt-PT.json @@ -9384,6 +9384,11 @@ "lose": "Sem prémio", "empty": "Ainda não há registos de jogadas" } + }, + "duplicateDialog": { + "title": "Duplicar minijogo", + "description": "Isto cria uma cópia do minijogo com todas as definições. Os prémios não são copiados — adicione-os à cópia depois.", + "success": "Minijogo duplicado com sucesso" } }, "mediaLibrary": { diff --git a/apps/builder/messages/ro.json b/apps/builder/messages/ro.json index da0f22f1d5..a57d7a5ac4 100644 --- a/apps/builder/messages/ro.json +++ b/apps/builder/messages/ro.json @@ -9384,6 +9384,11 @@ "lose": "Fără premiu", "empty": "Nu există încă înregistrări de joc" } + }, + "duplicateDialog": { + "title": "Duplică minijocul", + "description": "Se va crea o copie a minijocului cu toate setările. Premiile nu sunt copiate — adaugă-le ulterior în copie.", + "success": "Minijocul a fost duplicat cu succes" } }, "mediaLibrary": { diff --git a/apps/builder/messages/sv.json b/apps/builder/messages/sv.json index e5135be16d..85932b40bf 100644 --- a/apps/builder/messages/sv.json +++ b/apps/builder/messages/sv.json @@ -9384,6 +9384,11 @@ "lose": "Ingen vinst", "empty": "Inga spelloggar ännu" } + }, + "duplicateDialog": { + "title": "Duplicera minispel", + "description": "Detta skapar en kopia av minispelet med alla inställningar. Priser kopieras inte – lägg till dem i kopian efteråt.", + "success": "Minispelet har duplicerats" } }, "mediaLibrary": { diff --git a/apps/builder/messages/tr.json b/apps/builder/messages/tr.json index dc85ba6da2..c115ddf480 100644 --- a/apps/builder/messages/tr.json +++ b/apps/builder/messages/tr.json @@ -9384,6 +9384,11 @@ "lose": "Ödül yok", "empty": "Henüz oyun kaydı yok" } + }, + "duplicateDialog": { + "title": "Mini oyunu çoğalt", + "description": "Tüm ayarlarıyla mini oyunun bir kopyası oluşturulur. Ödüller kopyalanmaz — bunları daha sonra kopyaya ekleyin.", + "success": "Mini oyun başarıyla çoğaltıldı" } }, "mediaLibrary": { diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json index 8201a87e03..36f45b79b4 100644 --- a/apps/builder/messages/vi.json +++ b/apps/builder/messages/vi.json @@ -9426,6 +9426,11 @@ "lose": "Không trúng", "empty": "Chưa có bản ghi lượt chơi nào" } + }, + "duplicateDialog": { + "title": "Nhân bản minigame", + "description": "Tạo một bản sao của minigame với toàn bộ cài đặt. Giải thưởng không được sao chép — hãy thêm giải thưởng cho bản sao sau đó.", + "success": "Đã nhân bản minigame thành công" } }, "mediaLibrary": { diff --git a/apps/builder/messages/zh-CN.json b/apps/builder/messages/zh-CN.json index 3949838724..e11f3a7dd4 100644 --- a/apps/builder/messages/zh-CN.json +++ b/apps/builder/messages/zh-CN.json @@ -9360,6 +9360,11 @@ "lose": "未中奖", "empty": "暂无游戏记录" } + }, + "duplicateDialog": { + "title": "复制小游戏", + "description": "将创建包含全部设置的小游戏副本。奖品不会被复制,请之后在副本中添加。", + "success": "小游戏已复制" } }, "userDataWebview": { diff --git a/apps/builder/messages/zh-TW.json b/apps/builder/messages/zh-TW.json index c4176c1634..ae53a0fd3c 100644 --- a/apps/builder/messages/zh-TW.json +++ b/apps/builder/messages/zh-TW.json @@ -9384,6 +9384,11 @@ "lose": "未中獎", "empty": "尚無遊戲紀錄" } + }, + "duplicateDialog": { + "title": "複製小遊戲", + "description": "將建立包含所有設定的小遊戲副本。獎品不會被複製,請之後在副本中新增。", + "success": "小遊戲已複製" } }, "mediaLibrary": { diff --git a/apps/builder/src/features/minigames/actions/duplicate-minigame.action.ts b/apps/builder/src/features/minigames/actions/duplicate-minigame.action.ts new file mode 100644 index 0000000000..55d682bd0d --- /dev/null +++ b/apps/builder/src/features/minigames/actions/duplicate-minigame.action.ts @@ -0,0 +1,12 @@ +"use server" + +import { minigameService } from "@chatbotx.io/business/minigame" +import { workspaceIdAndIdRequestParams } from "@/features/common/schema" +import { workspaceActionClient } from "@/lib/safe-action" + +export const duplicateMinigameAction = workspaceActionClient + .bindArgsSchemas(workspaceIdAndIdRequestParams) + .action(async ({ bindArgsParsedInputs: [workspaceId, id] }) => { + const minigame = await minigameService.duplicate({ workspaceId, id }) + return { id: minigame.id } + }) diff --git a/apps/builder/src/features/minigames/api/public.ts b/apps/builder/src/features/minigames/api/public.ts index 567d61b625..6df8cd3cb4 100644 --- a/apps/builder/src/features/minigames/api/public.ts +++ b/apps/builder/src/features/minigames/api/public.ts @@ -155,6 +155,34 @@ export const minigamesPublicRouter = { ) }), + duplicate: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/minigames/{id}/duplicate", + summary: "Duplicate minigame", + description: + "Copies a minigame's settings into a new minigame named ` - Copy` (`- Copy 2`, … when taken). Prizes are not copied: the copy starts with no prizes and `nonWinning.loseRate` 100, so add prizes with `minigames.updatePartial`. Plays, players and counters start from zero. Find the id with `minigames.list`.", + successStatus: 201, + tags, + }) + .input( + z.object({ + id: zodBigintAsString().describe( + "Minigame id to copy. Get it from `minigames.list`.", + ), + }), + ) + .output(minigamePublicResource) + .errors(possibleErrorsOnMutatingMinigame) + .handler(async ({ context, input }) => + withPlayUrl( + await minigameService.duplicate({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + ), + delete: workspaceTokenAuthAPI .route({ method: "DELETE", diff --git a/apps/builder/src/features/minigames/components/duplicate-minigame-dialog.tsx b/apps/builder/src/features/minigames/components/duplicate-minigame-dialog.tsx new file mode 100644 index 0000000000..b00a726279 --- /dev/null +++ b/apps/builder/src/features/minigames/components/duplicate-minigame-dialog.tsx @@ -0,0 +1,77 @@ +"use client" + +import { Button } from "@chatbotx.io/ui/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@chatbotx.io/ui/components/ui/dialog" +import { CopyPlusIcon, Loader2Icon } from "lucide-react" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { useAction } from "next-safe-action/hooks" +import { toast } from "sonner" +import { duplicateMinigameAction } from "../actions/duplicate-minigame.action" + +type DuplicateMinigameDialogProps = { + workspaceId: string + minigameId: string | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function DuplicateMinigameDialog({ + workspaceId, + minigameId, + open, + onOpenChange, +}: DuplicateMinigameDialogProps) { + const t = useTranslations() + const router = useRouter() + const { execute, isPending } = useAction( + duplicateMinigameAction.bind(null, workspaceId, minigameId ?? ""), + { + onSuccess: ({ data }) => { + toast.success(t("minigames.duplicateDialog.success")) + onOpenChange(false) + if (data?.id) { + router.push(`/space/${workspaceId}/minigames/${data.id}/edit`) + } + }, + onError: ({ error }) => { + if (error.serverError) { + toast.error(error.serverError) + } + }, + }, + ) + + return ( + + + + {t("minigames.duplicateDialog.title")} + + {t("minigames.duplicateDialog.description")} + + + + + + + + + ) +} diff --git a/apps/builder/src/features/minigames/minigames-table.tsx b/apps/builder/src/features/minigames/minigames-table.tsx index a07549dbd2..dd539f72be 100644 --- a/apps/builder/src/features/minigames/minigames-table.tsx +++ b/apps/builder/src/features/minigames/minigames-table.tsx @@ -27,6 +27,7 @@ import { formatDate } from "@chatbotx.io/ui/lib/format" import type { DataTableRowAction } from "@chatbotx.io/ui/types/data-table" import type { ColumnDef } from "@tanstack/react-table" import { + CopyPlusIcon, HistoryIcon, MoreHorizontalIcon, PencilIcon, @@ -38,6 +39,7 @@ import { useLocale, useTranslations } from "next-intl" import React, { use, useMemo } from "react" import { EnabledSwitchCell } from "@/components/data-table/enabled-switch-cell" import { enableMinigameAction } from "./actions/enable-minigame.action" +import { DuplicateMinigameDialog } from "./components/duplicate-minigame-dialog" import { MINIGAME_TYPE_CONFIGS } from "./constants" import { DeleteMinigamesDialog } from "./delete-minigames" import { MinigamesTableToolbarActions } from "./minigames-table-toolbar-actions" @@ -273,6 +275,13 @@ export function MinigamesTable({ workspaceId, promises }: MinigamesTableProps) { {t("actions.edit")} + setRowAction({ row, variant: "duplicate" })} + > + + {t("actions.duplicate")} + + + + setRowAction(null)} + open={rowAction?.variant === "duplicate"} + workspaceId={workspaceId} + /> ) diff --git a/apps/cli/README.md b/apps/cli/README.md index 211ba1ce33..879a23fbb5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1210,6 +1210,7 @@ chatbotx minigames list # [--page --perPage] chatbotx minigames get chatbotx minigames create --name --type chatbotx minigames update # Full replace (PUT) / partial (PATCH) collide, see note +chatbotx minigames duplicate # Copy as " - Copy" (prizes, plays and counters are not copied) chatbotx minigames delete chatbotx minigames bulk-delete --ids chatbotx minigames enabled update --enabled diff --git a/packages/business/__tests__/minigame-duplicate.test.ts b/packages/business/__tests__/minigame-duplicate.test.ts new file mode 100644 index 0000000000..0de4269377 --- /dev/null +++ b/packages/business/__tests__/minigame-duplicate.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + insertValues, + insertReturning, + findFirst, + findMany, + isUniqueViolationError, +} = vi.hoisted(() => ({ + insertValues: vi.fn(), + insertReturning: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + isUniqueViolationError: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn(), + db: { + insert: vi.fn(() => ({ + values: (values: unknown) => { + insertValues(values) + return { returning: insertReturning } + }, + })), + query: { minigameModel: { findFirst, findMany } }, + }, + eq: vi.fn(), + ilike: vi.fn(), + inArray: vi.fn(), + isUniqueViolationError, +})) +vi.mock("@chatbotx.io/database/schema", () => ({ minigameModel: {} })) + +const { minigameService } = await import("../src/minigame/service") + +const source = (name: string) => ({ + id: "1", + workspaceId: "ws-1", + name, + type: "jackpot", + enabled: true, + generalSettings: { name, showName: true }, + appearance: { backgroundColor: "#000000" }, + playerSettings: { drawsPerPerson: 3 }, + prizeSettings: { + prizes: [ + { id: "p1", name: "Voucher", icon: null, winRate: 30, quantity: 2 }, + ], + nonWinning: { title: "Try again", loseRate: 70, loseImage: null }, + prizeNameCustomFieldId: "42", + }, + winningMessageSettings: { enabled: true }, + nonWinningMessageSettings: { enabled: false }, + playsCount: 9, + participantsCount: 5, + winnersCount: 2, + sharesCount: 1, +}) + +type InsertedValues = Record & { + name: string + generalSettings: { name: string } +} + +const inserted = () => + insertValues.mock.calls.map(([values]) => values as InsertedValues) + +const uniqueViolation = new Error("duplicate key") + +beforeEach(() => { + vi.clearAllMocks() + isUniqueViolationError.mockImplementation( + (error: unknown) => error === uniqueViolation, + ) + insertReturning.mockImplementation(async () => [{ id: "2" }]) +}) + +describe("minigameService.duplicate", () => { + test("copies settings but not prizes, plays, or counters", async () => { + findFirst.mockResolvedValue(source("Spin")) + findMany.mockResolvedValue([]) + + await minigameService.duplicate({ workspaceId: "ws-1", id: "1" }) + + const [values] = inserted() + expect(values).toEqual({ + workspaceId: "ws-1", + enabled: true, + name: "Spin - Copy", + type: "jackpot", + generalSettings: { name: "Spin - Copy", showName: true }, + appearance: { backgroundColor: "#000000" }, + playerSettings: { drawsPerPerson: 3 }, + prizeSettings: { + prizes: [], + nonWinning: { title: "Try again", loseRate: 100, loseImage: null }, + prizeNameCustomFieldId: "42", + }, + winningMessageSettings: { enabled: true }, + nonWinningMessageSettings: { enabled: false }, + }) + }) + + test("picks the first free copy name", async () => { + findFirst.mockResolvedValue(source("X")) + findMany.mockResolvedValue([{ name: "X - Copy" }, { name: "X - Copy 2" }]) + + await minigameService.duplicate({ workspaceId: "ws-1", id: "1" }) + + expect(inserted().map((values) => values.name)).toEqual(["X - Copy 3"]) + }) + + test("tries the next free name when a parallel copy took it", async () => { + findFirst.mockResolvedValue(source("X")) + findMany.mockResolvedValue([]) + insertReturning.mockRejectedValueOnce(uniqueViolation) + + await minigameService.duplicate({ workspaceId: "ws-1", id: "1" }) + + expect(inserted().map((values) => values.name)).toEqual([ + "X - Copy", + "X - Copy 2", + ]) + }) + + test("reports a name conflict after the retry", async () => { + findFirst.mockResolvedValue(source("X")) + findMany.mockResolvedValue([]) + insertReturning.mockRejectedValue(uniqueViolation) + + await expect( + minigameService.duplicate({ workspaceId: "ws-1", id: "1" }), + ).rejects.toMatchObject({ code: "nameAlreadyExists" }) + }) + + test("cuts a long name so the copy name fits 150 characters", async () => { + findFirst.mockResolvedValue(source("a".repeat(150))) + findMany.mockResolvedValue([]) + + await minigameService.duplicate({ workspaceId: "ws-1", id: "1" }) + + const [values] = inserted() + expect(values?.name).toHaveLength(150) + expect(values?.name.endsWith(" - Copy")).toBe(true) + expect(values?.generalSettings.name).toBe(values?.name) + }) + + test("fails when the minigame is not in the workspace", async () => { + findFirst.mockResolvedValue(undefined) + + await expect( + minigameService.duplicate({ workspaceId: "ws-1", id: "1" }), + ).rejects.toThrow("Minigame not found") + expect(insertValues).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/src/minigame/service.ts b/packages/business/src/minigame/service.ts index a6da47eb4c..d0fb10ba6d 100644 --- a/packages/business/src/minigame/service.ts +++ b/packages/business/src/minigame/service.ts @@ -18,12 +18,42 @@ import type { import { minigameModel } from "@chatbotx.io/database/schema" import type { MinigameModel } from "@chatbotx.io/database/types" import { + escapeLikePattern, getPaginationWithDefaults, likeContains, parseOrderBy, } from "@chatbotx.io/database/utils" import { BaseService } from "../base.service" import { ChatbotXException, notFoundException } from "../errors" +import { + type CopyNameOptions, + copyNamePrefix, + insertWithFreeCopyName, +} from "../lib/copy-name" + +/** `minigameGeneralSettingsSchema.name` allows up to 150 characters. */ +const MINIGAME_NAME_MAX_LENGTH = 150 + +/** How minigame copies are named: ` - Copy`, ` - Copy 2`, … */ +const MINIGAME_COPY_NAME: CopyNameOptions = { + style: "dash", + maxLength: MINIGAME_NAME_MAX_LENGTH, +} + +/** + * A copy starts with no prizes: every play is a loss until the admin adds + * prizes, so `loseRate` becomes 100 to keep the 100% total valid. The + * no-prize outcome's label/image and the prize-name custom field are kept. + */ +function withoutPrizes( + prizeSettings: MinigamePrizeSettings, +): MinigamePrizeSettings { + return { + ...prizeSettings, + prizes: [], + nonWinning: { ...prizeSettings.nonWinning, loseRate: 100 }, + } +} type ListInput = { workspaceId: string @@ -298,6 +328,74 @@ class MinigameService extends BaseService { } } + private async listNamesWithPrefix(input: { + workspaceId: string + prefix: string + }): Promise { + const rows = await db.query.minigameModel.findMany({ + where: { + workspaceId: input.workspaceId, + name: { like: `${escapeLikePattern(input.prefix)}%` }, + }, + columns: { name: true }, + }) + return rows.map((row) => row.name) + } + + /** + * Copies a minigame's settings into a new minigame named ` - Copy` + * (then `- Copy 2`, `- Copy 3`, … while taken). Prizes are not copied (see + * `withoutPrizes`); plays, players, and counters start from zero. + */ + async duplicate(input: { + workspaceId: string + id: string + }): Promise { + const source = await this.find(input) + const taken = new Set( + await this.listNamesWithPrefix({ + workspaceId: input.workspaceId, + prefix: copyNamePrefix(source.name, MINIGAME_COPY_NAME), + }), + ) + + const copy = await insertWithFreeCopyName({ + constraint: "Minigame_workspaceId_name_key", + base: source.name, + taken, + options: MINIGAME_COPY_NAME, + insert: async (name) => { + const [row] = await db + .insert(minigameModel) + .values({ + workspaceId: input.workspaceId, + enabled: source.enabled, + ...this.toColumns({ + workspaceId: input.workspaceId, + type: source.type, + generalSettings: { ...source.generalSettings, name }, + appearance: source.appearance, + playerSettings: source.playerSettings, + prizeSettings: withoutPrizes(source.prizeSettings), + winningMessageSettings: source.winningMessageSettings, + nonWinningMessageSettings: source.nonWinningMessageSettings, + }), + }) + .returning() + return row + }, + }) + + if (copy) { + return copy + } + throw new ChatbotXException( + "Minigame name already exists", + "nameAlreadyExists", + 409, + ) + } + async setEnabled( ctx: { workspaceId: string; id: string }, enabled: boolean,