feat(marketing): project-branded drafts + project badges on the X/video queues (#570)

Item B+C of the video/X per-project targeting spec, plus the
company_goals.company_name field they depend on (migration 075).

- CompanyGoalsService.resolve_product_name is the single fallback chain
  (project name -> charter company_name -> RoboCo); XEngine and
  VideoEngine both call it and their prompt builders are pure functions
  taking product_name — release posts/videos stop hardcoding RoboCo.
- The X and video queue responses carry project_slug/project_name via one
  shared unloaded-guard helper (api/schemas/project_fields.py); both
  panel queues render a shared ProjectBadge so multi-project drafts are
  tellable apart.
- Business -> Goals editor gains the company-name input.
- Fixes a pre-existing test-isolation leak: the company-goals routes test
  commits the charter singleton into the session-scoped test DB and
  polluted later suites; it now deletes the row on teardown.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 19:11:03 +02:00
committed by GitHub
co-authored by Renn F
parent 388bab2488
commit 7e01c0cecf
30 changed files with 750 additions and 40 deletions
@@ -50,6 +50,7 @@ function buildGoals(overrides: Partial<CompanyGoals> = {}): CompanyGoals {
constraints: [],
operating_policy: {},
brand_voice: "",
company_name: "",
updated_at: null,
updated_by: null,
...overrides,
+19 -1
View File
@@ -249,6 +249,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const [northStar, setNorthStar] = useState<string | null>(null);
const [brandVoice, setBrandVoice] = useState<string | null>(null);
const [companyName, setCompanyName] = useState<string | null>(null);
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<
Record<string, unknown>[] | null
@@ -257,6 +258,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const northStarVal = northStar ?? goals.north_star ?? "";
const brandVoiceVal = brandVoice ?? goals.brand_voice ?? "";
const companyNameVal = companyName ?? goals.company_name ?? "";
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
const objectivesVal = objectives ?? goals.objectives ?? [];
const policyVal = policy ?? goals.operating_policy ?? {};
@@ -267,6 +269,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
setNorthStar(null);
setBrandVoice(null);
setCompanyName(null);
setConstraints(null);
setObjectives(null);
setPolicy(null);
@@ -281,6 +284,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
saveMutation.mutate({
north_star: northStarVal,
brand_voice: brandVoiceVal,
company_name: companyNameVal,
objectives: objectivesVal,
constraints: constraintsVal
.split("\n")
@@ -333,6 +337,20 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
/>
</div>
{/* Company / product name */}
<div className="space-y-2">
<HelpTip label="Brands X/video marketing drafts when a project name isn't available — falls back to 'RoboCo' when both are unset">
<Label htmlFor="company-name">Company / product name</Label>
</HelpTip>
<Input
id="company-name"
value={companyNameVal}
disabled={saving}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="RoboCo"
/>
</div>
{/* Constraints */}
<div className="space-y-2">
<HelpTip label="Hard rules injected into every agent's briefing (e.g. licensing, no external data egress)">
@@ -373,7 +391,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
</div>
{/* Save */}
<HelpTip label="Saves north star, brand voice, objectives, constraints, and operating policy in one update">
<HelpTip label="Saves north star, brand voice, company/product name, objectives, constraints, and operating policy in one update">
<span className="inline-block">
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
@@ -141,6 +141,32 @@ describe("VideoPostQueue", () => {
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
});
it("renders a project badge when project_slug/project_name is present", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "Acme Robotics v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "Acme Robotics v0.19.0 is here!",
tiktok_caption: "New Acme Robotics drop!",
mp4_paths: {
vertical: "/fake/vertical.mp4",
square: "/fake/square.mp4",
},
project_slug: "acme-robotics",
project_name: "Acme Robotics",
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText("Acme Robotics")).toBeInTheDocument();
});
it("shows the fetched title and script instead of dropping them", async () => {
render(withQueryClient(<VideoPostQueue />));
expect(
@@ -93,6 +93,26 @@ describe("XPostQueue", () => {
expect(screen.getByText(/Playbook curation/)).toBeInTheDocument();
});
it("renders a project badge when project_slug/project_name is present", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "x-4",
source: "x_post",
title: "X post: release v0.18.0",
status: "pending",
body: "Acme Robotics v0.18.0 just shipped!",
char_count: 36,
release_version: "0.18.0",
project_slug: "acme-robotics",
project_name: "Acme Robotics",
},
] as XPost[]);
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Acme Robotics")).toBeInTheDocument();
});
it("disables only the row being approved, not every row's Approve", async () => {
render(withQueryClient(<XPostQueue />));
@@ -0,0 +1,36 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
/**
* Shared project (repository) badge — renders nothing when neither field is
* present. Used by both x-post-queue.tsx and video-post-queue.tsx, whose
* queue rows are otherwise identical here except for the tooltip wording.
*
* @example
* ```tsx
* <ProjectBadge
* slug={post.project_slug}
* name={post.project_name}
* label="The project (repository) this draft targets"
* />
* ```
*/
export function ProjectBadge({
slug,
name,
label,
}: {
slug?: string | null;
name?: string | null;
/** Tooltip text — phrase it per caller ("draft" vs "video"). */
label: string;
}) {
if (!slug && !name) return null;
return (
<HelpTip label={label}>
<Badge variant="outline">{name || slug}</Badge>
</HelpTip>
);
}
@@ -35,6 +35,7 @@ import { HelpTip } from "@/components/ui/help-tip";
import { ProjectSelector } from "@/components/projects/project-selector";
import { useProjects } from "@/hooks/use-projects";
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
import { ProjectBadge } from "@/components/dashboard/project-badge";
import { CheckCircle2, Film, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
@@ -222,6 +223,11 @@ function VideoPostRow({
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
<ProjectBadge
slug={post.project_slug}
name={post.project_name}
label="The project (repository) this video targets"
/>
{post.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{post.occasion}</Badge>
@@ -24,6 +24,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { ProjectBadge } from "@/components/dashboard/project-badge";
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
@@ -108,6 +109,11 @@ function XPostRow({
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
<ProjectBadge
slug={post.project_slug}
name={post.project_name}
label="The project (repository) this draft targets"
/>
{post.release_version && (
<HelpTip label="The release this post announces">
<Badge variant="outline">v{post.release_version}</Badge>
+2
View File
@@ -6,6 +6,7 @@ export interface CompanyGoals {
constraints: string[];
operating_policy: Record<string, unknown>;
brand_voice: string;
company_name: string;
updated_at?: string | null;
updated_by?: string | null;
}
@@ -18,6 +19,7 @@ export type CompanyGoalsUpdate = Partial<
| "constraints"
| "operating_policy"
| "brand_voice"
| "company_name"
>
>;
+6
View File
@@ -29,6 +29,8 @@ export interface VideoPost {
// "not stale" everywhere below, so the re-render control degrades safely.
composition_id?: string | null;
render_status?: string | null; // null | "rendered" | "failed"
project_slug?: string | null;
project_name?: string | null;
}
// One in-flight source=video authoring task — GET /video/pipeline
@@ -46,6 +48,8 @@ export interface VideoPipelineItem {
render_attempts: number;
max_attempts: number;
render_error: string | null;
project_slug?: string | null;
project_name?: string | null;
}
export interface VideoPostExecuteResult {
@@ -69,6 +73,8 @@ export interface VideoPostHistoryEntry {
posted: Record<string, string>; // platform -> posted id
acted_at: string;
source_task_id?: string | null; // the authoring task this draft rendered from
project_slug?: string | null;
project_name?: string | null;
}
export interface VideoRequestResult {
+4
View File
@@ -29,6 +29,8 @@ export interface XPost {
mention?: XMentionRef | null;
feature?: XFeatureRef | null;
reject_reason?: string | null;
project_slug?: string | null;
project_name?: string | null;
}
export interface XPostExecuteResult {
@@ -51,6 +53,8 @@ export interface XPostHistoryEntry {
tweet_id?: string | null;
reject_reason?: string | null;
acted_at: string;
project_slug?: string | null;
project_name?: string | null;
}
export interface XCredentialsStatus {