BlogEditor/docs/plan-link-posts.md

27 KiB
Raw Permalink Blame History

Product & Implementation Plan: Link Posts (Already-Hosted Webpage)

Document type: Product / Implementation Plan
Feature: Link-only posts (external URL to already-hosted webpage)
Scope: Single content type extension; no HTML storage, no new servers.
Status: Complete
Last updated: 2025-02-10


1. Document control

Version Date Author Changes
0.1 2025-02-10 Initial full plan (link-only)
0.2 2025-02-10 Complete: decisions, implementation details, request/response examples, migration script, manual test script, Definition of Done, exact file paths

Stakeholders: Product, Engineering (backend, frontend, mobile).
Success owner: Delivery of “publish link post in dashboard → viewable on web and in Android app” with zero regression on existing TipTap posts.


2. Executive summary

We are adding a second post type, Link, so authors can add a post that is only a title + URL pointing to an already-hosted webpage (any site: static HTML, React SPA, etc.). No content is stored beyond the URL. When a user publishes a link post:

  • Web: Viewing the post (dashboard “View” or public /blog/:slug) sends the user to that URL (redirect or new tab).
  • Android app: Opening the post shows the same URL inside an in-app WebView.

Existing TipTap posts (rich editor content stored in the DB) remain unchanged and fully supported. This plan defines scope, data model, APIs, UI/UX, implementation phases, testing, and rollout.


3. Problem & opportunity

Problem: Authors can only create posts by writing content in the TipTap editor. They cannot “add a post” that is simply a link to an external page they already host (e.g. a React app on Vercel, a landing page, a doc site).

Opportunity: One unified blog surface (dashboard + app) where some entries are rich editor content and others are links to external pages. Same publish/unpublish and listing; different rendering (TipTap vs WebView/redirect). No new infrastructure.


4. Goals and success criteria

Goal Success criteria
Support link posts Author can create a post with only title + URL; it appears in dashboard and (when published) in app and on web.
Parity web ↔ app If the URL is viewable in a browser, it is viewable in the Android app via WebView.
No regression All existing TipTap posts continue to list, edit, and display correctly on web and in app.
Single source of control Same backend and DB (blog-editor + Supabase); no new server or new app.

Out of scope for this plan: Stored HTML, file uploads, “show in app” toggle, multiple blogs. Those can be separate follow-up plans.


5. Scope

5.1 In scope

  • Database: Add content_type and external_url to posts (additive only).
  • Blog-editor backend: Accept and return new fields; validate link posts (title + URL required).
  • Blog-editor frontend: Post type selector (TipTap vs Link); Link form (title + URL); dashboard badge; public view redirect for link posts.
  • API-v1: Return contentType and externalUrl in blog-posts responses.
  • Android app: Extend model; for link posts, open URL in WebView instead of TipTap renderer.

5.2 Out of scope

  • Stored HTML or raw HTML content.
  • Uploading or hosting React/HTML builds; only storing a URL to an already-hosted page.
  • “Show in app” vs “public only” (single visibility: published = web + app).
  • New server or new deployment target; everything uses existing blog-editor backend and api-v1.

6. User personas and user stories

Persona: Blog author (uses dashboard at e.g. http://localhost:4000/dashboard and expects content to appear in the Android app.)

ID User story Acceptance criteria
US-1 As an author, I can create a “link” post with a title and URL so that I dont have to copy-paste content. Create flow has “Link” option; form has Title + URL; save creates post with content_type: 'link' and external_url set.
US-2 As an author, I can see which posts are links vs rich content in the dashboard. List shows a “Link” badge (or similar) for link posts.
US-3 As an author, I can edit or delete a link post like any other post. Edit opens Link form with URL; Update/Delete work via existing API.
US-4 As a reader on the web, when I open a published link post I am taken to the external page. Public /blog/:slug for a link post redirects to external_url (or opens in new tab).
US-5 As a reader in the Android app, when I open a published link post I see the external page inside the app. Detail screen opens the URL in a WebView for link posts; TipTap posts still use TipTap renderer.
US-6 As an author, my existing TipTap posts still work. No change in list, edit, or view for posts without content_type: 'link' (or with content_type: 'tiptap').

7. Current state (as-is)

Layer Component Behavior
DB Supabase posts Columns: id, user_id, title, content_json (JSONB), slug, status (draft/published), created_at, updated_at. All posts are editor content.
Backend blog-editor routes/posts.js GET list returns selected columns (no content_type). Create requires title + content_json. Update accepts title, content_json, status. GET by slug returns full row (public).
Frontend Dashboard, Editor, BlogPost Single editor type; create/update always send content_json. Public /blog/:slug renders TipTap from content_json.
API-v1 blog-posts.route.js Reads posts from Supabase; returns content (from content_json), no content_type or external_url.
Android BlogApiClient, BlogDetailScreen Fetches post; always renders body with TipTapContentRenderer(content).

8. Target state (to-be)

Layer Change
DB posts has content_type (default 'tiptap'), external_url (nullable). Existing rows default to tiptap.
Backend List returns content_type, external_url. Create/update accept content_type and external_url; for link, require external_url and allow minimal/empty content_json.
Frontend Editor: type selector (TipTap / Link). Link = title + URL only. Dashboard: badge for Link. Public: link post → redirect to external_url.
API-v1 All blog-posts responses include contentType, externalUrl.
Android Model has contentType, externalUrl. Detail: if link → WebView(externalUrl); else → TipTapContentRenderer.

9. Data model and schema

9.1 New columns (Supabase posts)

Column Type Default Nullable Description
content_type VARCHAR(20) 'tiptap' No Allowed: 'tiptap', 'link'.
external_url TEXT Yes For link posts: the URL to open.

9.2 Migration SQL

Run once (Supabase SQL editor or migration script):

ALTER TABLE posts
  ADD COLUMN IF NOT EXISTS content_type VARCHAR(20) DEFAULT 'tiptap'
  CHECK (content_type IN ('tiptap', 'link'));

ALTER TABLE posts
  ADD COLUMN IF NOT EXISTS external_url TEXT NULL;
  • Existing rows: content_type = 'tiptap', external_url = NULL.
  • New link post: content_type = 'link', external_url = 'https://...'. Keep content_json as '{}' if the column is NOT NULL.

9.3 Validation rules

  • Link post: content_type = 'link'external_url must be non-empty and should be a valid URL (scheme http/https). Backend may normalize (trim, add https if missing) per product choice.
  • TipTap post: content_type = 'tiptap' or omitted ⇒ content_json required as today.

10. API contract

10.1 Blog-editor backend (existing routes)

GET /api/posts (list, authenticated)

  • Response (per post): Include content_type, external_url in each object (in addition to existing fields).

GET /api/posts/:id (single, authenticated)

  • Response: Full row; will include content_type, external_url once columns exist (e.g. via SELECT *).

GET /api/posts/slug/:slug (public)

  • Response: Full row; same as above.

POST /api/posts (create, authenticated)

  • Request body (link): { "title": "...", "content_type": "link", "external_url": "https://...", "status": "draft" }. content_json may be omitted or {}.
  • Request body (tiptap): Unchanged; title and content_json required; content_type optional or 'tiptap'.
  • Response: Created row including content_type, external_url.

PUT /api/posts/:id (update, authenticated)

  • Request body: May include content_type, external_url. Same validation as create when present.
  • Response: Updated row including new columns.

10.2 API-v1 (blog-posts)

GET /blog-posts, GET /blog-posts/:id, GET /blog-posts/slug/:slug, GET /blog-posts/user/:userId

  • Response mapping (add): contentType: post.content_type, externalUrl: post.external_url. Keep existing content (from content_json) for backward compatibility.

11. UI/UX flows

11.1 Dashboard (list)

  • Display: Each card shows title, status, date. For link posts, show a “Link” badge (or icon) so authors can distinguish from TipTap.
  • Actions: Edit, (Un)Publish, Delete, View (for published). For link posts, “View” can open external_url in a new tab, or open /blog/:slug (which then redirects); both are acceptable; document choice.

11.2 Editor (create / edit)

  • Entry: “New Post” or “Edit” opens editor. First or prominent choice: Post type — “TipTap” (default) or “Link”.
  • If TipTap: Current UI (title + TipTap editor). Save sends content_json; optional content_type: 'tiptap'.
  • If Link: Show only Title (required) and URL (required). Optional: slug override, status. No rich editor. Save sends content_type: 'link', external_url: <trimmed URL>, content_json: {}. Publish button same as today (sets status to published).
  • Edit existing: On load, if content_type === 'link', show Link form and prefill URL; otherwise show TipTap editor.

11.3 Public blog page (web)

  • Route: /blog/:slug (unchanged).
  • Behavior: After fetching post by slug: if content_type === 'link' and external_url present ⇒ redirect to external_url (e.g. window.location.href = post.external_url). Else ⇒ render TipTap content as today.

11.4 Android app (detail)

  • Entry: User taps a post in the blog list.
  • Behavior: If contentType == "link" and externalUrl != null ⇒ open WebView with externalUrl (same URL as web). Else ⇒ render with TipTapContentRenderer using content (unchanged). Top bar (back, title) remains; content area is either WebView or TipTap.

12. Implementation phases

Phase 1: Data and backend (no UI change)

Task ID Task Owner Acceptance criteria Dependency
1.1 Run DB migration: add content_type, external_url Backend Columns exist; existing rows have default tiptap, NULL url
1.2 Blog-editor backend: extend GET list to return content_type, external_url Backend List response includes new fields 1.1
1.3 Blog-editor backend: create post — accept content_type, external_url; for link require URL, allow empty/minimal content_json Backend Create link post via API succeeds; tiptap create unchanged 1.1
1.4 Blog-editor backend: update post — accept and persist content_type, external_url Backend Update link post via API succeeds 1.1
1.5 API-v1: add content_type, external_url to select and response map (all blog-posts endpoints) Backend App can receive contentType, externalUrl 1.1

Phase 1 exit: Backend and DB support link posts; existing clients ignore new fields; no regression.


Task ID Task Owner Acceptance criteria Dependency
2.1 Android: add contentType, externalUrl to BlogPost model (nullable) Mobile Old API response still parses Phase 1
2.2 Android: add WebView composable (e.g. BlogWebView) that takes URL and loads in WebView Mobile Composable loads URL and shows loading state
2.3 Android: BlogDetailScreen — if link post, show WebView with externalUrl; else TipTapContentRenderer Mobile Link post opens in WebView; tiptap post unchanged 2.1, 2.2

Phase 2 exit: Published link posts open in WebView in app; TipTap posts unchanged.


Task ID Task Owner Acceptance criteria Dependency
3.1 Dashboard: show “Link” badge (or icon) for posts where content_type === 'link' Frontend Author can see which posts are links Phase 1
3.2 Editor: add post type selector (TipTap / Link); when Link, show Title + URL form only; save with content_type and external_url Frontend Author can create and save link post from UI Phase 1
3.3 Editor: on load existing post, if link type show Link form and prefill URL Frontend Author can edit link post 3.2
3.4 BlogPost.jsx: if post is link and has external_url, redirect to external_url Frontend Public view of link post goes to external page Phase 1
3.5 Dashboard: “View” for link post — open external_url in new tab (or keep View as /blog/:slug; ensure 3.4 is in place) Frontend Consistent with product choice (new tab vs redirect) 3.1

Phase 3 exit: Full flow: author creates link post in dashboard → publishes → sees it on web (redirect) and in app (WebView); existing TipTap flow unchanged.

12.4 Definition of Done (per phase)

Phase Done when
Phase 1 Migration applied; blog-editor and api-v1 return content_type and external_url; link post can be created/updated via API (e.g. cURL/Postman); existing tiptap create/update still works.
Phase 2 App builds; link post opens in WebView; tiptap post still uses TipTapContentRenderer; no crash when contentType is null or missing.
Phase 3 Author can select “Link” in editor, enter URL, save and publish; dashboard shows Link badge; opening published link post on web redirects to URL; “View” for link in dashboard opens URL (per decision below).

13. Testing strategy

Type Scope Key cases
Regression TipTap posts List, create, edit, delete, publish, view on web, view in app. No behavior change.
Link post API Backend + api-v1 Create link (title + URL); GET list/id/slug return contentType, externalUrl; update link URL; link post has empty/minimal content_json.
Link post Web Blog-editor frontend Create link post; dashboard shows badge; edit link post; public /blog/:slug redirects to URL.
Link post App Android List shows link post; open link post → WebView loads URL; back and TipTap post still render with TipTap.
Edge cases All Invalid URL (backend validation); missing external_url for link type (400); old app version (ignores new fields, no crash).

No new server or environment; use existing local/Supabase and api-v1.


14. Rollout and release

Step Action
1 Deploy DB migration (add columns).
2 Deploy blog-editor backend and api-v1 (backward compatible).
3 Deploy Android app (new model fields + WebView branch).
4 Deploy blog-editor frontend (type selector, Link form, redirect, badge).
5 Smoke: create one link post, publish, view on web and in app; verify one TipTap post still works.

Rollback: Remove new columns only if no link posts exist (or add a follow-up migration to clear link posts). Backend/frontend/app can be reverted to previous versions; default content_type and nullable external_url prevent breakage for existing rows.


15. Risks and mitigations

Risk Mitigation
Existing posts break if content_type default wrong Default 'tiptap' and nullable external_url; no backfill required.
App crashes on unknown content_type App treats only "link" as WebView; everything else (null, tiptap, future) uses TipTap.
Invalid or malicious URL stored Backend: validate scheme (http/https), length, and optionally allowlist domains if needed.
WebView security (Android) Use standard WebView; consider certificate pinning or safe-browsing only if required later.

16. Decisions (resolved)

Decision Choice Rationale
URL validation Moderate: Non-empty string, must start with http:// or https://. Trim whitespace. Max length 2048. Balances safety and flexibility; no allowlist so any already-hosted page works.
Dashboard “View” for link post Open external_url in new tab (target="_blank"). Author expects to see the live page; redirect from /blog/:slug is for shared/public links; dashboard is author-facing.
Public /blog/:slug for link post Redirect (window.location.href = external_url). Shared link behavior matches “one URL”; app and web both end on the same external page.
content_json for link posts Store {} (empty JSON object). Keeps content_json NOT NULL without schema change; backend sends '{}' when creating/updating link post.
Serialization (API-v1 / Android) camelCase in JSON: contentType, externalUrl. Matches existing api-v1 style (e.g. createdAt, updatedAt).

Future work (out of scope): Stored HTML, “show in app” toggle, multiple blogs — separate PRDs/plans.


17. Appendix

17.1 File reference (exact paths)

Area File(s)
DB migration blog-editor/backend/migrations/add-link-post-columns.js (new; see §20) or Supabase SQL editor
Blog-editor backend blog-editor/backend/routes/posts.js
Blog-editor frontend blog-editor/frontend/src/pages/Dashboard.jsx, blog-editor/frontend/src/pages/Editor.jsx, blog-editor/frontend/src/pages/BlogPost.jsx
API-v1 api-v1/routes/blog-posts.route.js
Android model android-app/app/src/main/java/com/livingai/android/api/BlogApiClient.kt (BlogPost data class)
Android detail android-app/app/src/main/java/com/livingai/android/ui/screens/BlogDetailScreen.kt
Android WebView New: android-app/app/src/main/java/com/livingai/android/ui/components/BlogWebView.kt

17.2 Glossary

Term Meaning
Link post Post with content_type: 'link' and external_url set; no rich body stored.
TipTap post Post with rich content in content_json, rendered by TipTap (web) and TipTapContentRenderer (app).
Already-hosted The URL points to a page the author hosts elsewhere (Vercel, S3, etc.); we do not host the page.

18. Implementation details (per file)

18.1 Blog-editor backend (blog-editor/backend/routes/posts.js)

  • GET / (list): Change SELECT to include new columns:
    • From: SELECT id, title, slug, status, created_at, updated_at
    • To: SELECT id, title, slug, status, content_type, external_url, created_at, updated_at
  • POST / (create):
    • Read content_type, external_url from req.body.
    • If content_type === 'link': require title and non-empty external_url; validate URL (starts with http:// or https://, length ≤ 2048, trim); set content_json = '{}' for INSERT. Return 400 if URL missing or invalid.
    • Else (tiptap or omitted): require title and content_json as today.
    • INSERT: add content_type and external_url to column list and values (e.g. $6, $7). Use default 'tiptap' and NULL when not link.
  • PUT /:id (update):
    • Read content_type, external_url from req.body.
    • If content_type === 'link' (or external_url provided): validate URL as above; add to dynamic updates: content_type = $n, external_url = $n+1.
    • Append these to the existing dynamic update logic; ensure slug/title/content_json/status logic is unchanged.
  • GET /:id and GET /slug/:slug: Use SELECT * (or add content_type, external_url to SELECT). No change if already SELECT *.

18.2 API-v1 (api-v1/routes/blog-posts.route.js)

  • In every blog-posts endpoint (GET list, GET :id, GET slug/:slug, GET user/:userId):
    • build/select: Add content_type, external_url to the .select() list (e.g. .select('id', 'title', 'slug', 'content_json', 'status', 'created_at', 'updated_at', 'user_id', 'content_type', 'external_url')). If using .first() without explicit select, ensure the table has the columns and the returned row includes them.
    • map: Add to the returned object: contentType: post.content_type, externalUrl: post.external_url. Use optional chaining or default so missing columns dont break (e.g. contentType: post.content_type ?? 'tiptap', externalUrl: post.external_url ?? null).

18.3 Android app

  • BlogPost model (BlogApiClient.kt): Add to BlogPost data class:
    • val contentType: String? = null
    • val externalUrl: String? = null
    • Use @SerialName("contentType") and @SerialName("externalUrl") if the serializer is strict about naming.
  • BlogDetailScreen.kt: In BlogPostContent (or the composable that chooses content): accept post: BlogPost. If post.contentType == "link" and post.externalUrl != null, render a full-screen WebView (or new composable BlogWebView(post.externalUrl)). Else, keep existing TipTapContentRenderer(content = post.content, ...).
  • BlogWebView.kt (new): Composable that takes url: String, uses AndroidView + WebView, calls webView.loadUrl(url), enables JavaScript, optionally shows a loading indicator until onPageFinished. Use Modifier.fillMaxSize() inside the content slot of the existing scaffold.

18.4 Blog-editor frontend

  • Dashboard.jsx: When mapping over posts, for each post if post.content_type === 'link' (or post.contentType if backend camelCases) render a small badge “Link” next to the status. For “View” button when post is link, use <a href={post.external_url} target="_blank" rel="noopener noreferrer">View</a> (or equivalent) so it opens in new tab.
  • Editor.jsx: Add state: contentType: 'tiptap' | 'link' (default 'tiptap'), externalUrl: string (default ''). On mount when editing (id present), after fetchPost set contentType from res.data.content_type and externalUrl from res.data.external_url. Add a type selector at the top (e.g. two buttons or radio: “Article” / “Link”). When “Link” is selected: show Title input + URL input (type="url" or type="text"); hide the TipTap Editor component. On save (auto-save and Publish): if contentType === 'link' send content_type: 'link', external_url: externalUrl.trim(), content_json: {}; else send current payload. For link, require title and externalUrl before save/publish.
  • BlogPost.jsx: After setPost(res.data) (or in a useEffect that runs when post is set), if post.content_type === 'link' and post.external_url then window.location.href = post.external_url (redirect). Else keep existing TipTap rendering (editor and setContent).

Note: If the public slug endpoint requires authentication, the blog-editor frontend may need to call a different endpoint for unauthenticated public view (e.g. api-v1 GET blog-posts/slug/:slug with no auth, or blog-editor backend may expose an unauthenticated slug route). Resolve per current auth setup.


19. Request/response examples

19.1 Create link post (blog-editor backend)

Request: POST /api/posts (with Authorization header)

{
  "title": "Our React Demo",
  "content_type": "link",
  "external_url": "https://my-app.vercel.app",
  "status": "draft"
}

Response: 201 Created

{
  "id": "uuid",
  "user_id": "uuid",
  "title": "Our React Demo",
  "content_json": {},
  "slug": "our-react-demo-1234567890",
  "status": "draft",
  "content_type": "link",
  "external_url": "https://my-app.vercel.app",
  "created_at": "...",
  "updated_at": "..."
}

19.2 Create TipTap post (unchanged)

Request: POST /api/posts

{
  "title": "My Article",
  "content_json": { "type": "doc", "content": [...] },
  "status": "draft"
}

19.3 API-v1 response (single post) with link fields

Response: GET /blog-posts/:id or GET /blog-posts/slug/:slug

{
  "id": "uuid",
  "title": "Our React Demo",
  "slug": "our-react-demo-1234567890",
  "content": {},
  "status": "published",
  "createdAt": "...",
  "updatedAt": "...",
  "userId": "uuid",
  "contentType": "link",
  "externalUrl": "https://my-app.vercel.app"
}

20. Migration script

Create blog-editor/backend/migrations/add-link-post-columns.js and run from blog-editor/backend: node migrations/add-link-post-columns.js. Ensure .env is loaded (same as existing migrate.js).

import { pool } from '../config/database.js'
import dotenv from 'dotenv'

dotenv.config()

async function up() {
  await pool.query(`
    ALTER TABLE posts
    ADD COLUMN IF NOT EXISTS content_type VARCHAR(20) DEFAULT 'tiptap'
    CHECK (content_type IN ('tiptap', 'link'));
  `)
  await pool.query(`
    ALTER TABLE posts
    ADD COLUMN IF NOT EXISTS external_url TEXT NULL;
  `)
  console.log('✓ add-link-post-columns: content_type, external_url added')
  process.exit(0)
}

up().catch(err => {
  console.error(err)
  process.exit(1)
})

Alternatively run the SQL in §9.2 directly in the Supabase SQL editor.


21. Manual test script

Use this after each phase to verify behavior.

Phase 1 (backend only)

  1. Run migration; verify columns exist in Supabase.
  2. Create link post (API): curl -X POST http://localhost:5001/api/posts -H "Content-Type: application/json" -H "Authorization: Bearer <token>" -d '{"title":"Test Link","content_type":"link","external_url":"https://example.com","status":"draft"}'. Expect 201 and response with content_type, external_url.
  3. List posts: GET /api/posts; response includes content_type, external_url for each.
  4. Create TipTap post (API): Same as before (title + content_json); expect 201. No regression.
  5. API-v1: With auth, GET /blog-posts; each post has contentType, externalUrl in response.

Phase 2 (Android)

  1. Install app; open Blogs list; tap a TipTap post → content renders as before.
  2. Publish one link post (via API or Phase 3 UI). In app, tap that post → WebView opens with the URL; back returns to list.
  3. Tap TipTap post again → still TipTap renderer. No crash.

Phase 3 (frontend)

  1. Dashboard: Create new post; select “Link”; enter title and URL; save. Card shows “Link” badge. “View” opens URL in new tab.
  2. Editor: Edit the link post; URL is prefilled; change URL and save; confirm update.
  3. Public view: Open /blog/:slug for the link post (in incognito or different browser if slug is public) → redirects to external_url.
  4. TipTap: Create/edit a normal post; list, edit, view unchanged.
  5. App: Same as Phase 2; link post in WebView, TipTap in renderer.

Regression checklist

  • Existing TipTap post lists in dashboard and in app.
  • Existing TipTap post opens in editor with content; save works.
  • Existing TipTap post view on web shows content; in app shows TipTap renderer.
  • Publish / Unpublish / Delete work for both link and TipTap posts.

End of plan.