548 lines
23 KiB
TypeScript
548 lines
23 KiB
TypeScript
import { lstat, realpath, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { implement, ORPCError } from "@orpc/server";
|
|
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
|
|
import type { MediaServices } from "@rakazo/adapters";
|
|
import type { Actor, BrandKit, MediaCapabilities, MediaSourceKind } from "@rakazo/contracts";
|
|
import { appContract, MediaInferenceOperationSchema } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import {
|
|
InvalidMediaInputError,
|
|
InvalidProjectWorkspacePathError,
|
|
IsolationError,
|
|
lockMediaKey,
|
|
MediaStateConflictError,
|
|
mapMediaSource,
|
|
resolveProjectWorkspaceDirectory,
|
|
} from "@rakazo/db";
|
|
import { summarizeProfile } from "@rakazo/media";
|
|
import { PathOutsideRootError, resolveInside } from "@rakazo/media/node";
|
|
import { beginMediaUpload, completeMediaUpload, UploadChunkError } from "./media-uploads.js";
|
|
|
|
export interface MediaRouterDeps {
|
|
prisma: PrismaClient;
|
|
/** Null when Media Studio is not configured: everything but capabilities refuses. */
|
|
services?: MediaServices | null;
|
|
/** DATA_DIR; a folder source may neither sit inside it nor contain it. */
|
|
dataDir: string;
|
|
/** Application install directory a folder source may not point into. */
|
|
appRoot?: string;
|
|
}
|
|
|
|
export const MEDIA_NOT_CONFIGURED = "Media Studio is not configured";
|
|
|
|
const DEFAULT_APP_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
|
|
/** Translate domain errors into RPC errors; everything else passes through. */
|
|
export function mediaRpcError(error: unknown): unknown {
|
|
if (error instanceof ORPCError) return error;
|
|
if (error instanceof IsolationError) return new ORPCError("NOT_FOUND");
|
|
if (
|
|
error instanceof InvalidMediaInputError ||
|
|
error instanceof InvalidProjectWorkspacePathError
|
|
) {
|
|
return new ORPCError("BAD_REQUEST", { message: error.message });
|
|
}
|
|
if (error instanceof PathOutsideRootError) {
|
|
return new ORPCError("BAD_REQUEST", { message: "That path is not allowed." });
|
|
}
|
|
if (error instanceof MediaStateConflictError) {
|
|
return new ORPCError("CONFLICT", { message: error.message });
|
|
}
|
|
if (error instanceof UploadChunkError) {
|
|
return new ORPCError(error.status === 409 ? "CONFLICT" : "BAD_REQUEST", {
|
|
message: error.message,
|
|
});
|
|
}
|
|
if (error instanceof UnsupportedMediaOperationError) {
|
|
return new ORPCError("PRECONDITION_FAILED", { message: error.message });
|
|
}
|
|
return error;
|
|
}
|
|
|
|
export function unconfiguredCapabilities(): MediaCapabilities {
|
|
const unavailable = {
|
|
available: false,
|
|
experimental: false,
|
|
model: null,
|
|
license: null,
|
|
commercialUse: null,
|
|
};
|
|
return {
|
|
configured: false,
|
|
operations: Object.fromEntries(
|
|
MediaInferenceOperationSchema.options.map((operation) => [operation, unavailable]),
|
|
) as MediaCapabilities["operations"],
|
|
accelerator: "none",
|
|
hardwareEncoders: [],
|
|
};
|
|
}
|
|
|
|
function overlaps(a: string, b: string): boolean {
|
|
const inside = (root: string, candidate: string) => {
|
|
const relative = path.relative(root, candidate);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
};
|
|
return inside(a, b) || inside(b, a);
|
|
}
|
|
|
|
async function realOrResolved(target: string): Promise<string> {
|
|
return realpath(target).catch(() => path.resolve(target));
|
|
}
|
|
|
|
/**
|
|
* A host folder for a folder source: absolute, an existing directory, not a
|
|
* symlink, and apart from DATA_DIR and the application itself. Returns the real path.
|
|
*/
|
|
export async function validateMediaFolder(
|
|
input: string,
|
|
forbidden: { dataDir: string; appRoot: string },
|
|
): Promise<string> {
|
|
const trimmed = input.trim();
|
|
if (!path.isAbsolute(trimmed)) {
|
|
throw new InvalidMediaInputError("Use an absolute folder path.");
|
|
}
|
|
const info = await lstat(trimmed).catch(() => null);
|
|
if (!info) throw new InvalidMediaInputError("That folder does not exist or is not readable.");
|
|
if (info.isSymbolicLink())
|
|
throw new InvalidMediaInputError("Choose the folder, not a link to it.");
|
|
const resolved = await resolveProjectWorkspaceDirectory(trimmed);
|
|
if (path.parse(resolved).root === resolved) {
|
|
throw new InvalidMediaInputError("Choose a specific folder.");
|
|
}
|
|
const dataDir = await realOrResolved(forbidden.dataDir);
|
|
const appRoot = await realOrResolved(forbidden.appRoot);
|
|
const relativeToApp = path.relative(appRoot, resolved);
|
|
const insideApp =
|
|
relativeToApp === "" || (!relativeToApp.startsWith("..") && !path.isAbsolute(relativeToApp));
|
|
if (overlaps(dataDir, resolved) || insideApp) {
|
|
throw new InvalidMediaInputError("That folder belongs to evOo itself.");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/** Artifacts referenced from media records must belong to the caller's space. */
|
|
export async function assertSpaceArtifacts(
|
|
prisma: PrismaClient,
|
|
spaceId: string,
|
|
artifactIds: string[],
|
|
): Promise<void> {
|
|
const ids = [...new Set(artifactIds)];
|
|
if (ids.length === 0) return;
|
|
const owned = await prisma.artifact.count({ where: { spaceId, id: { in: ids } } });
|
|
if (owned !== ids.length) throw new IsolationError();
|
|
}
|
|
|
|
function brandKitArtifactIds(kit: BrandKit): string[] {
|
|
return [
|
|
...kit.fonts.map((font) => font.artifactId),
|
|
...kit.logoArtifactIds,
|
|
...(kit.lutArtifactId ? [kit.lutArtifactId] : []),
|
|
...kit.musicBeds.map((bed) => bed.artifactId),
|
|
];
|
|
}
|
|
|
|
async function removeMediaPath(mediaRoot: string, relative: string | null): Promise<void> {
|
|
if (!relative) return;
|
|
await rm(await resolveInside(mediaRoot, relative), { recursive: true, force: true });
|
|
}
|
|
|
|
export function createMediaRouter(deps: MediaRouterDeps) {
|
|
const os = implement(appContract.media).$context<{ actor: Actor | null; signal?: AbortSignal }>();
|
|
const authed = os.use(async ({ context, next }) => {
|
|
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
|
|
try {
|
|
return await next({ context: { ...context, actor: context.actor } });
|
|
} catch (error) {
|
|
throw mediaRpcError(error);
|
|
}
|
|
});
|
|
const media = authed.use(async ({ next }) => {
|
|
if (!deps.services) {
|
|
throw new ORPCError("PRECONDITION_FAILED", { message: MEDIA_NOT_CONFIGURED });
|
|
}
|
|
return next({ context: { services: deps.services } });
|
|
});
|
|
const forbiddenRoots = { dataDir: deps.dataDir, appRoot: deps.appRoot ?? DEFAULT_APP_ROOT };
|
|
|
|
async function knownProfiles(services: MediaServices, profileIds: string[] | undefined) {
|
|
if (!profileIds) return;
|
|
const { profiles } = await services.deps.profiles();
|
|
const unknown = profileIds.filter((id) => !profiles.has(id));
|
|
if (unknown.length > 0) {
|
|
throw new InvalidMediaInputError(`Unknown profile: ${unknown.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
return os.router({
|
|
capabilities: authed.capabilities.handler(async ({ context }) => {
|
|
const services = deps.services;
|
|
if (!services) return unconfiguredCapabilities();
|
|
const hardwareEncoders = await services.deps.renderer.hardwareEncoders().catch(() => []);
|
|
const inference = services.deps.inference;
|
|
if (!inference) return { ...unconfiguredCapabilities(), hardwareEncoders };
|
|
const capabilities = await inference
|
|
.capabilities(context.signal)
|
|
.catch(() => ({ ...unconfiguredCapabilities(), configured: true }));
|
|
return { ...capabilities, configured: true, hardwareEncoders };
|
|
}),
|
|
sources: {
|
|
list: media.sources.list.handler(({ context }) =>
|
|
context.services.deps.repos.listSources(context.actor.spaceId),
|
|
),
|
|
createFolder: media.sources.createFolder.handler(async ({ context, input }) => {
|
|
// A folder lives on the host, and the host is the deployment owner's machine.
|
|
if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
|
|
const folder = await validateMediaFolder(input.path, forbiddenRoots);
|
|
const { repos } = context.services.deps;
|
|
const source = await repos.createSource(context.actor.spaceId, {
|
|
kind: "folder",
|
|
label: input.label,
|
|
ownerUserId: context.actor.userId,
|
|
config: { path: folder },
|
|
});
|
|
await context.services.ingest.requestSync(context.actor, source.id);
|
|
return source;
|
|
}),
|
|
registerClient: media.sources.registerClient.handler(async ({ context, input }) => {
|
|
const { spaceId, userId } = context.actor;
|
|
const kind: MediaSourceKind = input.kind;
|
|
// One source per device: re-registering returns the existing one.
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
await lockMediaKey(tx, `source:${spaceId}:${kind}:${input.deviceId}`);
|
|
const existing = await tx.mediaSource.findFirst({
|
|
where: { spaceId, kind, config: { path: ["deviceId"], equals: input.deviceId } },
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
include: { _count: { select: { assets: true } } },
|
|
});
|
|
if (existing) return mapMediaSource(existing, existing._count.assets);
|
|
const created = await tx.mediaSource.create({
|
|
data: {
|
|
spaceId,
|
|
kind,
|
|
label: input.label,
|
|
ownerUserId: userId,
|
|
config: { deviceId: input.deviceId },
|
|
},
|
|
});
|
|
return mapMediaSource(created, 0);
|
|
});
|
|
}),
|
|
pushItems: media.sources.pushItems.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
|
|
return context.services.ingest.pushItems(context.actor, input);
|
|
}),
|
|
sync: media.sources.sync.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
|
|
await context.services.ingest.requestSync(context.actor, input.sourceId);
|
|
return { ok: true as const };
|
|
}),
|
|
remove: media.sources.remove.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
|
|
await context.services.ingest.removeSource(context.actor, input.sourceId);
|
|
return { ok: true as const };
|
|
}),
|
|
},
|
|
assets: {
|
|
search: media.assets.search.handler(({ context, input }) =>
|
|
context.services.deps.repos.searchAssets(context.actor.spaceId, input),
|
|
),
|
|
get: media.assets.get.handler(({ context, input }) =>
|
|
context.services.deps.repos.getAsset(context.actor.spaceId, input.assetId),
|
|
),
|
|
setPaidOnly: media.assets.setPaidOnly.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.setSegmentsPaidOnly(
|
|
context.actor.spaceId,
|
|
input.segmentIds,
|
|
input.paidOnly,
|
|
);
|
|
return { ok: true as const };
|
|
}),
|
|
requestUpload: media.assets.requestUpload.handler(async ({ context, input }) => ({
|
|
awaiting: await context.services.deps.repos.requestUpload(
|
|
context.actor.spaceId,
|
|
input.assetIds,
|
|
),
|
|
})),
|
|
},
|
|
uploads: {
|
|
begin: media.uploads.begin.handler(({ context, input }) =>
|
|
beginMediaUpload(context.services, context.actor, input),
|
|
),
|
|
status: media.uploads.status.handler(({ context, input }) =>
|
|
context.services.deps.repos.uploadStatus(context.actor.spaceId, input.uploadId),
|
|
),
|
|
complete: media.uploads.complete.handler(({ context, input }) =>
|
|
completeMediaUpload(context.services, context.actor, input.uploadId),
|
|
),
|
|
},
|
|
collections: {
|
|
list: media.collections.list.handler(({ context, input }) =>
|
|
context.services.deps.repos.listCollections(context.actor.spaceId, input),
|
|
),
|
|
get: media.collections.get.handler(({ context, input }) =>
|
|
context.services.deps.repos.getCollection(context.actor.spaceId, input.collectionId),
|
|
),
|
|
rename: media.collections.rename.handler(({ context, input }) =>
|
|
context.services.deps.repos.renameCollection(
|
|
context.actor.spaceId,
|
|
input.collectionId,
|
|
input.title,
|
|
),
|
|
),
|
|
merge: media.collections.merge.handler(({ context, input }) =>
|
|
context.services.deps.repos.mergeCollections(context.actor.spaceId, input.collectionIds),
|
|
),
|
|
split: media.collections.split.handler(({ context, input }) =>
|
|
context.services.deps.repos.splitCollection(
|
|
context.actor.spaceId,
|
|
input.collectionId,
|
|
input.atAssetId,
|
|
),
|
|
),
|
|
createManual: media.collections.createManual.handler(({ context, input }) =>
|
|
context.services.deps.repos.createManualCollection(
|
|
context.actor.spaceId,
|
|
input.title,
|
|
input.assetIds,
|
|
),
|
|
),
|
|
recluster: media.collections.recluster.handler(async ({ context }) => {
|
|
await context.services.ingest.recluster(context.actor);
|
|
return { ok: true as const };
|
|
}),
|
|
},
|
|
people: {
|
|
list: media.people.list.handler(({ context }) =>
|
|
context.services.deps.repos.listPeople(context.actor.spaceId),
|
|
),
|
|
update: media.people.update.handler(({ context, input }) =>
|
|
context.services.deps.repos.updatePerson(context.actor.spaceId, input.personId, {
|
|
displayName: input.displayName,
|
|
isSelf: input.isSelf,
|
|
confirmed: input.confirmed,
|
|
anonymizeByDefault: input.anonymizeByDefault,
|
|
}),
|
|
),
|
|
merge: media.people.merge.handler(({ context, input }) =>
|
|
context.services.deps.repos.mergePeople(context.actor.spaceId, input.personIds),
|
|
),
|
|
assignTrack: media.people.assignTrack.handler(({ context, input }) =>
|
|
context.services.deps.repos.assignTrack(
|
|
context.actor.spaceId,
|
|
input.trackId,
|
|
input.personId,
|
|
),
|
|
),
|
|
// The only consent write path: the signed-in member is the recorder.
|
|
recordConsent: media.people.recordConsent.handler(async ({ context, input }) => {
|
|
await assertSpaceArtifacts(
|
|
deps.prisma,
|
|
context.actor.spaceId,
|
|
input.consentArtifactId ? [input.consentArtifactId] : [],
|
|
);
|
|
return context.services.deps.repos.recordConsent(
|
|
{ spaceId: context.actor.spaceId, recordedByUserId: context.actor.userId },
|
|
input,
|
|
);
|
|
}),
|
|
remove: media.people.remove.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getPerson(context.actor.spaceId, input.personId);
|
|
await context.services.privacy.removePerson(context.actor, input.personId);
|
|
return { ok: true as const };
|
|
}),
|
|
},
|
|
marks: {
|
|
list: media.marks.list.handler(({ context, input }) =>
|
|
context.services.deps.repos.listMarks(context.actor.spaceId, {
|
|
...(input.personId ? { personId: input.personId } : {}),
|
|
...(input.assetId ? { assetId: input.assetId } : {}),
|
|
}),
|
|
),
|
|
create: media.marks.create.handler(async ({ context, input }) => {
|
|
const { repos } = context.services.deps;
|
|
await repos.getAssetRecord(context.actor.spaceId, input.assetId);
|
|
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
|
|
return context.services.privacy.createMark(context.actor, input);
|
|
}),
|
|
update: media.marks.update.handler(async ({ context, input }) => {
|
|
const { repos } = context.services.deps;
|
|
await repos.getMark(context.actor.spaceId, input.markId);
|
|
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
|
|
return context.services.privacy.updateMark(context.actor, input);
|
|
}),
|
|
remove: media.marks.remove.handler(async ({ context, input }) => {
|
|
const { repos, mediaRoot } = context.services.deps;
|
|
const { maskDir } = await repos.removeMark(context.actor.spaceId, input.markId);
|
|
await removeMediaPath(mediaRoot, maskDir);
|
|
return { ok: true as const };
|
|
}),
|
|
},
|
|
brandKits: {
|
|
list: media.brandKits.list.handler(({ context }) =>
|
|
context.services.deps.repos.listBrandKits(context.actor.spaceId),
|
|
),
|
|
upsert: media.brandKits.upsert.handler(async ({ context, input }) => {
|
|
await assertSpaceArtifacts(
|
|
deps.prisma,
|
|
context.actor.spaceId,
|
|
brandKitArtifactIds(input.kit),
|
|
);
|
|
return context.services.deps.repos.upsertBrandKit(context.actor.spaceId, input);
|
|
}),
|
|
remove: media.brandKits.remove.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.removeBrandKit(context.actor.spaceId, input.id);
|
|
return { ok: true as const };
|
|
}),
|
|
templates: media.brandKits.templates.handler(({ context }) =>
|
|
context.services.deps.motion.templates(),
|
|
),
|
|
introVariants: media.brandKits.introVariants.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getBrandKit(context.actor.spaceId, input.id);
|
|
return context.services.marketing.introVariants(context.actor, {
|
|
brandKitId: input.id,
|
|
count: input.count,
|
|
});
|
|
}),
|
|
},
|
|
profiles: {
|
|
list: media.profiles.list.handler(async ({ context }) => {
|
|
const { profiles } = await context.services.deps.profiles();
|
|
const now = context.services.deps.now();
|
|
return [...profiles.values()].map(({ profile, origin }) =>
|
|
summarizeProfile(profile, origin, now),
|
|
);
|
|
}),
|
|
get: media.profiles.get.handler(async ({ context, input }) => {
|
|
const { profiles } = await context.services.deps.profiles();
|
|
const entry = profiles.get(input.profileId);
|
|
if (!entry) throw new ORPCError("NOT_FOUND");
|
|
return entry.profile;
|
|
}),
|
|
},
|
|
projects: {
|
|
list: media.projects.list.handler(({ context, input }) =>
|
|
context.services.deps.repos.listProjects(context.actor.spaceId, input),
|
|
),
|
|
get: media.projects.get.handler(({ context, input }) =>
|
|
context.services.deps.repos.getProject(context.actor.spaceId, input.projectId),
|
|
),
|
|
create: media.projects.create.handler(async ({ context, input }) => {
|
|
await knownProfiles(context.services, input.profileIds);
|
|
return context.services.deps.repos.createProject(
|
|
context.actor.spaceId,
|
|
context.actor.userId,
|
|
input,
|
|
);
|
|
}),
|
|
update: media.projects.update.handler(async ({ context, input }) => {
|
|
await knownProfiles(context.services, input.profileIds);
|
|
const { projectId, ...patch } = input;
|
|
return context.services.deps.repos.updateProject(context.actor.spaceId, projectId, patch);
|
|
}),
|
|
remove: media.projects.remove.handler(async ({ context, input }) => {
|
|
const { repos, mediaRoot } = context.services.deps;
|
|
const { spaceId } = context.actor;
|
|
await repos.getProjectRecord(spaceId, input.projectId);
|
|
const renders = await repos.listRenders(spaceId, input.projectId);
|
|
await repos.removeProject(spaceId, input.projectId);
|
|
for (const render of renders)
|
|
await removeMediaPath(mediaRoot, `${spaceId}/renders/${render.id}`);
|
|
return { ok: true as const };
|
|
}),
|
|
autoPlan: media.projects.autoPlan.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
|
|
await knownProfiles(context.services, input.profileIds);
|
|
return context.services.marketing.autoPlan(context.actor, input);
|
|
}),
|
|
},
|
|
plans: {
|
|
submit: media.plans.submit.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
|
|
return context.services.marketing.submitPlan(context.actor, {
|
|
projectId: input.projectId,
|
|
edl: input.edl,
|
|
rationale: input.rationale,
|
|
createdBy: "user",
|
|
});
|
|
}),
|
|
},
|
|
renders: {
|
|
start: media.renders.start.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getPlan(context.actor.spaceId, input.planId);
|
|
return context.services.render.start(context.actor, input);
|
|
}),
|
|
get: media.renders.get.handler(({ context, input }) =>
|
|
context.services.deps.repos.getRender(context.actor.spaceId, input.renderId),
|
|
),
|
|
cancel: media.renders.cancel.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
|
|
return context.services.render.cancel(context.actor, input.renderId);
|
|
}),
|
|
thumbnails: media.renders.thumbnails.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
|
|
return context.services.marketing.thumbnails(context.actor, input);
|
|
}),
|
|
},
|
|
marketing: {
|
|
copy: media.marketing.copy.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
|
|
return context.services.marketing.copy(context.actor, input);
|
|
}),
|
|
},
|
|
publications: {
|
|
list: media.publications.list.handler(({ context, input }) =>
|
|
context.services.deps.repos.listPublications(context.actor.spaceId, input),
|
|
),
|
|
create: media.publications.create.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
|
|
return context.services.marketing.createPublication(context.actor, input);
|
|
}),
|
|
bundle: media.publications.bundle.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getPublication(
|
|
context.actor.spaceId,
|
|
input.publicationId,
|
|
);
|
|
return context.services.marketing.bundle(context.actor, input.publicationId);
|
|
}),
|
|
markPublished: media.publications.markPublished.handler(({ context, input }) =>
|
|
context.services.deps.repos.markPublicationPublished(
|
|
context.actor.spaceId,
|
|
input.publicationId,
|
|
input.externalUrl,
|
|
context.services.deps.now(),
|
|
),
|
|
),
|
|
cancel: media.publications.cancel.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getPublication(
|
|
context.actor.spaceId,
|
|
input.publicationId,
|
|
);
|
|
return context.services.marketing.cancelPublication(context.actor, input.publicationId);
|
|
}),
|
|
},
|
|
metrics: {
|
|
mappings: media.metrics.mappings.handler(({ context }) =>
|
|
context.services.deps.repos.listMetricMappings(context.actor.spaceId),
|
|
),
|
|
saveMapping: media.metrics.saveMapping.handler(({ context, input }) =>
|
|
context.services.deps.repos.saveMetricMapping(context.actor.spaceId, input),
|
|
),
|
|
importCsv: media.metrics.importCsv.handler(async ({ context, input }) => {
|
|
await context.services.deps.repos.getMetricMapping(context.actor.spaceId, input.mappingId);
|
|
return context.services.marketing.importMetricsCsv(context.actor, input);
|
|
}),
|
|
record: media.metrics.record.handler(({ context, input }) =>
|
|
context.services.deps.repos.recordMetric(context.actor.spaceId, {
|
|
...input,
|
|
source: "manual",
|
|
}),
|
|
),
|
|
funnel: media.metrics.funnel.handler(({ context, input }) =>
|
|
context.services.deps.repos.funnel(context.actor.spaceId, input.projectId),
|
|
),
|
|
},
|
|
});
|
|
}
|