Phase 0+1: native post writes, Ghost no longer write-primary
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s

- Final sync script with HTML verification + author→user migration
- Make ghost_id nullable on posts/authors/tags, add UUID/timestamp defaults
- Add user profile fields (bio, slug, profile_image, etc.) to User model
- New PostUser M2M table (replaces post_authors for new posts)
- PostWriter service: direct DB CRUD with Lexical rendering, optimistic
  locking, AP federation, tag upsert
- Rewrite create/edit/settings routes to use PostWriter (no Ghost API calls)
- Neuter Ghost webhooks (post/page/author/tag → 204 no-op)
- Disable Ghost startup sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:33:37 +00:00
parent e8bc228c7f
commit 0f9af31ffe
10 changed files with 1203 additions and 211 deletions

View File

@@ -19,7 +19,7 @@ class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -50,8 +50,8 @@ class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, server_default=func.gen_random_uuid())
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
@@ -89,8 +89,8 @@ class Post(Base):
comment_id: Mapped[Optional[str]] = mapped_column(String(191))
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
user_id: Mapped[Optional[int]] = mapped_column(
@@ -136,7 +136,7 @@ class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -192,3 +192,17 @@ class PostTag(Base):
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class PostUser(Base):
"""Multi-author M2M: links posts to users (cross-DB, no FK on user_id)."""
__tablename__ = "post_users"
post_id: Mapped[int] = mapped_column(
ForeignKey("posts.id", ondelete="CASCADE"),
primary_key=True,
)
user_id: Mapped[int] = mapped_column(
Integer, primary_key=True, index=True,
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View File

@@ -23,6 +23,17 @@ class User(Base):
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True)
ghost_raw: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Author profile fields (merged from Ghost Author)
slug: Mapped[str | None] = mapped_column(String(191), unique=True, index=True, nullable=True)
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
profile_image: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_image: Mapped[str | None] = mapped_column(Text, nullable=True)
website: Mapped[str | None] = mapped_column(Text, nullable=True)
location: Mapped[str | None] = mapped_column(Text, nullable=True)
facebook: Mapped[str | None] = mapped_column(Text, nullable=True)
twitter: Mapped[str | None] = mapped_column(Text, nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=func.false())
# Relationships to Ghost-related entities
user_newsletters = relationship("UserNewsletter", back_populates="user", cascade="all, delete-orphan", lazy="selectin")