Skip to content

Commit 5967ded

Browse files
MaxBrychclaude
andcommitted
fix(publisher): critical issues in businessToSpec and buildSpecs
1. Status gate: add mapper-level guard rejecting pending/rejected businesses - append &status=eq.published to businesses select - businessToSpec returns null for status !== published - prevents unpublished businesses leaking names via their deals 2. opening_hours JSONB: handle both string and object forms - column is OpeningHours JSONB, not a string - str() only works with strings, was a dead code path - now accepts string directly or JSON.stringifies objects - new test fixture with object-shaped hours serialization 3. buildSpecs fetch efficiency: add test coverage - datasets [businesses] alone: businesses fetched once - datasets [deals,businesses] together: businesses fetched once - nameById map reuses shared fetch, no duplicate fetches All 51 tests pass, including 4 new tests and 2 status guard tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5df817c commit 5967ded

4 files changed

Lines changed: 137 additions & 3 deletions

File tree

packages/publisher/src/mappers.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,14 @@ export function orgToSpec(row: Row, nodeId: string): PublishSpec | null {
243243
* deals with, so a record-mode client joins profile and offers by pubkey,
244244
* exactly the rule organisations already follow. Contact PERSONS are personal
245245
* data and are never read; the business's own public storefront data is not.
246+
*
247+
* Only published businesses show on the record; pending/rejected entries stay
248+
* private (the app uses status as the moderation gate). This mirrors the
249+
* eventToSpec guard and ensures deals from unpublished businesses don't leak
250+
* their names via the deals feed.
246251
*/
247252
export function businessToSpec(row: Row, nodeId: string): PublishSpec | null {
253+
if (str(row, "status") !== "published") return null;
248254
const id = str(row, "id");
249255
const name = str(row, "name");
250256
if (!id || !name) return null;
@@ -258,8 +264,15 @@ export function businessToSpec(row: Row, nodeId: string): PublishSpec | null {
258264
if (banner) profile.banner = banner;
259265
const bizCategory = str(row, "category");
260266
if (bizCategory) profile.business_category = bizCategory;
261-
const hours = str(row, "opening_hours");
262-
if (hours) profile.opening_hours = hours;
267+
// opening_hours is JSONB; handle both string and object forms.
268+
const hoursRaw = row["opening_hours"];
269+
if (hoursRaw) {
270+
if (typeof hoursRaw === "string" && hoursRaw.trim()) {
271+
profile.opening_hours = hoursRaw;
272+
} else if (hoursRaw && typeof hoursRaw === "object") {
273+
profile.opening_hours = JSON.stringify(hoursRaw);
274+
}
275+
}
263276
const website = str(row, "website_url");
264277
if (website) profile.website = website;
265278
const address = str(row, "address");

packages/publisher/src/sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export async function buildSpecs(
191191
if (wantsDeals || wantsBusinesses) {
192192
const businesses = await deps.fetchRows(
193193
"businesses",
194-
"select=id,name,slug,description,category,logo_url,cover_image_url,address,opening_hours,website_url,updated_at,created_at",
194+
"select=id,name,slug,description,category,logo_url,cover_image_url,address,opening_hours,website_url,status,updated_at,created_at&status=eq.published",
195195
);
196196
if (wantsBusinesses) {
197197
for (const row of businesses) {

packages/publisher/test/mappers.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ describe("consented personal organisers and the wider record", () => {
367367
{ id: "b1", name: "Bäckerei Müritz", description: "Brot seit 1904",
368368
category: "handwerk", logo_url: "https://x/l.png", cover_image_url: "https://x/c.png",
369369
address: "Marktplatz 1", opening_hours: "Mo-Fr 6-18", website_url: "https://baeckerei.example",
370+
status: "published",
370371
updated_at: "2026-07-02T10:00:00Z" },
371372
"roebel",
372373
);
@@ -380,9 +381,41 @@ describe("consented personal organisers and the wider record", () => {
380381
assert.deepEqual(spec!.tags[0], ["netizen_org", "b1", "roebel"]);
381382
});
382383

384+
it("businessToSpec: refuses unpublished businesses — status is the moderation gate", () => {
385+
assert.equal(businessToSpec(
386+
{ id: "b3", name: "Hidden", status: "pending", updated_at: "2026-07-02T10:00:00Z" },
387+
"roebel",
388+
), null);
389+
assert.equal(businessToSpec(
390+
{ id: "b4", name: "Rejected", status: "rejected", updated_at: "2026-07-02T10:00:00Z" },
391+
"roebel",
392+
), null);
393+
});
394+
395+
it("businessToSpec: opening_hours accepts both string and JSONB object forms", () => {
396+
const stringHours = businessToSpec(
397+
{ id: "b5", name: "Cafe", status: "published", opening_hours: "Mo-Fr 8-20",
398+
updated_at: "2026-07-02T10:00:00Z" },
399+
"roebel",
400+
)!;
401+
const stringProfile = JSON.parse(stringHours.content);
402+
assert.equal(stringProfile.opening_hours, "Mo-Fr 8-20");
403+
404+
const objectHours = businessToSpec(
405+
{ id: "b6", name: "Restaurant", status: "published",
406+
opening_hours: { montag: { open: "09:00", close: "22:00" }, dienstag: { open: "09:00", close: "22:00" } },
407+
updated_at: "2026-07-02T10:00:00Z" },
408+
"roebel",
409+
)!;
410+
const objectProfile = JSON.parse(objectHours.content);
411+
const hoursObj = JSON.parse(objectProfile.opening_hours);
412+
assert.deepEqual(hoursObj.montag, { open: "09:00", close: "22:00" });
413+
});
414+
383415
it("businessToSpec: planted contact PII never serializes", () => {
384416
const spec = businessToSpec(
385417
{ id: "b2", name: "X", email: "leak@example.com", phone: "01761234567",
418+
status: "published",
386419
updated_at: "2026-07-02T10:00:00Z" },
387420
"roebel",
388421
);

packages/publisher/test/sync.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ const TABLES: Record<string, Record<string, unknown>[]> = {
3737
updated_at: "2026-07-29T08:00:00+00:00",
3838
},
3939
],
40+
businesses: [
41+
{
42+
id: "biz-1",
43+
name: "Bäckerei Müritz",
44+
slug: "baeckerei",
45+
description: "Brot seit 1904",
46+
category: "handwerk",
47+
logo_url: "https://cdn/logo.png",
48+
cover_image_url: "https://cdn/cover.png",
49+
address: "Marktplatz 1",
50+
opening_hours: "Mo-Fr 6-18",
51+
website_url: "https://baeckerei.example",
52+
status: "published",
53+
updated_at: "2026-07-28T10:00:00+00:00",
54+
},
55+
],
56+
business_deals: [
57+
{
58+
id: "deal-1",
59+
business_id: "biz-1",
60+
title: "2-für-1 Brot",
61+
description: "Diese Woche",
62+
deal_type: "rabatt",
63+
deal_value: "50%",
64+
image_url: "https://cdn/deal.png",
65+
start_date: "2026-08-01",
66+
end_date: "2026-08-07",
67+
status: "active",
68+
is_active: true,
69+
updated_at: "2026-07-30T10:00:00+00:00",
70+
},
71+
],
4072
};
4173

4274
function harness(result: { ok: boolean; message: string } = { ok: true, message: "" }) {
@@ -115,3 +147,59 @@ describe("a publish pass", () => {
115147
assert.equal(summary.accepted, 0);
116148
});
117149
});
150+
151+
describe("businesses dataset and buildSpecs fetch efficiency", () => {
152+
it("datasets [businesses] alone: fetches businesses once, builds kind-0 profile specs", async () => {
153+
let fetchCount = 0;
154+
const deps: PublisherDeps = {
155+
nodeSecret: SECRET,
156+
nodeId: "roebel",
157+
datasets: ["businesses"],
158+
fetchRows: async (table) => {
159+
if (table === "businesses") fetchCount++;
160+
return TABLES[table] ?? [];
161+
},
162+
relayUrl: "ws://relay",
163+
makeClient: () => ({
164+
publish: async () => ({ ok: true, message: "" }),
165+
close: () => {},
166+
}),
167+
};
168+
const summary = await publishOnce(deps);
169+
170+
// One fetch for businesses (status filter applied server-side via query string)
171+
assert.equal(fetchCount, 1);
172+
// One business spec built
173+
assert.equal(summary.built, 1);
174+
assert.equal(summary.accepted, 1);
175+
});
176+
177+
it("datasets [deals, businesses] together: fetches businesses once, builds both deal and profile specs", async () => {
178+
const fetchLog: string[] = [];
179+
const deps: PublisherDeps = {
180+
nodeSecret: SECRET,
181+
nodeId: "roebel",
182+
datasets: ["deals", "businesses"],
183+
fetchRows: async (table) => {
184+
fetchLog.push(table);
185+
return TABLES[table] ?? [];
186+
},
187+
relayUrl: "ws://relay",
188+
makeClient: () => ({
189+
publish: async () => ({ ok: true, message: "" }),
190+
close: () => {},
191+
}),
192+
};
193+
const summary = await publishOnce(deps);
194+
195+
// Businesses fetched once, business_deals fetched once
196+
const businessesFetches = fetchLog.filter((t) => t === "businesses").length;
197+
const dealsFetches = fetchLog.filter((t) => t === "business_deals").length;
198+
assert.equal(businessesFetches, 1, "businesses table should be fetched exactly once");
199+
assert.equal(dealsFetches, 1, "business_deals table should be fetched exactly once");
200+
201+
// Both a business profile spec and a deal spec are built (2 total)
202+
assert.equal(summary.built, 2);
203+
assert.equal(summary.accepted, 2);
204+
});
205+
});

0 commit comments

Comments
 (0)