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
498 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|