diff --git a/app/(app)/projects/[id]/members/members-client.tsx b/app/(app)/projects/[id]/members/members-client.tsx new file mode 100644 index 00000000..53bac28d --- /dev/null +++ b/app/(app)/projects/[id]/members/members-client.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + inviteProjectMember, + revokeProjectInvitation, + removeProjectMember, + type TeamMember, + type PendingInvitation, +} from "@/app/actions/project-members"; + +export function MembersClient({ + projectId, + isOwner, + members, + invitations, +}: { + projectId: string; + isOwner: boolean; + members: TeamMember[]; + invitations: PendingInvitation[]; +}) { + return ( +
+ {isOwner && } + +
+

Team members

+ {members.length === 0 ? ( +

+ No team members yet. Invite someone to get started. +

+ ) : ( +
    + {members.map((m) => ( + + ))} +
+ )} +
+ + {isOwner && invitations.length > 0 && ( +
+

Pending invitations

+
    + {invitations.map((inv) => ( + + ))} +
+
+ )} +
+ ); +} + +function InviteForm({ projectId }: { projectId: string }) { + const [email, setEmail] = useState(""); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [pending, start] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + start(async () => { + const res = await inviteProjectMember(projectId, email); + if (res.ok) { + setEmail(""); + setMessage({ ok: true, text: "Invitation sent." }); + } else { + setMessage({ ok: false, text: res.error ?? "Something went wrong." }); + } + }); + } + + return ( +
+

Invite a team member

+

+ They'll get an email with a link to join this project. +

+
+ setEmail(e.target.value)} + autoComplete="off" + /> + +
+ {message && ( +

+ {message.text} +

+ )} +
+ ); +} + +function MemberRow({ + projectId, + member, + isOwner, +}: { + projectId: string; + member: TeamMember; + isOwner: boolean; +}) { + const [pending, start] = useTransition(); + const label = + member.profile?.display_name || member.profile?.email || "Unknown user"; + const sub = member.profile?.display_name + ? member.profile.email + : new Date(member.created_at).toLocaleDateString(); + + function onRemove() { + start(async () => { + await removeProjectMember(projectId, member.user_id); + }); + } + + return ( +
  • +
    +

    {label}

    + {sub && ( +

    {sub}

    + )} +
    + {isOwner && ( + + )} +
  • + ); +} + +function InvitationRow({ + projectId, + invitation, +}: { + projectId: string; + invitation: PendingInvitation; +}) { + const [pending, start] = useTransition(); + const expired = new Date(invitation.expires_at) < new Date(); + + function onRevoke() { + start(async () => { + await revokeProjectInvitation(projectId, invitation.id); + }); + } + + return ( +
  • +
    +

    {invitation.email}

    +

    + {expired + ? "Expired" + : `Expires ${new Date(invitation.expires_at).toLocaleDateString()}`} +

    +
    + +
  • + ); +} diff --git a/app/(app)/projects/[id]/members/page.tsx b/app/(app)/projects/[id]/members/page.tsx new file mode 100644 index 00000000..2e2c7878 --- /dev/null +++ b/app/(app)/projects/[id]/members/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation"; +import { listProjectTeam } from "@/app/actions/project-members"; +import { MembersClient } from "./members-client"; + +export default async function ProjectMembersPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const result = await listProjectTeam(id); + if (!result.ok) notFound(); + + return ( + + ); +} diff --git a/app/actions/project-members.ts b/app/actions/project-members.ts new file mode 100644 index 00000000..bde51b73 --- /dev/null +++ b/app/actions/project-members.ts @@ -0,0 +1,208 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { sendProjectInviteEmail } from "@/lib/email"; +import { env } from "@/lib/env"; + +async function requireOwner(projectId: string) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false as const, error: "Not authenticated." }; + + const { data: project } = await supabase + .from("projects") + .select("id, name, owner_id") + .eq("id", projectId) + .eq("owner_id", user.id) + .maybeSingle(); + if (!project) return { ok: false as const, error: "Project not found." }; + + return { ok: true as const, user, project, supabase }; +} + +export async function inviteProjectMember( + projectId: string, + email: string, +): Promise<{ ok: boolean; error?: string }> { + const normalized = email.trim().toLowerCase(); + if (!normalized.includes("@")) { + return { ok: false, error: "Invalid email address." }; + } + + const ctx = await requireOwner(projectId); + if (!ctx.ok) return { ok: false, error: ctx.error }; + const { user, project } = ctx; + + const { data: ownerProfile } = await ctx.supabase + .from("profiles") + .select("email") + .eq("id", user.id) + .maybeSingle(); + if (ownerProfile?.email?.toLowerCase() === normalized) { + return { ok: false, error: "You can't invite yourself." }; + } + + const svc = serviceClient(); + + // Delete any existing invitation for this email (re-invite resets the timer) + await svc + .from("project_invitations") + .delete() + .eq("project_id", projectId) + .eq("email", normalized); + + const { data: invitation, error: insertErr } = await svc + .from("project_invitations") + .insert({ project_id: projectId, email: normalized, invited_by: user.id }) + .select("token") + .single(); + + if (insertErr || !invitation) { + return { ok: false, error: insertErr?.message ?? "Failed to create invitation." }; + } + + const { data: inviterProfile } = await svc + .from("profiles") + .select("display_name, email") + .eq("id", user.id) + .maybeSingle(); + + const inviterName = + inviterProfile?.display_name || inviterProfile?.email || "Someone"; + const acceptUrl = `${env.siteUrl}/invite/${invitation.token}`; + + const emailResult = await sendProjectInviteEmail({ + to: normalized, + invitedBy: inviterName, + projectName: project.name, + acceptUrl, + }); + + if (!emailResult.sent) { + await svc + .from("project_invitations") + .delete() + .eq("project_id", projectId) + .eq("email", normalized); + return { ok: false, error: `Email failed: ${emailResult.error}` }; + } + + revalidatePath(`/projects/${projectId}/members`); + return { ok: true }; +} + +export async function revokeProjectInvitation( + projectId: string, + invitationId: string, +): Promise<{ ok: boolean; error?: string }> { + const ctx = await requireOwner(projectId); + if (!ctx.ok) return { ok: false, error: ctx.error }; + + const { error } = await ctx.supabase + .from("project_invitations") + .delete() + .eq("id", invitationId) + .eq("project_id", projectId); + + if (error) return { ok: false, error: error.message }; + revalidatePath(`/projects/${projectId}/members`); + return { ok: true }; +} + +export async function removeProjectMember( + projectId: string, + userId: string, +): Promise<{ ok: boolean; error?: string }> { + const ctx = await requireOwner(projectId); + if (!ctx.ok) return { ok: false, error: ctx.error }; + + const { error } = await ctx.supabase + .from("project_members") + .delete() + .eq("project_id", projectId) + .eq("user_id", userId); + + if (error) return { ok: false, error: error.message }; + revalidatePath(`/projects/${projectId}/members`); + return { ok: true }; +} + +export type TeamMember = { + id: string; + user_id: string; + created_at: string; + profile: { id: string; email: string; display_name: string } | null; +}; + +export type PendingInvitation = { + id: string; + email: string; + expires_at: string; + created_at: string; +}; + +export async function listProjectTeam(projectId: string): Promise< + | { ok: false; error: string } + | { + ok: true; + isOwner: boolean; + members: TeamMember[]; + invitations: PendingInvitation[]; + } +> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Not authenticated." }; + + const { data: project } = await supabase + .from("projects") + .select("id, owner_id") + .eq("id", projectId) + .maybeSingle(); + if (!project) return { ok: false, error: "Project not found." }; + + const svc = serviceClient(); + + const { data: membersRaw } = await svc + .from("project_members") + .select("id, user_id, created_at") + .eq("project_id", projectId) + .order("created_at", { ascending: true }); + + const userIds = (membersRaw ?? []).map((m: any) => m.user_id); + const { data: profilesRaw } = + userIds.length > 0 + ? await svc + .from("profiles") + .select("id, email, display_name") + .in("id", userIds) + : { data: [] }; + + const members: TeamMember[] = (membersRaw ?? []).map((m: any) => ({ + id: m.id, + user_id: m.user_id, + created_at: m.created_at, + profile: + (profilesRaw ?? []).find((p: any) => p.id === m.user_id) ?? null, + })); + + const { data: invitationsRaw } = await svc + .from("project_invitations") + .select("id, email, expires_at, created_at") + .eq("project_id", projectId) + .is("accepted_at", null) + .order("created_at", { ascending: false }); + + return { + ok: true, + isOwner: project.owner_id === user.id, + members, + invitations: invitationsRaw ?? [], + }; +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx new file mode 100644 index 00000000..6eccb927 --- /dev/null +++ b/app/invite/[token]/page.tsx @@ -0,0 +1,132 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; + +export const metadata = { robots: { index: false } }; + +export default async function InvitePage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + const svc = serviceClient(); + + const { data: inv } = await svc + .from("project_invitations") + .select("id, project_id, email, expires_at, accepted_at") + .eq("token", token) + .maybeSingle(); + + if (!inv) { + return ( + +

    Invalid invitation

    +

    + This link is invalid or has already been revoked. +

    +
    + ); + } + + if (inv.accepted_at) { + return ( + +

    Already accepted

    +

    + This invitation has already been used. +

    + + Go to project + +
    + ); + } + + if (new Date(inv.expires_at) < new Date()) { + return ( + +

    Invitation expired

    +

    + This invitation link has expired. Ask the project owner to send a new one. +

    +
    + ); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect(`/login?redirect=/invite/${token}`); + } + + const { data: userProfile } = await svc + .from("profiles") + .select("email") + .eq("id", user.id) + .maybeSingle(); + + if (userProfile?.email?.toLowerCase() !== inv.email.toLowerCase()) { + return ( + +

    Wrong account

    +

    + This invitation was sent to{" "} + {inv.email}. + Please sign in with that email address to accept. +

    + + Sign in with correct account + +
    + ); + } + + // Check if already a member + const { data: existing } = await svc + .from("project_members") + .select("id") + .eq("project_id", inv.project_id) + .eq("user_id", user.id) + .maybeSingle(); + + if (!existing) { + // Fetch invited_by to populate the foreign key + const { data: invitationFull } = await svc + .from("project_invitations") + .select("invited_by") + .eq("id", inv.id) + .single(); + + await svc.from("project_members").insert({ + project_id: inv.project_id, + user_id: user.id, + invited_by: invitationFull?.invited_by ?? user.id, + }); + } + + await svc + .from("project_invitations") + .update({ accepted_at: new Date().toISOString() }) + .eq("id", inv.id); + + redirect(`/projects/${inv.project_id}`); +} + +function InviteShell({ children }: { children: React.ReactNode }) { + return ( +
    + + ← CrawlProof + +
    {children}
    +
    + ); +} diff --git a/components/project-tabs-nav.tsx b/components/project-tabs-nav.tsx index e4b554ee..f057e81c 100644 --- a/components/project-tabs-nav.tsx +++ b/components/project-tabs-nav.tsx @@ -66,6 +66,12 @@ const TABS: ProjectTab[] = [ href: (id) => `/projects/${id}/repos`, matches: (p, id) => p.startsWith(`/projects/${id}/repos`), }, + { + id: "members", + label: "Members", + href: (id) => `/projects/${id}/members`, + matches: (p, id) => p.startsWith(`/projects/${id}/members`), + }, ]; export function ProjectTabsNav({ projectId }: { projectId: string }) { diff --git a/lib/email.ts b/lib/email.ts index d236fdef..1d4dca6a 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -547,3 +547,60 @@ export async function sendHireInquiryEmail(input: { if (res.error) return { sent: false, error: String(res.error) }; return { sent: true }; } + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export async function sendProjectInviteEmail(input: { + to: string; + invitedBy: string; + projectName: string; + acceptUrl: string; +}): Promise<{ sent: boolean; error?: string }> { + const c = client(); + if (!c) return { sent: false, error: "RESEND_API_KEY not set" }; + + const innerHtml = ` + + +

    You've been invited

    +

    + ${escapeHtml(input.invitedBy)} invited you to join the + ${escapeHtml(input.projectName)} project on CrawlProof. +

    + + + + + + Accept Invitation + +

    + This link expires in 7 days. If you don't have a CrawlProof account you'll be prompted to create one. +

    + + + `; + + const html = emailShell({ + title: `Invitation to join ${input.projectName} on CrawlProof`, + innerHtml, + footerNote: "If you weren't expecting this invitation, you can safely ignore this email.", + }); + + const res = await c.emails.send({ + from: env.resendFrom, + to: input.to, + subject: `${escapeHtml(input.invitedBy)} invited you to ${escapeHtml(input.projectName)} on CrawlProof`, + html, + }); + if (res.error) return { sent: false, error: String(res.error) }; + return { sent: true }; +} diff --git a/lib/sp/renderPost.ts b/lib/sp/renderPost.ts index 2446a44b..1b990f3f 100644 --- a/lib/sp/renderPost.ts +++ b/lib/sp/renderPost.ts @@ -22,7 +22,9 @@ const RenderSchema = z.object({ .describe("Reddit post title; empty string for every other platform."), hashtags: z .array(z.string()) - .describe("Platform-appropriate hashtags (no leading #). Empty if the platform doesn't use them."), + .describe( + "Platform-appropriate hashtags (no leading #). Empty if the platform doesn't use them.", + ), }); export type RenderedPost = { @@ -65,7 +67,8 @@ const PLATFORM_PROFILES: Record = { threads: { maxChars: 480, usesHashtags: "few", - voice: "Conversational. One short hook, then one supporting line if needed.", + voice: + "Conversational. One short hook, then one supporting line if needed.", }, mastodon: { maxChars: 480, @@ -135,7 +138,9 @@ export async function renderPostForPlatform(args: { throw new Error(`No platform profile configured for "${platform}".`); } if (!anthropic && !openai) { - throw new Error("No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY)."); + throw new Error( + "No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY).", + ); } const tone = config.tone || "casual"; @@ -184,7 +189,9 @@ export async function renderPostForPlatform(args: { throw new Error("Render produced empty text."); } const title = - platform === "reddit" && output.title?.trim() ? output.title.trim() : undefined; + platform === "reddit" && output.title?.trim() + ? output.title.trim() + : undefined; const hashtags = (output.hashtags ?? []) .filter((h): h is string => typeof h === "string") .map((h) => (h.startsWith("#") ? h : `#${h}`)) @@ -193,35 +200,94 @@ export async function renderPostForPlatform(args: { return { text, title, hashtags }; } +// Real per-platform hard limits — the length each platform actually rejects +// above. These are the SAME values enforced in lib/sp/platforms/* and at the +// postViaAccount guard; hardcoded here (they don't change) so the renderer can +// guarantee a fitting post without importing every platform module. NOTE these +// differ from PLATFORM_PROFILES[*].maxChars, which is the *soft* cap fed to the +// LLM (deliberately lower to leave room for the URL + hashtags). +const PLATFORM_HARD_LIMIT: Record = { + bluesky: 300, + x: 280, + threads: 500, + mastodon: 500, + facebook: 5000, + facebook_page: 5000, + linkedin: 3000, + discord: 2000, + telegram: 4096, + reddit: 40_000, +}; + +const ELLIPSIS = "…"; + +// Join body + url + hashtags and guarantee the result fits within `limit`. +// The body is the only truncatable piece — the URL must stay intact (it's the +// point of the post), and hashtags are dropped before the URL is sacrificed. +function clampAssembled( + body: string, + url: string, + tags: string, + joiner: string, + limit: number, +): string { + const assemble = (b: string, includeTags: boolean) => + [b, url, includeTags ? tags : ""].filter(Boolean).join(joiner); + + // Fits as-is. + if (assemble(body, true).length <= limit) return assemble(body, true); + + // Decide whether we can keep hashtags: only if doing so still leaves a + // reasonable amount of room for the body. + const suffixWithTags = assemble("", true); // = url (+ joiner + tags) + const suffixNoTags = assemble("", false); // = url + const keepTags = limit - suffixWithTags.length >= 24; + const suffix = keepTags ? suffixWithTags : suffixNoTags; + + // Budget left for the body once the suffix and one joiner are reserved. + const bodyBudget = limit - suffix.length - joiner.length; + if (bodyBudget <= ELLIPSIS.length) { + // URL (and maybe tags) alone already fill the limit — return the suffix, + // trimmed as a last resort. This effectively never triggers for the short + // platforms because their URLs are far shorter than the limit. + return suffix.slice(0, limit); + } + + // Trim the body to budget, preferring a word boundary so we don't cut mid-word. + let cut = body.slice(0, bodyBudget - ELLIPSIS.length); + const lastSpace = cut.lastIndexOf(" "); + if (lastSpace > bodyBudget * 0.6) cut = cut.slice(0, lastSpace); + const trimmedBody = cut.trimEnd() + ELLIPSIS; + + return [trimmedBody, suffix].join(joiner); +} + // Assemble final post text for the platform from the rendered pieces + // the article URL. Different platforms want different orderings; some -// embed the URL inline, others trail it after a blank line. +// embed the URL inline, others trail it after a blank line. The result is +// always clamped to the platform's hard character limit. export function assemblePostText(args: { rendered: RenderedPost; url: string; platform: string; }): string { const { rendered, url, platform } = args; - const profile = PLATFORM_PROFILES[platform]; - const tagsBlock = rendered.hashtags.length > 0 ? rendered.hashtags.join(" ") : ""; + const tagsBlock = + rendered.hashtags.length > 0 ? rendered.hashtags.join(" ") : ""; + const limit = PLATFORM_HARD_LIMIT[platform] ?? Infinity; if (platform === "reddit") { // Reddit's "text" body is its self-post; URL goes in the body. Title - // is handled at the postViaAccount layer. - return [rendered.text, url].filter(Boolean).join("\n\n"); + // is handled at the postViaAccount layer. No hashtags. + return clampAssembled(rendered.text, url, "", "\n\n", limit); } if (platform === "linkedin") { - return [rendered.text, url, tagsBlock].filter(Boolean).join("\n\n"); + return clampAssembled(rendered.text, url, tagsBlock, "\n\n", limit); } if (platform === "discord" || platform === "telegram") { // No hashtags for these. - return [rendered.text, url].filter(Boolean).join("\n\n"); + return clampAssembled(rendered.text, url, "", "\n\n", limit); } // Default short-form (bluesky/x/threads/mastodon/facebook): inline. - const head = - profile && rendered.text.length + url.length + 2 > profile.maxChars - ? rendered.text.slice(0, Math.max(0, profile.maxChars - url.length - 8)) + "…" - : rendered.text; - return [head, url, tagsBlock].filter(Boolean).join("\n"); + return clampAssembled(rendered.text, url, tagsBlock, "\n", limit); } - diff --git a/supabase/migrations/20260601120000_project_members.sql b/supabase/migrations/20260601120000_project_members.sql new file mode 100644 index 00000000..0bfc0ea6 --- /dev/null +++ b/supabase/migrations/20260601120000_project_members.sql @@ -0,0 +1,47 @@ +-- project_members: accepted team memberships +create table public.project_members ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + user_id uuid not null references public.profiles(id) on delete cascade, + invited_by uuid not null references public.profiles(id), + created_at timestamptz not null default now(), + unique(project_id, user_id) +); + +alter table public.project_members enable row level security; + +create policy "project_members owner all" + on public.project_members for all + using (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid())) + with check (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid())); + +create policy "project_members self read" + on public.project_members for select + using (user_id = auth.uid()); + +-- project_invitations: pending email invitations +create table public.project_invitations ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + email text not null, + token text not null unique default encode(gen_random_bytes(32), 'hex'), + invited_by uuid not null references public.profiles(id), + expires_at timestamptz not null default (now() + interval '7 days'), + accepted_at timestamptz, + created_at timestamptz not null default now(), + unique(project_id, email) +); + +alter table public.project_invitations enable row level security; + +create policy "project_invitations owner all" + on public.project_invitations for all + using (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid())) + with check (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid())); + +-- team members can also read projects they belong to +create policy "projects member read" + on public.projects for select + using ( + exists(select 1 from public.project_members where project_id = id and user_id = auth.uid()) + ); diff --git a/tests/sp/render-post.test.ts b/tests/sp/render-post.test.ts new file mode 100644 index 00000000..1c4da457 --- /dev/null +++ b/tests/sp/render-post.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { assemblePostText, type RenderedPost } from "@/lib/sp/renderPost"; + +const BLUESKY_LIMIT = 300; +const X_LIMIT = 280; + +function rendered(over: Partial = {}): RenderedPost { + return { text: "Body.", hashtags: [], ...over }; +} + +describe("assemblePostText", () => { + it("leaves a short post untouched", () => { + // Hashtags arrive already "#"-prefixed from renderPostForPlatform. + const out = assemblePostText({ + rendered: rendered({ + text: "Quick take on bot traffic.", + hashtags: ["#seo"], + }), + url: "https://crawlproof.com/blog/bots", + platform: "bluesky", + }); + expect(out).toBe( + "Quick take on bot traffic.\nhttps://crawlproof.com/blog/bots\n#seo", + ); + expect(out.length).toBeLessThanOrEqual(BLUESKY_LIMIT); + }); + + it("clamps an over-limit Bluesky post (body + url + hashtags) to <= 300", () => { + // Body alone is near the limit; url + hashtags would have pushed the old + // assembler well past 300 (the reported 323-char failure). + const out = assemblePostText({ + rendered: rendered({ + text: "A".repeat(290), + hashtags: ["crawlproof", "seo", "bots"], + }), + url: "https://crawlproof.com/blog/some-long-article-slug", + platform: "bluesky", + }); + expect(out.length).toBeLessThanOrEqual(BLUESKY_LIMIT); + // The URL is never sacrificed — it's the point of the post. + expect(out).toContain("https://crawlproof.com/blog/some-long-article-slug"); + // Body was truncated with an ellipsis. + expect(out).toContain("…"); + }); + + it("drops hashtags before sacrificing the URL when room is tight", () => { + const out = assemblePostText({ + rendered: rendered({ + text: "B".repeat(295), + hashtags: ["one", "two", "three", "four", "five"], + }), + url: "https://crawlproof.com/x", + platform: "bluesky", + }); + expect(out.length).toBeLessThanOrEqual(BLUESKY_LIMIT); + expect(out).toContain("https://crawlproof.com/x"); + }); + + it("respects the tighter X limit of 280", () => { + const out = assemblePostText({ + rendered: rendered({ text: "C".repeat(300), hashtags: ["growth"] }), + url: "https://crawlproof.com/blog/post", + platform: "x", + }); + expect(out.length).toBeLessThanOrEqual(X_LIMIT); + expect(out).toContain("https://crawlproof.com/blog/post"); + }); + + it("does not truncate long-form platforms with generous limits", () => { + const body = "D".repeat(1200); + const out = assemblePostText({ + rendered: rendered({ text: body, hashtags: ["a", "b", "c"] }), + url: "https://crawlproof.com/blog/post", + platform: "linkedin", + }); + expect(out).toContain(body); + expect(out).not.toContain("…"); + }); +});