Skip to content

Commit 1c51cd1

Browse files
ralyodioclaude
andauthored
fix(lx): count guest posts by author so a site cannot get stuck on guest slots (#357)
A guest post is stored under the partner's site_id with the writer in author_site_id. isGuestPostSlot and the partner cooldown both filtered by site_id, so neither ever saw the guest posts a site wrote: - the cadence count never moved once it sat at 9 mod 10, so every slot became a guest slot for ever. pairux.com has published nothing to its own blog since 2026-08-19. - the 30-day cooldown found nothing, so pairux wrote the same "screen sharing software development" post for one partner 17 times. Count the site's own posts plus the guest posts it authored, and look up the cooldown by author_site_id. The test stub now records its filters so the column choice is pinned. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7bb2fe3 commit 1c51cd1

2 files changed

Lines changed: 52 additions & 16 deletions

File tree

‎lib/lx/autoGuestPost.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,19 @@ export async function isGuestPostSlot(
6868
supabase: SupabaseClient<any>,
6969
authorSiteId: string,
7070
): Promise<boolean> {
71+
// A guest post is stored under the PARTNER's site_id, with the author in
72+
// author_site_id. Counting by site_id alone missed every guest post this site
73+
// wrote, so once its own count sat one short of a multiple of ten the slot
74+
// stayed a guest slot for ever: pairux.com published nothing to its own blog
75+
// from 2026-08-19 while writing the same guest post daily. Count what this
76+
// site authored — its own posts plus the guest posts it wrote elsewhere —
77+
// and not the guest posts partners wrote onto it.
7178
const { count, error } = await supabase
7279
.from("lx_article")
7380
.select("id", { count: "exact", head: true })
74-
.eq("site_id", authorSiteId);
81+
.or(
82+
`and(site_id.eq.${authorSiteId},is_guest_post.eq.false),author_site_id.eq.${authorSiteId}`,
83+
);
7584

7685
// A failed count must not silently turn every slot into a guest post, nor
7786
// silently turn none of them into one. Falling back to "no" keeps the
@@ -114,10 +123,11 @@ export async function planGuestPost(
114123
// Who we have written for lately. One query for the whole cooldown rather
115124
// than one per candidate: the list is short and the cron is walking every
116125
// active site.
126+
// By author_site_id, not site_id: the article lives under the partner.
117127
const { data: recent } = await supabase
118128
.from("lx_article")
119129
.select("target_site_id")
120-
.eq("site_id", authorSiteId)
130+
.eq("author_site_id", authorSiteId)
121131
.eq("is_guest_post", true)
122132
.gte("created_at", since);
123133

‎tests/contract/auto-guest-post.test.ts‎

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,36 +22,43 @@ const matcher = vi.mocked(findGuestPostOpportunities);
2222
* getting one of those wrong is precisely the kind of mistake that returns
2323
* `undefined` and silently disables the feature rather than failing.
2424
*/
25-
function stubSupabase(opts: {
26-
articleCount?: number;
27-
countError?: string;
28-
recentGuestTargets?: string[];
29-
existingRequests?: Array<{ target_site_id: string; topic: string }>;
30-
}) {
25+
function stubSupabase(
26+
opts: {
27+
articleCount?: number;
28+
countError?: string;
29+
recentGuestTargets?: string[];
30+
existingRequests?: Array<{ target_site_id: string; topic: string }>;
31+
},
32+
filters: string[] = [],
33+
) {
3134
return {
3235
from(table: string) {
3336
if (table === "lx_article") {
3437
return {
3538
select(_cols: string, o?: { count?: string; head?: boolean }) {
3639
if (o?.head) {
3740
return {
38-
eq: () =>
39-
Promise.resolve(
41+
or: (f: string) => {
42+
filters.push(`or:${f}`);
43+
return Promise.resolve(
4044
opts.countError
4145
? { count: null, error: { message: opts.countError } }
4246
: { count: opts.articleCount ?? 0, error: null },
43-
),
47+
);
48+
},
4449
};
4550
}
4651
return {
47-
eq: () => ({
48-
eq: () => ({
49-
gte: () =>
50-
Promise.resolve({
52+
eq: (col: string, val: unknown) => ({
53+
eq: (col2: string, val2: unknown) => ({
54+
gte: () => {
55+
filters.push(`eq:${col}=${String(val)}`, `eq:${col2}=${String(val2)}`);
56+
return Promise.resolve({
5157
data: (opts.recentGuestTargets ?? []).map((id) => ({
5258
target_site_id: id,
5359
})),
54-
}),
60+
});
61+
},
5562
}),
5663
}),
5764
};
@@ -107,6 +114,16 @@ describe("guest post cadence", () => {
107114
);
108115
expect(slot).toBe(false);
109116
});
117+
118+
it("counts the guest posts this site wrote, which live under the partner", async () => {
119+
// Counting by site_id alone never saw them, so a site one short of a
120+
// multiple of ten was handed a guest slot on every tick, for ever.
121+
const filters: string[] = [];
122+
await isGuestPostSlot(stubSupabase({}, filters), "author");
123+
expect(filters).toEqual([
124+
"or:and(site_id.eq.author,is_guest_post.eq.false),author_site_id.eq.author",
125+
]);
126+
});
110127
});
111128

112129
describe("choosing a partner", () => {
@@ -142,6 +159,15 @@ describe("choosing a partner", () => {
142159
expect(plan?.targetSiteId).toBe("fresh");
143160
});
144161

162+
it("looks up the cooldown by author, not by the site the post lives on", async () => {
163+
// The same mistake as the cadence count: by site_id the cooldown found
164+
// nothing, and one partner got the same post seventeen days running.
165+
matcher.mockResolvedValue([opportunity("partner", ["topic a"])]);
166+
const filters: string[] = [];
167+
await planGuestPost(stubSupabase({}, filters), "author");
168+
expect(filters).toEqual(["eq:author_site_id=author", "eq:is_guest_post=true"]);
169+
});
170+
145171
it("does not commission a topic a human already requested", async () => {
146172
// The manual path and the cron share one ledger so they cannot
147173
// independently order the same article.

0 commit comments

Comments
 (0)