Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Next.js App Router in JavaScript, Tailwind CSS with shadcn/ui, Cloudflare D1 thr
- **Storage**: models in `models/` (D1 in production, SQLite for tests and local scripts), images in the R2 bucket bound as `IMAGES`, or S3 behind CloudFront, or Vercel Blob (`lib/image-storage.js`), rate limits in Upstash Redis or memory (`lib/rate-limit.js`).
- **Comments**: subscribers comment on the web issues with no account: a signed link (`lib/reader-links.js`, four kinds, each signed with a key of its own) starts a session for one newsletter (`lib/reader-session.js`), and every function checks the contact is still an active subscriber. A comment's text is parsed to parts and printed as text, never HTML (`lib/comment-body.js`). Readers act through `lib/comments.js`, the owner through `lib/comment-moderation.js` and the `Comments` actions; hiding and removing always carry a reason the author sees. Emails (`lib/comment-notify.js`) are one digest an hour at most to the owner and one reply email to an author, never one per comment. A reader's email never appears in anything public.
- **Undo send**: the dashboard's Send (the `startSend` server action, `send_campaign` with its dashboard-only `holdForUndo`) puts a draft in the `held` status for the newsletter's `sendUndoSeconds` (`lib/send-hold-rules.js`, default 30) instead of queueing it (`lib/send-hold.js`), and a `release-held-send` job hands it to `queueCampaign` like any send. Release and `undo_campaign_send` are each one write on the same row (held and not yet claimed), so exactly one wins; the hourly cron releases a hold whose job never ran. API, MCP and approved agent sends are never held, and a scheduled send is unaffected. Limits are checked at hold time and again by `queueCampaign`. `BarSend` (in `LayoutDashboard`) is the countdown.
- **Briefings sending by itself**: `reviewMode: send_if_checks_pass` (`docs/BRIEFINGS-DESIGN.md`, Auto-send). Only the dashboard-only `set_briefing_auto_send` turns it on (with `confirm`, paid plans, a daily cap); `create_briefing` and `update_briefing` refuse the mode, and an agent's change to what a briefing writes from turns it off. The engine only makes drafts; `settleSend` in `lib/briefings-delivery.js` reads the briefing, plan, cap and `check_campaign` (with the claim check) again, then sends through `send_campaign` with `via: "briefing"` (no new send path), or asks for approval as an app named `briefing:<id>` when `Newsletter.agentApproval` is on. A held run is a draft with `heldReason` and the owner's note says why; a sent one emails the owner a pause link (`lib/briefing-pause-links.js`, `/briefing-pause`).
- **Ready-to-send check**: `check_campaign` (`lib/ready-check-data.js` gathers, `lib/ready-check.js` words each item as pass, warning, blocker or info; the review step's `PanelReady` and the approval card show it). A blocker is what the send itself refuses (`checkCampaignSend` and `getPresendChecks` give the words); a warning never stops a send. The links (`lib/link-check.js`, the `check-links` job, results in `LinkCheck`, kept for the same set of addresses) are one fetch of an address in an issue, held to `lib/link-check-rules.js`: public hosts, default ports, three redirects followed by hand and each checked. The claim check is the other (`docs/CLAIM-CHECK.md`: `lib/claim-check-rules.js` reads the issue's figures and words the report, `lib/claim-check.js` keeps it in `ClaimCheck` per `campaignContentHash`, the `check-claims` job runs `lib/claim-check-run.js` through the Briefings fetcher's guards): dead links, figures not on the pages their paragraph links to, figures with no source, code only. It never runs in a send; its findings are warnings unless the newsletter has `requireClaimCheck`, which `getSendBlocker` reads.
- **Editing a sent issue's page**: `update_published_issue` (`lib/actions/published-issues.js`, class `write`) changes only the web version of a sent issue (title, subtitle, body, tags, search settings) in place, once its send is over; the emails that went out are built from the campaign at send time, so nothing already sent changes, but a later step or resend reads the new words. The page as it was goes into `CampaignVersion` (reason `edit`, the same 30) and `restore_campaign_version` works on a sent issue the same way. The RSS feed has no updated date: the entry keeps its `pubDate` and guid and carries the new content; the sitemap's lastmod follows `seo.updatedAt`.
- **Content calendar**: issues and LinkedIn posts planned in one place (`docs/CONTENT-CALENDAR.md` has the model). `ContentItem` (channel `newsletter` or `linkedin`; copy, funnel stage, pillar, references, private notes, visuals as pointers to `Upload` records, a plan, review), `EditorialBatch` (with an optional recurring pattern that only `apply_editorial_pattern` uses) and `ContentRevision` (the last 50 states of an item's content), in `lib/content-items.js`, `lib/content-batches.js`, `lib/content-plan.js` and the rules in `lib/content-rules.js`; the actions are `lib/actions/content-items.js` and `content-batches.js`, the pages are under `app/(app)/campaigns/calendar`. Planning never sends, schedules or publishes: nothing in these files writes a campaign or queues a job, every planning action is class `write-draft` (reads are `read`, deletes `destructive`), and approving is `decide_content_review`, a `dashboardOnly` action, so no key can approve. Editorial state (`idea` to `delivered`) is apart from a campaign's delivery; a LinkedIn post is posted by hand and only recorded (`record_content_publication`, which needs an approved item and a LinkedIn address), and nothing may call it scheduled or published otherwise. A planned day is a `YYYY-MM-DD` plus an optional wall-clock time in an IANA zone, with its instant worked out by `zonedTimeToInstant` (`lib/time-zone.js`). An edit to the title, hook, body, call to action, destination, visuals or linked issue of an approved item takes the approval back (`changes_requested`, `editedAfterApproval`); a move does not. `externalId` makes creates and imports repeat safely (unique per account). `get_content_calendar` and `lib/calendar.js` are unchanged and leave drafts out; `get_content_plan` is the one that shows plans beside real deliveries.
Expand Down
19 changes: 18 additions & 1 deletion app/(app)/briefings/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { History } from "lucide-react";
import BadgeStatus from "@/components/BadgeStatus";
import CardBriefingAutoSend from "@/components/CardBriefingAutoSend";
import CardSettings from "@/components/CardSettings";
import FormBriefing from "@/components/FormBriefing";
import HeaderPage from "@/components/HeaderPage";
import StateEmpty from "@/components/StateEmpty";
import TimeLocal from "@/components/TimeLocal";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { runAction } from "@/lib/actions/run-action";
import { scoped } from "@/lib/account-scope";
import { AUTO_SEND } from "@/lib/briefing-rules";
import { isOnFreeTier } from "@/lib/plans";
import { isValidObjectId } from "@/lib/docdb";
import { READER_LANGUAGES } from "@/lib/reader-language-copy";
import { getSelectedNewsletter, openNewsletter } from "@/lib/selected-newsletter";
import Newsletter from "@/models/Newsletter";

export const metadata = { title: "Briefing" };

Expand Down Expand Up @@ -41,6 +46,7 @@ export default async function BriefingPage({ params }) {
throw error;
}
openNewsletter(view, briefing.newsletterId, `/briefings/${id}`);
const newsletter = await Newsletter.findOne(scoped(account, { _id: briefing.newsletterId })).select({ agentApproval: 1 }).lean();
const [{ runs }, { keys }] = await Promise.all([
runAction("list_briefing_runs", { account, user, input: { id }, via: "dashboard" }),
runAction("list_ai_keys", { account, user, input: {}, via: "dashboard" }),
Expand All @@ -50,7 +56,18 @@ export default async function BriefingPage({ params }) {
<>
<HeaderPage title={briefing.name} backHref="/briefings" meta={<BadgeStatus status={briefing.status} />} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_26rem]">
<FormBriefing newsletterId={briefing.newsletterId} briefing={briefing} keys={keys} languages={READER_LANGUAGES} defaultTimeZone={briefing.schedule.timeZone} />
<div className="flex min-w-0 flex-col gap-6">
<CardBriefingAutoSend
key={`${briefing.reviewMode}-${briefing.autoSendDailyCap}`}
briefingId={briefing.id}
isOn={briefing.reviewMode === "send_if_checks_pass"}
dailyCap={briefing.autoSendDailyCap ?? AUTO_SEND.dailyCap}
maxDailyCap={AUTO_SEND.maxDailyCap}
isFree={isOnFreeTier(account)}
askBeforeAgentSends={Boolean(newsletter?.agentApproval)}
/>
<FormBriefing newsletterId={briefing.newsletterId} briefing={briefing} keys={keys} languages={READER_LANGUAGES} defaultTimeZone={briefing.schedule.timeZone} />
</div>
<CardSettings title="Runs" className="self-start">
{runs.length === 0 ? (
<StateEmpty isCompact icon={History} title={briefing.manualRunAt ? "A run is waiting to start" : "Nothing has run yet"} />
Expand Down
12 changes: 12 additions & 0 deletions app/(app)/briefings/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ export async function updateBriefing(id, values) {
return { ok: true };
}

// Sending by itself, on or off. Only the dashboard can turn it on, and only with the owner's confirmation (set_briefing_auto_send).
export async function setBriefingAutoSend(id, { enabled, confirm = false, dailyCap } = {}) {
const { failure } = await runDashboardAction("set_briefing_auto_send", {
id: String(id ?? ""),
enabled: enabled === true,
...(enabled === true && { confirm: confirm === true, ...(dailyCap && { dailyCap: Number(dailyCap) }) }),
});
if (failure) return toFormError(failure);
refresh(id);
return { ok: true };
}

export async function deleteBriefing(id) {
const { failure } = await runDashboardAction("delete_briefing", { id: String(id ?? "") });
if (failure) return toFormError(failure);
Expand Down
4 changes: 2 additions & 2 deletions app/(site)/docs/briefings-bot/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default function BriefingsBotPage() {
<>
<HeaderDocs kicker="Guides" title="The FlockStack-Briefings fetcher">
FlockStack-Briefings is the program that reads web pages for briefings: a FlockStack feature where a newsletter owner describes a
topic, and FlockStack finds the week&apos;s stories on it and writes a draft issue that links to each original page.
topic, and FlockStack finds the week&apos;s stories on it and writes an issue that links to each original page, kept as a draft or, if the owner turns that on, sent once its checks pass.
</HeaderDocs>

<Section title="How to recognize it" stagger={1}>
Expand All @@ -34,7 +34,7 @@ export default function BriefingsBotPage() {
<p className="mt-4">
It also opens the pages a FlockStack issue links to when its writer checks the issue before sending it: it looks for the
issue&apos;s figures on the pages it cites and checks that its links load, at most 25 links and 12 pages per check, and keeps
nothing of the pages it read.
nothing of the pages it read. When the owner has let the briefing send by itself, the same check runs on the issue just before it would go out, and a failing one keeps it as a draft.
</p>
<p className="mt-4">
It reads at most 1 MB of a page, waits at most 8 seconds, makes at most 2 requests to one host at a time and at most 300
Expand Down
70 changes: 70 additions & 0 deletions app/briefing-pause/[token]/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Check, Link2Off, PauseCircle } from "lucide-react";
import { confirmPause } from "@/app/briefing-pause/actions";
import CardNotice from "@/components/CardNotice";
import { Button } from "@/components/ui/button";
import { scoped } from "@/lib/account-scope";
import { isDeletionPending } from "@/lib/account-state";
import { verifyPauseToken } from "@/lib/briefing-pause-links";
import { connectDb } from "@/lib/db";
import Account from "@/models/Account";
import Briefing from "@/models/Briefing";

// The page the pause link in a briefing's note after an auto-send opens. Opening it pauses nothing (mail scanners and link previews
// open links); only its button does. The URL carries the token: keep it out of search results, caches and referrer headers.
export const dynamic = "force-dynamic";
export const metadata = {
title: "Pause a briefing",
robots: { index: false, follow: false },
referrer: "no-referrer",
};

// The token as it was signed; a broken escape (a link cut or mangled by a mail client) is simply not a link of ours.
function readToken(value) {
try {
return decodeURIComponent(String(value ?? ""));
} catch {
return "";
}
}

async function load(link) {
try {
await connectDb();
const account = await Account.findById(link.accountId).lean();
if (!account || isDeletionPending(account)) return null;
return await Briefing.findOne(scoped(account, { _id: link.briefingId })).select({ name: 1, status: 1 }).lean();
} catch (error) {
console.error("[briefing-pause] Could not load:", error?.message);
return null;
}
}

export default async function BriefingPausePage({ params, searchParams }) {
const [{ token }, query] = await Promise.all([params, searchParams]);
const signed = readToken(token);
const link = verifyPauseToken(signed);
const briefing = link ? await load(link) : null;

return (
<main className="theme-neutral flex min-h-dvh items-center justify-center bg-background px-4 py-16">
{!briefing ? (
<CardNotice icon={Link2Off} title="This link doesn't work">
It may be older than 14 days, or its briefing was deleted. Pause the briefing in the dashboard instead.
</CardNotice>
) : briefing.status === "paused" ? (
<CardNotice icon={Check} tone="success" title={query?.result === "paused" ? "Paused" : "Already paused"}>
{briefing.name} makes nothing until you turn it back on in the dashboard. Issues it already sent stay sent.
</CardNotice>
) : (
<CardNotice icon={PauseCircle} title="Pause this briefing?">
<span className="block">{briefing.name} stops running on its schedule until you turn it back on. An issue already sent can&apos;t be taken back.</span>
{query?.result === "busy" && <span className="mt-2 block">Too many tries from here: wait a few minutes and press it again.</span>}
<form action={confirmPause} className="mt-4">
<input type="hidden" name="token" value={signed} />
<Button type="submit">Pause {briefing.name}</Button>
</form>
</CardNotice>
)}
</main>
);
}
34 changes: 34 additions & 0 deletions app/briefing-pause/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { isDeletionPending } from "@/lib/account-state";
import { runAction } from "@/lib/actions/run-action";
import { verifyPauseToken } from "@/lib/briefing-pause-links";
import { connectDb } from "@/lib/db";
import { getClientIp, isRateLimited } from "@/lib/rate-limit";
import Account from "@/models/Account";

const PER_IP = { name: "briefing-pause-ip", limit: 30, window: "10 m" };

// The confirmation page's button: the token comes back in the form, the same one the page was opened with. Pausing a briefing that
// is already paused changes nothing, so a link can be pressed again.
export async function confirmPause(formData) {
const token = String(formData.get("token") ?? "");
const page = `/briefing-pause/${encodeURIComponent(token)}`;
if (await isRateLimited({ ...PER_IP, key: getClientIp(await headers()) })) redirect(`${page}?result=busy`);
const link = verifyPauseToken(token);
if (!link) redirect(page);
let result = "gone";
try {
await connectDb();
const account = await Account.findById(link.accountId).lean();
if (account && !isDeletionPending(account)) {
await runAction("pause_briefing", { account, input: { id: link.briefingId }, via: "briefing" });
result = "paused";
}
} catch (error) {
if (error?.code !== "not_found") console.error("[briefing-pause] Could not pause:", error?.message);
}
redirect(`${page}?result=${result}`);
}
71 changes: 71 additions & 0 deletions components/CardBriefingAutoSend.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import { useState, useTransition } from "react";
import { setBriefingAutoSend } from "@/app/(app)/briefings/actions";
import { Button } from "@/components/ui/button";
import { toast } from "@/lib/toast";

// "Sending by itself" for a briefing. Dashboard only: no API key or connected app can turn it on. Turning it on asks for a tick that
// the owner understands issues go out with nobody reading them first.
export default function CardBriefingAutoSend({ briefingId, isOn, dailyCap, maxDailyCap, isFree, askBeforeAgentSends }) {
const [confirmed, setConfirmed] = useState(false);
const [cap, setCap] = useState(String(dailyCap ?? 1));
const [isPending, startTransition] = useTransition();

function change(enabled) {
startTransition(async () => {
const result = await setBriefingAutoSend(briefingId, { enabled, confirm: confirmed, dailyCap: cap });
if (result?.error) toast.error(result.error);
else {
setConfirmed(false);
toast.success(enabled ? "This briefing sends by itself when every check passes" : "Every issue waits as a draft again");
}
});
}

return (
<section className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border animate-enter sm:p-5">
<div>
<h2 className="font-semibold tracking-tight">Sending by itself</h2>
<p className="mt-1 max-w-xl text-sm text-pretty text-muted-foreground">
{isOn
? "Each scheduled run that passes every check is sent to your whole audience with nobody reading it first. Anything that fails a check, a thin run or one started by hand stays a draft, and the note you get says why. You are emailed after each send, with a link to pause."
: "Today every issue waits as a draft for you. Turn this on and a scheduled run that passes every check, including the claim check, is sent to your audience by itself."}
</p>
{askBeforeAgentSends && <p className="mt-1 max-w-xl text-sm text-pretty text-muted-foreground">This newsletter asks before an agent sends, so each send waits for you on the Approvals page.</p>}
</div>
{isFree ? (
<p className="text-sm text-muted-foreground">The Free plan keeps a briefing&apos;s issues as drafts. A paid plan can send them by itself.</p>
) : isOn ? (
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">On: up to {dailyCap} {dailyCap === 1 ? "issue" : "issues"} a day.</span>
<Button type="button" variant="outline" disabled={isPending} onClick={() => change(false)}>
Turn off
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" className="mt-0.5" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} />
<span>I understand that issues will be sent to my whole audience without me reading them first, and that a send can&apos;t be taken back.</span>
</label>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Issues a day, at most</span>
<select value={cap} onChange={(event) => setCap(event.target.value)} className="rounded-md bg-background px-2 py-1 shadow-border">
{Array.from({ length: maxDailyCap }, (_, index) => index + 1).map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
</label>
<div>
<Button type="button" disabled={!confirmed || isPending} onClick={() => change(true)}>
Turn on sending by itself
</Button>
</div>
</div>
)}
</section>
);
}
Loading
Loading