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
By purchasing this prompt, you agree to our terms of service
GPT-5.2
Ideal for SaaS builders, freelancers, technical teams, and AI automation workflows.
...more
Added over 1 month ago
