PromptBase
Upgrade
Close icon
General
Home
Marketplace
Create
Hire
Login
Chat
Sell
Explore

Prompt Details

Model
Chat - (gpt-5.2)
Token size
1,652
Example input
[AI Generated Social Media Content Scheduler with Auto Caption and Hashtag Optimization]: AI Generated Social Media Content Scheduler with Auto Caption and Hashtag Optimization [Next.js TypeScript OpenAI API PostgreSQL Tailwind]: Next.js TypeScript OpenAI API PostgreSQL Tailwind [/pages/scheduler.tsx /components/PostGenerator.tsx /lib/ai.ts /pages/api/generate.ts]: /pages/scheduler.tsx /components/PostGenerator.tsx /lib/ai.ts /pages/api/generate.ts [User can generate captions User can schedule posts Hashtags auto optimized Errors handled clearly]: User can generate captions User can schedule posts Hashtags auto optimized Errors handled clearly [No new dependencies Must support mobile UI Fast response time]: No new dependencies Must support mobile UI Fast response time
Example output
0. EXECUTIVE OVERVIEW What will be built * A Scheduler page that lets a user: * Generate an AI caption and optimized hashtags for a given post draft and platform * Schedule that generated content for a future timestamp (persisted to PostgreSQL) * View a list of scheduled posts * A single API route (/pages/api/generate.ts) that supports: * POST action=generate for caption + hashtag generation via OpenAI API * POST action=schedule for scheduling into PostgreSQL * GET action=list for listing scheduled posts * A small AI utility (/lib/ai.ts) that calls OpenAI and returns structured output with safe parsing and normalization What will not be built * No automatic publishing to Instagram/TikTok/X/LinkedIn (only scheduling records in DB) * No background job/cron to “execute” scheduled posts * No auth, multi-user isolation, team features, or rate limiting beyond lightweight validation Key risks * RISK: PostgreSQL connectivity and table schema are unknown; scheduling will fail if DATABASE_URL/table is missing * RISK: OpenAI API model/endpoint availability and response formatting may vary; JSON parsing must be resilient * RISK: Serverless execution constraints; DB pooling must be handled carefully Verification strategy * Unit-level checks for hashtag normalization and payload validation * API-level tests (manual or automated) for generate/schedule/list success paths and error paths * UI smoke tests on mobile viewport sizes for generation, scheduling, and clear error rendering 1. CONTEXT AND SIGNAL CHECK CONFIRMED: * FEATURE_NAME: AI Generated Social Media Content Scheduler with Auto Caption and Hashtag Optimization * TECH_STACK: Next.js, TypeScript, OpenAI API, PostgreSQL, Tailwind * FILES_IN_SCOPE: * /pages/scheduler.tsx * /components/PostGenerator.tsx * /lib/ai.ts * /pages/api/generate.ts * ACCEPTANCE_CRITERIA: * User can generate captions * User can schedule posts * Hashtags auto optimized * Errors handled clearly * CONSTRAINTS: * No new dependencies * Must support mobile UI * Fast response time INFERRED: * The project already has Tailwind configured * The app uses a Next.js pages router (since /pages/* is in scope) * “Schedule posts” means persisting a scheduled record and showing it in UI (not publishing) ASSUMPTION: * DATABASE_URL is available at runtime for server-side DB access * A PostgreSQL client library is already available in the repository (commonly pg). If not, DB scheduling cannot be implemented without adding a dependency (forbidden) * OPENAI_API_KEY is available at runtime for server-side OpenAI calls * The repository can accept an API route that multiplexes multiple actions (generate/schedule/list) under /api/generate VERIFY: * VERIFY: Is the PostgreSQL client dependency available (pg or equivalent) without adding dependencies? * VERIFY: Confirm or create the scheduled_posts table schema (DDL provided below under OUT_OF_SCOPE contract) * VERIFY: Confirm which OpenAI endpoint/model is intended in your app (defaults included, but may need adaptation) * VERIFY: Confirm timezone expectations for scheduled_at (UI uses local datetime; DB stores timestamptz) Acceptance criteria translated into testable checks: * TESTABLE: POST /api/generate with action=generate returns { caption, hashtags[] } and UI displays them * TESTABLE: POST /api/generate with action=schedule persists a row in PostgreSQL and returns scheduled post record * TESTABLE: GET /api/generate?action=list returns a list including newly scheduled post * TESTABLE: Hashtags returned are normalized (prefixed with #, deduped, limited count, platform-appropriate) * TESTABLE: Invalid inputs (missing post text, past date, missing env) show consistent, user-safe errors (no provider internals) 2. INTENT AND USER JOURNEY User trigger: * User opens /scheduler, enters a draft post and selects a platform, then taps Generate Success outcome: * User receives a ready-to-post caption + optimized hashtags * User selects a future date/time and schedules it successfully * User can see the scheduled item in the list Minimal viable path: * Enter post text -> Generate -> Review/edit -> Pick datetime -> Schedule -> See it in scheduled list State transitions (explicit): * idle -> generating -> generated * generated -> scheduling -> scheduled_success * any -> error (recoverable) -> idle or generated (depending on previous state) * page_load -> loading_list -> list_loaded or list_error Non-goals: * No social provider integrations, tokens, posting pipelines * No multi-tenant access controls * No recurring schedules, queues, or approvals 3. SCOPE CONTRACT Files to modify (must match FILES_IN_SCOPE): * /pages/scheduler.tsx * /components/PostGenerator.tsx * /lib/ai.ts * /pages/api/generate.ts OUT_OF_SCOPE contracts (only if needed): * OUT_OF_SCOPE: Database migration/DDL to create table scheduled_posts Integration contract (execute via your migration tool or psql): * Table name: scheduled_posts * Columns required by this implementation: * id bigserial primary key * platform text not null * original_text text not null * caption text not null * hashtags text[] not null default '{}' * scheduled_at timestamptz not null * status text not null default 'scheduled' * created_at timestamptz not null default now() Example DDL: * VERIFY and adapt to your migration conventions create table if not exists scheduled_posts ( id bigserial primary key, platform text not null, original_text text not null, caption text not null, hashtags text[] not null default '{}', scheduled_at timestamptz not null, status text not null default 'scheduled', created_at timestamptz not null default now() ); create index if not exists scheduled_posts_scheduled_at_idx on scheduled_posts (scheduled_at); * OUT_OF_SCOPE: Any cron/queue worker to publish scheduled posts Diff-minimization plan: * Implement narrowly scoped UI and API logic with minimal helpers * Keep all scheduling DB calls inside /pages/api/generate.ts * Keep OpenAI calling and normalization inside /lib/ai.ts * Avoid refactors or changes to unrelated styles/components 4. DEPENDENCY MAP Internal dependencies: * /pages/scheduler.tsx depends on /components/PostGenerator.tsx and /api/generate * /components/PostGenerator.tsx depends on /api/generate * /pages/api/generate.ts depends on /lib/ai.ts and PostgreSQL client * /lib/ai.ts depends on global fetch and env vars External services/SDKs: * OpenAI API (server-side call) * PostgreSQL Configuration dependencies: * VERIFY: process.env.OPENAI_API_KEY * VERIFY: process.env.OPENAI_MODEL (optional; defaults provided) * VERIFY: process.env.DATABASE_URL for PostgreSQL * VERIFY: Node runtime supports fetch (Next.js provides it server-side) DECISION log (3–10 items with rationale): * DECISION: Use a single API route (/api/generate) with action multiplexing (generate/schedule/list) to stay within FILES_IN_SCOPE * DECISION: Use OpenAI via direct fetch (no new dependency) to satisfy “No new dependencies” * DECISION: Store hashtags as text[] in PostgreSQL for straightforward retrieval and display * DECISION: Treat “schedule posts” as persistence + listing, not publishing, because provider integrations are OUT_OF_SCOPE * DECISION: Normalize hashtags server-side to guarantee acceptance criteria regardless of model formatting * DECISION: Return user-safe error messages and log minimal telemetry to console without secrets (no new telemetry dependencies) 5. FAILURE ATLAS | Failure | Detected at | Handling | User message | TELEMETRY event | TEST_CASE id | | ------------------------------------ | ----------------- | --------------------------------------------------------- | -------------------------------------------------------- | -------------------------------- | ------------ | | Missing OPENAI_API_KEY | API generate | Return 500 with safe message | AI generation is not configured. Please contact support. | ai_generate_config_missing | TC-004 | | OpenAI request timeout/network | API generate | Catch error, return 502 | AI generation is temporarily unavailable. Try again. | ai_generate_upstream_unavailable | TC-005 | | OpenAI returns non-JSON or malformed | AI parse | Fallback parsing + normalization; if unusable, return 502 | Could not format AI result. Please retry. | ai_generate_parse_failed | TC-006 | | User submits empty post text | UI + API validate | Block on UI; API returns 400 | Please enter post text. | validation_post_text_missing | TC-001 | | User schedules in the past | UI + API validate | Block on UI; API returns 400 | Scheduled time must be in the future. | validation_schedule_past | TC-002 | | Missing DATABASE_URL | API schedule/list | Return 500 with safe message | Scheduling is not configured. Please contact support. | scheduler_db_config_missing | TC-007 | | DB table missing/wrong schema | API schedule/list | Catch DB error, return 500 | Scheduling is temporarily unavailable. | scheduler_db_query_failed | TC-008 | | DB insert fails | API schedule | Catch, return 500 | Could not schedule this post. Try again. | scheduler_insert_failed | TC-003 | | DB list fails | API list | Catch, return 500 | Could not load scheduled posts. Refresh to try again. | scheduler_list_failed | TC-009 | | Large payload (very long post) | API validate | Return 413 | Post text is too long. Please shorten it. | validation_payload_too_large | TC-010 | 6. IMPLEMENTATION PLAN Step-by-step plan (8–20 steps max): 1. Implement /lib/ai.ts with OpenAI fetch call, structured JSON prompt, robust parsing, and hashtag normalization 2. Implement /pages/api/generate.ts: * POST action=generate calls /lib/ai.ts * POST action=schedule validates and inserts into scheduled_posts * GET action=list queries upcoming scheduled posts * Centralize safe error responses 3. Implement /components/PostGenerator.tsx: * Form inputs for platform and post text (plus optional tone/goal/keywords) * Generate button and display editable caption + hashtags * Datetime picker + schedule button * Clear inline error and loading states 4. Implement /pages/scheduler.tsx: * Page container with scheduled list * Fetch list on mount and after scheduling * Display list in mobile-friendly cards 5. Add UI validation to prevent avoidable API calls (empty text, past schedule) 6. Ensure server returns consistent error shapes { error: { message } } 7. Manual smoke test on mobile viewport (narrow width) for layout and flows 8. Validate DB schema via DDL contract and confirm inserts/lists work Validation rules: * postText: required, trimmed, length 1..2000 (server-enforced) * platform: required, one of: instagram, tiktok, x, linkedin (server-enforced) * scheduledAt: required for scheduling, must be future by at least 1 minute (server-enforced) * caption: required for scheduling, length 1..4000 (server-enforced) * hashtags: max 15; each must be normalized to #tag, alphanumeric/underscore only after normalization UI/state machine (states + transitions): * generatorState: idle -> generating -> generated -> scheduling -> scheduled_success * errorState: null or { message } (set on any failure; cleared on next action) * listState: loading_list -> list_loaded or list_error Security notes: * Do not expose upstream OpenAI errors or DB errors to the client * Do not log OPENAI_API_KEY or full prompt payloads * Avoid open redirects (not applicable here) * Keep input size limits to reduce abuse Accessibility notes (if UI is present): * Use labels tied to inputs * Ensure buttons have discernible text * Ensure error messages are text (not color-only) * Maintain focus order and readable spacing on mobile 7. CODE ```tsx // /pages/scheduler.tsx // Patch Notes: Adds scheduler page UI integrating PostGenerator and scheduled-post list with refresh and error handling. import { useCallback, useEffect, useState } from "react"; import PostGenerator, { ScheduledPost } from "../components/PostGenerator"; type ApiErrorShape = { error?: { message?: string } }; export default function SchedulerPage() { const [scheduled, setScheduled] = useState<ScheduledPost[]>([]); const [isLoading, setIsLoading] = useState<boolean>(true); const [errorMessage, setErrorMessage] = useState<string>(""); const loadScheduled = useCallback(async () => { setIsLoading(true); setErrorMessage(""); try { const res = await fetch("/api/generate?action=list", { method: "GET" }); const data = (await res.json()) as ScheduledPost[] | ApiErrorShape; if (!res.ok) { const msg = (data as ApiErrorShape).error?.message || "Could not load scheduled posts."; setErrorMessage(msg); setScheduled([]); return; } setScheduled(data as ScheduledPost[]); } catch { setErrorMessage("Could not load scheduled posts. Refresh to try again."); setScheduled([]); } finally { setIsLoading(false); } }, []); useEffect(() => { void loadScheduled(); }, [loadScheduled]); return ( <main className="min-h-screen bg-white text-slate-900"> <div className="mx-auto w-full max-w-5xl px-4 py-6"> <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <div> <h1 className="text-xl font-medium tracking-tight">Content Scheduler</h1> <p className="text-sm text-slate-600"> Generate captions + optimized hashtags, then schedule posts into PostgreSQL. </p> </div> <button type="button" onClick={() => void loadScheduled()} className="inline-flex w-full items-center justify-center rounded-md border border-slate-200 px-3 py-2 text-sm hover:bg-slate-50 sm:w-auto" > Refresh list </button> </div> <div className="mt-6"> <PostGenerator onScheduled={() => void loadScheduled()} /> </div> <section className="mt-10"> <div className="flex items-center justify-between gap-3"> <h2 className="text-lg font-medium">Scheduled posts</h2> {isLoading ? ( <span className="text-sm text-slate-600">Loading…</span> ) : null} </div> {errorMessage ? ( <div className="mt-3 rounded-md border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800"> {errorMessage} </div> ) : null} {!isLoading && !errorMessage && scheduled.length === 0 ? ( <div className="mt-3 rounded-md border border-slate-200 p-4 text-sm text-slate-700"> No scheduled posts yet. </div> ) : null} <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2"> {scheduled.map((p) => ( <article key={p.id} className="rounded-md border border-slate-200 p-4" > <div className="flex items-start justify-between gap-3"> <div className="text-sm"> <div className="text-slate-700">Platform: {p.platform}</div> <div className="text-slate-700"> Scheduled:{" "} {new Date(p.scheduledAt).toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", })} </div> <div className="mt-1 text-xs text-slate-500"> Status: {p.status} </div> </div> </div> <div className="mt-3"> <div className="text-xs text-slate-500">Caption</div> <div className="mt-1 whitespace-pre-wrap text-sm text-slate-900"> {p.caption} </div> </div> {p.hashtags.length > 0 ? ( <div className="mt-3"> <div className="text-xs text-slate-500">Hashtags</div> <div className="mt-1 flex flex-wrap gap-1"> {p.hashtags.map((h) => ( <span key={h} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-700" > {h} </span> ))} </div> </div> ) : null} </article> ))} </div> </section> </div> </main> ); } ``` ```tsx // /components/PostGenerator.tsx // Patch Notes: Adds a mobile-friendly generator + scheduling form with clear loading/error states and editable outputs. import { useMemo, useState } from "react"; export type ScheduledPost = { id: string; platform: string; originalText: string; caption: string; hashtags: string[]; scheduledAt: string; status: string; createdAt: string; }; type GenerateResult = { caption: string; hashtags: string[] }; type ApiErrorShape = { error?: { message?: string } }; type Props = { onScheduled?: () => void; }; const PLATFORM_OPTIONS = [ { value: "instagram", label: "Instagram" }, { value: "tiktok", label: "TikTok" }, { value: "x", label: "X" }, { value: "linkedin", label: "LinkedIn" }, ] as const; function splitHashtags(input: string): string[] { return input .split(/[\s,]+/g) .map((s) => s.trim()) .filter(Boolean); } export default function PostGenerator({ onScheduled }: Props) { const [platform, setPlatform] = useState<string>("instagram"); const [postText, setPostText] = useState<string>(""); const [tone, setTone] = useState<string>(""); const [goal, setGoal] = useState<string>(""); const [keywords, setKeywords] = useState<string>(""); const [caption, setCaption] = useState<string>(""); const [hashtagsText, setHashtagsText] = useState<string>(""); const [scheduledAtLocal, setScheduledAtLocal] = useState<string>(""); const [isGenerating, setIsGenerating] = useState<boolean>(false); const [isScheduling, setIsScheduling] = useState<boolean>(false); const [errorMessage, setErrorMessage] = useState<string>(""); const [successMessage, setSuccessMessage] = useState<string>(""); const hashtagsArray = useMemo(() => splitHashtags(hashtagsText), [hashtagsText]); async function onGenerate() { setErrorMessage(""); setSuccessMessage(""); const trimmed = postText.trim(); if (!trimmed) { setErrorMessage("Please enter post text."); return; } setIsGenerating(true); try { const res = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "generate", platform, postText: trimmed, tone: tone.trim() || undefined, goal: goal.trim() || undefined, keywords: keywords.trim() ? keywords .split(",") .map((k: string) => k.trim()) .filter(Boolean) : undefined, }), }); const data = (await res.json()) as GenerateResult | ApiErrorShape; if (!res.ok) { const msg = (data as ApiErrorShape).error?.message || "AI generation failed."; setErrorMessage(msg); return; } const out = data as GenerateResult; setCaption(out.caption || ""); setHashtagsText((out.hashtags || []).join(" ")); } catch { setErrorMessage("AI generation is temporarily unavailable. Try again."); } finally { setIsGenerating(false); } } async function onSchedule() { setErrorMessage(""); setSuccessMessage(""); const trimmed = postText.trim(); if (!trimmed) { setErrorMessage("Please enter post text."); return; } if (!caption.trim()) { setErrorMessage("Please generate (or enter) a caption before scheduling."); return; } if (!scheduledAtLocal) { setErrorMessage("Please choose a schedule time."); return; } const scheduledDate = new Date(scheduledAtLocal); if (Number.isNaN(scheduledDate.getTime())) { setErrorMessage("Invalid schedule time."); return; } if (scheduledDate.getTime() <= Date.now()) { setErrorMessage("Scheduled time must be in the future."); return; } setIsScheduling(true); try { const res = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "schedule", platform, originalText: trimmed, caption: caption.trim(), hashtags: hashtagsArray, scheduledAt: scheduledDate.toISOString(), }), }); const data = (await res.json()) as ScheduledPost | ApiErrorShape; if (!res.ok) { const msg = (data as ApiErrorShape).error?.message || "Could not schedule this post."; setErrorMessage(msg); return; } setSuccessMessage("Scheduled successfully."); if (onScheduled) onScheduled(); } catch { setErrorMessage("Could not schedule this post. Try again."); } finally { setIsScheduling(false); } } return ( <section className="rounded-md border border-slate-200 p-4"> <h2 className="text-lg font-medium">Generate + schedule</h2> <p className="mt-1 text-sm text-slate-600"> Enter a draft, generate a caption and hashtags, then schedule. </p> {errorMessage ? ( <div className="mt-3 rounded-md border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800"> {errorMessage} </div> ) : null} {successMessage ? ( <div className="mt-3 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800"> {successMessage} </div> ) : null} <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="flex flex-col gap-3"> <label className="text-sm"> <div className="text-slate-700">Platform</div> <select value={platform} onChange={(e) => setPlatform(e.target.value)} className="mt-1 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" > {PLATFORM_OPTIONS.map((opt) => ( <option key={opt.value} value={opt.value}> {opt.label} </option> ))} </select> </label> <label className="text-sm"> <div className="text-slate-700">Post draft</div> <textarea value={postText} onChange={(e) => setPostText(e.target.value)} rows={7} maxLength={2000} placeholder="Paste your draft post text here…" className="mt-1 w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" /> <div className="mt-1 text-xs text-slate-500"> {postText.length}/2000 </div> </label> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <label className="text-sm sm:col-span-1"> <div className="text-slate-700">Tone (optional)</div> <input value={tone} onChange={(e) => setTone(e.target.value)} placeholder="Friendly" className="mt-1 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" /> </label> <label className="text-sm sm:col-span-2"> <div className="text-slate-700">Goal (optional)</div> <input value={goal} onChange={(e) => setGoal(e.target.value)} placeholder="Drive clicks to landing page" className="mt-1 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" /> </label> </div> <label className="text-sm"> <div className="text-slate-700">Keywords (optional, comma-separated)</div> <input value={keywords} onChange={(e) => setKeywords(e.target.value)} placeholder="ai tools, content, marketing" className="mt-1 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" /> </label> <button type="button" onClick={() => void onGenerate()} disabled={isGenerating} className="inline-flex w-full items-center justify-center rounded-md bg-slate-900 px-3 py-2 text-sm text-white disabled:opacity-60" > {isGenerating ? "Generating…" : "Generate caption + hashtags"} </button> </div> <div className="flex flex-col gap-3"> <label className="text-sm"> <div className="text-slate-700">Caption (editable)</div> <textarea value={caption} onChange={(e) => setCaption(e.target.value)} rows={7} maxLength={4000} placeholder="Generated caption will appear here…" className="mt-1 w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" /> </label> <label className="text-sm"> <div className="text-slate-700">Hashtags (editable)</div> <textarea value={hashtagsText} onChange={(e) => setHashtagsText(e.target.value)} rows={3} placeholder="#tag1 #tag2 #tag3" className="mt-1 w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" /> <div className="mt-1 text-xs text-slate-500"> Tip: separate with spaces or commas. </div> </label> <label className="text-sm"> <div className="text-slate-700">Schedule time</div> <input type="datetime-local" value={scheduledAtLocal} onChange={(e) => setScheduledAtLocal(e.target.value)} className="mt-1 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" /> </label> <button type="button" onClick={() => void onSchedule()} disabled={isScheduling} className="inline-flex w-full items-center justify-center rounded-md border border-slate-200 bg-white px-3 py-2 text-sm hover:bg-slate-50 disabled:opacity-60" > {isScheduling ? "Scheduling…" : "Schedule post"} </button> </div> </div> </section> ); } ``` ```ts // /lib/ai.ts // Patch Notes: Adds OpenAI caption + hashtag generation with resilient parsing and server-side hashtag normalization. export type GenerateInput = { platform: "instagram" | "tiktok" | "x" | "linkedin"; postText: string; tone?: string; goal?: string; keywords?: string[]; }; export type GenerateOutput = { caption: string; hashtags: string[]; }; type OpenAIChatResponse = { choices?: Array<{ message?: { content?: string } }>; }; function toPlatformLabel(p: GenerateInput["platform"]): string { switch (p) { case "instagram": return "Instagram"; case "tiktok": return "TikTok"; case "x": return "X"; case "linkedin": return "LinkedIn"; default: return "Social"; } } function limitForPlatform(platform: GenerateInput["platform"]): { captionMax: number; hashtagMax: number; } { if (platform === "x") return { captionMax: 280, hashtagMax: 6 }; if (platform === "linkedin") return { captionMax: 3000, hashtagMax: 8 }; if (platform === "tiktok") return { captionMax: 2200, hashtagMax: 12 }; return { captionMax: 2200, hashtagMax: 15 }; // instagram default } function safeTrim(s: unknown): string { return typeof s === "string" ? s.trim() : ""; } function normalizeHashtag(raw: string): string { const cleaned = raw .trim() .replace(/^#+/, "") .replace(/[^\p{L}\p{N}_]/gu, ""); // letters/numbers/underscore only if (!cleaned) return ""; return `#${cleaned}`; } export function normalizeHashtags( rawHashtags: string[], platform: GenerateInput["platform"] ): string[] { const { hashtagMax } = limitForPlatform(platform); const seen = new Set<string>(); const out: string[] = []; for (const h of rawHashtags) { const norm = normalizeHashtag(h); if (!norm) continue; const key = norm.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(norm); if (out.length >= hashtagMax) break; } return out; } function clampCaption(caption: string, platform: GenerateInput["platform"]): string { const { captionMax } = limitForPlatform(platform); const trimmed = caption.trim(); if (trimmed.length <= captionMax) return trimmed; return trimmed.slice(0, captionMax - 1).trimEnd() + "…"; } function extractJsonObject(text: string): string | null { const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) return null; return text.slice(start, end + 1); } function parseModelOutput( content: string, platform: GenerateInput["platform"] ): GenerateOutput | null { const attempt = (jsonText: string): GenerateOutput | null => { try { const parsed = JSON.parse(jsonText) as Partial<GenerateOutput>; const caption = safeTrim(parsed.caption); const hashtagsRaw = Array.isArray(parsed.hashtags) ? parsed.hashtags : []; const hashtags = normalizeHashtags( hashtagsRaw.map((h) => (typeof h === "string" ? h : "")), platform ); if (!caption) return null; return { caption: clampCaption(caption, platform), hashtags }; } catch { return null; } }; const direct = attempt(content); if (direct) return direct; const extracted = extractJsonObject(content); if (extracted) { const parsed = attempt(extracted); if (parsed) return parsed; } // Fallback heuristic: try to find a caption line and hashtag tokens const lines = content .split("\n") .map((l) => l.trim()) .filter(Boolean); const hashTokens = content.match(/#[\p{L}\p{N}_]+/gu) || []; const hashtags = normalizeHashtags(hashTokens, platform); const captionCandidate = lines.find((l) => !l.startsWith("#") && l.length >= 10) || safeTrim(lines[0]); if (!captionCandidate) return null; return { caption: clampCaption(captionCandidate, platform), hashtags }; } export async function generateCaptionAndHashtags( input: GenerateInput ): Promise<GenerateOutput> { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { const err = new Error("OPENAI_API_KEY missing"); (err as any).code = "CONFIG_MISSING"; throw err; } const model = process.env.OPENAI_MODEL || "gpt-4o-mini"; // VERIFY: set your preferred model const platformLabel = toPlatformLabel(input.platform); const { captionMax, hashtagMax } = limitForPlatform(input.platform); const toneLine = input.tone ? `Tone: ${input.tone}` : "Tone: natural, clear"; const goalLine = input.goal ? `Goal: ${input.goal}` : "Goal: engagement"; const keywordsLine = input.keywords && input.keywords.length ? `Keywords: ${input.keywords.join(", ")}` : "Keywords: none"; const system = [ "You write high-performing social media captions.", "Return only valid JSON, no markdown.", "No extra keys. No explanations.", ].join(" "); const user = [ `Platform: ${platformLabel}`, `Constraints: caption <= ${captionMax} chars, hashtags <= ${hashtagMax}`, toneLine, goalLine, keywordsLine, "Draft post text:", input.postText, "", "Output JSON schema:", '{ "caption": "string", "hashtags": ["#tag1", "#tag2"] }', ].join("\n"); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); // fast response time target try { const res = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, signal: controller.signal, body: JSON.stringify({ model, temperature: 0.7, max_tokens: 250, messages: [ { role: "system", content: system }, { role: "user", content: user }, ], }), }); if (!res.ok) { const err = new Error("OpenAI upstream error"); (err as any).code = "UPSTREAM_ERROR"; throw err; } const data = (await res.json()) as OpenAIChatResponse; const content = data.choices?.[0]?.message?.content || ""; const parsed = parseModelOutput(content, input.platform); if (!parsed) { const err = new Error("Parse failed"); (err as any).code = "PARSE_FAILED"; throw err; } return { caption: parsed.caption, hashtags: parsed.hashtags, }; } finally { clearTimeout(timeout); } } ``` ```ts // /pages/api/generate.ts // Patch Notes: Implements generate/schedule/list actions with safe validation, OpenAI integration, and PostgreSQL persistence. import type { NextApiRequest, NextApiResponse } from "next"; import { generateCaptionAndHashtags, normalizeHashtags } from "../../lib/ai"; // Minimal runtime typing to avoid leaking internals type ErrorResponse = { error: { message: string } }; type GenerateRequestBody = { action?: "generate"; platform?: "instagram" | "tiktok" | "x" | "linkedin"; postText?: string; tone?: string; goal?: string; keywords?: string[]; }; type ScheduleRequestBody = { action?: "schedule"; platform?: "instagram" | "tiktok" | "x" | "linkedin"; originalText?: string; caption?: string; hashtags?: string[]; scheduledAt?: string; // ISO }; type ScheduledPostRow = { id: string | number; platform: string; original_text: string; caption: string; hashtags: string[]; scheduled_at: string; status: string; created_at: string; }; type ScheduledPost = { id: string; platform: string; originalText: string; caption: string; hashtags: string[]; scheduledAt: string; status: string; createdAt: string; }; function sendError(res: NextApiResponse<ErrorResponse>, status: number, message: string) { return res.status(status).json({ error: { message } }); } function isPlatform(v: any): v is "instagram" | "tiktok" | "x" | "linkedin" { return v === "instagram" || v === "tiktok" || v === "x" || v === "linkedin"; } function toScheduledPost(row: ScheduledPostRow): ScheduledPost { return { id: String(row.id), platform: row.platform, originalText: row.original_text, caption: row.caption, hashtags: Array.isArray(row.hashtags) ? row.hashtags : [], scheduledAt: new Date(row.scheduled_at).toISOString(), status: row.status, createdAt: new Date(row.created_at).toISOString(), }; } function requireDatabaseUrl(): string { const url = process.env.DATABASE_URL; if (!url) { const err = new Error("DATABASE_URL missing"); (err as any).code = "DB_CONFIG_MISSING"; throw err; } return url; } // PostgreSQL access (no new deps). VERIFY: ensure `pg` is already installed in your repo. let _pool: any | null = null; function getPool() { if (_pool) return _pool; // eslint-disable-next-line @typescript-eslint/no-var-requires const pg = require("pg"); // VERIFY: if this fails, pg is not installed (OUT_OF_SCOPE to add deps) const { Pool } = pg; _pool = new Pool({ connectionString: requireDatabaseUrl(), max: 5, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 3_000, }); return _pool; } async function insertScheduledPost(input: { platform: string; originalText: string; caption: string; hashtags: string[]; scheduledAtIso: string; }): Promise<ScheduledPost> { const pool = getPool(); const sql = ` insert into scheduled_posts (platform, original_text, caption, hashtags, scheduled_at) values ($1, $2, $3, $4, $5) returning id, platform, original_text, caption, hashtags, scheduled_at, status, created_at `; const params = [ input.platform, input.originalText, input.caption, input.hashtags, input.scheduledAtIso, ]; const result = await pool.query(sql, params); const row = result.rows?.[0] as ScheduledPostRow | undefined; if (!row) throw new Error("Insert returned no row"); return toScheduledPost(row); } async function listScheduledPosts(): Promise<ScheduledPost[]> { const pool = getPool(); const sql = ` select id, platform, original_text, caption, hashtags, scheduled_at, status, created_at from scheduled_posts order by scheduled_at asc limit 50 `; const result = await pool.query(sql); const rows = (result.rows || []) as ScheduledPostRow[]; return rows.map(toScheduledPost); } function isTooLarge(text: string): boolean { return text.length > 2000; } export default async function handler( req: NextApiRequest, res: NextApiResponse<any | ErrorResponse> ) { const action = (req.method === "GET" ? (req.query.action as string | undefined) : (req.body?.action as string | undefined))?.toLowerCase(); // GET: list scheduled posts if (req.method === "GET") { if (action && action !== "list") { return sendError(res, 400, "Unsupported action."); } try { const posts = await listScheduledPosts(); return res.status(200).json(posts); } catch (e: any) { const code = e?.code || "DB_LIST_FAILED"; console.error("scheduler_list_failed", { code }); // TELEMETRY if (code === "DB_CONFIG_MISSING") { return sendError(res, 500, "Scheduling is not configured. Please contact support."); } return sendError(res, 500, "Could not load scheduled posts. Refresh to try again."); } } if (req.method !== "POST") { return sendError(res, 405, "Method not allowed."); } // POST: generate or schedule if (!action || action === "generate") { const body = req.body as GenerateRequestBody; const platform = body.platform; const postText = (body.postText || "").trim(); if (!isPlatform(platform)) { return sendError(res, 400, "Please choose a valid platform."); } if (!postText) { return sendError(res, 400, "Please enter post text."); } if (isTooLarge(postText)) { return sendError(res, 413, "Post text is too long. Please shorten it."); } try { const out = await generateCaptionAndHashtags({ platform, postText, tone: body.tone?.trim() || undefined, goal: body.goal?.trim() || undefined, keywords: Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).trim()).filter(Boolean) : undefined, }); return res.status(200).json(out); } catch (e: any) { const code = e?.code || "AI_GENERATE_FAILED"; console.error("ai_generate_failed", { code }); // TELEMETRY if (code === "CONFIG_MISSING") { return sendError(res, 500, "AI generation is not configured. Please contact support."); } if (code === "PARSE_FAILED") { return sendError(res, 502, "Could not format AI result. Please retry."); } return sendError(res, 502, "AI generation is temporarily unavailable. Try again."); } } if (action === "schedule") { const body = req.body as ScheduleRequestBody; const platform = body.platform; const originalText = (body.originalText || "").trim(); const caption = (body.caption || "").trim(); const scheduledAt = (body.scheduledAt || "").trim(); if (!isPlatform(platform)) { return sendError(res, 400, "Please choose a valid platform."); } if (!originalText) { return sendError(res, 400, "Please enter post text."); } if (isTooLarge(originalText)) { return sendError(res, 413, "Post text is too long. Please shorten it."); } if (!caption) { return sendError(res, 400, "Caption is required to schedule a post."); } if (!scheduledAt) { return sendError(res, 400, "Scheduled time is required."); } const dt = new Date(scheduledAt); if (Number.isNaN(dt.getTime())) { return sendError(res, 400, "Invalid scheduled time."); } if (dt.getTime() <= Date.now()) { return sendError(res, 400, "Scheduled time must be in the future."); } const hashtagsNormalized = normalizeHashtags(body.hashtags || [], platform); try { const created = await insertScheduledPost({ platform, originalText, caption, hashtags: hashtagsNormalized, scheduledAtIso: dt.toISOString(), }); return res.status(200).json(created); } catch (e: any) { const code = e?.code || "DB_INSERT_FAILED"; console.error("scheduler_insert_failed", { code }); // TELEMETRY if (code === "DB_CONFIG_MISSING") { return sendError(res, 500, "Scheduling is not configured. Please contact support."); } // Common failure: missing table/column. Keep message generic. return sendError(res, 500, "Could not schedule this post. Try again."); } } return sendError(res, 400, "Unsupported action."); } ``` 8. VERIFICATION PACK TEST_CASE list with inputs and expected outputs TC-001 validation_post_text_missing * Input: POST /api/generate { action:"generate", platform:"instagram", postText:" " } * Expected: 400, { error.message = "Please enter post text." } TC-002 validation_schedule_past * Input: POST /api/generate { action:"schedule", platform:"x", originalText:"hi", caption:"cap", hashtags:["#a"], scheduledAt: (ISO in past) } * Expected: 400, { error.message = "Scheduled time must be in the future." } TC-003 scheduler_insert_failed * Setup: DATABASE_URL set but table missing * Input: POST schedule with valid data * Expected: 500, { error.message = "Could not schedule this post. Try again." } * Expected: server logs event scheduler_insert_failed TC-004 ai_generate_config_missing * Setup: OPENAI_API_KEY unset * Input: POST generate with valid data * Expected: 500, { error.message = "AI generation is not configured. Please contact support." } TC-005 ai_generate_upstream_unavailable * Setup: Simulate network failure (block outbound) * Input: POST generate with valid data * Expected: 502, { error.message = "AI generation is temporarily unavailable. Try again." } TC-006 ai_generate_parse_failed * Setup: Force model to return malformed output (manual test by temporarily changing prompt to break) * Input: POST generate * Expected: 502, { error.message = "Could not format AI result. Please retry." } TC-007 scheduler_db_config_missing * Setup: DATABASE_URL unset * Input: POST schedule valid data * Expected: 500, { error.message = "Scheduling is not configured. Please contact support." } TC-008 scheduler_db_query_failed * Setup: DATABASE_URL points to DB user without permission * Input: GET /api/generate?action=list * Expected: 500, { error.message = "Could not load scheduled posts. Refresh to try again." } TC-009 scheduler_list_failed * Setup: DB temporarily down * Input: GET list * Expected: 500, safe error message, UI shows error banner TC-010 validation_payload_too_large * Input: POST generate with postText length 2001 * Expected: 413, { error.message = "Post text is too long. Please shorten it." } Smoke checklist (pre-merge) * Open /scheduler on desktop and narrow mobile width (responsive) * Generate caption for each platform option * Edit caption and hashtags and schedule in the future * Verify scheduled post appears in list after scheduling * Verify Refresh list updates the list * Unset OPENAI_API_KEY locally and confirm UI shows safe error message * Unset DATABASE_URL locally and confirm scheduling/list show safe error messages Regression checklist (post-merge) * Confirm /api/generate supports existing clients (if any) that previously used it for generation only * Confirm no unexpected layout shift on /scheduler * Confirm scheduled posts list renders when empty and when populated * Confirm hashtags normalization does not introduce invalid characters (# only + alnum/underscore) 9. RELEASE AND ROLLBACK Release steps: * Deploy code changes * VERIFY: Ensure OPENAI_API_KEY is set in the deployment environment * VERIFY: Ensure DATABASE_URL is set in the deployment environment * Apply OUT_OF_SCOPE DDL migration to create scheduled_posts table * Run smoke checklist in production-like environment * Monitor server logs for ai_generate_failed / scheduler_insert_failed / scheduler_list_failed ROLLBACK plan: * Revert the deployment to the previous release * If DB migration was applied, it is additive; no rollback required unless it conflicts (unlikely). If needed, drop table scheduled_posts manually (only if safe in your environment) Monitoring/alerts suggestions (minimal, practical): * Alert on elevated rate of 5xx on /api/generate * Dashboard count of scheduler_insert_failed and ai_generate_failed logs * Track latency p95 for /api/generate POST generate (target under ~2–3s; VERIFY based on your infra) 10. LIMITATIONS AND NEXT HARDENING What is intentionally not covered * Automatic publishing to social platforms * Background job execution of scheduled items * Authentication/authorization (anyone can schedule in current form) * Rate limiting, abuse prevention, and audit logs * Advanced hashtag research from external trend sources Smallest next steps to harden * Add auth and scope scheduled_posts by user_id (requires DB schema + auth integration, currently OUT_OF_SCOPE) * Add a worker/cron that picks due scheduled_posts and transitions status to queued/sent/failed (OUT_OF_SCOPE) * Add basic rate limiting (prefer platform-native, e.g., edge middleware or hosting-level throttles) * Add structured telemetry (without new deps, can still standardize JSON logs and filter in log platform) * Add optimistic UI updates and better list pagination once volume grows
🤖 GPT

Vibe Coding Pro Developer Workflow

Add to Cart
Instant accessInstant access
Usage rightsCommercial use
Money-back guaranteeMoney‑back
By purchasing this prompt, you agree to our terms of service
GPT-5.2
Tested icon
Guide icon
4 examples icon
Free credits icon
Ideal for SaaS builders, freelancers, technical teams, and AI automation workflows.
...more
Added over 1 month ago
Report
Browse Marketplace