mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -50,6 +50,7 @@ function buildGoals(overrides: Partial<CompanyGoals> = {}): CompanyGoals {
|
||||
constraints: [],
|
||||
operating_policy: {},
|
||||
brand_voice: "",
|
||||
company_name: "",
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
>;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user