Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/features/admin/actions/admin-user-actions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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 },
Expand All @@ -117,7 +118,7 @@ export async function saveAdminUserAccessAction(
},
});
return true;
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
});

if (!updated) {
return {
Expand Down
99 changes: 58 additions & 41 deletions src/features/admin/actions/booking-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
7 changes: 4 additions & 3 deletions src/features/admin/actions/service-actions.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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." };
Expand Down
25 changes: 22 additions & 3 deletions src/features/admin/lib/admin-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(),
) {
const before = beforeValue && typeof beforeValue === "object" && !Array.isArray(beforeValue) ? beforeValue : {};
const after = afterValue && typeof afterValue === "object" && !Array.isArray(afterValue) ? afterValue : {};
const labels: Record<string, string> = {
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",
Expand All @@ -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(" • ");
}

Expand Down Expand Up @@ -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}`;
Expand All @@ -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<string, string>)[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 })),
Expand Down
36 changes: 35 additions & 1 deletion src/features/admin/lib/admin-logs.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 } });
}
});
5 changes: 2 additions & 3 deletions src/features/admin/lib/admin-owner-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -45,7 +45,7 @@ export function wouldRemoveLastActiveOwner({
export async function updateAdminUserWithOwnerProtection(
mutation: OwnerMutation,
): Promise<OwnerMutationResult> {
return prisma.$transaction(
return runSerializableTransaction(
async (tx) => {
const target = await tx.adminUser.findUnique({
where: { id: mutation.userId },
Expand Down Expand Up @@ -117,6 +117,5 @@ export async function updateAdminUserWithOwnerProtection(

return "updated";
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
}
Loading