Skip to content

Commit 594166f

Browse files
committed
feat(social): Phase 1.1 — Bluesky vertical slice end-to-end
What's live: /social dashboard: connected accounts, post-now form, recent-posts list /social/setup connect-account flow + currently-connected list Schema (migration 20260516010000_social_posting.sql): sp_account user-scoped pool of connected accounts (one user can connect many platforms). access_token + refresh_token AES-GCM-encrypted at rest. sp_site_account M:N binding sites <-> accounts. carries the `auto` flag for the auto-AI-publish pipeline that ships in a later phase. sp_post queued + sent post log. one row per (account, scheduled_for) slot. sp_publish_attempt append-only audit log for support triage. All four RLS'd via user_id; sp_publish_attempt joins through sp_post for visibility. Encryption (lib/sp/vault.ts): Static-key AES-GCM, 256-bit key from env SOCIAL_VAULT_KEY (generate with `openssl rand -base64 32`). Output is base64(nonce || ct || tag). Phase 1 simplicity; the envelope-encryption upgrade (per-user DEK under a KEK) ships when cookie + puppeteer modes land in Phase 3. Bluesky module (lib/sp/platforms/bluesky.ts): - createBlueskySession exchanges handle + app password for the access/refresh JWT pair. The app password is never stored. - refreshBlueskySession rolls the JWT pair before they expire. - createBlueskyPost POSTs via com.atproto.repo.createRecord on the app.bsky.feed.post collection. Enforces the 300-char limit on our side too so the user sees the limit before the request fires. Server actions (app/actions/socialPosting.ts): - connectBluesky handle + app password -> session JWTs -> upsert sp_account - disconnectAccount delete the sp_account row (RLS scopes to owner; CASCADE deletes sp_site_account + sp_post automatically) - postNow synchronous "post this text right now" path. v1 ignores scheduling; the worker tick + queue path lands when more platforms join. Writes sp_post in 'publishing' state BEFORE the HTTP call so dashboard reflects intent even if the call hangs. Required env: SOCIAL_VAULT_KEY (base64 32-byte AES key). Without it, connectBluesky returns "SOCIAL_VAULT_KEY env not set" on first use; nothing else breaks. Migration applied to prod. Next phase commits add Reddit, Mastodon, LinkedIn, and the worker tick / scheduled publishing path.
1 parent 1493a8a commit 594166f

10 files changed

Lines changed: 1097 additions & 0 deletions

File tree

‎app/(app)/layout.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
4242
<Link href="/autoblog" className="hidden sm:inline">
4343
Autoblog
4444
</Link>
45+
<Link href="/social" className="hidden sm:inline">
46+
Social
47+
</Link>
4548
<SitePicker sites={sites} currentId={currentSiteId} />
4649
<Link href="/settings" className="hidden sm:inline">
4750
Settings

‎app/(app)/social/page.tsx‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import Link from "next/link";
2+
import { redirect } from "next/navigation";
3+
import { createClient } from "@/lib/supabase/server";
4+
import { PostNowForm } from "./post-now";
5+
6+
export const metadata = { title: "Social" };
7+
8+
function fmtDate(iso: string | null): string {
9+
if (!iso) return "—";
10+
try {
11+
return new Date(iso).toLocaleString();
12+
} catch {
13+
return iso;
14+
}
15+
}
16+
17+
export default async function SocialDashboardPage() {
18+
const supabase = await createClient();
19+
const {
20+
data: { user },
21+
} = await supabase.auth.getUser();
22+
if (!user) redirect("/login");
23+
24+
const [{ data: accounts }, { data: posts }] = await Promise.all([
25+
supabase
26+
.from("sp_account")
27+
.select("id, platform, handle, status")
28+
.eq("user_id", user.id)
29+
.eq("status", "active")
30+
.order("created_at", { ascending: false }),
31+
supabase
32+
.from("sp_post")
33+
.select(
34+
"id, account_id, rendered_text, status, published_at, platform_post_url, last_error, created_at",
35+
)
36+
.eq("user_id", user.id)
37+
.order("created_at", { ascending: false })
38+
.limit(20),
39+
]);
40+
41+
const accountList = (accounts ?? []) as Array<{
42+
id: string;
43+
platform: string;
44+
handle: string;
45+
status: string;
46+
}>;
47+
const accountById = new Map(accountList.map((a) => [a.id, a]));
48+
49+
return (
50+
<div className="mx-auto max-w-3xl space-y-8">
51+
<header className="flex flex-wrap items-baseline justify-between gap-4">
52+
<div>
53+
<h1 className="text-3xl font-bold">Social</h1>
54+
<p className="mt-1 text-sm text-[var(--color-muted)]">
55+
Post to your connected accounts. v1 supports Bluesky; more
56+
platforms ship in subsequent phases.
57+
</p>
58+
</div>
59+
<Link href="/social/setup" className="btn">
60+
Manage accounts
61+
</Link>
62+
</header>
63+
64+
{accountList.length === 0 ? (
65+
<section className="card p-5">
66+
<p className="text-sm text-[var(--color-muted)]">
67+
No connected accounts yet.{" "}
68+
<Link href="/social/setup" className="underline">
69+
Connect one
70+
</Link>{" "}
71+
to start posting.
72+
</p>
73+
</section>
74+
) : (
75+
<section className="card p-5">
76+
<h2 className="text-lg font-semibold">Post now</h2>
77+
<PostNowForm accounts={accountList} />
78+
</section>
79+
)}
80+
81+
<section>
82+
<h2 className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
83+
Recent posts
84+
</h2>
85+
{(posts ?? []).length === 0 ? (
86+
<p className="mt-2 text-sm text-[var(--color-muted)]">
87+
No posts yet.
88+
</p>
89+
) : (
90+
<ul className="mt-2 divide-y divide-[var(--color-border)] rounded border border-[var(--color-border)]">
91+
{posts!.map((p: any) => {
92+
const a = accountById.get(p.account_id);
93+
return (
94+
<li key={p.id} className="px-3 py-2 text-sm">
95+
<div className="flex items-baseline justify-between gap-3">
96+
<span className="line-clamp-2 flex-1">{p.rendered_text}</span>
97+
<span
98+
className={
99+
"badge shrink-0 " +
100+
(p.status === "published"
101+
? "badge-pass"
102+
: p.status === "failed"
103+
? "badge-fail"
104+
: "badge-warn")
105+
}
106+
>
107+
{p.status}
108+
</span>
109+
</div>
110+
<div className="mt-1 flex items-baseline gap-2 text-xs text-[var(--color-muted)]">
111+
{a && (
112+
<span>
113+
{a.handle} <span className="opacity-60">({a.platform})</span>
114+
</span>
115+
)}
116+
<span>·</span>
117+
<span>
118+
{p.status === "published"
119+
? fmtDate(p.published_at)
120+
: fmtDate(p.created_at)}
121+
</span>
122+
{p.platform_post_url && (
123+
<>
124+
<span>·</span>
125+
<a
126+
href={p.platform_post_url}
127+
target="_blank"
128+
rel="noreferrer"
129+
className="underline"
130+
>
131+
view
132+
</a>
133+
</>
134+
)}
135+
</div>
136+
{p.last_error && p.status === "failed" && (
137+
<p className="mt-1 text-xs text-[var(--color-fail)]">
138+
{p.last_error}
139+
</p>
140+
)}
141+
</li>
142+
);
143+
})}
144+
</ul>
145+
)}
146+
</section>
147+
</div>
148+
);
149+
}

‎app/(app)/social/post-now.tsx‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { postNow } from "@/app/actions/socialPosting";
6+
7+
const BLUESKY_MAX = 300;
8+
9+
export function PostNowForm({
10+
accounts,
11+
}: {
12+
accounts: Array<{ id: string; platform: string; handle: string }>;
13+
}) {
14+
const router = useRouter();
15+
const [pending, start] = useTransition();
16+
const [accountId, setAccountId] = useState(accounts[0]?.id ?? "");
17+
const [text, setText] = useState("");
18+
const [error, setError] = useState<string | null>(null);
19+
const [notice, setNotice] = useState<string | null>(null);
20+
21+
const acct = accounts.find((a) => a.id === accountId);
22+
const remaining =
23+
acct?.platform === "bluesky" ? BLUESKY_MAX - text.length : null;
24+
25+
function submit(e: React.FormEvent) {
26+
e.preventDefault();
27+
setError(null);
28+
setNotice(null);
29+
start(async () => {
30+
const r = await postNow({ accountId, text });
31+
if (!r.ok) {
32+
setError(r.error);
33+
return;
34+
}
35+
setText("");
36+
setNotice(`Posted. View at ${r.webUrl}`);
37+
router.refresh();
38+
});
39+
}
40+
41+
return (
42+
<form onSubmit={submit} className="mt-3 space-y-3">
43+
<div>
44+
<label className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
45+
Post from
46+
</label>
47+
<select
48+
className="input mt-1"
49+
value={accountId}
50+
onChange={(e) => setAccountId(e.target.value)}
51+
>
52+
{accounts.map((a) => (
53+
<option key={a.id} value={a.id}>
54+
{a.handle} ({a.platform})
55+
</option>
56+
))}
57+
</select>
58+
</div>
59+
<div>
60+
<label className="flex items-baseline justify-between text-xs uppercase tracking-wider text-[var(--color-muted)]">
61+
<span>Text</span>
62+
{remaining !== null && (
63+
<span
64+
className={
65+
remaining < 0
66+
? "text-[var(--color-fail)]"
67+
: remaining < 30
68+
? "text-[var(--color-warn)]"
69+
: ""
70+
}
71+
>
72+
{remaining}
73+
</span>
74+
)}
75+
</label>
76+
<textarea
77+
className="input mt-1 min-h-[8rem]"
78+
placeholder="What's on your mind?"
79+
required
80+
value={text}
81+
onChange={(e) => setText(e.target.value)}
82+
/>
83+
</div>
84+
{error && <p className="text-sm text-[var(--color-fail)]">{error}</p>}
85+
{notice && <p className="text-sm text-[var(--color-pass)]">{notice}</p>}
86+
<button
87+
type="submit"
88+
className="btn btn-primary"
89+
disabled={pending || !accountId || !text.trim() || (remaining !== null && remaining < 0)}
90+
>
91+
{pending ? "Posting…" : "Post now"}
92+
</button>
93+
</form>
94+
);
95+
}

‎app/(app)/social/setup/form.tsx‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import {
6+
connectBluesky,
7+
disconnectAccount,
8+
} from "@/app/actions/socialPosting";
9+
10+
export function ConnectBlueskyForm() {
11+
const router = useRouter();
12+
const [pending, start] = useTransition();
13+
const [handle, setHandle] = useState("");
14+
const [appPassword, setAppPassword] = useState("");
15+
const [error, setError] = useState<string | null>(null);
16+
const [notice, setNotice] = useState<string | null>(null);
17+
18+
function submit(e: React.FormEvent) {
19+
e.preventDefault();
20+
setError(null);
21+
setNotice(null);
22+
start(async () => {
23+
const r = await connectBluesky({ handle, appPassword });
24+
if (!r.ok) {
25+
setError(r.error);
26+
return;
27+
}
28+
setNotice("Connected. You can post from the social dashboard.");
29+
setAppPassword("");
30+
router.refresh();
31+
});
32+
}
33+
34+
return (
35+
<form onSubmit={submit} className="mt-4 space-y-3">
36+
<div>
37+
<label className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
38+
Handle
39+
</label>
40+
<input
41+
className="input mt-1"
42+
type="text"
43+
placeholder="you.bsky.social"
44+
autoComplete="off"
45+
required
46+
value={handle}
47+
onChange={(e) => setHandle(e.target.value)}
48+
/>
49+
</div>
50+
<div>
51+
<label className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
52+
App password
53+
</label>
54+
<input
55+
className="input mt-1 font-mono"
56+
type="text"
57+
placeholder="xxxx-xxxx-xxxx-xxxx"
58+
autoComplete="off"
59+
spellCheck={false}
60+
required
61+
value={appPassword}
62+
onChange={(e) => setAppPassword(e.target.value)}
63+
/>
64+
<p className="mt-1 text-xs text-[var(--color-muted)]">
65+
App password, NOT your main Bluesky password. Generate at{" "}
66+
bsky.app → Settings → App Passwords.
67+
</p>
68+
</div>
69+
{error && <p className="text-sm text-[var(--color-fail)]">{error}</p>}
70+
{notice && <p className="text-sm text-[var(--color-pass)]">{notice}</p>}
71+
<button
72+
type="submit"
73+
className="btn btn-primary"
74+
disabled={pending || !handle || !appPassword}
75+
>
76+
{pending ? "Connecting…" : "Connect Bluesky"}
77+
</button>
78+
</form>
79+
);
80+
}
81+
82+
export function DisconnectButton({
83+
accountId,
84+
handle,
85+
}: {
86+
accountId: string;
87+
handle: string;
88+
}) {
89+
const router = useRouter();
90+
const [pending, start] = useTransition();
91+
return (
92+
<button
93+
type="button"
94+
className="text-xs text-[var(--color-fail)] hover:underline"
95+
disabled={pending}
96+
onClick={() => {
97+
if (!confirm(`Disconnect ${handle}? Queued posts will be deleted.`))
98+
return;
99+
start(async () => {
100+
await disconnectAccount(accountId);
101+
router.refresh();
102+
});
103+
}}
104+
>
105+
{pending ? "…" : "Disconnect"}
106+
</button>
107+
);
108+
}

0 commit comments

Comments
 (0)