import type { FunnelRow, MediaMetric, MediaPublication, MediaPublicationStatus, MetricCsvMapping, TeaserWeights, Utm, } from "@rakazo/contracts"; import { MetricCsvMappingSchema, TeaserWeightsSchema } from "@rakazo/contracts"; import type { MediaPublication as MediaPublicationRow, PrismaClient } from "./client.js"; import { Prisma } from "./client.js"; import type { MediaDb, MediaPage, MediaPageInput } from "./media-shared.js"; import { decodeKeysetCursor, InvalidMediaInputError, isUniqueViolation, MediaStateConflictError, mapMediaMetric, mapMediaPublication, mapMetricMapping, pageLimit, toPage, } from "./media-shared.js"; import { IsolationError } from "./scope.js"; export interface NewMediaPublication { renderId: string; destination: string; targetUrl: string | null; trackingUrl: string | null; utm: Utm | null; title: string | null; caption: string | null; tags: string[]; thumbnailVariant: string | null; scheduledAt: Date | null; } export type PublicationKey = MetricCsvMapping["publicationKey"]; export type NewMediaMetric = Omit; export interface TeaserModelRecord { weights: TeaserWeights; sampleCount: number; updatedAt: string; } function assertMetric(metric: NewMediaMetric): void { for (const value of [metric.views, metric.clicks, metric.conversions, metric.revenueCents]) { if (!Number.isInteger(value) || value < 0) { throw new InvalidMediaInputError("Metric values must be non-negative integers"); } } if (!/^[A-Z]{3}$/.test(metric.currency)) throw new InvalidMediaInputError("Invalid currency"); } /** Publications, their metrics and the learned teaser weights. */ export function createMediaPublishingRepos(prisma: PrismaClient) { async function requireRender(spaceId: string, renderId: string) { const row = await prisma.mediaRender.findFirst({ where: { id: renderId, spaceId } }); if (!row) throw new IsolationError(); return row; } async function requireProject(spaceId: string, projectId: string): Promise { const row = await prisma.mediaProject.findFirst({ where: { id: projectId, spaceId }, select: { id: true }, }); if (!row) throw new IsolationError(); } async function requirePublication(spaceId: string, publicationId: string) { const row = await prisma.mediaPublication.findFirst({ where: { id: publicationId, spaceId } }); if (!row) throw new IsolationError(); return row; } /** Only a finished render that passed its gate can be published. */ async function createPublication( spaceId: string, createdBy: string, input: NewMediaPublication, ): Promise { const render = await requireRender(spaceId, input.renderId); if (render.status !== "done") { throw new MediaStateConflictError("Only a finished render can be published"); } const row = await prisma.mediaPublication.create({ data: { spaceId, createdBy, renderId: render.id, profileId: render.profileId, destination: input.destination, status: input.scheduledAt ? "scheduled" : "draft", scheduledAt: input.scheduledAt, targetUrl: input.targetUrl, trackingUrl: input.trackingUrl, utm: input.utm === null ? Prisma.DbNull : (input.utm as Prisma.InputJsonObject), title: input.title, caption: input.caption, tags: input.tags, thumbnailVariant: input.thumbnailVariant, }, }); return mapMediaPublication(row); } async function getPublication(spaceId: string, publicationId: string): Promise { return mapMediaPublication(await requirePublication(spaceId, publicationId)); } async function getPublicationRecord( spaceId: string, publicationId: string, ): Promise { return requirePublication(spaceId, publicationId); } async function listPublications( spaceId: string, input: MediaPageInput & { projectId?: string } = {}, ): Promise> { const limit = pageLimit(input.limit); const cursor = input.cursor ? decodeKeysetCursor(input.cursor) : null; if (cursor && !cursor.at) throw new InvalidMediaInputError("Invalid cursor"); const rows = await prisma.mediaPublication.findMany({ where: { spaceId, ...(input.projectId ? { render: { projectId: input.projectId, spaceId } } : {}), ...(cursor?.at ? { OR: [ { createdAt: { lt: cursor.at } }, { createdAt: cursor.at, id: { lt: cursor.id } }, ], } : {}), }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: limit + 1, }); return toPage(rows, limit, (row) => ({ at: row.createdAt, id: row.id }), mapMediaPublication); } async function transitionPublication( spaceId: string, publicationId: string, from: MediaPublicationStatus[], data: Prisma.MediaPublicationUpdateManyMutationInput, ): Promise { const result = await prisma.mediaPublication.updateMany({ where: { id: publicationId, spaceId, status: { in: from } }, data, }); const row = await requirePublication(spaceId, publicationId); if (result.count !== 1) { throw new MediaStateConflictError(`Publication is already ${row.status}`); } return mapMediaPublication(row); } async function schedulePublication( spaceId: string, publicationId: string, scheduledAt: Date, ): Promise { return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "failed"], { status: "scheduled", scheduledAt, }); } async function cancelPublication( spaceId: string, publicationId: string, ): Promise { return transitionPublication( spaceId, publicationId, ["draft", "scheduled", "bundled", "failed"], { status: "canceled" }, ); } async function markPublicationBundled( spaceId: string, publicationId: string, bundleArtifactId: string | null, ): Promise { return transitionPublication( spaceId, publicationId, ["draft", "scheduled", "bundled", "failed"], { status: "bundled", bundleArtifactId }, ); } async function markPublicationPublished( spaceId: string, publicationId: string, externalUrl: string | null, publishedAt: Date = new Date(), ): Promise { return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], { status: "published", externalUrl, publishedAt, }); } async function markPublicationFailed( spaceId: string, publicationId: string, ): Promise { return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], { status: "failed", }); } /** * Map CSV keys to publication ids of this space. Values that match nothing (or a * publication in another space) are absent from the result. */ async function resolvePublicationKeys( spaceId: string, key: PublicationKey, values: string[], ): Promise> { const wanted = new Set(values.map((value) => value.trim()).filter(Boolean)); if (wanted.size === 0) return new Map(); const rows = await prisma.mediaPublication.findMany({ where: { spaceId, ...(key === "id" ? { id: { in: [...wanted] } } : {}), ...(key === "tracking_url" ? { trackingUrl: { in: [...wanted] } } : {}), ...(key === "external_url" ? { externalUrl: { in: [...wanted] } } : {}), }, select: { id: true, trackingUrl: true, externalUrl: true, utm: true }, }); const resolved = new Map(); for (const row of rows) { const utm = row.utm as { content?: unknown } | null; const value = key === "id" ? row.id : key === "tracking_url" ? row.trackingUrl : key === "external_url" ? row.externalUrl : typeof utm?.content === "string" ? utm.content : null; if (value && wanted.has(value) && !resolved.has(value)) resolved.set(value, row.id); } return resolved; } // ------------------------------------------------------------------------- // Metrics // ------------------------------------------------------------------------- async function upsertMetric(db: MediaDb, spaceId: string, metric: NewMediaMetric) { assertMetric(metric); const observedAt = new Date(metric.observedAt); if (Number.isNaN(observedAt.getTime())) throw new InvalidMediaInputError("Invalid date"); const values = { views: metric.views, clicks: metric.clicks, conversions: metric.conversions, revenueCents: metric.revenueCents, currency: metric.currency, }; return db.mediaMetric.upsert({ where: { publicationId_observedAt_source: { publicationId: metric.publicationId, observedAt, source: metric.source, }, }, create: { ...values, spaceId, publicationId: metric.publicationId, observedAt, source: metric.source, }, update: values, }); } /** One observation; re-recording the same (publication, time, source) replaces it. */ async function recordMetric(spaceId: string, metric: NewMediaMetric): Promise { await requirePublication(spaceId, metric.publicationId); return mapMediaMetric(await upsertMetric(prisma, spaceId, metric)); } /** Batch import, all or nothing. */ async function recordMetrics(spaceId: string, metrics: NewMediaMetric[]): Promise { if (metrics.length === 0) return 0; return prisma.$transaction( async (tx) => { const ids = [...new Set(metrics.map((metric) => metric.publicationId))]; const owned = await tx.mediaPublication.count({ where: { spaceId, id: { in: ids } } }); if (owned !== ids.length) throw new IsolationError(); for (const metric of metrics) await upsertMetric(tx, spaceId, metric); return metrics.length; }, { timeout: 60_000 }, ); } async function listMetrics(spaceId: string, publicationId: string): Promise { await requirePublication(spaceId, publicationId); const rows = await prisma.mediaMetric.findMany({ where: { spaceId, publicationId }, orderBy: [{ observedAt: "asc" }, { source: "asc" }], }); return rows.map(mapMediaMetric); } async function saveMetricMapping( spaceId: string, input: MetricCsvMapping, ): Promise { const parsed = MetricCsvMappingSchema.safeParse(input); if (!parsed.success) throw new InvalidMediaInputError("Invalid CSV mapping"); const { id, ...mapping } = parsed.data; const json = mapping as Prisma.InputJsonObject; try { if (id) { const result = await prisma.mediaMetricMapping.updateMany({ where: { id, spaceId }, data: { name: mapping.name, mapping: json }, }); if (result.count !== 1) throw new IsolationError(); return getMetricMapping(spaceId, id); } const row = await prisma.mediaMetricMapping.upsert({ where: { spaceId_name: { spaceId, name: mapping.name } }, create: { spaceId, name: mapping.name, mapping: json }, update: { mapping: json }, }); return mapMetricMapping(row); } catch (error) { if (isUniqueViolation(error)) throw new InvalidMediaInputError("Mapping name already used"); throw error; } } async function listMetricMappings(spaceId: string): Promise { const rows = await prisma.mediaMetricMapping.findMany({ where: { spaceId }, orderBy: [{ name: "asc" }, { id: "asc" }], }); return rows.map(mapMetricMapping); } async function getMetricMapping(spaceId: string, id: string): Promise { const row = await prisma.mediaMetricMapping.findFirst({ where: { id, spaceId } }); if (!row) throw new IsolationError(); return mapMetricMapping(row); } /** * Views → clicks → conversions → revenue per publication. Observations are * cumulative snapshots, so only the latest one per (publication, source) counts; * importing the same report twice never doubles the numbers. */ async function funnel(spaceId: string, projectId?: string): Promise { if (projectId) await requireProject(spaceId, projectId); const rows = await prisma.$queryRaw< Array<{ publicationId: string; projectId: string; profileId: string; variant: string | null; thumbnailVariant: string | null; views: bigint; clicks: bigint; conversions: bigint; revenueCents: bigint; currency: string | null; }> >(Prisma.sql` WITH latest AS ( SELECT DISTINCT ON (m."publicationId", m.source) m."publicationId", m.views, m.clicks, m.conversions, m."revenueCents", m.currency FROM media_metrics m WHERE m."spaceId" = ${spaceId} ORDER BY m."publicationId", m.source, m."observedAt" DESC, m.id DESC ) SELECT p.id AS "publicationId", r."projectId", p."profileId", p.utm ->> 'content' AS variant, p."thumbnailVariant", coalesce(sum(l.views), 0)::bigint AS views, coalesce(sum(l.clicks), 0)::bigint AS clicks, coalesce(sum(l.conversions), 0)::bigint AS conversions, coalesce(sum(l."revenueCents"), 0)::bigint AS "revenueCents", min(l.currency) AS currency FROM media_publications p JOIN media_renders r ON r.id = p."renderId" AND r."spaceId" = p."spaceId" LEFT JOIN latest l ON l."publicationId" = p.id WHERE p."spaceId" = ${spaceId} ${projectId ? Prisma.sql`AND r."projectId" = ${projectId}` : Prisma.empty} GROUP BY p.id, r."projectId" ORDER BY p."createdAt" DESC, p.id DESC `); return rows.map((row) => ({ publicationId: row.publicationId, projectId: row.projectId, profileId: row.profileId, variant: row.variant, thumbnailVariant: row.thumbnailVariant, views: Number(row.views), clicks: Number(row.clicks), conversions: Number(row.conversions), revenueCents: Number(row.revenueCents), currency: row.currency ?? "EUR", })); } /** Publications with at least one metric: the sample size for teaser learning. */ async function publicationsWithMetricsCount(spaceId: string): Promise { return prisma.mediaPublication.count({ where: { spaceId, metrics: { some: {} } } }); } // ------------------------------------------------------------------------- // Teaser model // ------------------------------------------------------------------------- async function getTeaserModel(spaceId: string): Promise { const row = await prisma.mediaTeaserModel.findUnique({ where: { spaceId } }); if (!row) return null; const weights = TeaserWeightsSchema.safeParse(row.weights); if (!weights.success) return null; return { weights: weights.data, sampleCount: row.sampleCount, updatedAt: row.updatedAt.toISOString(), }; } async function putTeaserModel( spaceId: string, weights: TeaserWeights, sampleCount: number, ): Promise { const parsed = TeaserWeightsSchema.safeParse(weights); if (!parsed.success || !Number.isInteger(sampleCount) || sampleCount < 0) { throw new InvalidMediaInputError("Invalid teaser model"); } const json = parsed.data as Prisma.InputJsonObject; const row = await prisma.mediaTeaserModel.upsert({ where: { spaceId }, create: { spaceId, weights: json, sampleCount }, update: { weights: json, sampleCount }, }); return { weights: parsed.data, sampleCount: row.sampleCount, updatedAt: row.updatedAt.toISOString(), }; } /** Back to default weights. */ async function resetTeaserModel(spaceId: string): Promise { await prisma.mediaTeaserModel.deleteMany({ where: { spaceId } }); } return { createPublication, getPublication, getPublicationRecord, listPublications, schedulePublication, cancelPublication, markPublicationBundled, markPublicationPublished, markPublicationFailed, resolvePublicationKeys, recordMetric, recordMetrics, listMetrics, saveMetricMapping, listMetricMappings, getMetricMapping, funnel, publicationsWithMetricsCount, getTeaserModel, putTeaserModel, resetTeaserModel, }; }