fix(podcasts): serialize explicit as a real boolean in API responses

SQLite has no boolean type, so raw rows return explicit as 0/1. The
podcast edit form round-trips whatever GET /api/podcasts/:id sends it,
and the update schema requires z.boolean() — so saving any change
without also touching the explicit checkbox failed validation with
"Expected boolean, received number".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 15:42:02 +00:00
co-authored by Claude Sonnet 5
parent 5ebea55353
commit db8eb27e5f
2 changed files with 29 additions and 4 deletions
+17
View File
@@ -171,6 +171,23 @@ describe('podcasts, episodes, feed', () => {
expect(res.statusCode).toBe(201); expect(res.statusCode).toBe(201);
podcastId = res.json().id; podcastId = res.json().id;
expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/); expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/);
expect(res.json().explicit).toBe(false); // not the raw SQLite 0/1
});
it('lets the edit form round-trip a fetched podcast unmodified (regression: explicit came back as 0/1, not a bool)', async () => {
const fetched = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } });
expect(fetched.json().explicit).toBe(false);
const { episodes: _episodes, feed_url: _feedUrl, ...editForm } = fetched.json();
const res = await app.inject({
method: 'PUT',
url: `/api/podcasts/${podcastId}`,
headers: { cookie },
payload: editForm,
});
expect(res.statusCode).toBe(200);
expect(res.json().lightning_address).toBe('tester@getalby.com');
expect(res.json().explicit).toBe(false);
}); });
it('registers an episode after verifying the blob on blossom', async () => { it('registers an episode after verifying the blob on blossom', async () => {
+12 -4
View File
@@ -51,10 +51,18 @@ export default async function podcastRoutes(app: FastifyInstance) {
return p && p.owner_pubkey === pubkey ? p : null; return p && p.owner_pubkey === pubkey ? p : null;
} }
// SQLite has no boolean type — `explicit` comes back as a raw 0/1 integer.
// The client's edit form round-trips whatever this endpoint sends it, and the
// update schema requires a real boolean, so this needs to be a true boolean
// on the wire or re-submitting an untouched form fails validation.
function serializePodcast(p: Podcast): Omit<Podcast, 'explicit'> & { explicit: boolean } {
return { ...p, explicit: !!p.explicit };
}
app.get('/api/podcasts', { preHandler: app.requireAuth }, async (req) => { app.get('/api/podcasts', { preHandler: app.requireAuth }, async (req) => {
const podcasts = listPodcasts.all(req.userPubkey) as Podcast[]; const podcasts = listPodcasts.all(req.userPubkey) as Podcast[];
return podcasts.map((p) => ({ return podcasts.map((p) => ({
...p, ...serializePodcast(p),
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`, feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
})); }));
}); });
@@ -75,7 +83,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
d.category, d.explicit ? 1 : 0, d.lightning_address ?? null, d.keysend_node ?? null, d.category, d.explicit ? 1 : 0, d.lightning_address ?? null, d.keysend_node ?? null,
d.value_suggested ?? null, podcastGuidForFeedUrl(feedUrl), nowSecs(), nowSecs(), d.value_suggested ?? null, podcastGuidForFeedUrl(feedUrl), nowSecs(), nowSecs(),
); );
return reply.code(201).send({ ...(getPodcast.get(id) as Podcast), feed_url: feedUrl }); return reply.code(201).send({ ...serializePodcast(getPodcast.get(id) as Podcast), feed_url: feedUrl });
}); });
app.get('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => { app.get('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
@@ -83,7 +91,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
const p = ownedPodcast(id, req.userPubkey!); const p = ownedPodcast(id, req.userPubkey!);
if (!p) return reply.code(404).send({ error: 'podcast not found' }); if (!p) return reply.code(404).send({ error: 'podcast not found' });
return { return {
...p, ...serializePodcast(p),
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`, feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
episodes: listEpisodes.all(id) as Episode[], episodes: listEpisodes.all(id) as Episode[],
}; };
@@ -106,7 +114,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null, d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null,
d.resale_producer_share_pct, nowSecs(), id, d.resale_producer_share_pct, nowSecs(), id,
); );
return getPodcast.get(id) as Podcast; return serializePodcast(getPodcast.get(id) as Podcast);
}); });
app.delete('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => { app.delete('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {