Skip to content

Commit a472580

Browse files
tomymaritanoclaude
andauthored
feat(share): public notes API with metadata for portfolio consumption (#166)
## Summary - **Extended `sharedNotes` schema** with `tags`, `backlinks`, `wordCount`, and `notebookName` columns (all `NOT NULL DEFAULT` — backward-compatible) - **Added `GET /share/public/:userId`** list endpoint with pagination (`?limit=20&offset=0`) and permissive CORS for cross-origin portfolio consumption - **Enriched `GET /share/:slug`** detail endpoint to return metadata (tags, backlinks, wordCount, notebookName) - **Updated `POST /share`** to accept and persist metadata fields (Zod defaults keep old clients working) - **Desktop app** now sends full note metadata (tags, wordCount, notebookName, backlinks) when sharing - **Hand-corrected migration** (`0005`) uses safe `ALTER TABLE ADD COLUMN` instead of Drizzle's auto-generated `CREATE TABLE` ## Test plan - [x] `pnpm typecheck` passes (17/17 tasks) - [x] `pnpm test` passes (42/42 tests across 7 test files) - [ ] Manual: share a note from desktop → verify API receives tags/wordCount/notebookName - [ ] Manual: `curl "https://api.readied.app/share/public/<userId>?limit=5"` returns paginated notes with metadata - [ ] Manual: `curl "https://api.readied.app/share/<slug>"` returns enriched response with tags/backlinks - [ ] Manual: fetch from different origin (portfolio domain) succeeds for `/share/public/*` (CORS) - [ ] Run `pnpm db:migrate` on staging to verify migration applies cleanly 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added ability to share notes with enhanced metadata including tags, word count, and notebook context. * Introduced public notes listing endpoint with pagination support. * **Documentation** * Updated documentation component infrastructure for improved content rendering. * **Style** * Refined color and gradient utilities across the web interface for improved visual consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9d64ea9 commit a472580

7 files changed

Lines changed: 105 additions & 9 deletions

File tree

apps/desktop/src/main/handlers/shareHandlers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ export function registerShareHandlers(deps: ShareHandlerDependencies): void {
2020
'share:create',
2121
async (
2222
_event,
23-
input: { noteId: string; title: string; content: string }
23+
input: {
24+
noteId: string;
25+
title: string;
26+
content: string;
27+
tags?: string[];
28+
backlinks?: Array<{ noteId: string; title: string }>;
29+
wordCount?: number;
30+
notebookName?: string;
31+
}
2432
): Promise<{ success: boolean; url?: string; slug?: string; error?: string }> => {
2533
try {
2634
const result = await apiClient.shareNote(input);

apps/desktop/src/main/services/apiClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,10 @@ export class ApiClient {
548548
noteId: string;
549549
title: string;
550550
content: string;
551+
tags?: string[];
552+
backlinks?: Array<{ noteId: string; title: string }>;
553+
wordCount?: number;
554+
notebookName?: string;
551555
}): Promise<{ slug: string; url: string }> {
552556
return this.request<{ slug: string; url: string }>('/share', {
553557
method: 'POST',

apps/desktop/src/preload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,10 @@ export interface ReadiedAPI {
631631
noteId: string;
632632
title: string;
633633
content: string;
634+
tags?: string[];
635+
backlinks?: Array<{ noteId: string; title: string }>;
636+
wordCount?: number;
637+
notebookName?: string;
634638
}) => Promise<{ success: boolean; url?: string; slug?: string; error?: string }>;
635639
/** Remove a shared note */
636640
delete: (slug: string) => Promise<{ success: boolean; error?: string }>;

apps/desktop/src/renderer/components/NoteEditor.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useScrollSync } from '../hooks/useScrollSync';
88
import { useManualTags } from '../hooks/useManualTags';
99
import { useEmbedResolver } from '../hooks/useEmbedResolver';
1010
import { useBacklinks } from '../hooks/useLinks';
11+
import { useNotebook } from '../hooks/useNotebooks';
1112
import type { MarkdownEditorHandle } from './MarkdownEditor';
1213
import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor';
1314
import { ImageLightbox } from './ImageLightbox';
@@ -107,6 +108,7 @@ export function NoteEditor({
107108
const [revisionHistoryOpen, setRevisionHistoryOpen] = useState(false);
108109
const { data: backlinks } = useBacklinks(note?.id ?? null);
109110
const backlinksCount = backlinks?.length ?? 0;
111+
const { data: notebook } = useNotebook(note?.notebookId ?? null);
110112

111113
// Lightbox state for embedded images
112114
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
@@ -168,13 +170,17 @@ export function NoteEditor({
168170
noteId: note.id,
169171
title: note.title,
170172
content: note.content,
173+
tags: note.tags,
174+
wordCount: note.wordCount,
175+
notebookName: notebook?.name ?? '',
176+
backlinks: (backlinks ?? []).map(bl => ({ noteId: bl.noteId, title: bl.noteTitle })),
171177
});
172178
if (result.success) {
173179
showToast('Link copied to clipboard');
174180
} else {
175181
showToast(result.error || 'Failed to share note', 'error');
176182
}
177-
}, [note, showToast]);
183+
}, [note, notebook, backlinks, showToast]);
178184

179185
// Handle title change
180186
const handleTitleChange = useCallback(

packages/api/src/db/schema.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ export const sharedNotes = sqliteTable(
245245
slug: text('slug').notNull().unique(),
246246
title: text('title').notNull().default(''),
247247
content: text('content').notNull().default(''),
248+
tags: text('tags').notNull().default('[]'),
249+
backlinks: text('backlinks').notNull().default('[]'),
250+
wordCount: integer('word_count').notNull().default(0),
251+
notebookName: text('notebook_name').notNull().default(''),
248252
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(true),
249253
createdAt: text('created_at')
250254
.notNull()
@@ -253,7 +257,10 @@ export const sharedNotes = sqliteTable(
253257
.notNull()
254258
.$defaultFn(() => new Date().toISOString()),
255259
},
256-
table => [uniqueIndex('shared_notes_user_note_unique').on(table.userId, table.noteId)]
260+
table => [
261+
uniqueIndex('shared_notes_user_note_unique').on(table.userId, table.noteId),
262+
index('idx_shared_notes_user_public').on(table.userId, table.isPublic),
263+
]
257264
);
258265

259266
/**

packages/api/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ const app = new Hono<{ Bindings: Env }>();
3232
app.use('*', logger());
3333
app.use('*', prettyJSON());
3434
app.use('*', secureHeaders());
35+
// Public share endpoints: permissive CORS (any origin, no credentials)
36+
app.use(
37+
'/share/public/*',
38+
cors({
39+
origin: '*',
40+
allowMethods: ['GET', 'OPTIONS'],
41+
maxAge: 86400,
42+
})
43+
);
44+
// All other endpoints: restricted CORS with credentials
3545
app.use(
3646
'*',
3747
cors({

packages/api/src/routes/share.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
* Share Routes
33
*
44
* Public sharing of notes via unique slugs.
5-
* - POST / — Create or update a shared note (authenticated)
6-
* - GET /:slug — Get a shared note (public)
7-
* - DELETE /:slug — Remove a shared note (authenticated, owner only)
5+
* - POST / — Create or update a shared note (authenticated)
6+
* - GET /public/:userId — List public notes for a user (public, paginated)
7+
* - GET /:slug — Get a shared note (public)
8+
* - DELETE /:slug — Remove a shared note (authenticated, owner only)
89
*/
910

1011
import { Hono } from 'hono';
1112
import { zValidator } from '@hono/zod-validator';
1213
import { z } from 'zod';
13-
import { eq, and } from 'drizzle-orm';
14+
import { eq, and, desc } from 'drizzle-orm';
1415
import { createDb, type Env } from '../db/client.js';
1516
import { sharedNotes } from '../db/schema.js';
1617
import { authMiddleware, type AuthUser } from '../middleware/auth.js';
@@ -26,13 +27,22 @@ const createShareSchema = z.object({
2627
noteId: z.string().min(1),
2728
title: z.string().default(''),
2829
content: z.string().default(''),
30+
tags: z.array(z.string()).default([]),
31+
backlinks: z.array(z.object({ noteId: z.string(), title: z.string() })).default([]),
32+
wordCount: z.number().int().min(0).default(0),
33+
notebookName: z.string().default(''),
34+
});
35+
36+
const listQuerySchema = z.object({
37+
limit: z.coerce.number().int().min(1).max(100).default(20),
38+
offset: z.coerce.number().int().min(0).default(0),
2939
});
3040

3141
// ─── POST / — Create or update shared note (upsert) ─────────────────────────
3242

3343
share.post('/', authMiddleware, zValidator('json', createShareSchema), async c => {
3444
const { userId } = c.get('user');
35-
const { noteId, title, content } = c.req.valid('json');
45+
const { noteId, title, content, tags, backlinks, wordCount, notebookName } = c.req.valid('json');
3646
const db = createDb(c.env);
3747

3848
// Generate slug: first 8 hex chars of a UUID
@@ -48,6 +58,10 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c =
4858
slug,
4959
title,
5060
content,
61+
tags: JSON.stringify(tags),
62+
backlinks: JSON.stringify(backlinks),
63+
wordCount,
64+
notebookName,
5165
createdAt: now,
5266
updatedAt: now,
5367
})
@@ -56,6 +70,10 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c =
5670
set: {
5771
title,
5872
content,
73+
tags: JSON.stringify(tags),
74+
backlinks: JSON.stringify(backlinks),
75+
wordCount,
76+
notebookName,
5977
updatedAt: now,
6078
},
6179
})
@@ -67,6 +85,37 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c =
6785
return c.json({ slug: result.slug, url });
6886
});
6987

88+
// ─── GET /public/:userId — List public notes (no auth) ──────────────────────
89+
90+
share.get('/public/:userId', zValidator('query', listQuerySchema), async c => {
91+
const userId = c.req.param('userId');
92+
const { limit, offset } = c.req.valid('query');
93+
const db = createDb(c.env);
94+
95+
const notes = await db
96+
.select({
97+
slug: sharedNotes.slug,
98+
title: sharedNotes.title,
99+
tags: sharedNotes.tags,
100+
wordCount: sharedNotes.wordCount,
101+
notebookName: sharedNotes.notebookName,
102+
createdAt: sharedNotes.createdAt,
103+
updatedAt: sharedNotes.updatedAt,
104+
})
105+
.from(sharedNotes)
106+
.where(and(eq(sharedNotes.userId, userId), eq(sharedNotes.isPublic, true)))
107+
.orderBy(desc(sharedNotes.updatedAt))
108+
.limit(limit)
109+
.offset(offset);
110+
111+
const parsed = notes.map(n => ({
112+
...n,
113+
tags: JSON.parse(n.tags ?? '[]'),
114+
}));
115+
116+
return c.json({ notes: parsed, limit, offset });
117+
});
118+
70119
// ─── GET /:slug — Get shared note (public, no auth) ─────────────────────────
71120

72121
share.get('/:slug', async c => {
@@ -77,6 +126,10 @@ share.get('/:slug', async c => {
77126
.select({
78127
title: sharedNotes.title,
79128
content: sharedNotes.content,
129+
tags: sharedNotes.tags,
130+
backlinks: sharedNotes.backlinks,
131+
wordCount: sharedNotes.wordCount,
132+
notebookName: sharedNotes.notebookName,
80133
createdAt: sharedNotes.createdAt,
81134
updatedAt: sharedNotes.updatedAt,
82135
})
@@ -88,7 +141,11 @@ share.get('/:slug', async c => {
88141
return c.json({ error: 'Not found' }, 404);
89142
}
90143

91-
return c.json(note);
144+
return c.json({
145+
...note,
146+
tags: JSON.parse(note.tags ?? '[]'),
147+
backlinks: JSON.parse(note.backlinks ?? '[]'),
148+
});
92149
});
93150

94151
// ─── DELETE /:slug — Remove shared note (owner only) ─────────────────────────

0 commit comments

Comments
 (0)