|
| 1 | +import { Prisma } from "@prisma/client"; |
| 2 | +import type { Request, Response } from "express"; |
| 3 | +import { z } from "zod"; |
| 4 | +import { prisma } from "@/utils/prisma"; |
| 5 | + |
| 6 | +// The gallery holds far fewer than this; the cap just bounds the transaction. |
| 7 | +const MAX_GALLERY = 200; |
| 8 | + |
| 9 | +const bodySchema = z.object({ |
| 10 | + // The whole gallery, top slot first. |
| 11 | + templateIds: z.array(z.string().uuid()).min(1).max(MAX_GALLERY), |
| 12 | +}); |
| 13 | + |
| 14 | +const sendError = ( |
| 15 | + res: Response, |
| 16 | + status: number, |
| 17 | + error: { code: string; message: string }, |
| 18 | +) => { |
| 19 | + res.status(status).json({ error }); |
| 20 | +}; |
| 21 | + |
| 22 | +/** |
| 23 | + * Did Postgres refuse this transaction because another one moved the gallery |
| 24 | + * under it? |
| 25 | + * |
| 26 | + * Two shapes, because the transaction mixes query styles: Prisma maps a |
| 27 | + * serialization failure in its OWN queries to `P2034`, while a raw query |
| 28 | + * surfaces the driver's code instead — `40001`, serialization_failure — wrapped |
| 29 | + * as `P2010`. Matching only the first lets a genuine, expected conflict escape |
| 30 | + * as a 500. |
| 31 | + */ |
| 32 | +export const isSerializationFailure = (error: unknown): boolean => { |
| 33 | + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) { |
| 34 | + return false; |
| 35 | + } |
| 36 | + if (error.code === "P2034") { |
| 37 | + return true; |
| 38 | + } |
| 39 | + const meta = error.meta as { code?: string } | undefined; |
| 40 | + return error.code === "P2010" && meta?.code === "40001"; |
| 41 | +}; |
| 42 | + |
| 43 | +/** |
| 44 | + * Write the featured gallery's order — the whole thing, in one transaction. |
| 45 | + * |
| 46 | + * A reorder used to be one PATCH per row, and convos.org renders those as they |
| 47 | + * land: a failure halfway through left the homepage on a blend of the old order |
| 48 | + * and the new one, which no amount of client-side undo can rule out (the undo |
| 49 | + * is itself a sequence of writes that can fail). The order is a single value, |
| 50 | + * so it moves once or not at all. |
| 51 | + * |
| 52 | + * The body must be the *entire* gallery. A partial list would leave the rows it |
| 53 | + * omits holding weights that collide with the ones it sets, which is how you |
| 54 | + * get two templates claiming one slot. If the gallery has changed since the |
| 55 | + * caller read it — something published, featured, or dropped out — the ids |
| 56 | + * won't match and the write is refused rather than clobbering a set the caller |
| 57 | + * never saw. |
| 58 | + */ |
| 59 | +export async function featuredOrderHandler(req: Request, res: Response) { |
| 60 | + // Curation is the dashboard's, not an owner's — same rule the rank field |
| 61 | + // itself carries in PATCH. |
| 62 | + const isApiKeyListener = res.locals.isApiKeyListener ?? false; |
| 63 | + if (!isApiKeyListener) { |
| 64 | + req.log.warn( |
| 65 | + { callerAccountId: res.locals.accountId, action: "featured-order" }, |
| 66 | + "Unauthorized agent-template curation attempt", |
| 67 | + ); |
| 68 | + sendError(res, 403, { |
| 69 | + code: "FORBIDDEN", |
| 70 | + message: "Not authorized to set the featured gallery order", |
| 71 | + }); |
| 72 | + return; |
| 73 | + } |
| 74 | + |
| 75 | + const parsed = bodySchema.safeParse(req.body); |
| 76 | + if (!parsed.success) { |
| 77 | + res.status(400).json({ |
| 78 | + error: "Invalid request body", |
| 79 | + details: parsed.error.issues, |
| 80 | + }); |
| 81 | + return; |
| 82 | + } |
| 83 | + |
| 84 | + const { templateIds } = parsed.data; |
| 85 | + const unique = new Set(templateIds); |
| 86 | + if (unique.size !== templateIds.length) { |
| 87 | + sendError(res, 400, { |
| 88 | + code: "DUPLICATE_TEMPLATE", |
| 89 | + message: "templateIds contains the same template more than once", |
| 90 | + }); |
| 91 | + return; |
| 92 | + } |
| 93 | + |
| 94 | + try { |
| 95 | + const total = templateIds.length; |
| 96 | + |
| 97 | + // The membership check and the writes are one transaction, at SERIALIZABLE. |
| 98 | + // |
| 99 | + // Both halves are needed. Reading the gallery outside the write would make |
| 100 | + // this a check-then-act across two round trips; and moving the read inside a |
| 101 | + // default (READ COMMITTED) transaction wouldn't close it either, since every |
| 102 | + // statement there takes a fresh snapshot — a template featured or dropped |
| 103 | + // between the check and the writes would still slip past. The damaging case |
| 104 | + // is a row LEAVING the gallery mid-write: it stays in `templateIds`, takes a |
| 105 | + // weight it's no longer entitled to, and — because a weight is a slot — |
| 106 | + // silently reclaims that slot when it comes back. Exactly the invariant the |
| 107 | + // patch handler enforces, walked around from the side. |
| 108 | + // |
| 109 | + // SERIALIZABLE makes the read and the writes see one snapshot and aborts the |
| 110 | + // loser of a conflicting pair, which surfaces below as the same 409 a stale |
| 111 | + // set gets: re-read the gallery and try again. |
| 112 | + const updated = await prisma.$transaction( |
| 113 | + async (tx) => { |
| 114 | + const gallery = await tx.agentTemplate.findMany({ |
| 115 | + where: { featured: true, status: "published" }, |
| 116 | + select: { id: true }, |
| 117 | + }); |
| 118 | + |
| 119 | + const galleryIds = new Set(gallery.map((template) => template.id)); |
| 120 | + const matchesGallery = |
| 121 | + galleryIds.size === unique.size && |
| 122 | + templateIds.every((id) => galleryIds.has(id)); |
| 123 | + if (!matchesGallery) { |
| 124 | + return null; |
| 125 | + } |
| 126 | + |
| 127 | + // Heaviest leads, so the first id gets the largest weight. One statement |
| 128 | + // for the whole gallery, rather than an update per row: it keeps the |
| 129 | + // SERIALIZABLE window to a single round trip (fewer aborts under a |
| 130 | + // concurrent write), and it leaves `updatedAt` alone — Prisma's `update` |
| 131 | + // would touch it on every row, so a reorder would stamp the entire |
| 132 | + // gallery as freshly edited, and `updatedAt` is a sort the list API |
| 133 | + // offers. Ranking a template is not editing it. |
| 134 | + const written = await tx.$executeRaw` |
| 135 | + UPDATE "AgentTemplate" AS t |
| 136 | + SET "featuredRank" = v.rank |
| 137 | + FROM (VALUES ${Prisma.join( |
| 138 | + templateIds.map( |
| 139 | + (id, index) => Prisma.sql`(${id}::uuid, ${total - index}::int)`, |
| 140 | + ), |
| 141 | + )}) AS v(id, rank) |
| 142 | + WHERE t."id" = v.id |
| 143 | + `; |
| 144 | + // Membership was checked in this same snapshot, so every id must land. |
| 145 | + // If one didn't, the order we'd be storing isn't the one we validated. |
| 146 | + if (written !== total) { |
| 147 | + throw new Error( |
| 148 | + `featured-order wrote ${written} of ${total} templates`, |
| 149 | + ); |
| 150 | + } |
| 151 | + return total; |
| 152 | + }, |
| 153 | + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, |
| 154 | + ); |
| 155 | + |
| 156 | + if (updated === null) { |
| 157 | + sendError(res, 409, { |
| 158 | + code: "GALLERY_CHANGED", |
| 159 | + message: |
| 160 | + "templateIds must be exactly the featured, published templates — the gallery changed, so re-read it and try again", |
| 161 | + }); |
| 162 | + return; |
| 163 | + } |
| 164 | + |
| 165 | + res.status(200).json({ updated }); |
| 166 | + } catch (error) { |
| 167 | + // A serialization failure means the gallery moved under this write. The |
| 168 | + // caller's answer is the same as for a set that was already stale. |
| 169 | + if (isSerializationFailure(error)) { |
| 170 | + sendError(res, 409, { |
| 171 | + code: "GALLERY_CHANGED", |
| 172 | + message: |
| 173 | + "the gallery changed while this order was being written — re-read it and try again", |
| 174 | + }); |
| 175 | + return; |
| 176 | + } |
| 177 | + req.log.error( |
| 178 | + { error, stack: error instanceof Error ? error.stack : undefined }, |
| 179 | + "Failed to set featured gallery order", |
| 180 | + ); |
| 181 | + res.status(500).json({ error: "Failed to set featured gallery order" }); |
| 182 | + } |
| 183 | +} |
0 commit comments