diff --git a/apps/show/.gitignore b/apps/show/.gitignore index 5c74787f..913650ed 100644 --- a/apps/show/.gitignore +++ b/apps/show/.gitignore @@ -4,6 +4,9 @@ # Compiled code by Vite. /build/ +# CSV files used for data synchronisation scripts. +/data/ + # Dependencies installed by pnpm. /node_modules/ diff --git a/apps/show/package.json b/apps/show/package.json index 3285d09f..9fa3ec60 100644 --- a/apps/show/package.json +++ b/apps/show/package.json @@ -98,8 +98,10 @@ "@types/react-dom": "catalog:", "@types/source-map-support": "catalog:", "autoprefixer": "catalog:", + "@types/d3-dsv": "catalog:", "chokidar": "catalog:", "culori": "catalog:", + "d3-dsv": "catalog:", "dotenv-flow": "catalog:", "eslint": "catalog:", "msw": "catalog:", diff --git a/apps/show/scripts/sync-animations.ts b/apps/show/scripts/sync-animations.ts new file mode 100644 index 00000000..f6e7f904 --- /dev/null +++ b/apps/show/scripts/sync-animations.ts @@ -0,0 +1,207 @@ +import { readFile } from "node:fs/promises" +import { resolve } from "node:path" + +import type { Prisma } from "@animeaux/prisma/server" +import { + PrismaClient, + ShowActivityTarget, + ShowStandZone, +} from "@animeaux/prisma/server" +import { simpleUrl, zu } from "@animeaux/zod-utils" +import { csvParse } from "d3-dsv" +import { isEqual, orderBy } from "es-toolkit" +import invariant from "tiny-invariant" + +const csvPath = process.argv[2] +invariant(csvPath != null, "Missing path to CSV file in command line.") + +console.log("🔄 Synchronising animations...") + +const prisma = new PrismaClient() + +try { + const inputs = await getInputs(csvPath) + + const animationsIds = new Set() + + for (const input of inputs) { + animationsIds.add(input.id) + + await upsertAnimation(input) + } + + await deleteRemovedAnimations(animationsIds) + + console.log("🎉 Animations synchronised") +} finally { + await prisma.$disconnect() +} + +async function getInputs(csvPath: string) { + const exhibitors = await prisma.showExhibitor.findMany({ + select: { id: true, name: true }, + }) + + const exhibitorsByName = new Map( + exhibitors.map((exhibitor) => [exhibitor.name, exhibitor]), + ) + + const csvContent = await readFile(resolve(process.cwd(), csvPath), "utf-8") + const rows = csvParse(csvContent) + + const parsedInputs = zu + .array(createInputSchema(exhibitorsByName)) + .safeParse(rows) + + if (!parsedInputs.success) { + console.error(`Invalid CSV data:\n${formatErrors(parsedInputs.error)}`) + + process.exit(1) + } + + return parsedInputs.data +} + +type Input = zu.infer> + +function createInputSchema( + exhibitorsByName: Map, +) { + return zu.object({ + id: zu.string().uuid(), + description: zu.string().trim().min(1), + startTime: zu.coerce.date(), + endTime: zu.coerce.date(), + zone: zu.nativeEnum(ShowStandZone), + targets: zu + .preprocess(parsePipeArray, zu.array(zu.nativeEnum(ShowActivityTarget))) + .transform((targets) => targets.sort()), + registrationUrl: zu.preprocess( + (value) => (value === "" ? null : value), + simpleUrl().pipe(zu.string().max(128)).nullable(), + ), + animators: zu + .preprocess(parsePipeArray, zu.array(zu.string())) + .transform((names) => { + const maybeAnimators = names.map((name) => exhibitorsByName.get(name)) + const animators = maybeAnimators.filter(Boolean) + + if (maybeAnimators.length !== animators.length) { + return null + } + + return orderBy(animators, [(animator) => animator.id], ["asc"]) + }), + }) +} + +function parsePipeArray(value: unknown) { + if (typeof value !== "string") { + return value + } + + return value + .split("|") + .map((value) => value.trim()) + .filter(Boolean) +} + +function formatErrors(error: zu.ZodError) { + return error.issues + .map((issue) => `- \`${issue.path.join(".")}\`: ${issue.message}`) + .join("\n") +} + +async function upsertAnimation(input: Input) { + const description = truncateDescription(input.description) + + if (input.animators == null) { + console.warn( + `- ⚠️ Skipping animation "${description}": exhibitor name not found`, + ) + + return + } + + const currentAnimation = await prisma.showAnimation.findUnique({ + where: { id: input.id }, + select: { + animators: { select: { id: true, name: true }, orderBy: { id: "asc" } }, + description: true, + endTime: true, + id: true, + registrationUrl: true, + startTime: true, + targets: true, + zone: true, + }, + }) + + const payload = { + description: input.description, + startTime: input.startTime, + endTime: input.endTime, + zone: input.zone, + targets: input.targets, + registrationUrl: input.registrationUrl, + } satisfies Prisma.ShowAnimationUpdateInput + + if (currentAnimation == null) { + await prisma.showAnimation.create({ + data: { + ...payload, + id: input.id, + animators: { connect: input.animators }, + }, + select: { id: true }, + }) + + console.log(`- ✅ Created animation "${description}"`) + + return + } + + currentAnimation.targets.sort() + + if (isEqual(currentAnimation, input)) { + console.log(`- 🤷 Skipped animation "${description}": no changes`) + + return + } + + await prisma.showAnimation.update({ + where: { id: input.id }, + data: { ...payload, animators: { set: input.animators } }, + select: { id: true }, + }) + + console.log(`- 🔄 Updated animation "${description}"`) +} + +function truncateDescription(value: string, length = 20) { + return value.length > length ? `${value.slice(0, length)}…` : value +} + +async function deleteRemovedAnimations(animationsIds: Set) { + const toDelete = await prisma.showAnimation.findMany({ + where: { id: { notIn: Array.from(animationsIds) } }, + select: { id: true, description: true }, + }) + + if (toDelete.length === 0) { + return + } + + await prisma.showAnimation.deleteMany({ + where: { id: { in: toDelete.map(({ id }) => id) } }, + }) + + console.log( + toDelete + .map( + (animation) => + `- 🗑️ Deleted animation "${truncateDescription(animation.description)}"`, + ) + .join("\n"), + ) +} diff --git a/libs/zod-utils/src/urls.ts b/libs/zod-utils/src/urls.ts index 67fa1448..73956e35 100644 --- a/libs/zod-utils/src/urls.ts +++ b/libs/zod-utils/src/urls.ts @@ -5,7 +5,7 @@ import { z } from "zod" * Allow missing protocol in URL and adds it if needed. * * @example - * zu.string().pipe(simpleUrl).max(128) + * simpleUrl().pipe(zu.string().max(128)) */ export function simpleUrl(...params: Parameters) { return z.union([ diff --git a/libs/zod-utils/src/zod-utils.ts b/libs/zod-utils/src/zod-utils.ts index 73b6b573..23ff7a9d 100644 --- a/libs/zod-utils/src/zod-utils.ts +++ b/libs/zod-utils/src/zod-utils.ts @@ -6,6 +6,7 @@ export type { infer, inferFlattenedErrors, SafeParseReturnType, + ZodError, ZodRawShape, ZodTypeAny, } from "zod" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b3eb901..1684371f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -792,6 +792,9 @@ importers: '@types/culori': specifier: 'catalog:' version: 4.0.1 + '@types/d3-dsv': + specifier: 'catalog:' + version: 3.0.7 '@types/express': specifier: 'catalog:' version: 4.17.21 @@ -822,6 +825,9 @@ importers: culori: specifier: 'catalog:' version: 4.0.2 + d3-dsv: + specifier: 'catalog:' + version: 3.0.1 dotenv-flow: specifier: 'catalog:' version: 4.1.0