diff --git a/CHANGELOG.md b/CHANGELOG.md index d686a07..9c5d097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ Formát je inspirovaný Keep a Changelog. - Fallback snapshot nastavení webu je provozní stav serveru a již se nesleduje ve verzování, takže jeho automatická aktualizace z databáze nevytváří nechtěné změny pracovního stromu. +### Opraveno + +- Provozní audit voucherů nyní používá české popisky změněných údajů kupujícího a změna kategorie služby zobrazuje její název místo technického ID. +- Audit individuální ceny rezervace se při souběžných administrativních úpravách zapisuje podle skutečného bezprostředně předchozího stavu. +- Serializable transakce auditních změn administrátorů, voucherů, služeb a SiteSettings se při krátkodobém PostgreSQL write konfliktu automaticky zopakují, takže souběžná úprava zbytečně neskončí obecnou chybou. + ## [3.16.0] - 2026-08-08 ### Opraveno diff --git a/src/features/admin/actions/admin-user-actions.ts b/src/features/admin/actions/admin-user-actions.ts index ec97c53..55c52b9 100644 --- a/src/features/admin/actions/admin-user-actions.ts +++ b/src/features/admin/actions/admin-user-actions.ts @@ -1,6 +1,6 @@ "use server"; -import { AdminRole, AdminUserAuditOperation, Prisma } from "@prisma/client"; +import { AdminRole, AdminUserAuditOperation } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { type AdminUserResendInviteActionState } from "@/features/admin/actions/update-admin-user-resend-invite-action-state"; @@ -24,6 +24,7 @@ import { } from "@/features/admin/lib/admin-owner-protection"; import { sendOwnerSystemErrorPushover } from "@/lib/notifications/pushover"; import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; import { buildAuditChange } from "@/features/admin/lib/audit-change"; function readFormString(formData: FormData, key: string) { @@ -92,7 +93,7 @@ export async function saveAdminUserAccessAction( } if (userId) { - const updated = await prisma.$transaction(async (tx) => { + const updated = await runSerializableTransaction(async (tx) => { const existing = await tx.adminUser.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true }, @@ -117,7 +118,7 @@ export async function saveAdminUserAccessAction( }, }); return true; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }); if (!updated) { return { diff --git a/src/features/admin/actions/booking-actions.ts b/src/features/admin/actions/booking-actions.ts index e37be3e..94c3039 100644 --- a/src/features/admin/actions/booking-actions.ts +++ b/src/features/admin/actions/booking-actions.ts @@ -622,51 +622,68 @@ export async function updateBookingPriceAction( const nextStoredPrice = clearsAdjustment ? null : nextFinalPriceCzk; const nextStoredReason = clearsAdjustment ? null : normalizedReason; - if (booking.finalPriceCzk !== nextStoredPrice || booking.priceAdjustmentReason !== nextStoredReason) { + await prisma.$transaction(async (tx) => { + await tx.$queryRaw(Prisma.sql` + SELECT "id" FROM "Booking" WHERE "id" = ${booking.id} FOR UPDATE + `); + const currentBooking = await tx.booking.findUnique({ + where: { id: booking.id }, + select: { + status: true, + finalPriceCzk: true, + priceAdjustmentReason: true, + priceAdjustedAt: true, + priceAdjustedByUserId: true, + }, + }); + + if (!currentBooking || ( + currentBooking.finalPriceCzk === nextStoredPrice + && currentBooking.priceAdjustmentReason === nextStoredReason + )) return; + const changedAt = new Date(); - await prisma.$transaction(async (tx) => { - await tx.booking.update({ - where: { id: booking.id }, - data: clearsAdjustment - ? { - finalPriceCzk: null, - priceAdjustmentReason: null, - priceAdjustedAt: null, - priceAdjustedByUserId: null, - } - : { - finalPriceCzk: nextFinalPriceCzk, - priceAdjustmentReason: normalizedReason, - priceAdjustedAt: changedAt, - priceAdjustedByUserId: actorUserId, - }, - }); - await tx.bookingStatusHistory.create({ - data: { - bookingId: booking.id, - status: booking.status, - actorType: BookingActorType.USER, - actorUserId, - reason: clearsAdjustment ? "Individuální cena zrušena" : "Individuální cena upravena", - metadata: { - source: "admin-booking-price-update-v1", - before: { - finalPriceCzk: booking.finalPriceCzk, - priceAdjustmentReason: booking.priceAdjustmentReason, - priceAdjustedAt: booking.priceAdjustedAt?.toISOString() ?? null, - priceAdjustedByUserId: booking.priceAdjustedByUserId, - }, - after: { - finalPriceCzk: nextStoredPrice, - priceAdjustmentReason: nextStoredReason, - priceAdjustedAt: clearsAdjustment ? null : changedAt.toISOString(), - priceAdjustedByUserId: clearsAdjustment ? null : actorUserId, - }, + await tx.booking.update({ + where: { id: booking.id }, + data: clearsAdjustment + ? { + finalPriceCzk: null, + priceAdjustmentReason: null, + priceAdjustedAt: null, + priceAdjustedByUserId: null, + } + : { + finalPriceCzk: nextFinalPriceCzk, + priceAdjustmentReason: normalizedReason, + priceAdjustedAt: changedAt, + priceAdjustedByUserId: actorUserId, + }, + }); + await tx.bookingStatusHistory.create({ + data: { + bookingId: booking.id, + status: currentBooking.status, + actorType: BookingActorType.USER, + actorUserId, + reason: clearsAdjustment ? "Individuální cena zrušena" : "Individuální cena upravena", + metadata: { + source: "admin-booking-price-update-v1", + before: { + finalPriceCzk: currentBooking.finalPriceCzk, + priceAdjustmentReason: currentBooking.priceAdjustmentReason, + priceAdjustedAt: currentBooking.priceAdjustedAt?.toISOString() ?? null, + priceAdjustedByUserId: currentBooking.priceAdjustedByUserId, + }, + after: { + finalPriceCzk: nextStoredPrice, + priceAdjustmentReason: nextStoredReason, + priceAdjustedAt: clearsAdjustment ? null : changedAt.toISOString(), + priceAdjustedByUserId: clearsAdjustment ? null : actorUserId, }, }, - }); + }, }); - } + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); revalidateBookingAdminPaths(booking.id); revalidatePath(`/admin/klienti/${booking.clientId}`); diff --git a/src/features/admin/actions/service-actions.ts b/src/features/admin/actions/service-actions.ts index bd3123e..6e6f9d2 100644 --- a/src/features/admin/actions/service-actions.ts +++ b/src/features/admin/actions/service-actions.ts @@ -1,6 +1,6 @@ "use server"; -import { Prisma, ServiceChangeOperation } from "@prisma/client"; +import { ServiceChangeOperation } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; @@ -12,6 +12,7 @@ import { updateServiceSchema, } from "@/features/admin/lib/admin-service-validation"; import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; import { buildServiceOperationalAuditChange } from "@/features/admin/lib/service-audit-change"; import { toggleServiceOperationalFlag } from "@/features/admin/lib/service-change-operations"; @@ -358,7 +359,7 @@ export async function updateServiceAction( }; } - const serviceFound = await prisma.$transaction(async (tx) => { + const serviceFound = await runSerializableTransaction(async (tx) => { const service = await tx.service.findUnique({ where: { id: parsed.data.serviceId }, select: serviceAuditSelect, @@ -437,7 +438,7 @@ export async function updateServiceAction( }); } return true; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }); if (!serviceFound) { return { status: "error", formError: "Službu se nepodařilo najít." }; diff --git a/src/features/admin/lib/admin-data.ts b/src/features/admin/lib/admin-data.ts index d6145f3..b6f375a 100644 --- a/src/features/admin/lib/admin-data.ts +++ b/src/features/admin/lib/admin-data.ts @@ -1539,12 +1539,18 @@ function auditValueLabel(value: Prisma.JsonValue | undefined) { return String(value); } -function auditChangeDescription(beforeValue: Prisma.JsonValue, afterValue: Prisma.JsonValue) { +function auditChangeDescription( + beforeValue: Prisma.JsonValue, + afterValue: Prisma.JsonValue, + categoryNames = new Map(), +) { const before = beforeValue && typeof beforeValue === "object" && !Array.isArray(beforeValue) ? beforeValue : {}; const after = afterValue && typeof afterValue === "object" && !Array.isArray(afterValue) ? afterValue : {}; const labels: Record = { role: "Role", isActive: "Aktivní", validUntil: "Platnost do", status: "Stav", cancelledAt: "Zrušeno", hasInternalNote: "Interní poznámka", categoryId: "Kategorie", + purchaserNameChanged: "Jméno kupujícího", purchaserEmailChanged: "E-mail kupujícího", + internalNoteChanged: "Interní poznámka", name: "Název", publicName: "Veřejný název", seoTitle: "SEO titulek", durationMinutes: "Délka", cleanupMinutes: "Úklid", sortOrder: "Pořadí", isFeaturedOnHomepage: "Na homepage", homepageSortOrder: "Pořadí na homepage", @@ -1560,7 +1566,13 @@ function auditChangeDescription(beforeValue: Prisma.JsonValue, afterValue: Prism const label = labels[key] ?? key.replace(/Changed$/, ""); if (key.endsWith("Changed") && after[key] === true) return `${label}: upraveno`; const suffix = key.endsWith("Minutes") ? " min" : key.endsWith("Hours") ? " h" : key.endsWith("Days") ? " dní" : ""; - return `${label}: ${auditValueLabel(before[key])}${suffix} → ${auditValueLabel(after[key])}${suffix}`; + const beforeLabel = key === "categoryId" && typeof before[key] === "string" + ? categoryNames.get(before[key]) ?? auditValueLabel(before[key]) + : auditValueLabel(before[key]); + const afterLabel = key === "categoryId" && typeof after[key] === "string" + ? categoryNames.get(after[key]) ?? auditValueLabel(after[key]) + : auditValueLabel(after[key]); + return `${label}: ${beforeLabel}${suffix} → ${afterLabel}${suffix}`; }).join(" • "); } @@ -1763,6 +1775,13 @@ export async function getAdminLogsData(input: { adminAuditActive ? prisma.adminUserAuditEvent.findMany({ where: adminUserAuditWhere, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take, include: { targetUser: { select: { name: true } }, actorUser: { select: { name: true } } } }) : Promise.resolve([]), submissionActive ? prisma.bookingSubmissionLog.findMany({ where: submissionWhere, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take, include: { booking: { select: { id: true, clientNameSnapshot: true, serviceNameSnapshot: true } }, client: { select: { fullName: true } } } }) : Promise.resolve([]), ]); + const categoryIds = serviceChanges.flatMap((entry) => [entry.before, entry.after] + .map((value) => value && typeof value === "object" && !Array.isArray(value) && typeof value.categoryId === "string" ? value.categoryId : null) + .filter((id): id is string => id !== null)); + const categoryNames = new Map((categoryIds.length > 0 + ? await prisma.serviceCategory.findMany({ where: { id: { in: categoryIds } }, select: { id: true, name: true } }) + : []) + .map((category) => [category.id, category.name])); const bookingHref = (id: string) => getAdminBookingHref(input.area, id); const voucherHref = (id: string) => `${input.area === "owner" ? "/admin" : "/admin/provoz"}/vouchery/${id}`; @@ -1781,7 +1800,7 @@ export async function getAdminLogsData(input: { ...vouchers.map((voucher) => ({ id: `voucher:${voucher.id}`, occurredAt: voucher.createdAt.toISOString(), category: "event" as const, severity: "info" as const, title: "Voucher vytvořen", description: null, actorLabel: voucher.createdByUser?.name ?? null, entityLabel: `Voucher ${voucher.code}`, entityHref: voucherHref(voucher.id), sourceType: "voucher" as const, sourceId: voucher.id, primaryAction: "open" as const })), ...redemptions.map((redemption) => ({ id: `voucher-redemption:${redemption.id}`, occurredAt: redemption.redeemedAt.toISOString(), category: "event" as const, severity: "success" as const, title: "Voucher uplatněn", description: null, actorLabel: redemption.redeemedByUser?.name ?? null, entityLabel: redemption.voucher ? `Voucher ${redemption.voucher.code}` : "Odstraněný voucher", entityHref: redemption.voucher ? voucherHref(redemption.voucher.id) : null, sourceType: "voucher" as const, sourceId: redemption.id, primaryAction: redemption.voucher ? "open" as const : null })), ...voucherChanges.map((entry) => ({ id: `voucher-change:${entry.id}`, occurredAt: entry.createdAt.toISOString(), category: "event" as const, severity: "info" as const, title: entry.operation === "CANCEL" ? "Voucher zrušen" : "Voucher upraven", description: auditChangeDescription(entry.before, entry.after), actorLabel: entry.actorUser.name, entityLabel: `Voucher ${entry.voucher.code}`, entityHref: voucherHref(entry.voucher.id), sourceType: "voucher" as const, sourceId: entry.id, primaryAction: "open" as const })), - ...serviceChanges.map((entry) => ({ id: `service-change:${entry.id}`, occurredAt: entry.createdAt.toISOString(), category: "event" as const, severity: "info" as const, title: "Služba upravena", description: auditChangeDescription(entry.before, entry.after), actorLabel: entry.actorUser.name, entityLabel: entry.service.publicName ?? entry.service.name, entityHref: `${input.area === "owner" ? "/admin" : "/admin/provoz"}/sluzby?serviceId=${entry.service.id}`, sourceType: "service" as const, sourceId: entry.id, primaryAction: "open" as const })), + ...serviceChanges.map((entry) => ({ id: `service-change:${entry.id}`, occurredAt: entry.createdAt.toISOString(), category: "event" as const, severity: "info" as const, title: "Služba upravena", description: auditChangeDescription(entry.before, entry.after, categoryNames), actorLabel: entry.actorUser.name, entityLabel: entry.service.publicName ?? entry.service.name, entityHref: `${input.area === "owner" ? "/admin" : "/admin/provoz"}/sluzby?serviceId=${entry.service.id}`, sourceType: "service" as const, sourceId: entry.id, primaryAction: "open" as const })), ...siteSettingsChanges.map((entry) => ({ id: `settings-change:${entry.id}`, occurredAt: entry.createdAt.toISOString(), category: "event" as const, severity: "info" as const, title: entry.operation === "UPDATE_BOOKING_POLICY" ? "Pravidla rezervace upravena" : entry.operation === "UPDATE_SALON" ? "Údaje salonu upraveny" : "E-mailová nastavení upravena", description: auditChangeDescription(entry.before, entry.after), actorLabel: entry.actorUser.name, entityLabel: "Nastavení webu", entityHref: "/admin/nastaveni", sourceType: "settings" as const, sourceId: entry.id, primaryAction: "open" as const })), ...availabilityAudits.map((entry) => ({ id: `availability:${entry.id}`, occurredAt: entry.createdAt.toISOString(), category: "event" as const, severity: "info" as const, title: availabilityAuditLabel(entry.operation), description: availabilityAuditDescription(entry), actorLabel: entry.actorUser?.name ?? null, entityLabel: `Volné termíny • ${entry.dateKey}`, entityHref: `${input.area === "owner" ? "/admin" : "/admin/provoz"}/volne-terminy?week=${entry.dateKey}&day=${entry.dateKey}`, sourceType: "availability" as const, sourceId: entry.id, primaryAction: "open" as const })), ...adminUserAudits.map((entry) => ({ id: `admin-user-audit:${entry.id}`, occurredAt: entry.createdAt.toISOString(), category: "system" as const, severity: "info" as const, title: ({ CREATE: "Admin účet vytvořen", UPDATE_PROFILE: "Admin účet upraven", CHANGE_ROLE: "Role admina změněna", ACTIVATE: "Admin účet aktivován", DEACTIVATE: "Admin účet deaktivován", INVITE_RESEND: "Pozvánka znovu vydána" } as Record)[entry.operation] ?? "Admin účet upraven", description: auditChangeDescription(entry.before, entry.after), actorLabel: entry.actorUser.name, entityLabel: entry.targetUser.name, entityHref: "/admin/uzivatele", sourceType: "admin" as const, sourceId: entry.id, primaryAction: "open" as const })), diff --git a/src/features/admin/lib/admin-logs.integration.test.ts b/src/features/admin/lib/admin-logs.integration.test.ts index 3e393f7..624ac62 100644 --- a/src/features/admin/lib/admin-logs.integration.test.ts +++ b/src/features/admin/lib/admin-logs.integration.test.ts @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test from "node:test"; -import { BookingSubmissionOutcome } from "@prisma/client"; +import { AdminRole, BookingSubmissionOutcome, ServiceChangeOperation, VoucherChangeOperation, VoucherType } from "@prisma/client"; process.env.DATABASE_URL ??= "postgresql://postgres:postgres@localhost:5432/ppstudio?schema=public"; @@ -53,3 +53,37 @@ dbTest("admin logy rozliší submission typy, Pozornost a SALON scope", async () await prisma.bookingSubmissionLog.deleteMany({ where: { failureReason: { contains: suffix } } }); } }); + +dbTest("admin logy používají české popisky voucheru a název kategorie služby", async () => { + const [{ prisma }, { getAdminLogsData }] = await Promise.all([ + import("@/lib/prisma"), + import("./admin-data"), + ]); + const suffix = randomUUID(); + const actor = await prisma.adminUser.create({ data: { email: `admin-log-labels-${suffix}@example.com`, name: "Audit popisků", role: AdminRole.SALON } }); + const previousCategory = await prisma.serviceCategory.create({ data: { name: `Původní kategorie ${suffix}`, slug: `puvodni-${suffix}` } }); + const nextCategory = await prisma.serviceCategory.create({ data: { name: `Nová kategorie ${suffix}`, slug: `nova-${suffix}` } }); + const service = await prisma.service.create({ data: { categoryId: nextCategory.id, name: `Služba ${suffix}`, slug: `sluzba-${suffix}`, durationMinutes: 60 } }); + const voucher = await prisma.voucher.create({ data: { code: `AUDIT-${suffix}`, type: VoucherType.VALUE, originalValueCzk: 1000, remainingValueCzk: 1000 } }); + + try { + await prisma.serviceChangeLog.create({ + data: { serviceId: service.id, actorUserId: actor.id, operation: ServiceChangeOperation.UPDATE_OPERATIONAL_DETAILS, before: { categoryId: previousCategory.id }, after: { categoryId: nextCategory.id } }, + }); + await prisma.voucherChangeLog.create({ + data: { voucherId: voucher.id, actorUserId: actor.id, operation: VoucherChangeOperation.UPDATE_OPERATIONAL_DETAILS, before: { purchaserNameChanged: false, purchaserEmailChanged: false }, after: { purchaserNameChanged: true, purchaserEmailChanged: true } }, + }); + + const serviceLogs = await getAdminLogsData({ area: "salon", view: "events", source: "service", query: suffix }); + assert.equal(serviceLogs.items[0]?.description, `Kategorie: Původní kategorie ${suffix} → Nová kategorie ${suffix}`); + const voucherLogs = await getAdminLogsData({ area: "salon", view: "events", source: "voucher", query: suffix }); + assert.equal(voucherLogs.items[0]?.description, "Jméno kupujícího: upraveno • E-mail kupujícího: upraveno"); + } finally { + await prisma.voucherChangeLog.deleteMany({ where: { voucherId: voucher.id } }); + await prisma.serviceChangeLog.deleteMany({ where: { serviceId: service.id } }); + await prisma.voucher.delete({ where: { id: voucher.id } }); + await prisma.service.delete({ where: { id: service.id } }); + await prisma.serviceCategory.deleteMany({ where: { id: { in: [previousCategory.id, nextCategory.id] } } }); + await prisma.adminUser.delete({ where: { id: actor.id } }); + } +}); diff --git a/src/features/admin/lib/admin-owner-protection.ts b/src/features/admin/lib/admin-owner-protection.ts index 727deac..27a4061 100644 --- a/src/features/admin/lib/admin-owner-protection.ts +++ b/src/features/admin/lib/admin-owner-protection.ts @@ -3,7 +3,7 @@ import "server-only"; import { AdminRole, AdminUserAuditOperation, Prisma } from "@prisma/client"; import { buildAuditChange } from "@/features/admin/lib/audit-change"; -import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; export const LAST_ACTIVE_OWNER_MESSAGE = "Nelze odebrat posledního aktivního OWNERa. Nejdřív aktivujte nebo povyšte další účet OWNER."; @@ -45,7 +45,7 @@ export function wouldRemoveLastActiveOwner({ export async function updateAdminUserWithOwnerProtection( mutation: OwnerMutation, ): Promise { - return prisma.$transaction( + return runSerializableTransaction( async (tx) => { const target = await tx.adminUser.findUnique({ where: { id: mutation.userId }, @@ -117,6 +117,5 @@ export async function updateAdminUserWithOwnerProtection( return "updated"; }, - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); } diff --git a/src/features/admin/lib/admin-user-invite.ts b/src/features/admin/lib/admin-user-invite.ts index 36858e6..61b1ece 100644 --- a/src/features/admin/lib/admin-user-invite.ts +++ b/src/features/admin/lib/admin-user-invite.ts @@ -1,6 +1,6 @@ import "server-only"; -import { AdminRole, AdminUserAuditOperation, Prisma } from "@prisma/client"; +import { AdminRole, AdminUserAuditOperation } from "@prisma/client"; import { env } from "@/config/env"; import { @@ -14,6 +14,7 @@ import { import { isMissingInvitedAtColumnError } from "@/features/admin/lib/admin-user-db"; import { sendEmail } from "@/lib/email/provider"; import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; import { getPublicSalonProfile } from "@/lib/site-settings"; function getInviteCopy(role: AdminRole) { @@ -121,7 +122,7 @@ export async function reissueAdminInviteTokenWithAudit(input: { const token = buildAdminInviteToken(); const now = new Date(); - const inviteId = await prisma.$transaction(async (tx) => { + const inviteId = await runSerializableTransaction(async (tx) => { const user = await tx.adminUser.findUniqueOrThrow({ where: { id: input.userId }, select: { isActive: true, invitedAt: true }, @@ -161,7 +162,7 @@ export async function reissueAdminInviteTokenWithAudit(input: { }); return invite.id; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }); return { inviteId, inviteUrl: buildAdminInviteUrl(token.rawToken) }; } diff --git a/src/features/admin/lib/service-change-operations.ts b/src/features/admin/lib/service-change-operations.ts index 4d2af12..0676c08 100644 --- a/src/features/admin/lib/service-change-operations.ts +++ b/src/features/admin/lib/service-change-operations.ts @@ -1,15 +1,15 @@ import "server-only"; -import { Prisma, ServiceChangeOperation } from "@prisma/client"; +import { ServiceChangeOperation } from "@prisma/client"; -import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; export async function toggleServiceOperationalFlag(input: { serviceId: string; actorUserId: string; field: "isActive" | "isPubliclyBookable"; }) { - return prisma.$transaction(async (tx) => { + return runSerializableTransaction(async (tx) => { const service = await tx.service.findUnique({ where: { id: input.serviceId }, select: { id: true, isActive: true, isPubliclyBookable: true }, @@ -33,5 +33,5 @@ export async function toggleServiceOperationalFlag(input: { }, }); return true; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }); } diff --git a/src/features/admin/lib/site-settings-audit.ts b/src/features/admin/lib/site-settings-audit.ts index 01598cb..49e89ae 100644 --- a/src/features/admin/lib/site-settings-audit.ts +++ b/src/features/admin/lib/site-settings-audit.ts @@ -3,7 +3,7 @@ import "server-only"; import { Prisma, SiteSettingsChangeOperation, type SiteSettings } from "@prisma/client"; import { buildAuditChange, type AuditSnapshot } from "@/features/admin/lib/audit-change"; -import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; import { ensureSiteSettings, SITE_SETTINGS_ID } from "@/lib/site-settings"; export async function updateSiteSettingsWithAudit({ @@ -18,7 +18,7 @@ export async function updateSiteSettingsWithAudit({ snapshots: (current: SiteSettings) => { before: AuditSnapshot; after: AuditSnapshot }; }) { await ensureSiteSettings(); - return prisma.$transaction(async (tx) => { + return runSerializableTransaction(async (tx) => { const current = await tx.siteSettings.findUniqueOrThrow({ where: { id: SITE_SETTINGS_ID } }); const selected = snapshots(current); const auditChange = buildAuditChange(selected.before, selected.after); @@ -37,5 +37,5 @@ export async function updateSiteSettingsWithAudit({ }, }); return saved; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }); } diff --git a/src/features/vouchers/lib/voucher-operations.ts b/src/features/vouchers/lib/voucher-operations.ts index 75ea313..6549564 100644 --- a/src/features/vouchers/lib/voucher-operations.ts +++ b/src/features/vouchers/lib/voucher-operations.ts @@ -1,6 +1,6 @@ import { Prisma, VoucherChangeOperation, VoucherStatus } from "@prisma/client"; -import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; export const voucherOperationErrorCodes = { voucherNotFound: "VOUCHER_NOT_FOUND", @@ -35,7 +35,7 @@ export async function updateVoucherOperationalDetails(input: { internalNote?: string; updatedByUserId: string; }) { - return prisma.$transaction(async (tx) => { + return runSerializableTransaction(async (tx) => { const voucher = await tx.voucher.findUnique({ where: { id: input.voucherId }, select: { id: true, validFrom: true, validUntil: true, purchaserName: true, purchaserEmail: true, internalNote: true }, @@ -99,7 +99,7 @@ export async function updateVoucherOperationalDetails(input: { }, }); return updated; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }); } export async function cancelVoucherOperationally(input: { @@ -108,7 +108,7 @@ export async function cancelVoucherOperationally(input: { actorUserId: string; now?: Date; }) { - return prisma.$transaction(async (tx) => { + return runSerializableTransaction(async (tx) => { const voucher = await tx.voucher.findUnique({ where: { id: input.voucherId }, select: { @@ -165,5 +165,5 @@ export async function cancelVoucherOperationally(input: { }, }); return updated; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }); } diff --git a/src/features/vouchers/lib/voucher-redemption.ts b/src/features/vouchers/lib/voucher-redemption.ts index 80137c6..c2e81e6 100644 --- a/src/features/vouchers/lib/voucher-redemption.ts +++ b/src/features/vouchers/lib/voucher-redemption.ts @@ -6,7 +6,7 @@ import { redeemVoucherSchema, type RedeemVoucherInput, } from "@/features/vouchers/schemas/voucher-schemas"; -import { prisma } from "@/lib/prisma"; +import { runSerializableTransaction } from "@/lib/serializable-transaction"; type VoucherRedemptionDbClient = Pick< Prisma.TransactionClient, @@ -230,8 +230,5 @@ export async function redeemVoucherForBookingInTransaction( } export async function redeemVoucherForBooking(input: RedeemVoucherInput) { - return prisma.$transaction( - (tx) => redeemVoucherForBookingInTransaction(tx, input), - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ); + return runSerializableTransaction((tx) => redeemVoucherForBookingInTransaction(tx, input)); } diff --git a/src/lib/serializable-transaction.ts b/src/lib/serializable-transaction.ts new file mode 100644 index 0000000..937eb3e --- /dev/null +++ b/src/lib/serializable-transaction.ts @@ -0,0 +1,55 @@ +import "server-only"; + +import { Prisma } from "@prisma/client"; + +import { prisma } from "@/lib/prisma"; + +const MAX_RETRIES = 4; +const RETRY_DELAY_MS = 40; + +function isSerializableConflict(error: unknown) { + const cause = + typeof error === "object" && error !== null && "cause" in error + ? (error as { cause?: unknown }).cause + : null; + + return ( + ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2034" + ) || + ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "DriverAdapterError" && + typeof cause === "object" && + cause !== null && + "kind" in cause && + cause.kind === "TransactionWriteConflict" + ) + ); +} + +function waitForRetry(delayMs: number) { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +/** Opakuje pouze PostgreSQL serializační konflikty; ostatní chyby propouští beze změny. */ +export async function runSerializableTransaction( + operation: (tx: Prisma.TransactionClient) => Promise, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await prisma.$transaction(operation, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + } catch (error) { + if (!isSerializableConflict(error) || attempt >= MAX_RETRIES) { + throw error; + } + + await waitForRetry(RETRY_DELAY_MS * (attempt + 1)); + } + } +}