Skip to content
Open
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
132 changes: 132 additions & 0 deletions src/__tests__/feature-signal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect } from "vitest";
import { SELF } from "cloudflare:test";

/**
* Tests for PATCH /api/signals/:id/feature — publisher homepage curation.
* Exercises Worker-level validation (happens before BIP-322 auth check).
*
* Tests that require a valid BIP-322 signature + approved signal state are
* covered indirectly through the broader signal integration tests.
*/
describe("PATCH /api/signals/:id/feature — validation", () => {
it("returns 400 when body is not valid JSON", async () => {
const res = await SELF.fetch(
"http://example.com/api/signals/test-id/feature",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: "not-json",
}
);
expect(res.status).toBe(400);
});

it("returns 400 when btc_address is missing", async () => {
const res = await SELF.fetch(
"http://example.com/api/signals/test-id/feature",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ featured: true }),
}
);
expect(res.status).toBe(400);
const body = await res.json<{ error: string }>();
expect(body.error).toContain("btc_address");
});

it("returns 400 when featured is missing", async () => {
const res = await SELF.fetch(
"http://example.com/api/signals/test-id/feature",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
}),
}
);
expect(res.status).toBe(400);
const body = await res.json<{ error: string }>();
expect(body.error).toContain("boolean");
});

it("returns 400 when featured is an integer (not boolean)", async () => {
const res = await SELF.fetch(
"http://example.com/api/signals/test-id/feature",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
featured: 1,
}),
}
);
expect(res.status).toBe(400);
const body = await res.json<{ error: string }>();
expect(body.error).toContain("boolean");
});

it("returns 400 when featured is a string (not boolean)", async () => {
const res = await SELF.fetch(
"http://example.com/api/signals/test-id/feature",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
featured: "true",
}),
}
);
expect(res.status).toBe(400);
const body = await res.json<{ error: string }>();
expect(body.error).toContain("boolean");
});

it("returns 400 when btc_address format is invalid", async () => {
const res = await SELF.fetch(
"http://example.com/api/signals/test-id/feature",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
btc_address: "not-valid-btc",
featured: true,
}),
}
);
expect(res.status).toBe(400);
const body = await res.json<{ error: string }>();
expect(body.error).toContain("BTC address");
});

it("returns 401 when auth headers are missing (valid body format)", async () => {
// Once validation passes, missing auth headers return 401
const res = await SELF.fetch(
"http://example.com/api/signals/test-id/feature",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
featured: true,
}),
}
);
expect(res.status).toBe(401);
const body = await res.json<{ error: string }>();
expect(body.error).toBeTruthy();
});
});

describe("GET /api/front-page — featured ordering", () => {
it("returns a curated signals response with signals array and curated flag", async () => {
const res = await SELF.fetch("http://example.com/api/front-page");
expect(res.status).toBe(200);
const body = await res.json<{ signals: unknown[]; total: number; curated: boolean }>();
expect(body.curated).toBe(true);
expect(Array.isArray(body.signals)).toBe(true);
});
});
18 changes: 18 additions & 0 deletions src/lib/do-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,24 @@ export async function reviewSignal(
});
}

export interface FeatureSignalInput {
btc_address: string;
featured: boolean;
}

export async function featureSignal(
env: Env,
signalId: string,
input: FeatureSignalInput
): Promise<DOResult<Signal>> {
const stub = getStub(env);
return doFetch<Signal>(stub, `/signals/${encodeURIComponent(signalId)}/feature`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
}

// ---------------------------------------------------------------------------
// Brief Signals (inclusion tracking)
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ export interface Signal {
readonly reviewed_at: string | null;
/** Models, tools, and skills used to produce this signal */
readonly disclosure: string;
/** Publisher-curated homepage placement flag */
readonly featured: boolean;
}

/**
Expand Down
95 changes: 92 additions & 3 deletions src/objects/news-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Env, Beat, Signal, SignalStatus, Streak, Brief, Classified, Classi
import { validateSlug, validateHexColor, sanitizeString, validateDateFormat } from "../lib/validators";
import { generateId, getPacificDate, getPacificYesterday, getPacificDayStartUTC, getPacificDayEndUTC, getNextDate } from "../lib/helpers";
import { CLASSIFIED_DURATION_DAYS, CLASSIFIED_BRIEF_SLOTS, CLASSIFIED_BRIEF_MAX_CHARS, CLASSIFIED_STATUSES, SIGNAL_COOLDOWN_HOURS, BEAT_EXPIRY_DAYS, MAX_SIGNALS_PER_DAY, MAX_INCLUDED_SIGNALS_PER_BRIEF, MAX_APPROVED_SIGNALS_PER_DAY, SIGNAL_STATUSES, REVIEWABLE_SIGNAL_STATUSES, CONFIG_PUBLISHER_ADDRESS, BRIEF_INCLUSION_PAYOUT_SATS, WEEKLY_PRIZE_1ST_SATS, WEEKLY_PRIZE_2ND_SATS, WEEKLY_PRIZE_3RD_SATS, SCORING_WEIGHTS, PAYMENT_STAGE_TTL_MS } from "../lib/constants";
import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL } from "./schema";
import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL, MIGRATION_FEATURED_SIGNAL_SQL } from "./schema";

// ── State machine transition maps ──
// Hoisted to module level so they are created once and are testable.
Expand Down Expand Up @@ -48,6 +48,7 @@ interface RawSignalRow {
publisher_feedback: string | null;
reviewed_at: string | null;
disclosure: string;
featured: number;
}

interface RawCompiledSignalRow extends CompiledSignalRow {
Expand Down Expand Up @@ -78,6 +79,7 @@ function rowToSignal(row: Record<string, unknown>): Signal {
publisher_feedback: raw.publisher_feedback ?? null,
reviewed_at: raw.reviewed_at ?? null,
disclosure: raw.disclosure ?? "",
featured: (raw.featured ?? 0) === 1,
};
}

Expand Down Expand Up @@ -230,7 +232,10 @@ export class NewsDO extends DurableObject<Env> {
// 14 = Re-run beat inserts (idempotent fix — v12/v13 may have failed silently on staging)
// 15 = Payment staging (confirmed-only x402 finalization keyed by paymentId)
// 16 = Approval cap index — compound index on (status, reviewed_at) for daily count queries (#362)
const CURRENT_MIGRATION_VERSION = 16;
// 17 = (reserved — see PR #343)
// 18 = (reserved — see PR #333)
// 19 = Featured signal column (publisher homepage curation — signals.featured INTEGER 0/1, closes #347)
const CURRENT_MIGRATION_VERSION = 19;
const versionRows = this.ctx.storage.sql
.exec("SELECT value FROM config WHERE key = 'migration_version'")
.toArray();
Expand Down Expand Up @@ -448,6 +453,20 @@ export class NewsDO extends DurableObject<Env> {
}
}

// Featured signal column — publisher homepage curation (closes #347).
if (appliedVersion < 19) {
for (const stmt of MIGRATION_FEATURED_SIGNAL_SQL) {
try {
this.ctx.storage.sql.exec(stmt);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes("duplicate column") && !msg.includes("already exists")) {
console.error("Featured signal migration statement failed:", e);
}
}
}
}

// Record current migration version so future cold starts skip all of the above.
this.ctx.storage.sql.exec(
"INSERT INTO config (key, value) VALUES ('migration_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
Expand Down Expand Up @@ -861,6 +880,74 @@ export class NewsDO extends DurableObject<Env> {
return c.json({ ok: true, data: signal, approval_cap: approvalCap } as DOResult<Signal>);
});

// PATCH /signals/:id/feature — Publisher toggles homepage featured flag
// Body: { btc_address, featured: boolean }
// Only approved/brief_included signals can be featured.
this.router.patch("/signals/:id/feature", async (c) => {
const id = c.req.param("id");
const body = await parseRequiredJson(c);
if (!body) {
return c.json({ ok: false, error: "Invalid JSON body" } satisfies DOResult<Signal>, 400);
}

const { btc_address, featured } = body;
if (typeof featured !== "boolean") {
return c.json({ ok: false, error: '"featured" must be a boolean' } satisfies DOResult<Signal>, 400);
}

// Verify publisher designation
const publisherRows = this.ctx.storage.sql
.exec("SELECT value FROM config WHERE key = ?", CONFIG_PUBLISHER_ADDRESS)
.toArray();
if (publisherRows.length === 0) {
return c.json({ ok: false, error: "Publisher not yet designated" } satisfies DOResult<Signal>, 403);
}
const publisherAddress = (publisherRows[0] as { value: string }).value;
if (btc_address !== publisherAddress) {
return c.json({ ok: false, error: "Only the designated Publisher can perform this action" } satisfies DOResult<Signal>, 403);
}

// Verify signal exists
const signalRows = this.ctx.storage.sql
.exec("SELECT id, status FROM signals WHERE id = ?", id)
.toArray();
if (signalRows.length === 0) {
return c.json({ ok: false, error: `Signal "${id}" not found` } satisfies DOResult<Signal>, 404);
}

// Only approved or brief_included signals can be featured
const currentStatus = (signalRows[0] as { id: string; status: SignalStatus }).status;
if (currentStatus !== "approved" && currentStatus !== "brief_included") {
return c.json({
ok: false,
error: `Only approved or brief_included signals can be featured (current: "${currentStatus}")`,
} satisfies DOResult<Signal>, 400);
}

const now = new Date().toISOString();
this.ctx.storage.sql.exec(
"UPDATE signals SET featured = ?, updated_at = ? WHERE id = ?",
featured ? 1 : 0,
now,
id
);

const updated = this.ctx.storage.sql
.exec(
`SELECT s.*, b.name as beat_name, GROUP_CONCAT(st.tag) as tags_csv
FROM signals s
LEFT JOIN beats b ON s.beat_slug = b.slug
LEFT JOIN signal_tags st ON s.id = st.signal_id
WHERE s.id = ?1
GROUP BY s.id`,
id
)
.toArray();

const signal = rowToSignal(updated[0] as Record<string, unknown>);
return c.json({ ok: true, data: signal } satisfies DOResult<Signal>);
});

// -------------------------------------------------------------------------
// Beats CRUD
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -1424,6 +1511,8 @@ export class NewsDO extends DurableObject<Env> {
// GET /signals/front-page — all approved + brief_included signals in a single query
// Eliminates the need for two separate /signals calls from the Worker route.
// LIMIT 500 preserves the old behavior (200 approved + 200 brief_included = up to 400).
// Featured signals (featured=1) float to the top; within featured and non-featured,
// signals are ordered by created_at DESC.
this.router.get("/signals/front-page", (c) => {
const rows = this.ctx.storage.sql
.exec(
Expand All @@ -1433,7 +1522,7 @@ export class NewsDO extends DurableObject<Env> {
LEFT JOIN signal_tags st ON s.id = st.signal_id
WHERE s.status IN ('approved', 'brief_included')
GROUP BY s.id
ORDER BY s.created_at DESC
ORDER BY s.featured DESC, s.created_at DESC
LIMIT 500`
)
.toArray();
Expand Down
12 changes: 12 additions & 0 deletions src/objects/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,15 @@ ON CONFLICT(slug) DO UPDATE SET
export const MIGRATION_APPROVAL_CAP_INDEX_SQL = [
"CREATE INDEX IF NOT EXISTS idx_signals_status_reviewed ON signals(status, reviewed_at)",
] as const;

/**
* MIGRATION_FEATURED_SIGNAL_SQL — publisher homepage curation (#347).
* Adds a `featured` INTEGER column (0/1 flag) to signals so the Publisher can
* pin top stories to the front of the homepage feed.
*
* Closes #347.
*/
export const MIGRATION_FEATURED_SIGNAL_SQL = [
"ALTER TABLE signals ADD COLUMN featured INTEGER NOT NULL DEFAULT 0",
"CREATE INDEX IF NOT EXISTS idx_signals_featured ON signals(featured) WHERE featured = 1",
] as const;
Loading
Loading