feat(podcasts): add frontend contracts and lifecycle api service

This commit is contained in:
CREDO23 2026-06-11 10:04:51 +02:00
parent c84525897b
commit 64b36f2622
2 changed files with 178 additions and 0 deletions

View file

@ -0,0 +1,120 @@
import { z } from "zod";
// =============================================================================
// Lifecycle — mirror app/podcasts/persistence/enums/podcast_status.py
// =============================================================================
export const podcastStatus = z.enum([
"pending",
"awaiting_brief",
"drafting",
"awaiting_review",
"rendering",
"ready",
"failed",
"cancelled",
]);
export type PodcastStatus = z.infer<typeof podcastStatus>;
/** States waiting on user input before the lifecycle can proceed. */
export const GATE_STATUSES: ReadonlySet<PodcastStatus> = new Set([
"awaiting_brief",
"awaiting_review",
]);
/** States from which no further transition is possible. */
export const TERMINAL_STATUSES: ReadonlySet<PodcastStatus> = new Set([
"ready",
"failed",
"cancelled",
]);
// =============================================================================
// Brief (spec) — mirror app/podcasts/schemas/spec.py
// =============================================================================
export const speakerRole = z.enum(["host", "cohost", "guest", "expert", "narrator"]);
export type SpeakerRole = z.infer<typeof speakerRole>;
export const podcastStyle = z.enum([
"conversational",
"interview",
"debate",
"monologue",
"narrative",
]);
export type PodcastStyle = z.infer<typeof podcastStyle>;
export const MAX_SPEAKERS = 6;
export const speakerSpec = z.object({
slot: z.number().int().min(0),
name: z.string().min(1).max(120),
role: speakerRole,
voice_id: z.string().min(1),
});
export type SpeakerSpec = z.infer<typeof speakerSpec>;
export const durationTarget = z.object({
min_minutes: z.number().int().min(1),
max_minutes: z.number().int().min(1),
});
export type DurationTarget = z.infer<typeof durationTarget>;
export const podcastSpec = z.object({
language: z.string().min(2),
style: podcastStyle,
speakers: z.array(speakerSpec).min(1).max(MAX_SPEAKERS),
duration: durationTarget,
focus: z.string().max(2000).nullable().optional(),
});
export type PodcastSpec = z.infer<typeof podcastSpec>;
// =============================================================================
// Transcript — mirror app/podcasts/schemas/transcript.py
// =============================================================================
export const transcriptTurn = z.object({
speaker: z.number().int().min(0),
text: z.string().min(1),
});
export type TranscriptTurn = z.infer<typeof transcriptTurn>;
export const transcript = z.object({
turns: z.array(transcriptTurn).min(1),
});
export type Transcript = z.infer<typeof transcript>;
// =============================================================================
// API shapes — mirror app/podcasts/api/schemas.py
// =============================================================================
export const voiceOption = z.object({
voice_id: z.string(),
display_name: z.string(),
language: z.string(),
gender: z.string(),
});
export type VoiceOption = z.infer<typeof voiceOption>;
export const updateSpecRequest = z.object({
spec: podcastSpec,
expected_version: z.number().int().min(1),
});
export type UpdateSpecRequest = z.infer<typeof updateSpecRequest>;
export const podcastDetail = z.object({
id: z.number(),
title: z.string(),
status: podcastStatus,
spec_version: z.number(),
spec: podcastSpec.nullable(),
transcript: transcript.nullable(),
has_audio: z.boolean(),
duration_seconds: z.number().nullable(),
error: z.string().nullable(),
created_at: z.string(),
search_space_id: z.number(),
thread_id: z.number().nullable(),
});
export type PodcastDetail = z.infer<typeof podcastDetail>;

View file

@ -0,0 +1,58 @@
import { z } from "zod";
import {
type PodcastSpec,
podcastDetail,
updateSpecRequest,
voiceOption,
} from "@/contracts/types/podcast.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
const BASE = "/api/v1/podcasts";
const voiceOptionList = z.array(voiceOption);
class PodcastsApiService {
// Full state including the deserialized brief and transcript; thin lifecycle
// fields (status, spec, spec_version) also arrive live via Zero.
getDetail = async (podcastId: number) => {
return baseApiService.get(`${BASE}/${podcastId}`, podcastDetail);
};
// Guarded by the version the caller last saw; the backend answers 409 when
// the brief changed underneath them.
updateSpec = async (podcastId: number, spec: PodcastSpec, expectedVersion: number) => {
const parsed = updateSpecRequest.safeParse({ spec, expected_version: expectedVersion });
if (!parsed.success) {
throw new ValidationError(
`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`
);
}
return baseApiService.patch(`${BASE}/${podcastId}/spec`, podcastDetail, {
body: parsed.data,
});
};
approveBrief = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/brief/approve`, podcastDetail);
};
approveTranscript = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/transcript/approve`, podcastDetail);
};
regenerateTranscript = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/transcript/regenerate`, podcastDetail);
};
cancel = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/cancel`, podcastDetail);
};
listVoices = async (language?: string) => {
const qs = language ? `?${new URLSearchParams({ language })}` : "";
return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList);
};
}
export const podcastsApiService = new PodcastsApiService();