evOo-local/packages/db/src/media-publishing.ts
Claude c1c39b6e46
feat(media): space-scoped Media Studio repositories
Sources, assets with keyset search, analyses, segments, uploads,
collections with re-clustering, people with consent and complete
biometric removal, marks, brand kits, projects with versioned plans,
renders with atomic claims, publications, metrics and the funnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:55:49 +00:00

498 lines
16 KiB
TypeScript

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<MediaMetric, "id">;
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<void> {
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<MediaPublication> {
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<MediaPublication> {
return mapMediaPublication(await requirePublication(spaceId, publicationId));
}
async function getPublicationRecord(
spaceId: string,
publicationId: string,
): Promise<MediaPublicationRow> {
return requirePublication(spaceId, publicationId);
}
async function listPublications(
spaceId: string,
input: MediaPageInput & { projectId?: string } = {},
): Promise<MediaPage<MediaPublication>> {
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<MediaPublication> {
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<MediaPublication> {
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "failed"], {
status: "scheduled",
scheduledAt,
});
}
async function cancelPublication(
spaceId: string,
publicationId: string,
): Promise<MediaPublication> {
return transitionPublication(
spaceId,
publicationId,
["draft", "scheduled", "bundled", "failed"],
{ status: "canceled" },
);
}
async function markPublicationBundled(
spaceId: string,
publicationId: string,
bundleArtifactId: string | null,
): Promise<MediaPublication> {
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<MediaPublication> {
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], {
status: "published",
externalUrl,
publishedAt,
});
}
async function markPublicationFailed(
spaceId: string,
publicationId: string,
): Promise<MediaPublication> {
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<Map<string, string>> {
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<string, string>();
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<MediaMetric> {
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<number> {
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<MediaMetric[]> {
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<MetricCsvMapping> {
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<MetricCsvMapping[]> {
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<MetricCsvMapping> {
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<FunnelRow[]> {
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<number> {
return prisma.mediaPublication.count({ where: { spaceId, metrics: { some: {} } } });
}
// -------------------------------------------------------------------------
// Teaser model
// -------------------------------------------------------------------------
async function getTeaserModel(spaceId: string): Promise<TeaserModelRecord | null> {
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<TeaserModelRecord> {
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<void> {
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,
};
}