Muhammad Mehdi commited on
Commit
9b5cc9d
·
2 Parent(s): 2b579ed29c052e

Merge pull request #4 from FireBird-Technologies/fix/launchpad

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +13 -3
  2. backend/.env.example +34 -0
  3. backend/alembic/versions/0020_calendar_kit.py +35 -0
  4. backend/app/config.py +2 -1
  5. backend/app/infographics/image.py +2 -1
  6. backend/app/infographics/render.py +1 -1
  7. backend/app/llm/signatures.py +14 -5
  8. backend/app/models/content.py +38 -1
  9. backend/app/pipeline/launchkit.py +12 -1
  10. backend/app/pipeline/report.py +4 -3
  11. backend/app/routers/artifacts.py +16 -5
  12. backend/app/routers/auth.py +24 -10
  13. backend/app/routers/launchpad.py +233 -26
  14. backend/app/routers/reports.py +1 -1
  15. backend/app/routers/storage.py +1 -0
  16. backend/app/services/artifacts.py +1 -0
  17. backend/app/services/blog2video.py +1 -1
  18. backend/app/services/email.py +9 -1
  19. backend/app/services/email_verification.py +15 -4
  20. backend/app/services/launchpad.py +56 -10
  21. backend/app/services/renderer.py +8 -0
  22. backend/app/services/social/__init__.py +14 -4
  23. backend/app/services/social/bluesky.py +3 -1
  24. backend/app/services/social/health.py +103 -0
  25. backend/app/services/social/linkedin.py +122 -15
  26. backend/app/services/social/media.py +174 -0
  27. backend/app/services/social/x.py +99 -12
  28. backend/app/services/storage.py +26 -0
  29. backend/app/services/uploads.py +62 -0
  30. backend/app/services/usage.py +9 -2
  31. backend/app/support/scope.py +4 -1
  32. backend/app/worker.py +28 -2
  33. backend/tests/test_auth.py +36 -0
  34. backend/tests/test_buildout.py +241 -7
  35. backend/tests/test_social_posting.py +554 -0
  36. backend/tests/test_videos.py +1 -1
  37. frontend/src/App.tsx +6 -3
  38. frontend/src/api/auth.ts +3 -1
  39. frontend/src/api/client.ts +11 -1
  40. frontend/src/api/endpoints.ts +15 -1
  41. frontend/src/api/types.ts +37 -2
  42. frontend/src/components/AppShell.tsx +4 -7
  43. frontend/src/components/ArtifactCard.tsx +1 -1
  44. frontend/src/components/ScheduleModal.tsx +235 -108
  45. frontend/src/components/UpgradeModal.tsx +1 -1
  46. frontend/src/components/launchpad/Attachment.tsx +628 -0
  47. frontend/src/components/launchpad/PostButton.tsx +27 -0
  48. frontend/src/components/launchpad/SlotPicker.tsx +133 -0
  49. frontend/src/components/ui.tsx +52 -5
  50. frontend/src/components/video/EditorPanels.tsx +4 -4
README.md CHANGED
@@ -93,9 +93,19 @@ cd renderer && npm install && npm start # only needed for vid
93
  - With no R2 settings, files live on local disk under `backend/.storage` and are served through
94
  signed `/api/storage/local/...` URLs (range requests supported for audio and video seeking).
95
  - Audio needs `ELEVENLABS_API_KEY`; everything text based needs `LLM_API_KEY`.
96
- - Launchpad: Bluesky connects with an app password. X and LinkedIn need developer apps; the
97
- redirect URIs are listed in `.env.example` and on the Launchpad page. Substack Notes has no API,
98
- so those posts (and any post without a connected account) arrive as an email reminder at send time.
 
 
 
 
 
 
 
 
 
 
99
 
100
  ## What is in the app
101
 
 
93
  - With no R2 settings, files live on local disk under `backend/.storage` and are served through
94
  signed `/api/storage/local/...` URLs (range requests supported for audio and video seeking).
95
  - Audio needs `ELEVENLABS_API_KEY`; everything text based needs `LLM_API_KEY`.
96
+ - Launchpad: posts (text, images, videos from your Library; not audio) go to X and LinkedIn at their
97
+ scheduled time through a `publish_post` job. Set up the developer apps and keys as described in
98
+ `backend/.env.example`:
99
+ - `X_CLIENT_ID` / `X_CLIENT_SECRET`: the X app's **OAuth 2.0 Client ID and Client Secret** (Read and write,
100
+ Web App, callback `{API_URL}/api/social/x/callback`, scopes incl. `media.write`).
101
+ - `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET`: products "Sign In with LinkedIn using OpenID Connect" and
102
+ "Share on LinkedIn", redirect `{API_URL}/api/social/linkedin/callback`.
103
+ - `API_URL` must be reachable from the internet for the callbacks (an https tunnel locally).
104
+ X and LinkedIn posts need a connected account. X tokens are refreshed automatically; LinkedIn connections
105
+ last about 60 days. If a connection goes down (expired, revoked on the platform, or disconnected here) its
106
+ scheduled posts are **paused** and never attempted; reconnecting the same account resumes the ones still
107
+ ahead, and missed ones wait for a new time. Bluesky connects with an app password; Substack Notes has no API,
108
+ so those posts arrive as an email reminder at send time.
109
 
110
  ## What is in the app
111
 
backend/.env.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Launchpad: posting to X and LinkedIn (copy what you need into .env)
2
+
3
+ # Where the app and the API are reached. Both platforms send people back to
4
+ # {API_URL}/api/social/{x,linkedin}/callback after they connect an account, so API_URL must be reachable from the
5
+ # internet (when running locally, an https tunnel such as ngrok: API_URL=https://<id>.ngrok-free.app).
6
+ FRONTEND_URL=http://localhost:5173
7
+ API_URL=http://localhost:8000
8
+
9
+ # X: developer.x.com > your app > Keys and tokens > "OAuth 2.0 Client ID and Client Secret"
10
+ # (not the API Key / Bearer Token). Then User authentication settings:
11
+ # App permissions: Read and write. Type of app: Web App (confidential client).
12
+ # Callback URI: {API_URL}/api/social/x/callback
13
+ # Scopes asked for: tweet.read tweet.write users.read offline.access media.write
14
+ # (media.write posts images and videos; accounts connected before it was added are asked to reconnect)
15
+ # X is switched off for now (LinkedIn only). Set to true to bring it back.
16
+ X_ENABLED=false
17
+ X_CLIENT_ID=
18
+ X_CLIENT_SECRET=
19
+
20
+ # LinkedIn: linkedin.com/developers > your app > Auth > "Client ID" and "Primary Client Secret"
21
+ # Products: "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn".
22
+ # Auth > Authorized redirect URLs: {API_URL}/api/social/linkedin/callback
23
+ # Scopes asked for: openid profile w_member_social
24
+ # LinkedIn tokens last about 60 days; then the account shows Reconnect.
25
+ LINKEDIN_CLIENT_ID=
26
+ LINKEDIN_CLIENT_SECRET=
27
+
28
+ # Connections: X tokens are refreshed automatically (every 10 minutes the worker checks them, and every 6 hours asks
29
+ # each platform whether the connection still works). When a connection goes down, its scheduled posts are paused and
30
+ # never attempted; reconnecting the same account resumes the ones still ahead.
31
+
32
+ # Encrypts the stored social tokens (a Fernet key). Empty: derived from JWT_SECRET.
33
+ # Make one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
34
+ SOCIAL_TOKEN_KEY=
backend/alembic/versions/0020_calendar_kit.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """calendar_items.kit_id: the Launch Kit a scheduled post was written from, kept apart from its attachment
2
+
3
+ Revision ID: 0020_calendar_kit
4
+ Revises: 0019_infographics_limit
5
+ Create Date: 2026-10-06
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0020_calendar_kit"
11
+ down_revision = "0019_infographics_limit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ if "kit_id" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("calendar_items")}:
19
+ with op.batch_alter_table("calendar_items") as batch:
20
+ batch.add_column(sa.Column("kit_id", sa.Uuid(), nullable=True))
21
+ batch.create_foreign_key("fk_calendar_items_kit_id", "artifacts", ["kit_id"], ["id"], ondelete="SET NULL")
22
+ # Posts that carried a Launch Kit as their "attachment": the kit is where they came from, not an attachment.
23
+ op.execute("UPDATE calendar_items SET kit_id = artifact_id, artifact_id = NULL "
24
+ "WHERE artifact_id IN (SELECT id FROM artifacts WHERE type = 'launch_kit')")
25
+
26
+
27
+ def downgrade() -> None:
28
+ op.execute("UPDATE calendar_items SET artifact_id = kit_id WHERE artifact_id IS NULL AND kit_id IS NOT NULL")
29
+ # The foreign key is named here, or by the database when 0001 made the column from the models.
30
+ fks = [fk["name"] for fk in sa.inspect(op.get_bind()).get_foreign_keys("calendar_items")
31
+ if fk["constrained_columns"] == ["kit_id"] and fk.get("name")]
32
+ with op.batch_alter_table("calendar_items") as batch:
33
+ for name in fks:
34
+ batch.drop_constraint(name, type_="foreignkey")
35
+ batch.drop_column("kit_id")
backend/app/config.py CHANGED
@@ -62,7 +62,7 @@ class Settings(BaseSettings):
62
  # Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root.
63
  r2_prefix: str = ""
64
  r2_presign_ttl_seconds: int = 3600
65
- max_upload_bytes: int = 200 * 1024 * 1024
66
 
67
  # LLM (LiteLLM model strings; Z.ai GLM by default)
68
  llm_model: str = "openai/glm-5.3"
@@ -117,6 +117,7 @@ class Settings(BaseSettings):
117
 
118
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
119
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
 
120
  x_client_id: str = ""
121
  x_client_secret: str = ""
122
  linkedin_client_id: str = ""
 
62
  # Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root.
63
  r2_prefix: str = ""
64
  r2_presign_ttl_seconds: int = 3600
65
+ max_upload_bytes: int = 300 * 1024 * 1024
66
 
67
  # LLM (LiteLLM model strings; Z.ai GLM by default)
68
  llm_model: str = "openai/glm-5.3"
 
117
 
118
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
119
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
120
+ x_enabled: bool = False # X posting is switched off for now (LinkedIn only); set X_ENABLED=true to bring it back
121
  x_client_id: str = ""
122
  x_client_secret: str = ""
123
  linkedin_client_id: str = ""
backend/app/infographics/image.py CHANGED
@@ -32,7 +32,8 @@ def chrome() -> str | None:
32
  if found := shutil.which(name):
33
  return found
34
  patterns = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
35
- os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
 
36
  return next((p for pat in patterns for p in glob.glob(pat)), None)
37
 
38
 
 
32
  if found := shutil.which(name):
33
  return found
34
  patterns = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
35
+ os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/"
36
+ "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
37
  return next((p for pat in patterns for p in glob.glob(pat)), None)
38
 
39
 
backend/app/infographics/render.py CHANGED
@@ -5,8 +5,8 @@ import hashlib
5
  import random
6
  from pathlib import Path
7
 
8
- from markupsafe import Markup
9
  from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
 
10
 
11
  from app.infographics.content import HEIGHT, WIDTH
12
  from app.infographics.themes import DEFAULT_THEME, THEMES, theme_id
 
5
  import random
6
  from pathlib import Path
7
 
 
8
  from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
9
+ from markupsafe import Markup
10
 
11
  from app.infographics.content import HEIGHT, WIDTH
12
  from app.infographics.themes import DEFAULT_THEME, THEMES, theme_id
backend/app/llm/signatures.py CHANGED
@@ -451,8 +451,12 @@ class WriteReport(dspy.Signature):
451
  "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where
452
  they help. When the material has numbered lines (FILE blocks), put a [n] marker after each
453
  claim it supports and list each n in citations with the path and lines it came from; when it is a chat transcript,
454
- write no markers and no citations. Do not write a quiz, flashcards, practice or review questions into the report unless the reader's instructions
455
- ask for one: those are offered to the reader separately as visuals they can add. Never write about visuals, mind maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions mention them: the app offers those to the reader separately, so write only the report itself. Write in the language requested.
 
 
 
 
456
  Never use em dashes."""
457
 
458
  title: str = dspy.InputField(desc="The notebook, post or chat the material comes from")
@@ -483,7 +487,8 @@ class PlanInteractiveReport(dspy.Signature):
483
  section that is itself a quiz, flashcards, practice questions or a mind map, because those are visuals, and are
484
  suggested through a section's visual instead. Choose 4 to 8 sections in a sensible
485
  order, starting with an Introduction and ending with a Summary unless the reader asks for something else. Each
486
- heading is specific and descriptive of what the section covers, never generic like "Details" or "Section 2". Then decide, section by section, where a visual would help the reader and which one. These are only
 
487
  suggestions: nothing is built until the reader clicks Add, so suggest the spots where each really fits: a mind_map to show how
488
  many linked ideas fit together (at most one, usually near the start or the end), flashcards to learn a set of
489
  terms or facts, a quiz to check understanding after a dense section, an infographic for a one page picture of the
@@ -502,11 +507,15 @@ class PlanInteractiveReport(dspy.Signature):
502
 
503
  class WriteReportSection(dspy.Signature):
504
  """Write one section of a report, using only the material given. Follow the reader's instructions on style, tone
505
- and length; a section is two to five short paragraphs, or a list or table where that reads better. Do not write quiz questions, flashcards or
 
506
  review questions in the section (those are offered to the reader separately). Do not repeat
507
  the heading, and do not use "#" or "##" headings; "###" subheadings are fine. When the material has numbered lines (FILE
508
  blocks), put a [n] marker after each claim it supports and list each n in citations with the path and lines; when it
509
- is a chat transcript, write no markers and no citations. Never write about visuals, mind maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions mention them: the app offers those to the reader separately, so write only the report itself. Write in the language requested. Never use em dashes."""
 
 
 
510
 
511
  title: str = dspy.InputField()
512
  report_title: str = dspy.InputField()
 
451
  "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where
452
  they help. When the material has numbered lines (FILE blocks), put a [n] marker after each
453
  claim it supports and list each n in citations with the path and lines it came from; when it is a chat transcript,
454
+ write no markers and no citations. Do not write a quiz, flashcards, practice or review questions into the report
455
+ unless the reader's instructions
456
+ ask for one: those are offered to the reader separately as visuals they can add. Never write about visuals, mind
457
+ maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the
458
+ reader's instructions mention them: the app offers those to the reader separately, so write only the report
459
+ itself. Write in the language requested.
460
  Never use em dashes."""
461
 
462
  title: str = dspy.InputField(desc="The notebook, post or chat the material comes from")
 
487
  section that is itself a quiz, flashcards, practice questions or a mind map, because those are visuals, and are
488
  suggested through a section's visual instead. Choose 4 to 8 sections in a sensible
489
  order, starting with an Introduction and ending with a Summary unless the reader asks for something else. Each
490
+ heading is specific and descriptive of what the section covers, never generic like "Details" or "Section 2".
491
+ Then decide, section by section, where a visual would help the reader and which one. These are only
492
  suggestions: nothing is built until the reader clicks Add, so suggest the spots where each really fits: a mind_map to show how
493
  many linked ideas fit together (at most one, usually near the start or the end), flashcards to learn a set of
494
  terms or facts, a quiz to check understanding after a dense section, an infographic for a one page picture of the
 
507
 
508
  class WriteReportSection(dspy.Signature):
509
  """Write one section of a report, using only the material given. Follow the reader's instructions on style, tone
510
+ and length; a section is two to five short paragraphs, or a list or table where that reads better. Do not write
511
+ quiz questions, flashcards or
512
  review questions in the section (those are offered to the reader separately). Do not repeat
513
  the heading, and do not use "#" or "##" headings; "###" subheadings are fine. When the material has numbered lines (FILE
514
  blocks), put a [n] marker after each claim it supports and list each n in citations with the path and lines; when it
515
+ is a chat transcript, write no markers and no citations. Never write about visuals, mind maps, flashcards,
516
+ quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions
517
+ mention them: the app offers those to the reader separately, so write only the report itself. Write in the
518
+ language requested. Never use em dashes."""
519
 
520
  title: str = dspy.InputField()
521
  report_title: str = dspy.InputField()
backend/app/models/content.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import uuid
2
  from datetime import datetime
3
 
@@ -14,10 +15,11 @@ from sqlalchemy import (
14
  Text,
15
  UniqueConstraint,
16
  Uuid,
 
17
  text,
18
  true,
19
  )
20
- from sqlalchemy.orm import Mapped, mapped_column, relationship
21
 
22
  from app.db import Base
23
  from app.models.base import IdMixin, TimestampMixin
@@ -343,11 +345,46 @@ class Upload(IdMixin, TimestampMixin, Base):
343
  status: Mapped[str] = mapped_column(String(20), default="pending") # pending | complete
344
 
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  class CalendarItem(IdMixin, TimestampMixin, Base):
347
  __tablename__ = "calendar_items"
348
 
349
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
 
350
  artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
 
 
351
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
352
  platform: Mapped[str] = mapped_column(String(30)) # x | linkedin | bluesky | substack_notes
353
  scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
 
1
+ import logging
2
  import uuid
3
  from datetime import datetime
4
 
 
15
  Text,
16
  UniqueConstraint,
17
  Uuid,
18
+ event,
19
  text,
20
  true,
21
  )
22
+ from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
23
 
24
  from app.db import Base
25
  from app.models.base import IdMixin, TimestampMixin
 
345
  status: Mapped[str] = mapped_column(String(20), default="pending") # pending | complete
346
 
347
 
348
+ # Deleting an upload record removes its file from storage too, once the delete is committed (never on a rollback).
349
+ # This covers deletes made through the ORM; rows removed straight in the database are caught by the daily orphan
350
+ # sweep (app.services.uploads.sweep_orphan_uploads).
351
+ _PENDING_FILE_DELETES = "upload_files_to_delete"
352
+
353
+
354
+ @event.listens_for(Session, "after_flush")
355
+ def _note_deleted_uploads(session: Session, _ctx) -> None:
356
+ keys = [obj.key for obj in session.deleted if isinstance(obj, Upload) and obj.key]
357
+ if keys:
358
+ session.info.setdefault(_PENDING_FILE_DELETES, []).extend(keys)
359
+
360
+
361
+ @event.listens_for(Session, "after_commit")
362
+ def _delete_upload_files(session: Session) -> None:
363
+ keys = session.info.pop(_PENDING_FILE_DELETES, [])
364
+ if not keys:
365
+ return
366
+ from app.services.storage import storage
367
+
368
+ for key in keys:
369
+ try:
370
+ storage.delete(key)
371
+ except Exception: # left for the daily sweep
372
+ logging.getLogger("notestack.storage").warning("couldn't delete upload file %s", key, exc_info=True)
373
+
374
+
375
+ @event.listens_for(Session, "after_rollback")
376
+ def _forget_upload_files(session: Session) -> None:
377
+ session.info.pop(_PENDING_FILE_DELETES, None)
378
+
379
+
380
  class CalendarItem(IdMixin, TimestampMixin, Base):
381
  __tablename__ = "calendar_items"
382
 
383
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
384
+ # What the post carries (a video, quote card, carousel...): never a Launch Kit, which is kit_id.
385
  artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
386
+ # The Launch Kit the post was written from, kept apart from the attachment (nothing of it is uploaded).
387
+ kit_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"))
388
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
389
  platform: Mapped[str] = mapped_column(String(30)) # x | linkedin | bluesky | substack_notes
390
  scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
backend/app/pipeline/launchkit.py CHANGED
@@ -1,16 +1,22 @@
1
  """Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
2
  the post's own claims."""
3
 
 
 
4
  from sqlalchemy.orm import Session
5
 
 
6
  from app.corpus import INDEX, Corpus
7
  from app.llm import run
8
  from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
9
  from app.models import Artifact, Document, Job
10
  from app.pipeline.generate import NothingToDo
11
  from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
 
12
  from app.services.jobs import create_job, update_job
13
 
 
 
14
  PLATFORMS = {
15
  "x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
16
  "No hashtags except at most one at the end. The last post links to the article with {url}."),
@@ -94,8 +100,13 @@ def build_launch_kit(db: Session, job: Job, artifact: Artifact) -> dict:
94
  artifact.status = "ready"
95
  db.commit()
96
 
97
- # Quote cards render as their own artifacts, so a missing renderer does not sink the kit.
 
98
  card_ids = []
 
 
 
 
99
  for q in quotes:
100
  card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
101
  content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
 
1
  """Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
2
  the post's own claims."""
3
 
4
+ import logging
5
+
6
  from sqlalchemy.orm import Session
7
 
8
+ from app.config import settings
9
  from app.corpus import INDEX, Corpus
10
  from app.llm import run
11
  from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
12
  from app.models import Artifact, Document, Job
13
  from app.pipeline.generate import NothingToDo
14
  from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
15
+ from app.services import renderer
16
  from app.services.jobs import create_job, update_job
17
 
18
+ log = logging.getLogger(__name__)
19
+
20
  PLATFORMS = {
21
  "x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
22
  "No hashtags except at most one at the end. The last post links to the article with {url}."),
 
100
  artifact.status = "ready"
101
  db.commit()
102
 
103
+ # Quote cards render as their own artifacts, so a missing renderer does not sink the kit. With the renderer
104
+ # down they would only fail, so skip them: the kit keeps its quotes as text either way.
105
  card_ids = []
106
+ if quotes and not renderer.available():
107
+ log.warning("Renderer unreachable at %s: launch kit %s made without quote cards",
108
+ settings.renderer_url, artifact.id)
109
+ quotes = []
110
  for q in quotes:
111
  card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
112
  content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
backend/app/pipeline/report.py CHANGED
@@ -10,6 +10,7 @@ import uuid
10
 
11
  from sqlalchemy.orm import Session
12
 
 
13
  from app.llm import run
14
  from app.llm.provider import fast_lm, report_lm
15
  from app.llm.signatures import (
@@ -22,10 +23,9 @@ from app.models import Artifact, Job
22
  from app.pipeline.flashcards import make_cards
23
  from app.pipeline.generate import NothingToDo, mind_map_content
24
  from app.pipeline.material import Material, load_material
25
- from app.pipeline.quiz import make_questions
26
  from app.pipeline.passages import as_citations
 
27
  from app.pipeline.research import verify_citations
28
- from app.infographics.themes import theme_id
29
  from app.services.artifacts import start_artifact
30
  from app.services.jobs import update_job
31
  from app.services.renderer import PermanentJobError
@@ -319,7 +319,8 @@ def make_block(db: Session, job: Job, report: Artifact, kind: str, brief: str, t
319
  if kind == "infographic":
320
  # A picture is drawn by the renderer, so it is its own infographic (listed in the Library, counted in the
321
  # plan) that the block points at; the report page shows it once it is ready.
322
- child, _ = start_artifact(db, report.workspace_id, "infographic", title=f"Infographic: {brief[:60] or report.content_json.get('title', '')}",
 
323
  notebook_id=report.notebook_id,
324
  params={**_source_params(source), "theme": theme_id(theme), "instructions": brief[:600]})
325
  return {"title": f"Infographic: {brief[:60]}", "artifact_id": str(child.id), "theme": theme_id(theme)}
 
10
 
11
  from sqlalchemy.orm import Session
12
 
13
+ from app.infographics.themes import theme_id
14
  from app.llm import run
15
  from app.llm.provider import fast_lm, report_lm
16
  from app.llm.signatures import (
 
23
  from app.pipeline.flashcards import make_cards
24
  from app.pipeline.generate import NothingToDo, mind_map_content
25
  from app.pipeline.material import Material, load_material
 
26
  from app.pipeline.passages import as_citations
27
+ from app.pipeline.quiz import make_questions
28
  from app.pipeline.research import verify_citations
 
29
  from app.services.artifacts import start_artifact
30
  from app.services.jobs import update_job
31
  from app.services.renderer import PermanentJobError
 
319
  if kind == "infographic":
320
  # A picture is drawn by the renderer, so it is its own infographic (listed in the Library, counted in the
321
  # plan) that the block points at; the report page shows it once it is ready.
322
+ title = f"Infographic: {brief[:60] or report.content_json.get('title', '')}"
323
+ child, _ = start_artifact(db, report.workspace_id, "infographic", title=title,
324
  notebook_id=report.notebook_id,
325
  params={**_source_params(source), "theme": theme_id(theme), "instructions": brief[:600]})
326
  return {"title": f"Infographic: {brief[:60]}", "artifact_id": str(child.id), "theme": theme_id(theme)}
backend/app/routers/artifacts.py CHANGED
@@ -8,20 +8,21 @@ from sqlalchemy import func, or_, select
8
 
9
  from app.auth import Ctx, get_ctx
10
  from app.config import settings
 
 
11
  from app.models import Artifact, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
12
  from app.routers.notebooks import ensure_archive_notebook
13
- from app.infographics.image import ImageUnavailable, render_png
14
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
15
  from app.services.jobs import create_job, serialize_job
16
  from app.services.plans import effective_plan, plan_limit_error
17
- from app.infographics.themes import theme_id
18
  from app.services.renderer import COMPOSITIONS
19
  from app.services.storage import storage
20
  from app.services.usage import check_limit
21
 
22
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
23
 
24
- ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz", "flashcards", "report", "infographic"]
 
25
 
26
 
27
  SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic") # made from posts or chats picked in the dialog
@@ -33,7 +34,7 @@ class RenderIn(BaseModel):
33
 
34
 
35
  class GenerateIn(BaseModel):
36
- type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz", "flashcards", "report", "infographic"]
37
  notebook_id: uuid.UUID | None = None
38
  document_id: uuid.UUID | None = None
39
  archive: bool = False # no notebook or post picked: use the "All posts" notebook
@@ -213,6 +214,8 @@ def list_artifacts(
213
  query = select(Artifact).where(Artifact.workspace_id == ctx.workspace.id)
214
  if type:
215
  query = query.where(Artifact.type.in_(type.split(",")))
 
 
216
  if notebook_id:
217
  query = query.where(Artifact.notebook_id == notebook_id)
218
  if document_id:
@@ -260,9 +263,13 @@ def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get
260
  content = dict(a.content_json or {})
261
  if body.content is not None:
262
  protected = {"slide_keys", "quote_card_ids", "segments", "voices"}
 
 
263
  content.update({k: v for k, v in body.content.items() if k not in protected})
264
  if body.title is not None:
265
- content["title"] = body.title
 
 
266
  a.content_json = content
267
  ctx.db.commit()
268
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
@@ -277,6 +284,10 @@ def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
277
  remove_video(ctx, a)
278
  return {"ok": True}
279
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
 
 
 
 
280
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
281
  ctx.db.delete(a)
282
  ctx.db.commit()
 
8
 
9
  from app.auth import Ctx, get_ctx
10
  from app.config import settings
11
+ from app.infographics.image import ImageUnavailable, render_png
12
+ from app.infographics.themes import theme_id
13
  from app.models import Artifact, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
14
  from app.routers.notebooks import ensure_archive_notebook
 
15
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
16
  from app.services.jobs import create_job, serialize_job
17
  from app.services.plans import effective_plan, plan_limit_error
 
18
  from app.services.renderer import COMPOSITIONS
19
  from app.services.storage import storage
20
  from app.services.usage import check_limit
21
 
22
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
23
 
24
+ ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
25
+ "flashcards", "report", "infographic"]
26
 
27
 
28
  SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic") # made from posts or chats picked in the dialog
 
34
 
35
 
36
  class GenerateIn(BaseModel):
37
+ type: ArtifactType
38
  notebook_id: uuid.UUID | None = None
39
  document_id: uuid.UUID | None = None
40
  archive: bool = False # no notebook or post picked: use the "All posts" notebook
 
214
  query = select(Artifact).where(Artifact.workspace_id == ctx.workspace.id)
215
  if type:
216
  query = query.where(Artifact.type.in_(type.split(",")))
217
+ else: # files uploaded to post live in the Launchpad's picker, not among the things Notestack made
218
+ query = query.where(Artifact.type != "upload")
219
  if notebook_id:
220
  query = query.where(Artifact.notebook_id == notebook_id)
221
  if document_id:
 
263
  content = dict(a.content_json or {})
264
  if body.content is not None:
265
  protected = {"slide_keys", "quote_card_ids", "segments", "voices"}
266
+ if a.type == "upload": # what the file is, as posting reads it: only its title can change
267
+ protected |= {"filename", "content_type", "media", "size_bytes", "duration_s"}
268
  content.update({k: v for k, v in body.content.items() if k not in protected})
269
  if body.title is not None:
270
+ if not body.title.strip():
271
+ raise HTTPException(400, "Give it a name.")
272
+ content["title"] = body.title.strip()
273
  a.content_json = content
274
  ctx.db.commit()
275
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
 
284
  remove_video(ctx, a)
285
  return {"ok": True}
286
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
287
+ if a.type == "upload": # a file from the user's computer: its upload record (and file) go too
288
+ from app.services.uploads import remove_upload_artifact
289
+
290
+ remove_upload_artifact(ctx.db, a)
291
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
292
  ctx.db.delete(a)
293
  ctx.db.commit()
backend/app/routers/auth.py CHANGED
@@ -36,6 +36,15 @@ def _err(exc: ident.IdentityError | ev.VerificationError, status: int = 400) ->
36
  return HTTPException(getattr(exc, "status", status), {"code": exc.code, "message": exc.message})
37
 
38
 
 
 
 
 
 
 
 
 
 
39
  def _serialize_user(user: User) -> dict:
40
  return {
41
  "id": str(user.id),
@@ -279,7 +288,7 @@ def register_start(body: RegisterStartIn, db: Session = Depends(get_db)):
279
  except (ident.IdentityError, ev.VerificationError) as exc:
280
  raise _err(exc, 429 if getattr(exc, "code", "") == "cooldown" else 400) from exc
281
  db.commit()
282
- email_service.send_verification_code(email, issued.code)
283
  return {"ok": True}
284
 
285
 
@@ -314,7 +323,7 @@ def register_resend(body: EmailIn, db: Session = Depends(get_db)):
314
  except ev.VerificationError as exc:
315
  raise _err(exc, 429) from exc
316
  db.commit()
317
- email_service.send_verification_code(email, issued.code)
318
  return {"ok": True}
319
 
320
 
@@ -338,14 +347,19 @@ def email_login(body: LoginIn, db: Session = Depends(get_db)):
338
  @router.post("/password/forgot/start")
339
  def forgot_start(body: EmailIn, db: Session = Depends(get_db)):
340
  user = ident.find_user(db, body.email)
341
- # Always 200 so the endpoint does not reveal which emails exist.
342
- if user and user.auth_provider == AuthProvider.EMAIL and user.is_active:
343
- try:
344
- issued = ev.issue_code(db, user.email, VerificationPurpose.PASSWORD_RESET)
345
- db.commit()
346
- email_service.send_password_reset_code(user.email, issued.code)
347
- except ev.VerificationError:
348
- pass
 
 
 
 
 
349
  return {"ok": True}
350
 
351
 
 
36
  return HTTPException(getattr(exc, "status", status), {"code": exc.code, "message": exc.message})
37
 
38
 
39
+ def _sent_or_fail(db: Session, sent: bool, email: str, purpose: VerificationPurpose) -> None:
40
+ """A code the user never receives is useless: drop it and say so instead of reporting success."""
41
+ if sent:
42
+ return
43
+ ev.clear_codes(db, email, purpose)
44
+ db.commit()
45
+ raise HTTPException(502, {"code": "email_failed", "message": "We couldn't send the code. Try again in a moment."})
46
+
47
+
48
  def _serialize_user(user: User) -> dict:
49
  return {
50
  "id": str(user.id),
 
288
  except (ident.IdentityError, ev.VerificationError) as exc:
289
  raise _err(exc, 429 if getattr(exc, "code", "") == "cooldown" else 400) from exc
290
  db.commit()
291
+ _sent_or_fail(db, email_service.send_verification_code(email, issued.code), email, VerificationPurpose.SIGNUP)
292
  return {"ok": True}
293
 
294
 
 
323
  except ev.VerificationError as exc:
324
  raise _err(exc, 429) from exc
325
  db.commit()
326
+ _sent_or_fail(db, email_service.send_verification_code(email, issued.code), email, VerificationPurpose.SIGNUP)
327
  return {"ok": True}
328
 
329
 
 
347
  @router.post("/password/forgot/start")
348
  def forgot_start(body: EmailIn, db: Session = Depends(get_db)):
349
  user = ident.find_user(db, body.email)
350
+ # Sign up already tells whether an email is taken, so naming the problem here leaks nothing new.
351
+ if not user or user.deleted_at or not user.is_active:
352
+ raise HTTPException(404, {"code": "not_found", "message": "No account found with this email. Create one instead."})
353
+ if user.auth_provider != AuthProvider.EMAIL:
354
+ raise _err(ident.wrong_provider_error(user))
355
+ try:
356
+ issued = ev.issue_code(db, user.email, VerificationPurpose.PASSWORD_RESET)
357
+ except ev.VerificationError as exc:
358
+ raise _err(exc, 429) from exc
359
+ db.commit()
360
+ _sent_or_fail(
361
+ db, email_service.send_password_reset_code(user.email, issued.code), user.email, VerificationPurpose.PASSWORD_RESET
362
+ )
363
  return {"ok": True}
364
 
365
 
backend/app/routers/launchpad.py CHANGED
@@ -1,11 +1,13 @@
1
  """Launchpad: the calendar, connected social accounts (OAuth / app password) and tracked short links."""
2
 
3
  import json
 
4
  import secrets
5
  import uuid
6
  from datetime import UTC, datetime, timedelta
7
  from typing import Literal
8
  from urllib.parse import urlencode
 
9
 
10
  from fastapi import APIRouter, Depends, HTTPException, Request
11
  from fastapi.concurrency import run_in_threadpool
@@ -17,10 +19,14 @@ from sqlalchemy.orm import Session
17
  from app.auth import Ctx, decode_token_full, encode_signed, get_ctx
18
  from app.config import settings
19
  from app.db import get_db
20
- from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink
21
  from app.services import launchpad as lp
 
22
  from app.services.crypto import decrypt, encrypt
23
  from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
 
 
 
24
 
25
  router = APIRouter(prefix="/api", tags=["launchpad"])
26
 
@@ -30,32 +36,104 @@ Platform = Literal["x", "linkedin", "bluesky", "substack_notes"]
30
  # Calendar
31
 
32
 
33
- class ItemIn(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  platform: Platform
35
- scheduled_at: datetime
36
  content: str = Field(min_length=1, max_length=10000)
37
  thread: list[str] = Field(default_factory=list, max_length=25)
38
- artifact_id: uuid.UUID | None = None
 
39
  document_id: uuid.UUID | None = None
40
  social_account_id: uuid.UUID | None = None
41
  remind_by_email: bool = False
42
  draft: bool = False
43
 
44
 
45
- class ItemPatch(BaseModel):
46
- scheduled_at: datetime | None = None
47
  content: str | None = Field(None, min_length=1, max_length=10000)
48
  thread: list[str] | None = None
49
  social_account_id: uuid.UUID | None = None
50
  remind_by_email: bool | None = None
51
- status: Literal["scheduled", "draft"] | None = None
52
 
53
 
54
  def _aware(dt: datetime) -> datetime:
55
  return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
56
 
57
 
58
- def serialize_item(item: CalendarItem, account: SocialAccount | None = None, clicks: int = 0) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  return {
60
  "id": str(item.id),
61
  "platform": item.platform,
@@ -65,6 +143,9 @@ def serialize_item(item: CalendarItem, account: SocialAccount | None = None, cli
65
  "content": item.content,
66
  "thread": item.thread or [],
67
  "artifact_id": str(item.artifact_id) if item.artifact_id else None,
 
 
 
68
  "document_id": str(item.document_id) if item.document_id else None,
69
  "social_account_id": str(item.social_account_id) if item.social_account_id else None,
70
  "account_handle": account.handle if account else None,
@@ -96,6 +177,26 @@ def _account(ctx: Ctx, account_id: uuid.UUID | None, platform: str) -> SocialAcc
96
  return account
97
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def _validate_lengths(platform: str, posts: list[str]) -> None:
100
  limit = LIMITS.get(platform)
101
  if platform in ("x", "bluesky") and limit:
@@ -110,7 +211,28 @@ def _enrich(ctx: Ctx, items: list[CalendarItem]) -> list[dict]:
110
  accounts = {a.id: a for a in ctx.db.scalars(
111
  select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id))}
112
  clicks = lp.clicks_by_item(ctx.db, [i.id for i in items])
113
- return [serialize_item(i, accounts.get(i.social_account_id), clicks.get(i.id, 0)) for i in items]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  @router.get("/calendar")
@@ -129,15 +251,21 @@ def list_items(ctx: Ctx = Depends(get_ctx), start: datetime | None = None, end:
129
 
130
  @router.post("/calendar")
131
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
 
 
 
132
  account = _account(ctx, body.social_account_id, body.platform)
133
  _validate_lengths(body.platform, [body.content, *body.thread])
134
- for model, value in ((Artifact, body.artifact_id), (Document, body.document_id)):
135
- if value and not ctx.db.scalar(select(model.id).where(model.id == value,
136
- model.workspace_id == ctx.workspace.id)):
137
- raise HTTPException(404, f"{model.__name__} not found")
 
 
138
  item = CalendarItem(
139
- workspace_id=ctx.workspace.id, platform=body.platform, scheduled_at=_aware(body.scheduled_at),
140
  content=body.content, thread=[t for t in body.thread if t.strip()], artifact_id=body.artifact_id,
 
141
  document_id=body.document_id, social_account_id=account.id if account else None,
142
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
143
  status="draft" if body.draft else "scheduled",
@@ -152,12 +280,15 @@ def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)
152
  item = _item(ctx, item_id)
153
  if item.status in ("posted", "publishing"):
154
  raise HTTPException(409, "This post already went out.")
155
- if body.scheduled_at is not None:
156
- item.scheduled_at = _aware(body.scheduled_at)
 
157
  if body.content is not None:
158
  item.content = body.content
159
  if body.thread is not None:
160
  item.thread = [t for t in body.thread if t.strip()]
 
 
161
  if "social_account_id" in body.model_fields_set:
162
  item.social_account_id = (_account(ctx, body.social_account_id, item.platform).id
163
  if body.social_account_id else None)
@@ -165,10 +296,18 @@ def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)
165
  item.remind_by_email = body.remind_by_email
166
  if not item.social_account_id or item.platform not in AUTO_POST:
167
  item.remind_by_email = True
168
- if body.status is not None:
169
- item.status = body.status
170
- elif item.status in ("failed", "reminded"):
171
- item.status = "scheduled"
 
 
 
 
 
 
 
 
172
  item.error = None
173
  item.attempts = 0
174
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
@@ -192,16 +331,82 @@ async def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
192
  item.status = "publishing"
193
  item.attempts = 0
194
  ctx.db.commit()
195
- await run_in_threadpool(lp.publish_item, ctx.db, item)
 
 
 
196
  return _enrich(ctx, [item])[0]
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  # Social accounts
200
 
201
 
202
  def serialize_account(a: SocialAccount) -> dict:
203
  return {"id": str(a.id), "platform": a.platform, "platform_label": PLATFORM_LABELS[a.platform],
204
  "handle": a.handle, "status": a.status, "avatar_url": a.avatar_url,
 
 
 
 
 
 
 
205
  "connected_at": a.created_at.isoformat() if a.created_at else None}
206
 
207
 
@@ -222,11 +427,12 @@ def disconnect(account_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
222
  SocialAccount.workspace_id == ctx.workspace.id))
223
  if not account:
224
  raise HTTPException(404, "Account not found")
225
- for item in ctx.db.scalars(select(CalendarItem).where(CalendarItem.social_account_id == account.id,
226
- CalendarItem.status == "scheduled")):
227
- item.social_account_id = None
228
- item.remind_by_email = True
229
- ctx.db.delete(account)
 
230
  ctx.db.commit()
231
  return {"ok": True}
232
 
@@ -247,6 +453,7 @@ def _upsert_account(db: Session, workspace_id: uuid.UUID, platform: str, info: d
247
  account.expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in)) if expires_in else None
248
  account.scopes = scopes
249
  account.status = "active"
 
250
  db.commit()
251
 
252
 
 
1
  """Launchpad: the calendar, connected social accounts (OAuth / app password) and tracked short links."""
2
 
3
  import json
4
+ import re
5
  import secrets
6
  import uuid
7
  from datetime import UTC, datetime, timedelta
8
  from typing import Literal
9
  from urllib.parse import urlencode
10
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
11
 
12
  from fastapi import APIRouter, Depends, HTTPException, Request
13
  from fastapi.concurrency import run_in_threadpool
 
19
  from app.auth import Ctx, decode_token_full, encode_signed, get_ctx
20
  from app.config import settings
21
  from app.db import get_db
22
+ from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
23
  from app.services import launchpad as lp
24
+ from app.services.artifacts import TYPE_LABELS
25
  from app.services.crypto import decrypt, encrypt
26
  from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
27
+ from app.services.social.health import pause_account_posts, resume_account_posts
28
+ from app.services.social.media import MB, SIZE_LIMITS, UPLOAD_TYPES, check_postable, duration, is_blog2video, media_count, upload_kind
29
+ from app.services.storage import storage
30
 
31
  router = APIRouter(prefix="/api", tags=["launchpad"])
32
 
 
36
  # Calendar
37
 
38
 
39
+ class When(BaseModel):
40
+ """When a post goes out: the wall time the user picked in their own time zone (local_time "2026-10-07T11:00" +
41
+ timezone "Asia/Karachi", on a half hour), converted to UTC here; or an exact scheduled_at (Post now)."""
42
+ scheduled_at: datetime | None = None
43
+ local_time: str | None = Field(None, max_length=32)
44
+ timezone: str | None = Field(None, max_length=64)
45
+
46
+ def utc(self) -> datetime | None:
47
+ if self.local_time:
48
+ try:
49
+ zone = ZoneInfo(self.timezone or "")
50
+ except (ZoneInfoNotFoundError, ValueError) as exc:
51
+ raise HTTPException(400, {"code": "bad_timezone", "message": "Unknown time zone."}) from exc
52
+ try:
53
+ wall = datetime.fromisoformat(self.local_time)
54
+ except ValueError as exc:
55
+ raise HTTPException(400, {"code": "bad_time", "message": "Pick a date and time."}) from exc
56
+ if wall.tzinfo or wall.minute not in (0, 30) or wall.second or wall.microsecond:
57
+ raise HTTPException(400, {"code": "half_hour_only", "message": "Pick a time on the hour or half hour."})
58
+ return wall.replace(tzinfo=zone).astimezone(UTC)
59
+ return _aware(self.scheduled_at) if self.scheduled_at else None
60
+
61
+
62
+ class ItemIn(When):
63
  platform: Platform
 
64
  content: str = Field(min_length=1, max_length=10000)
65
  thread: list[str] = Field(default_factory=list, max_length=25)
66
+ artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
67
+ kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
68
  document_id: uuid.UUID | None = None
69
  social_account_id: uuid.UUID | None = None
70
  remind_by_email: bool = False
71
  draft: bool = False
72
 
73
 
74
+ class ItemPatch(When):
75
+ artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
76
  content: str | None = Field(None, min_length=1, max_length=10000)
77
  thread: list[str] | None = None
78
  social_account_id: uuid.UUID | None = None
79
  remind_by_email: bool | None = None
80
+ status: Literal["scheduled", "draft", "paused"] | None = None
81
 
82
 
83
  def _aware(dt: datetime) -> datetime:
84
  return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
85
 
86
 
87
+ # audio can't be posted; "upload" is a file the user brought from their computer
88
+ POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload")
89
+
90
+
91
+ def _cite_free(text: str) -> str:
92
+ return re.sub(r"\s*\[\d+\]", "", text or "").strip()
93
+
94
+
95
+ def _prefill(a: Artifact) -> dict:
96
+ """A starting text per platform for a post of this artifact: X as a list of posts (a thread), LinkedIn as one."""
97
+ c = a.content_json or {}
98
+ title = c.get("title") or TYPE_LABELS.get(a.type, a.type)
99
+ if a.type == "upload": # their own file: the words are theirs to write
100
+ return {"x": [""], "linkedin": ""}
101
+ if a.type == "launch_kit":
102
+ posts = c.get("posts") or {}
103
+ x_posts = posts.get("x_thread") or posts.get("x") or [title]
104
+ return {"x": x_posts, "linkedin": "\n\n".join(posts.get("linkedin") or x_posts)}
105
+ text = {
106
+ "summary": _cite_free(c.get("summary") or title),
107
+ "quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
108
+ else title,
109
+ "carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
110
+ }.get(a.type, title)
111
+ return {"x": [text], "linkedin": text}
112
+
113
+
114
+ def artifact_brief(a: Artifact) -> dict:
115
+ """What the composer and the calendar show of the attached artifact."""
116
+ c = a.content_json or {}
117
+ thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
118
+ else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
119
+ return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
120
+ "title": c.get("title") or TYPE_LABELS.get(a.type, a.type),
121
+ "media": upload_kind(a) or ("video" if a.type == "video" else "image" if media_count(a) else "text"),
122
+ "media_count": media_count(a), "duration_s": duration(a),
123
+ "thumb_url": storage.presign_get(thumb) if thumb else None,
124
+ # The user's own file, to preview before posting it.
125
+ "view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
126
+ # A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
127
+ # Made by the video service (blog2video): it opens in the video editor.
128
+ "editable_video": is_blog2video(a),
129
+ "needs_render": is_blog2video(a) and not (a.content_json or {}).get("video_url"),
130
+ # Its MP4 is being made right now: it can't be scheduled until the render finishes.
131
+ "rendering": is_blog2video(a) and a.status == "rendering",
132
+ "created_at": a.created_at.isoformat() if a.created_at else None, "prefill": _prefill(a)}
133
+
134
+
135
+ def serialize_item(item: CalendarItem, account: SocialAccount | None = None, clicks: int = 0,
136
+ artifact: Artifact | None = None, kit: Artifact | None = None) -> dict:
137
  return {
138
  "id": str(item.id),
139
  "platform": item.platform,
 
143
  "content": item.content,
144
  "thread": item.thread or [],
145
  "artifact_id": str(item.artifact_id) if item.artifact_id else None,
146
+ "artifact": artifact_brief(artifact) if artifact else None,
147
+ "kit_id": str(item.kit_id) if item.kit_id else None,
148
+ "kit_title": ((kit.content_json or {}).get("post_title") or (kit.content_json or {}).get("title")) if kit else None,
149
  "document_id": str(item.document_id) if item.document_id else None,
150
  "social_account_id": str(item.social_account_id) if item.social_account_id else None,
151
  "account_handle": account.handle if account else None,
 
177
  return account
178
 
179
 
180
+ CONNECTED_ONLY = ("x", "linkedin") # these post only through a live connection (no email fallback)
181
+
182
+
183
+ def can_post_media(a: SocialAccount) -> bool:
184
+ return {"x": x.can_post_media, "linkedin": linkedin.can_post_media}.get(a.platform, lambda _: False)(a)
185
+
186
+
187
+ def _require_connected(platform: str, account: SocialAccount | None, artifact: Artifact | None) -> None:
188
+ """X and LinkedIn: scheduling needs a live connection (and one allowed to post media, when media is attached)."""
189
+ if platform not in CONNECTED_ONLY:
190
+ return
191
+ label = PLATFORM_LABELS[platform]
192
+ if not account or account.status != "active":
193
+ raise HTTPException(409, {"code": "not_connected",
194
+ "message": f"Connect {label} to schedule posts there."
195
+ if not account else f"Your {label} connection is down. Reconnect {label} first."})
196
+ if artifact and media_count(artifact) and not can_post_media(account):
197
+ raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
198
+
199
+
200
  def _validate_lengths(platform: str, posts: list[str]) -> None:
201
  limit = LIMITS.get(platform)
202
  if platform in ("x", "bluesky") and limit:
 
211
  accounts = {a.id: a for a in ctx.db.scalars(
212
  select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id))}
213
  clicks = lp.clicks_by_item(ctx.db, [i.id for i in items])
214
+ ids = {i.artifact_id for i in items if i.artifact_id} | {i.kit_id for i in items if i.kit_id}
215
+ artifacts = {a.id: a for a in ctx.db.scalars(select(Artifact).where(Artifact.id.in_(ids)))} if ids else {}
216
+ return [serialize_item(i, accounts.get(i.social_account_id), clicks.get(i.id, 0), artifacts.get(i.artifact_id),
217
+ artifacts.get(i.kit_id)) for i in items]
218
+
219
+
220
+ def _kit(ctx: Ctx, kit_id: uuid.UUID) -> Artifact:
221
+ """This workspace's Launch Kit (what a post was written from)."""
222
+ kit = ctx.db.scalar(select(Artifact).where(Artifact.id == kit_id, Artifact.workspace_id == ctx.workspace.id,
223
+ Artifact.type == "launch_kit"))
224
+ if not kit:
225
+ raise HTTPException(404, "Launch Kit not found")
226
+ return kit
227
+
228
+
229
+ def _postable(ctx: Ctx, artifact_id: uuid.UUID, platform: str) -> Artifact:
230
+ """This workspace's artifact, checked for the platform (audio, unfinished, too long for X: 400)."""
231
+ a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
232
+ if not a:
233
+ raise HTTPException(404, "Artifact not found")
234
+ check_postable(a, platform)
235
+ return a
236
 
237
 
238
  @router.get("/calendar")
 
251
 
252
  @router.post("/calendar")
253
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
254
+ when = body.utc()
255
+ if when is None:
256
+ raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
257
  account = _account(ctx, body.social_account_id, body.platform)
258
  _validate_lengths(body.platform, [body.content, *body.thread])
259
+ artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
260
+ kit = _kit(ctx, body.kit_id) if body.kit_id else None
261
+ _require_connected(body.platform, account, artifact)
262
+ if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
263
+ Document.workspace_id == ctx.workspace.id)):
264
+ raise HTTPException(404, "Document not found")
265
  item = CalendarItem(
266
+ workspace_id=ctx.workspace.id, platform=body.platform, scheduled_at=when,
267
  content=body.content, thread=[t for t in body.thread if t.strip()], artifact_id=body.artifact_id,
268
+ kit_id=kit.id if kit else None,
269
  document_id=body.document_id, social_account_id=account.id if account else None,
270
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
271
  status="draft" if body.draft else "scheduled",
 
280
  item = _item(ctx, item_id)
281
  if item.status in ("posted", "publishing"):
282
  raise HTTPException(409, "This post already went out.")
283
+ when = body.utc()
284
+ if when is not None:
285
+ item.scheduled_at = when
286
  if body.content is not None:
287
  item.content = body.content
288
  if body.thread is not None:
289
  item.thread = [t for t in body.thread if t.strip()]
290
+ if "artifact_id" in body.model_fields_set:
291
+ item.artifact_id = _postable(ctx, body.artifact_id, item.platform).id if body.artifact_id else None
292
  if "social_account_id" in body.model_fields_set:
293
  item.social_account_id = (_account(ctx, body.social_account_id, item.platform).id
294
  if body.social_account_id else None)
 
296
  item.remind_by_email = body.remind_by_email
297
  if not item.social_account_id or item.platform not in AUTO_POST:
298
  item.remind_by_email = True
299
+ account = ctx.db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
300
+ artifact = ctx.db.get(Artifact, item.artifact_id) if item.artifact_id else None
301
+ if body.status == "paused":
302
+ item.status = "paused" # held by hand: never attempted until resumed
303
+ elif body.status is not None or item.status in ("failed", "reminded", "paused"):
304
+ # Going out (again): only through a live connection, and only at a time still ahead.
305
+ _require_connected(item.platform, account, artifact)
306
+ if item.status == "paused" and _aware(item.scheduled_at) <= datetime.now(UTC):
307
+ raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
308
+ item.status = body.status or "scheduled"
309
+ elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
310
+ _require_connected(item.platform, account, artifact)
311
  item.error = None
312
  item.attempts = 0
313
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
 
331
  item.status = "publishing"
332
  item.attempts = 0
333
  ctx.db.commit()
334
+ if lp.has_media(ctx.db, item):
335
+ lp.queue_publish(ctx.db, item) # uploading takes a while: a job does it, the page follows the status
336
+ else:
337
+ await run_in_threadpool(lp.publish_item, ctx.db, item)
338
  return _enrich(ctx, [item])[0]
339
 
340
 
341
+ class UploadIn(BaseModel):
342
+ upload_id: uuid.UUID
343
+ duration_s: float | None = Field(None, ge=0, le=24 * 3600) # a video's length, read by the browser
344
+
345
+
346
+ @router.post("/launchpad/uploads")
347
+ def register_upload(body: UploadIn, ctx: Ctx = Depends(get_ctx)):
348
+ """A file the user uploaded from their computer (through /api/storage/uploads), made postable: it becomes an
349
+ `upload` artifact, attached and posted like anything Notestack made, and offered again in the picker later."""
350
+ upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
351
+ if not upload:
352
+ raise HTTPException(404, "Upload not found")
353
+ if upload.status != "complete":
354
+ raise HTTPException(409, "This file hasn't finished uploading.")
355
+ kind = UPLOAD_TYPES.get(upload.content_type)
356
+ if not kind:
357
+ raise HTTPException(415, "LinkedIn takes JPG, PNG or GIF images and MP4 videos.")
358
+ low, high = SIZE_LIMITS[("linkedin", kind)]
359
+ size = upload.size_bytes or 0
360
+ if size > high:
361
+ raise HTTPException(413, f"This {kind} is {size / MB:.0f} MB; LinkedIn takes up to {high // MB} MB.")
362
+ if size < low:
363
+ raise HTTPException(400, f"This {kind} is too small for LinkedIn (at least {low // 1024} KB).")
364
+ title = upload.filename.rsplit(".", 1)[0] if "." in upload.filename else upload.filename
365
+ a = Artifact(workspace_id=ctx.workspace.id, type="upload", status="ready", storage_key=upload.key,
366
+ content_json={"title": title[:200] or "Upload", "filename": upload.filename,
367
+ "content_type": upload.content_type, "media": kind, "size_bytes": size,
368
+ **({"duration_s": body.duration_s} if body.duration_s else {})})
369
+ ctx.db.add(a)
370
+ ctx.db.commit()
371
+ return artifact_brief(a)
372
+
373
+
374
+ @router.get("/launchpad/postable")
375
+ def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
376
+ """What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps
377
+ (not audio), newest first. Videos made but not rendered yet are listed too, flagged needs_render (they can only be
378
+ posted once rendered), and so are videos being rendered, flagged rendering."""
379
+ rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
380
+ Artifact.type.in_(POSTABLE))
381
+ .order_by(Artifact.created_at.desc()).limit(400))
382
+ needle = q.strip().lower()
383
+ out = []
384
+ for a in rows:
385
+ # A blog2video video counts once blog2video made it (ready), rendered or not; anything else once it is ready.
386
+ # A blog2video video mid-render stays listed (flagged rendering).
387
+ ready = a.status == "ready" or (is_blog2video(a) and (bool((a.content_json or {}).get("video_url"))
388
+ or a.status == "rendering"))
389
+ title = ((a.content_json or {}).get("title") or "").lower()
390
+ if ready and (not needle or needle in title):
391
+ out.append(artifact_brief(a))
392
+ if len(out) >= min(limit, 200):
393
+ break
394
+ return out
395
+
396
+
397
  # Social accounts
398
 
399
 
400
  def serialize_account(a: SocialAccount) -> dict:
401
  return {"id": str(a.id), "platform": a.platform, "platform_label": PLATFORM_LABELS[a.platform],
402
  "handle": a.handle, "status": a.status, "avatar_url": a.avatar_url,
403
+ "can_post_media": can_post_media(a),
404
+ # No refresh token and the connection ends within a week (LinkedIn's 60 day tokens).
405
+ "expires_soon": bool(a.expires_at and not a.refresh_token
406
+ and _aware(a.expires_at) - datetime.now(UTC) < timedelta(days=7)),
407
+ "expires_at": _aware(a.expires_at).isoformat() if a.expires_at else None,
408
+ # Down (expired, revoked, disconnected), or unable to post images and videos.
409
+ "needs_reconnect": a.status != "active" or (a.platform in CONNECTED_ONLY and not can_post_media(a)),
410
  "connected_at": a.created_at.isoformat() if a.created_at else None}
411
 
412
 
 
427
  SocialAccount.workspace_id == ctx.workspace.id))
428
  if not account:
429
  raise HTTPException(404, "Account not found")
430
+ # Kept (its posts stay linked to it), tokens gone, posts paused: reconnecting the same account resumes them.
431
+ account.status = "revoked"
432
+ account.access_token = "" # no usable token kept
433
+ account.refresh_token = None
434
+ label = PLATFORM_LABELS[account.platform]
435
+ pause_account_posts(ctx.db, account, f"{label} disconnected. Reconnect {label} to resume.")
436
  ctx.db.commit()
437
  return {"ok": True}
438
 
 
453
  account.expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in)) if expires_in else None
454
  account.scopes = scopes
455
  account.status = "active"
456
+ resume_account_posts(db, account) # its paused posts still ahead go back to scheduled
457
  db.commit()
458
 
459
 
backend/app/routers/reports.py CHANGED
@@ -15,8 +15,8 @@ from app.routers.artifacts import get_artifact_or_404
15
  from app.routers.videos import FocusIn, _focus_material
16
  from app.services.artifacts import latest_jobs, serialize_artifact
17
  from app.services.jobs import create_job
18
- from app.services.usage import check_limit
19
  from app.services.report_templates import INTERACTIVE, TEMPLATES
 
20
 
21
  router = APIRouter(prefix="/api/reports", tags=["reports"])
22
 
 
15
  from app.routers.videos import FocusIn, _focus_material
16
  from app.services.artifacts import latest_jobs, serialize_artifact
17
  from app.services.jobs import create_job
 
18
  from app.services.report_templates import INTERACTIVE, TEMPLATES
19
+ from app.services.usage import check_limit
20
 
21
  router = APIRouter(prefix="/api/reports", tags=["reports"])
22
 
backend/app/routers/storage.py CHANGED
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/api/storage", tags=["storage"])
18
  ALLOWED_TYPES = {
19
  "image/png",
20
  "image/jpeg",
 
21
  "image/webp",
22
  "image/svg+xml",
23
  "audio/mpeg",
 
18
  ALLOWED_TYPES = {
19
  "image/png",
20
  "image/jpeg",
21
+ "image/gif",
22
  "image/webp",
23
  "image/svg+xml",
24
  "audio/mpeg",
backend/app/services/artifacts.py CHANGED
@@ -22,6 +22,7 @@ TYPE_LABELS = {
22
  "flashcards": "Flashcards",
23
  "report": "Report",
24
  "infographic": "Infographic",
 
25
  }
26
 
27
 
 
22
  "flashcards": "Flashcards",
23
  "report": "Report",
24
  "infographic": "Infographic",
25
+ "upload": "Upload",
26
  }
27
 
28
 
backend/app/services/blog2video.py CHANGED
@@ -85,7 +85,7 @@ class Denied(B2VError):
85
 
86
 
87
  UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
88
- NOT_RESPONDING = "The video service is not responding. Try again in a minute."
89
 
90
 
91
  def configured() -> bool:
 
85
 
86
 
87
  UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
88
+ NOT_RESPONDING = "The video service is being updated. Please try again in a while."
89
 
90
 
91
  def configured() -> bool:
backend/app/services/email.py CHANGED
@@ -55,7 +55,8 @@ class ConsoleEmailProvider(BaseEmailProvider):
55
 
56
  def send(self, email: OutgoingEmail) -> str | None:
57
  ConsoleEmailProvider.sent.append(email)
58
- log.info("[email] to=%s subject=%s\n%s", email.to, email.subject, email.text)
 
59
  return None
60
 
61
 
@@ -264,6 +265,13 @@ class EmailService:
264
  def _build() -> EmailService:
265
  if settings.email_provider == "resend" and settings.resend_api_key:
266
  return EmailService(ResendEmailProvider(settings.resend_api_key))
 
 
 
 
 
 
 
267
  return EmailService(ConsoleEmailProvider())
268
 
269
 
 
55
 
56
  def send(self, email: OutgoingEmail) -> str | None:
57
  ConsoleEmailProvider.sent.append(email)
58
+ # Warning, not info: locally this is the only place a sign up or reset code shows up.
59
+ log.warning("[email] to=%s subject=%s\n%s", email.to, email.subject, email.text)
60
  return None
61
 
62
 
 
265
  def _build() -> EmailService:
266
  if settings.email_provider == "resend" and settings.resend_api_key:
267
  return EmailService(ResendEmailProvider(settings.resend_api_key))
268
+ if settings.email_provider == "resend":
269
+ log.warning("EMAIL_PROVIDER=resend but RESEND_API_KEY is empty: emails are only logged, not sent")
270
+ elif settings.email_provider != "console":
271
+ log.warning("Unknown EMAIL_PROVIDER=%r (use resend or console): emails are only logged, not sent",
272
+ settings.email_provider)
273
+ else:
274
+ log.warning("EMAIL_PROVIDER=console: emails are only logged, not sent")
275
  return EmailService(ConsoleEmailProvider())
276
 
277
 
backend/app/services/email_verification.py CHANGED
@@ -52,6 +52,11 @@ def _latest(db: Session, email: str, purpose: VerificationPurpose) -> EmailVerif
52
  )
53
 
54
 
 
 
 
 
 
55
  def issue_code(
56
  db: Session,
57
  email: str,
@@ -61,7 +66,7 @@ def issue_code(
61
  ) -> IssuedCode:
62
  now = datetime.now(UTC)
63
  latest = _latest(db, email, purpose)
64
- if latest and now - _aware(latest.created_at) < RESEND_COOLDOWN:
65
  raise VerificationError("cooldown", "Please wait a minute before requesting another code.")
66
  clear_codes(db, email, purpose)
67
  code = f"{secrets.randbelow(1_000_000):06d}"
@@ -83,14 +88,20 @@ def check_code(db: Session, email: str, purpose: VerificationPurpose, code: str)
83
  record = _latest(db, email, purpose)
84
  if not record:
85
  raise VerificationError("not_found", "No active code. Request a new one.")
 
 
86
  if datetime.now(UTC) > _aware(record.expires_at):
87
  raise VerificationError("expired", "That code expired. Request a new one.")
88
- if record.attempts >= MAX_ATTEMPTS:
89
- raise VerificationError("too_many_attempts", "Too many attempts. Request a new code.")
90
  if not hmac.compare_digest(record.code_hash, _hash(email, code.strip())):
91
  record.attempts += 1
 
 
 
 
 
 
92
  db.commit()
93
- raise VerificationError("invalid", "That code is not right.")
94
  return record
95
 
96
 
 
52
  )
53
 
54
 
55
+ def _alive(record: EmailVerificationCode, now: datetime) -> bool:
56
+ """A code that can still be entered. Dead codes (expired, out of tries) never hold up a resend."""
57
+ return now <= _aware(record.expires_at) and record.attempts < MAX_ATTEMPTS
58
+
59
+
60
  def issue_code(
61
  db: Session,
62
  email: str,
 
66
  ) -> IssuedCode:
67
  now = datetime.now(UTC)
68
  latest = _latest(db, email, purpose)
69
+ if latest and _alive(latest, now) and now - _aware(latest.created_at) < RESEND_COOLDOWN:
70
  raise VerificationError("cooldown", "Please wait a minute before requesting another code.")
71
  clear_codes(db, email, purpose)
72
  code = f"{secrets.randbelow(1_000_000):06d}"
 
88
  record = _latest(db, email, purpose)
89
  if not record:
90
  raise VerificationError("not_found", "No active code. Request a new one.")
91
+ if record.attempts >= MAX_ATTEMPTS:
92
+ raise VerificationError("too_many_attempts", "Too many wrong tries. This code has expired. Request a new one.")
93
  if datetime.now(UTC) > _aware(record.expires_at):
94
  raise VerificationError("expired", "That code expired. Request a new one.")
 
 
95
  if not hmac.compare_digest(record.code_hash, _hash(email, code.strip())):
96
  record.attempts += 1
97
+ left = MAX_ATTEMPTS - record.attempts
98
+ if left <= 0:
99
+ # Expire rather than consume: sign up resend still needs the pending name and password.
100
+ record.expires_at = datetime.now(UTC)
101
+ db.commit()
102
+ raise VerificationError("too_many_attempts", "Too many wrong tries. This code has expired. Request a new one.")
103
  db.commit()
104
+ raise VerificationError("invalid", f"That code is not right. {left} {'try' if left == 1 else 'tries'} left.")
105
  return record
106
 
107
 
backend/app/services/launchpad.py CHANGED
@@ -1,9 +1,13 @@
1
- """Launchpad: publishes due calendar items, emails reminders for platforms without an API, rewrites links
2
- to tracked short links and pulls engagement back in."""
 
 
 
3
 
4
  import logging
5
  import re
6
  import secrets
 
7
  from collections.abc import Callable
8
  from datetime import UTC, datetime, timedelta
9
 
@@ -12,13 +16,17 @@ from sqlalchemy.orm import Session
12
 
13
  from app.config import settings
14
  from app.db import SessionLocal
15
- from app.models import CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, User, Workspace
16
  from app.services.email import email_service
 
17
  from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
 
 
18
 
19
  log = logging.getLogger("notestack.launchpad")
20
  _URL = re.compile(r"https?://[^\s)\]>]+")
21
  MAX_ATTEMPTS = 3
 
22
 
23
 
24
  def _public_api() -> bool:
@@ -58,6 +66,11 @@ def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
58
  return item
59
  account = db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
60
  label = PLATFORM_LABELS.get(item.platform, item.platform)
 
 
 
 
 
61
  if item.platform not in AUTO_POST or item.remind_by_email or not account:
62
  to = owner_email(db, item.workspace_id)
63
  sent = bool(to) and email_service.send_post_reminder(
@@ -71,8 +84,21 @@ def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
71
  try:
72
  tracked = [track_links(db, item, p) for p in posts]
73
  db.flush()
74
- external_id, url = module(item.platform).publish(db, account, tracked)
 
 
 
 
 
 
 
 
75
  except SocialError as exc:
 
 
 
 
 
76
  _fail_or_retry(db, item, str(exc), exc.permanent)
77
  return item
78
  except Exception as exc: # network trouble: retry
@@ -99,13 +125,34 @@ def _fail_or_retry(db: Session, item: CalendarItem, error: str, permanent: bool)
99
  db.commit()
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def publish_due(session_factory: Callable[[], Session] = SessionLocal) -> int:
 
103
  now = datetime.now(UTC)
104
- count = 0
105
  with session_factory() as db:
106
- # A tick that died mid publish leaves items in "publishing"; put them back after 10 minutes.
107
  for stuck in db.scalars(select(CalendarItem).where(
108
- CalendarItem.status == "publishing", CalendarItem.updated_at < now - timedelta(minutes=10))):
109
  stuck.status = "scheduled"
110
  due = db.scalars(
111
  select(CalendarItem).where(CalendarItem.status == "scheduled", CalendarItem.scheduled_at <= now)
@@ -115,9 +162,8 @@ def publish_due(session_factory: Callable[[], Session] = SessionLocal) -> int:
115
  item.status = "publishing" # claim, so an overlapping tick skips it
116
  db.commit()
117
  for item in due:
118
- publish_item(db, item)
119
- count += 1
120
- return count
121
 
122
 
123
  def clicks_by_item(db: Session, item_ids: list) -> dict:
 
1
+ """Launchpad: publishes due calendar items (with the images or video of the artifact they were made from), emails
2
+ reminders for platforms without an API, rewrites links to tracked short links and pulls engagement back in.
3
+
4
+ Due items are published by a "publish_post" job each, not inside the worker's 30 second tick: a video upload and the
5
+ platform's processing can take minutes."""
6
 
7
  import logging
8
  import re
9
  import secrets
10
+ import uuid
11
  from collections.abc import Callable
12
  from datetime import UTC, datetime, timedelta
13
 
 
16
 
17
  from app.config import settings
18
  from app.db import SessionLocal
19
+ from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, User, Workspace
20
  from app.services.email import email_service
21
+ from app.services.jobs import create_job
22
  from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
23
+ from app.services.social.health import mark_down
24
+ from app.services.social.media import media_count, media_for
25
 
26
  log = logging.getLogger("notestack.launchpad")
27
  _URL = re.compile(r"https?://[^\s)\]>]+")
28
  MAX_ATTEMPTS = 3
29
+ STUCK_AFTER = timedelta(minutes=30) # longer than a video upload and the platform's processing take
30
 
31
 
32
  def _public_api() -> bool:
 
66
  return item
67
  account = db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
68
  label = PLATFORM_LABELS.get(item.platform, item.platform)
69
+ if item.platform in ("x", "linkedin") and not item.remind_by_email and (not account or account.status != "active"):
70
+ # The connection is down: never attempted; it resumes when the account is reconnected.
71
+ item.status, item.error = "paused", f"{label} disconnected. Reconnect {label} to resume."
72
+ db.commit()
73
+ return item
74
  if item.platform not in AUTO_POST or item.remind_by_email or not account:
75
  to = owner_email(db, item.workspace_id)
76
  sent = bool(to) and email_service.send_post_reminder(
 
84
  try:
85
  tracked = [track_links(db, item, p) for p in posts]
86
  db.flush()
87
+ artifact = db.get(Artifact, item.artifact_id) if item.artifact_id else None
88
+ if artifact and media_count(artifact) and item.platform in ("x", "linkedin") \
89
+ and not module(item.platform).can_post_media(account):
90
+ # Scheduled before the account lost the right to post media: held, not attempted, until reconnected.
91
+ item.status, item.error = "paused", f"Reconnect {label} to post images and videos."
92
+ db.commit()
93
+ return item
94
+ with media_for(artifact, item.platform) as media: # temporary video files go when this ends
95
+ external_id, url = module(item.platform).publish(db, account, tracked, media)
96
  except SocialError as exc:
97
+ if exc.reconnect: # the platform refused the connection: it is down, and its posts pause
98
+ mark_down(db, account, "expired", str(exc))
99
+ item.status, item.error = "paused", str(exc)[:1000]
100
+ db.commit()
101
+ return item
102
  _fail_or_retry(db, item, str(exc), exc.permanent)
103
  return item
104
  except Exception as exc: # network trouble: retry
 
125
  db.commit()
126
 
127
 
128
+ def has_media(db: Session, item: CalendarItem) -> bool:
129
+ """The post uploads images or a video (slow: it goes through a job, also for Publish now)."""
130
+ artifact = db.get(Artifact, item.artifact_id) if item.artifact_id else None
131
+ return bool(artifact) and item.platform in ("x", "linkedin") and media_count(artifact) > 0
132
+
133
+
134
+ def queue_publish(db: Session, item: CalendarItem) -> None:
135
+ """Hand the (already claimed, "publishing") item to a worker job."""
136
+ create_job(db, item.workspace_id, "publish_post", {"item_id": str(item.id)}, max_attempts=1)
137
+
138
+
139
+ def publish_job(db: Session, item_id: str) -> CalendarItem | None:
140
+ """The publish_post job: publish the item if it is still waiting for it."""
141
+ item = db.get(CalendarItem, uuid.UUID(str(item_id)))
142
+ if not item or item.status != "publishing":
143
+ return item
144
+ item.updated_at = datetime.now(UTC) # the stuck limit counts from the job's start, not the claim
145
+ db.commit()
146
+ return publish_item(db, item)
147
+
148
+
149
  def publish_due(session_factory: Callable[[], Session] = SessionLocal) -> int:
150
+ """Claim the due items and queue a publish_post job for each. Returns how many were queued."""
151
  now = datetime.now(UTC)
 
152
  with session_factory() as db:
153
+ # A job that died mid publish leaves its item in "publishing"; put it back once that has clearly happened.
154
  for stuck in db.scalars(select(CalendarItem).where(
155
+ CalendarItem.status == "publishing", CalendarItem.updated_at < now - STUCK_AFTER)):
156
  stuck.status = "scheduled"
157
  due = db.scalars(
158
  select(CalendarItem).where(CalendarItem.status == "scheduled", CalendarItem.scheduled_at <= now)
 
162
  item.status = "publishing" # claim, so an overlapping tick skips it
163
  db.commit()
164
  for item in due:
165
+ queue_publish(db, item)
166
+ return len(due)
 
167
 
168
 
169
  def clicks_by_item(db: Session, item_ids: list) -> dict:
backend/app/services/renderer.py CHANGED
@@ -17,6 +17,14 @@ class PermanentJobError(RuntimeError):
17
  """Fail the job now; retrying will not help."""
18
 
19
 
 
 
 
 
 
 
 
 
20
  def request_render(job: Job, artifact: Artifact, composition: str, props: dict | None = None,
21
  stills: list[dict] | None = None) -> None:
22
  """Start a render. `stills` renders one PNG per props dict (carousels) instead of a single output."""
 
17
  """Fail the job now; retrying will not help."""
18
 
19
 
20
+ def available() -> bool:
21
+ """Whether the render service answers right now (a quick health check, not a promise for later)."""
22
+ try:
23
+ return httpx.get(f"{settings.renderer_url}/health", timeout=2).status_code == 200
24
+ except httpx.HTTPError:
25
+ return False
26
+
27
+
28
  def request_render(job: Job, artifact: Artifact, composition: str, props: dict | None = None,
29
  stills: list[dict] | None = None) -> None:
30
  """Start a render. `stills` renders one PNG per props dict (carousels) instead of a single output."""
backend/app/services/social/__init__.py CHANGED
@@ -1,9 +1,10 @@
1
  """Publishing to X, LinkedIn and Bluesky. Each platform module exposes:
2
 
3
- publish(db, account, posts: list[str]) -> (external_id, external_url)
4
  metrics(db, account, external_id) -> dict
5
 
6
- A list with more than one post is published as a thread (reply chain) where the platform supports it.
 
7
  """
8
 
9
  from datetime import UTC, datetime
@@ -15,9 +16,13 @@ from app.services.crypto import decrypt, encrypt
15
 
16
 
17
  class SocialError(RuntimeError):
18
- def __init__(self, message: str, permanent: bool = False):
 
 
 
19
  super().__init__(message)
20
- self.permanent = permanent
 
21
 
22
 
23
  PLATFORM_LABELS = {"x": "X", "linkedin": "LinkedIn", "bluesky": "Bluesky", "substack_notes": "Substack Notes"}
@@ -49,6 +54,11 @@ def expired(account: SocialAccount) -> bool:
49
  return exp.timestamp() - 120 < datetime.now(UTC).timestamp()
50
 
51
 
 
 
 
 
 
52
  def module(platform: str):
53
  from app.services.social import bluesky, linkedin, x
54
 
 
1
  """Publishing to X, LinkedIn and Bluesky. Each platform module exposes:
2
 
3
+ publish(db, account, posts: list[str], media: list[Media] | None) -> (external_id, external_url)
4
  metrics(db, account, external_id) -> dict
5
 
6
+ A list with more than one post is published as a thread (reply chain) where the platform supports it. Media (images or
7
+ one video, from social.media) goes on the first post; only X and LinkedIn take it.
8
  """
9
 
10
  from datetime import UTC, datetime
 
16
 
17
 
18
  class SocialError(RuntimeError):
19
+ """permanent: don't retry this post. reconnect: the account's connection is gone (expired, revoked, refused):
20
+ the account is marked down and its scheduled posts pause until it is reconnected."""
21
+
22
+ def __init__(self, message: str, permanent: bool = False, reconnect: bool = False):
23
  super().__init__(message)
24
+ self.permanent = permanent or reconnect
25
+ self.reconnect = reconnect
26
 
27
 
28
  PLATFORM_LABELS = {"x": "X", "linkedin": "LinkedIn", "bluesky": "Bluesky", "substack_notes": "Substack Notes"}
 
54
  return exp.timestamp() - 120 < datetime.now(UTC).timestamp()
55
 
56
 
57
+ def scope_set(scopes: str | None) -> set[str]:
58
+ """Granted scopes, whether the platform listed them with spaces or commas."""
59
+ return {s for s in (scopes or "").replace(",", " ").split() if s}
60
+
61
+
62
  def module(platform: str):
63
  from app.services.social import bluesky, linkedin, x
64
 
backend/app/services/social/bluesky.py CHANGED
@@ -34,7 +34,9 @@ def link_facets(text: str) -> list[dict]:
34
  return facets
35
 
36
 
37
- def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
 
 
38
  session = create_session(account.handle, token(account))
39
  headers = {"Authorization": f"Bearer {session['accessJwt']}"}
40
  root = parent = None
 
34
  return facets
35
 
36
 
37
+ def publish(db: Session, account: SocialAccount, posts: list[str], media: list | None = None) -> tuple[str, str]:
38
+ if media:
39
+ raise SocialError("Images and videos can only be posted to X and LinkedIn.", permanent=True)
40
  session = create_session(account.handle, token(account))
41
  headers = {"Authorization": f"Bearer {session['accessJwt']}"}
42
  root = parent = None
backend/app/services/social/health.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Keeping X and LinkedIn connected, and what happens to scheduled posts when a connection goes down.
2
+
3
+ A connection that is down (expired, revoked on the platform, or disconnected here) pauses every post scheduled through
4
+ it: paused posts are never attempted (publish_due only takes "scheduled" ones). Reconnecting the same account resumes
5
+ the ones still ahead; posts whose time passed meanwhile stay paused, for a new time to be picked.
6
+
7
+ social_health (every 10 minutes, worker.PERIODIC) refreshes X tokens before they expire, refreshes LinkedIn tokens
8
+ where LinkedIn gave a refresh token, marks expired LinkedIn connections down, and every 6 hours asks each platform
9
+ once whether the connection still works (it catches access revoked on x.com or linkedin.com)."""
10
+
11
+ import logging
12
+ import time
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from datetime import UTC, datetime, timedelta
16
+
17
+ from sqlalchemy import select
18
+ from sqlalchemy.orm import Session
19
+
20
+ from app.db import SessionLocal
21
+ from app.models import CalendarItem, SocialAccount
22
+ from app.services.social import PLATFORM_LABELS, SocialError, module
23
+
24
+ log = logging.getLogger("notestack.social")
25
+
26
+ ACTIVE_STATES = ("scheduled", "draft", "failed") # what a dropped connection pauses
27
+ LIVENESS_EVERY = 6 * 3600 # seconds between "is this connection still accepted" calls per account
28
+ X_REFRESH_AHEAD = timedelta(minutes=15) # X access tokens last 2 hours: refresh before they lapse
29
+ LINKEDIN_REFRESH_AHEAD = timedelta(days=7)
30
+ _last_checked: dict[uuid.UUID, float] = {} # account id -> monotonic time of its last liveness call
31
+
32
+
33
+ def _aware(dt: datetime | None) -> datetime | None:
34
+ return dt if dt is None or dt.tzinfo else dt.replace(tzinfo=UTC)
35
+
36
+
37
+ def pause_account_posts(db: Session, account: SocialAccount, reason: str) -> int:
38
+ """Pause every post still to go out through this account. The caller commits."""
39
+ items = db.scalars(select(CalendarItem).where(CalendarItem.social_account_id == account.id,
40
+ CalendarItem.status.in_(ACTIVE_STATES))).all()
41
+ for item in items:
42
+ item.status, item.error = "paused", reason
43
+ return len(items)
44
+
45
+
46
+ def mark_down(db: Session, account: SocialAccount, status: str, reason: str | None = None) -> None:
47
+ """The connection is gone (expired | revoked): mark it and pause its posts."""
48
+ label = PLATFORM_LABELS.get(account.platform, account.platform)
49
+ account.status = status
50
+ paused = pause_account_posts(db, account, reason or f"{label} disconnected. Reconnect {label} to resume.")
51
+ db.commit()
52
+ log.info("%s account %s is %s; paused %d posts", account.platform, account.id, status, paused)
53
+
54
+
55
+ def resume_account_posts(db: Session, account: SocialAccount) -> None:
56
+ """After a reconnect: paused posts still ahead go back to scheduled; missed ones stay paused. Caller commits."""
57
+ now = datetime.now(UTC)
58
+ for item in db.scalars(select(CalendarItem).where(CalendarItem.social_account_id == account.id,
59
+ CalendarItem.status == "paused")):
60
+ if _aware(item.scheduled_at) > now:
61
+ item.status, item.error, item.attempts = "scheduled", None, 0
62
+ else:
63
+ item.error = "Missed while disconnected: pick a new time."
64
+
65
+
66
+ def _due_for_refresh(account: SocialAccount) -> bool:
67
+ expires = _aware(account.expires_at)
68
+ if not expires:
69
+ return False
70
+ ahead = X_REFRESH_AHEAD if account.platform == "x" else LINKEDIN_REFRESH_AHEAD
71
+ return expires - ahead <= datetime.now(UTC)
72
+
73
+
74
+ def check_account(db: Session, account: SocialAccount) -> None:
75
+ """Refresh what is about to expire, mark down what is gone, and now and then ask the platform."""
76
+ mod = module(account.platform)
77
+ expires = _aware(account.expires_at)
78
+ try:
79
+ if _due_for_refresh(account):
80
+ if account.refresh_token:
81
+ mod.refresh(db, account)
82
+ elif expires and expires <= datetime.now(UTC):
83
+ raise SocialError("expired", reconnect=True)
84
+ if time.monotonic() - _last_checked.get(account.id, -LIVENESS_EVERY) >= LIVENESS_EVERY:
85
+ mod.check_alive(db, account)
86
+ _last_checked[account.id] = time.monotonic()
87
+ except SocialError as exc:
88
+ if exc.reconnect:
89
+ mark_down(db, account, "revoked" if "no longer accepts" in str(exc) else "expired")
90
+ else:
91
+ log.warning("%s check for %s: %s", account.platform, account.id, exc)
92
+ except Exception: # network trouble: try again next round
93
+ log.warning("%s check for %s failed", account.platform, account.id, exc_info=True)
94
+
95
+
96
+ def social_health(session_factory: Callable[[], Session] = SessionLocal) -> int:
97
+ """Every active X and LinkedIn connection, checked. Returns how many were looked at."""
98
+ with session_factory() as db:
99
+ accounts = db.scalars(select(SocialAccount).where(SocialAccount.status == "active",
100
+ SocialAccount.platform.in_(("x", "linkedin")))).all()
101
+ for account in accounts:
102
+ check_account(db, account)
103
+ return len(accounts)
backend/app/services/social/linkedin.py CHANGED
@@ -1,6 +1,8 @@
1
- """LinkedIn member posts (Share on LinkedIn + Sign In with LinkedIn using OpenID Connect products)."""
 
2
 
3
  import re
 
4
  from urllib.parse import quote, urlencode
5
 
6
  import httpx
@@ -8,12 +10,16 @@ from sqlalchemy.orm import Session
8
 
9
  from app.config import settings
10
  from app.models import SocialAccount
11
- from app.services.social import SocialError, expired, token
 
12
 
13
  AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization"
14
  TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
15
  SCOPES = "openid profile w_member_social"
16
- VERSION = "202509" # LinkedIn-Version header, YYYYMM; LinkedIn supports each version for a year
 
 
 
17
 
18
 
19
  def redirect_uri() -> str:
@@ -55,11 +61,113 @@ def little_text(text: str) -> str:
55
  return _RESERVED.sub(r"\\\1", text)
56
 
57
 
58
- def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  if expired(account):
60
- account.status = "expired"
61
- db.commit()
62
- raise SocialError("The LinkedIn connection expired. Reconnect the account.", permanent=True)
63
  body = {
64
  "author": f"urn:li:person:{account.external_id}",
65
  "commentary": little_text("\n\n".join(posts)),
@@ -68,14 +176,13 @@ def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str,
68
  "lifecycleState": "PUBLISHED",
69
  "isReshareDisabledByAuthor": False,
70
  }
71
- resp = httpx.post("https://api.linkedin.com/rest/posts", json=body, timeout=20, headers={
72
- "Authorization": f"Bearer {token(account)}", "LinkedIn-Version": VERSION,
73
- "X-Restli-Protocol-Version": "2.0.0",
74
- })
75
- if resp.status_code == 401:
76
- account.status = "expired"
77
- db.commit()
78
- raise SocialError("LinkedIn rejected the token. Reconnect the account.", permanent=True)
79
  if resp.status_code >= 400:
80
  raise SocialError(f"LinkedIn {resp.status_code}: {resp.text[:300]}",
81
  permanent=resp.status_code in (400, 403, 422))
 
1
+ """LinkedIn member posts (Share on LinkedIn + Sign In with LinkedIn using OpenID Connect products), with an image,
2
+ several images (multiImage) or a video uploaded first through the Images / Videos APIs."""
3
 
4
  import re
5
+ import time
6
  from urllib.parse import quote, urlencode
7
 
8
  import httpx
 
10
 
11
  from app.config import settings
12
  from app.models import SocialAccount
13
+ from app.services.crypto import decrypt
14
+ from app.services.social import SocialError, expired, save_tokens, scope_set, token
15
 
16
  AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization"
17
  TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
18
  SCOPES = "openid profile w_member_social"
19
+ VERSION = "202609" # LinkedIn-Version header, YYYYMM; LinkedIn supports each version for a year
20
+ REST = "https://api.linkedin.com/rest"
21
+ PROCESSING_WAIT = 600 # seconds LinkedIn may take to process a video before we give up
22
+ PART_ATTEMPTS = 4 # a video part upload is tried this often (network errors, 5xx) before the post fails
23
 
24
 
25
  def redirect_uri() -> str:
 
61
  return _RESERVED.sub(r"\\\1", text)
62
 
63
 
64
+ def _call(method: str, url: str, **kw) -> httpx.Response:
65
+ """Every request to LinkedIn goes through here (tests replace it)."""
66
+ return httpx.request(method, url, timeout=kw.pop("timeout", 60), **kw)
67
+
68
+
69
+ def _headers(account: SocialAccount) -> dict:
70
+ return {"Authorization": f"Bearer {token(account)}", "LinkedIn-Version": VERSION,
71
+ "X-Restli-Protocol-Version": "2.0.0"}
72
+
73
+
74
+ def can_post_media(account: SocialAccount) -> bool:
75
+ return "w_member_social" in scope_set(account.scopes)
76
+
77
+
78
+ def refresh(db: Session, account: SocialAccount) -> None:
79
+ """Programmatic refresh, for apps LinkedIn gives refresh tokens to; without one the connection simply expires."""
80
+ if not account.refresh_token:
81
+ raise SocialError("The LinkedIn connection expired. Reconnect LinkedIn.", reconnect=True)
82
+ resp = _call("POST", TOKEN_URL, data={
83
+ "grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token),
84
+ "client_id": settings.linkedin_client_id, "client_secret": settings.linkedin_client_secret}, timeout=20)
85
+ if resp.status_code in (400, 401):
86
+ raise SocialError("The LinkedIn connection expired. Reconnect LinkedIn.", reconnect=True)
87
+ tokens = _check(resp, "token refresh")
88
+ save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
89
+
90
+
91
+ def check_alive(db: Session, account: SocialAccount) -> None:
92
+ """One cheap call: raises SocialError(reconnect=True) when LinkedIn no longer accepts this connection."""
93
+ resp = _call("GET", "https://api.linkedin.com/v2/userinfo", headers={"Authorization": f"Bearer {token(account)}"},
94
+ timeout=20)
95
+ if resp.status_code == 401:
96
+ raise SocialError("LinkedIn no longer accepts this connection. Reconnect LinkedIn.", reconnect=True)
97
+
98
+
99
+ def _check(resp: httpx.Response, what: str) -> dict:
100
+ if resp.status_code == 426 or "NONEXISTENT_VERSION" in resp.text:
101
+ raise SocialError(f"LinkedIn retired API version {VERSION}; Notestack needs an update to post.", permanent=True)
102
+ if resp.status_code == 401:
103
+ raise SocialError("LinkedIn rejected the connection. Reconnect LinkedIn.", reconnect=True)
104
+ if resp.status_code >= 400:
105
+ raise SocialError(f"LinkedIn {what} {resp.status_code}: {resp.text[:300]}",
106
+ permanent=400 <= resp.status_code < 500 and resp.status_code != 429)
107
+ return resp.json() if resp.content else {}
108
+
109
+
110
+ def _upload_image(account: SocialAccount, m) -> str:
111
+ owner = f"urn:li:person:{account.external_id}"
112
+ got = _check(_call("POST", f"{REST}/images", params={"action": "initializeUpload"}, headers=_headers(account),
113
+ json={"initializeUploadRequest": {"owner": owner}}), "image upload")["value"]
114
+ _check(_call("PUT", got["uploadUrl"], content=m.read(),
115
+ headers={"Authorization": _headers(account)["Authorization"], "Content-Type": m.content_type}),
116
+ "image upload")
117
+ return got["image"]
118
+
119
+
120
+ def _upload_video(account: SocialAccount, m) -> str:
121
+ owner = f"urn:li:person:{account.external_id}"
122
+ got = _check(_call("POST", f"{REST}/videos", params={"action": "initializeUpload"}, headers=_headers(account),
123
+ json={"initializeUploadRequest": {"owner": owner, "fileSizeBytes": m.size,
124
+ "uploadCaptions": False, "uploadThumbnail": False}}),
125
+ "video upload")["value"]
126
+ etags = [_put_part(part, m) for part in got["uploadInstructions"]]
127
+ _check(_call("POST", f"{REST}/videos", params={"action": "finalizeUpload"}, headers=_headers(account),
128
+ json={"finalizeUploadRequest": {"video": got["video"], "uploadToken": got.get("uploadToken", ""),
129
+ "uploadedPartIds": etags}}), "video upload")
130
+ waited = 0
131
+ while True: # usable in a post only once LinkedIn has processed it
132
+ state = _check(_call("GET", f"{REST}/videos/{quote(got['video'])}", headers=_headers(account)),
133
+ "video status").get("status")
134
+ if state == "AVAILABLE":
135
+ return got["video"]
136
+ if state == "PROCESSING_FAILED":
137
+ raise SocialError("LinkedIn could not process the video.", permanent=True)
138
+ if waited >= PROCESSING_WAIT:
139
+ raise SocialError("LinkedIn took too long to process the video.")
140
+ time.sleep(5)
141
+ waited += 5
142
+
143
+
144
+ def _put_part(part: dict, m) -> str:
145
+ """One part of a video, read from the file. Sent to LinkedIn's upload URL with no bearer token; network errors
146
+ and 5xx are retried with backoff. Returns the part's ETag."""
147
+ data = m.read(part["firstByte"], part["lastByte"] + 1)
148
+ for attempt in range(1, PART_ATTEMPTS + 1):
149
+ try:
150
+ resp = _call("PUT", part["uploadUrl"], content=data, headers={"Content-Type": "application/octet-stream"},
151
+ timeout=300)
152
+ except httpx.HTTPError as exc:
153
+ if attempt == PART_ATTEMPTS:
154
+ raise SocialError(f"LinkedIn video upload failed: {exc}") from exc
155
+ else:
156
+ if resp.status_code in (401, 403):
157
+ raise SocialError("LinkedIn's upload session expired; trying again later.")
158
+ if resp.status_code < 400:
159
+ return resp.headers.get("etag", "").strip('"')
160
+ if resp.status_code < 500 or attempt == PART_ATTEMPTS:
161
+ raise SocialError(f"LinkedIn video upload {resp.status_code}: {resp.text[:200]}",
162
+ permanent=resp.status_code < 500)
163
+ time.sleep(2 ** attempt)
164
+ raise SocialError("LinkedIn video upload failed.") # not reached
165
+
166
+
167
+ def publish(db: Session, account: SocialAccount, posts: list[str], media: list | None = None) -> tuple[str, str]:
168
  if expired(account):
169
+ refresh(db, account) # without a refresh token: reconnect
170
+ urns = [(_upload_video if m.kind == "video" else _upload_image)(account, m) for m in media or []]
 
171
  body = {
172
  "author": f"urn:li:person:{account.external_id}",
173
  "commentary": little_text("\n\n".join(posts)),
 
176
  "lifecycleState": "PUBLISHED",
177
  "isReshareDisabledByAuthor": False,
178
  }
179
+ if len(urns) == 1:
180
+ body["content"] = {"media": {"id": urns[0]}}
181
+ elif urns:
182
+ body["content"] = {"multiImage": {"images": [{"id": u} for u in urns]}}
183
+ resp = _call("POST", f"{REST}/posts", json=body, timeout=20, headers=_headers(account))
184
+ if resp.status_code in (401, 426) or "NONEXISTENT_VERSION" in resp.text:
185
+ _check(resp, "post") # reconnect, or the API version was retired
 
186
  if resp.status_code >= 400:
187
  raise SocialError(f"LinkedIn {resp.status_code}: {resp.text[:300]}",
188
  permanent=resp.status_code in (400, 403, 422))
backend/app/services/social/media.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What a scheduled post carries from the artifact it was made from: images (quote cards, carousel slides) or one
2
+ video (an audiogram, or a blog2video video), or a file the user uploaded from their computer (an `upload`: one image or
3
+ one MP4). Summaries, launch kits and mind maps post as text; audio overviews can't
4
+ be posted (X and LinkedIn take no audio files).
5
+
6
+ Videos are streamed to a temporary file (never held whole in memory) and read in slices by the uploaders; the files are
7
+ removed when the post is done (media_for is a context manager). Sizes are checked before anything is sent."""
8
+
9
+ import contextlib
10
+ import os
11
+ import tempfile
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass
14
+
15
+ import httpx
16
+ from fastapi import HTTPException
17
+
18
+ from app.models import Artifact
19
+ from app.services.social import SocialError
20
+
21
+ MAX_IMAGES = {"x": 4, "linkedin": 20}
22
+ X_VIDEO_SECONDS = 140 # X's limit for videos from standard accounts (2:20)
23
+ TEXT_ONLY = {"summary", "launch_kit", "mind_map"}
24
+ MEDIA_PLATFORMS = {"x", "linkedin"}
25
+ MB = 1024 * 1024
26
+ # What the user can upload from their computer to post: LinkedIn takes JPG, PNG and GIF images and MP4 videos.
27
+ UPLOAD_TYPES = {"image/png": "image", "image/jpeg": "image", "image/gif": "image", "video/mp4": "video"}
28
+ # (min, max) bytes per platform and kind, checked before any upload starts
29
+ SIZE_LIMITS = {
30
+ ("x", "image"): (1, 5 * MB),
31
+ ("x", "video"): (1, 512 * MB),
32
+ ("linkedin", "image"): (1, 36 * MB),
33
+ ("linkedin", "video"): (75 * 1024, 500 * MB),
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class Media:
39
+ kind: str # image | video
40
+ filename: str
41
+ content_type: str
42
+ data: bytes | None = None # images: the bytes
43
+ path: str | None = None # videos: a temporary file
44
+ size: int = 0
45
+
46
+ def __post_init__(self):
47
+ if self.data is not None:
48
+ self.size = len(self.data)
49
+ elif self.path:
50
+ self.size = os.path.getsize(self.path)
51
+
52
+ def read(self, start: int = 0, end: int | None = None) -> bytes:
53
+ """Bytes [start, end) (all of them by default)."""
54
+ end = self.size if end is None else min(end, self.size)
55
+ if self.data is not None:
56
+ return self.data[start:end]
57
+ with open(self.path, "rb") as f:
58
+ f.seek(start)
59
+ return f.read(end - start)
60
+
61
+
62
+ def is_blog2video(a: Artifact) -> bool:
63
+ return (a.content_json or {}).get("provider") == "blog2video"
64
+
65
+
66
+ def media_count(a: Artifact) -> int:
67
+ """How many files a post of this artifact carries (before a platform's cap)."""
68
+ if a.type == "quote_card":
69
+ return 1
70
+ if a.type == "carousel":
71
+ return len((a.content_json or {}).get("slide_keys") or ([a.storage_key] if a.storage_key else []))
72
+ if a.type in ("video", "upload"):
73
+ return 1
74
+ return 0
75
+
76
+
77
+ def upload_kind(a: Artifact) -> str | None:
78
+ """An uploaded file's kind: image | video."""
79
+ return (a.content_json or {}).get("media") if a.type == "upload" else None
80
+
81
+
82
+ def duration(a: Artifact) -> float | None:
83
+ """A video's length when we know it (audiograms record it; blog2video's is only known to blog2video)."""
84
+ value = (a.content_json or {}).get("duration_s")
85
+ return float(value) if value else None
86
+
87
+
88
+ def check_postable(a: Artifact, platform: str) -> None:
89
+ """400 when this artifact can't go out on this platform, so it is refused when scheduled, not at post time."""
90
+ if a.type == "audio_overview":
91
+ raise HTTPException(400, "Audio can't be posted: X and LinkedIn don't take audio files.")
92
+ if a.type == "launch_kit": # a post is written from a kit (kit_id); the kit itself is never an attachment
93
+ raise HTTPException(400, "A launch kit can't be attached. Attach a video, quote card or carousel instead.")
94
+ if a.type not in TEXT_ONLY and a.type not in ("quote_card", "carousel", "video", "upload"):
95
+ raise HTTPException(400, "This can't be posted.")
96
+ if a.type in TEXT_ONLY:
97
+ return
98
+ if platform not in MEDIA_PLATFORMS:
99
+ raise HTTPException(400, "Images and videos can only be posted to X and LinkedIn.")
100
+ if a.type == "video" and is_blog2video(a):
101
+ if not (a.content_json or {}).get("video_url"):
102
+ raise HTTPException(400, "This video isn't finished yet. Post it once it has rendered.")
103
+ elif a.status != "ready" or not a.storage_key:
104
+ raise HTTPException(400, "This isn't ready yet. Post it once it has finished.")
105
+ seconds = duration(a) if a.type == "video" else None
106
+ if platform == "x" and seconds and seconds > X_VIDEO_SECONDS:
107
+ raise HTTPException(400, f"This video is {int(seconds // 60)}:{int(seconds % 60):02d} long; X takes videos "
108
+ "up to 2:20. Post it to LinkedIn, or make a shorter one.")
109
+
110
+
111
+ def check_size(m: Media, platform: str) -> None:
112
+ """Too big (or, on LinkedIn, too small a video): a clear permanent error before any network call."""
113
+ low, high = SIZE_LIMITS.get((platform, m.kind), (1, 1 << 40))
114
+ label = "X" if platform == "x" else "LinkedIn"
115
+ if m.size > high:
116
+ raise SocialError(f"This {m.kind} is {m.size / MB:.0f} MB; {label} takes up to {high // MB} MB.", permanent=True)
117
+ if m.size < low:
118
+ raise SocialError(f"This {m.kind} is too small for {label} (at least {low // 1024} KB).", permanent=True)
119
+
120
+
121
+ @contextlib.contextmanager
122
+ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
123
+ """The files to upload with a post of this artifact (none for text-only ones), size checked. Temporary video
124
+ files are deleted when the block ends, also on errors."""
125
+ from app.services.storage import storage
126
+
127
+ temps: list[str] = []
128
+ try:
129
+ out: list[Media] = []
130
+ if a is not None and a.type not in TEXT_ONLY and platform in MEDIA_PLATFORMS:
131
+ if a.type == "quote_card":
132
+ out = [Media("image", "quote.png", "image/png", data=storage.get_bytes(a.storage_key))]
133
+ elif a.type == "carousel":
134
+ keys = (a.content_json or {}).get("slide_keys") or [a.storage_key]
135
+ out = [Media("image", f"slide-{i + 1}.png", "image/png", data=storage.get_bytes(k))
136
+ for i, k in enumerate(keys[:MAX_IMAGES[platform]])]
137
+ elif a.type == "upload" and upload_kind(a) == "image":
138
+ c = a.content_json or {}
139
+ out = [Media("image", c.get("filename") or "image", c.get("content_type") or "image/png",
140
+ data=storage.get_bytes(a.storage_key))]
141
+ elif a.type == "upload":
142
+ path = _temp_file(temps)
143
+ with open(path, "wb") as f:
144
+ storage.download_to(a.storage_key, f)
145
+ out = [Media("video", (a.content_json or {}).get("filename") or "video.mp4", "video/mp4", path=path)]
146
+ elif a.type == "video":
147
+ path = _temp_file(temps)
148
+ with open(path, "wb") as f:
149
+ if is_blog2video(a):
150
+ _download(a.content_json["video_url"], f)
151
+ else:
152
+ storage.download_to(a.storage_key, f)
153
+ out = [Media("video", "video.mp4", "video/mp4", path=path)]
154
+ for m in out:
155
+ check_size(m, platform)
156
+ yield out
157
+ finally:
158
+ for path in temps:
159
+ with contextlib.suppress(OSError):
160
+ os.unlink(path)
161
+
162
+
163
+ def _temp_file(temps: list[str]) -> str:
164
+ fd, path = tempfile.mkstemp(prefix="notestack-post-", suffix=".mp4")
165
+ os.close(fd)
166
+ temps.append(path)
167
+ return path
168
+
169
+
170
+ def _download(url: str, f) -> None:
171
+ with httpx.stream("GET", url, timeout=120, follow_redirects=True) as resp:
172
+ resp.raise_for_status()
173
+ for chunk in resp.iter_bytes(1024 * 1024):
174
+ f.write(chunk)
backend/app/services/social/x.py CHANGED
@@ -1,8 +1,10 @@
1
- """X (Twitter) API v2 with OAuth 2.0 user context (PKCE). Threads are reply chains."""
 
2
 
3
  import base64
4
  import hashlib
5
  import secrets
 
6
  from urllib.parse import urlencode
7
 
8
  import httpx
@@ -11,12 +13,14 @@ from sqlalchemy.orm import Session
11
  from app.config import settings
12
  from app.models import SocialAccount
13
  from app.services.crypto import decrypt
14
- from app.services.social import SocialError, expired, save_tokens, token
15
 
16
  AUTH_URL = "https://x.com/i/oauth2/authorize"
17
  TOKEN_URL = "https://api.x.com/2/oauth2/token"
18
  API = "https://api.x.com/2"
19
- SCOPES = "tweet.read tweet.write users.read offline.access"
 
 
20
 
21
 
22
  def redirect_uri() -> str:
@@ -24,7 +28,7 @@ def redirect_uri() -> str:
24
 
25
 
26
  def configured() -> bool:
27
- return bool(settings.x_client_id and settings.x_client_secret)
28
 
29
 
30
  def pkce_pair() -> tuple[str, str]:
@@ -59,25 +63,108 @@ def exchange_code(code: str, verifier: str) -> dict:
59
  "avatar_url": user.get("profile_image_url")}
60
 
61
 
62
- def _access(db: Session, account: SocialAccount) -> str:
63
- if expired(account) and account.refresh_token:
 
 
 
 
64
  tokens = _token_request({"grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token)})
65
- save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
 
 
 
 
 
 
 
 
 
66
  return token(account)
67
 
68
 
69
- def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  headers = {"Authorization": f"Bearer {_access(db, account)}"}
 
 
 
 
 
71
  first_id, reply_to = None, None
72
  for text in posts:
73
  body: dict = {"text": text}
74
  if reply_to:
75
  body["reply"] = {"in_reply_to_tweet_id": reply_to}
76
- resp = httpx.post(f"{API}/tweets", json=body, headers=headers, timeout=20)
 
 
77
  if resp.status_code == 401:
78
- account.status = "expired"
79
- db.commit()
80
- raise SocialError("X rejected the token. Reconnect the account.", permanent=True)
81
  if resp.status_code >= 400:
82
  raise SocialError(f"X {resp.status_code}: {resp.text[:300]}", permanent=resp.status_code in (400, 403))
83
  reply_to = resp.json()["data"]["id"]
 
1
+ """X (Twitter) API v2 with OAuth 2.0 user context (PKCE). Threads are reply chains; images and a video go on the
2
+ first post (v2 media upload: one call for an image, initialize / append / finalize / status for a video)."""
3
 
4
  import base64
5
  import hashlib
6
  import secrets
7
+ import time
8
  from urllib.parse import urlencode
9
 
10
  import httpx
 
13
  from app.config import settings
14
  from app.models import SocialAccount
15
  from app.services.crypto import decrypt
16
+ from app.services.social import SocialError, expired, save_tokens, scope_set, token
17
 
18
  AUTH_URL = "https://x.com/i/oauth2/authorize"
19
  TOKEN_URL = "https://api.x.com/2/oauth2/token"
20
  API = "https://api.x.com/2"
21
+ SCOPES = "tweet.read tweet.write users.read offline.access media.write"
22
+ CHUNK = 4 * 1024 * 1024 # video upload segments
23
+ PROCESSING_WAIT = 600 # seconds X may take to process a video before we give up
24
 
25
 
26
  def redirect_uri() -> str:
 
28
 
29
 
30
  def configured() -> bool:
31
+ return bool(settings.x_enabled and settings.x_client_id and settings.x_client_secret)
32
 
33
 
34
  def pkce_pair() -> tuple[str, str]:
 
63
  "avatar_url": user.get("profile_image_url")}
64
 
65
 
66
+ def refresh(db: Session, account: SocialAccount) -> None:
67
+ """Swap the refresh token for new tokens (X rotates the refresh token too). A refused refresh means the
68
+ connection is gone: reconnect."""
69
+ if not account.refresh_token:
70
+ raise SocialError("The X connection expired. Reconnect X.", reconnect=True)
71
+ try:
72
  tokens = _token_request({"grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token)})
73
+ except SocialError as exc:
74
+ if exc.permanent:
75
+ raise SocialError("The X connection expired. Reconnect X.", reconnect=True) from exc
76
+ raise
77
+ save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
78
+
79
+
80
+ def _access(db: Session, account: SocialAccount) -> str:
81
+ if expired(account):
82
+ refresh(db, account)
83
  return token(account)
84
 
85
 
86
+ def check_alive(db: Session, account: SocialAccount) -> None:
87
+ """One cheap call: raises SocialError(reconnect=True) when X no longer accepts this connection."""
88
+ resp = _call("GET", f"{API}/users/me", headers={"Authorization": f"Bearer {_access(db, account)}"}, timeout=20)
89
+ if resp.status_code == 401:
90
+ raise SocialError("X no longer accepts this connection. Reconnect X.", reconnect=True)
91
+
92
+
93
+ def _call(method: str, url: str, **kw) -> httpx.Response:
94
+ """Every request to X goes through here (tests replace it)."""
95
+ return httpx.request(method, url, timeout=kw.pop("timeout", 60), **kw)
96
+
97
+
98
+ def can_post_media(account: SocialAccount) -> bool:
99
+ """Accounts connected before media.write was asked for have to reconnect to post images or videos."""
100
+ return "media.write" in scope_set(account.scopes)
101
+
102
+
103
+ def _media_error(resp: httpx.Response) -> SocialError:
104
+ if resp.status_code in (401, 403):
105
+ return SocialError("X refused the upload. Reconnect X to post images and videos.", reconnect=True)
106
+ return SocialError(f"X media upload {resp.status_code}: {resp.text[:300]}",
107
+ permanent=400 <= resp.status_code < 500 and resp.status_code != 429)
108
+
109
+
110
+ def _upload_image(headers: dict, m) -> str:
111
+ resp = _call("POST", f"{API}/media/upload", headers=headers, data={"media_category": "tweet_image"},
112
+ files={"media": (m.filename, m.read(), m.content_type)})
113
+ if resp.status_code >= 400:
114
+ raise _media_error(resp)
115
+ return resp.json()["data"]["id"]
116
+
117
+
118
+ def _upload_video(headers: dict, m) -> str:
119
+ resp = _call("POST", f"{API}/media/upload/initialize", headers=headers,
120
+ json={"media_type": m.content_type, "total_bytes": m.size, "media_category": "tweet_video"})
121
+ if resp.status_code >= 400:
122
+ raise _media_error(resp)
123
+ media_id = resp.json()["data"]["id"]
124
+ for i, start in enumerate(range(0, m.size, CHUNK)):
125
+ resp = _call("POST", f"{API}/media/upload/{media_id}/append", headers=headers, data={"segment_index": i},
126
+ files={"media": (m.filename, m.read(start, start + CHUNK), "application/octet-stream")})
127
+ if resp.status_code >= 400:
128
+ raise _media_error(resp)
129
+ resp = _call("POST", f"{API}/media/upload/{media_id}/finalize", headers=headers)
130
+ if resp.status_code >= 400:
131
+ raise _media_error(resp)
132
+ info = (resp.json().get("data") or {}).get("processing_info")
133
+ waited = 0
134
+ while info and info.get("state") in ("pending", "in_progress"):
135
+ if waited >= PROCESSING_WAIT:
136
+ raise SocialError("X took too long to process the video.")
137
+ pause = int(info.get("check_after_secs") or 5)
138
+ time.sleep(pause)
139
+ waited += pause
140
+ resp = _call("GET", f"{API}/media/upload", headers=headers,
141
+ params={"command": "STATUS", "media_id": media_id})
142
+ if resp.status_code >= 400:
143
+ raise _media_error(resp)
144
+ info = (resp.json().get("data") or {}).get("processing_info")
145
+ if info and info.get("state") == "failed":
146
+ reason = (info.get("error") or {}).get("message") or "X could not process the video"
147
+ raise SocialError(f"{reason}. X takes videos up to 2:20.", permanent=True)
148
+ return media_id
149
+
150
+
151
+ def publish(db: Session, account: SocialAccount, posts: list[str], media: list | None = None) -> tuple[str, str]:
152
  headers = {"Authorization": f"Bearer {_access(db, account)}"}
153
+ media_ids: list[str] = []
154
+ if media:
155
+ if not can_post_media(account):
156
+ raise SocialError("Reconnect X to post images and videos.", permanent=True)
157
+ media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
158
  first_id, reply_to = None, None
159
  for text in posts:
160
  body: dict = {"text": text}
161
  if reply_to:
162
  body["reply"] = {"in_reply_to_tweet_id": reply_to}
163
+ elif media_ids:
164
+ body["media"] = {"media_ids": media_ids} # the images or video go on the first post
165
+ resp = _call("POST", f"{API}/tweets", json=body, headers=headers, timeout=20)
166
  if resp.status_code == 401:
167
+ raise SocialError("X rejected the connection. Reconnect X.", reconnect=True)
 
 
168
  if resp.status_code >= 400:
169
  raise SocialError(f"X {resp.status_code}: {resp.text[:300]}", permanent=resp.status_code in (400, 403))
170
  reply_to = resp.json()["data"]["id"]
backend/app/services/storage.py CHANGED
@@ -93,6 +93,10 @@ class Storage:
93
  def get_bytes(self, key: str) -> bytes:
94
  return self.client.get_object(Bucket=self.bucket, Key=self._k(key))["Body"].read()
95
 
 
 
 
 
96
  def head(self, key: str) -> dict | None:
97
  try:
98
  return self.client.head_object(Bucket=self.bucket, Key=self._k(key))
@@ -107,6 +111,14 @@ class Storage:
107
  def delete(self, key: str) -> None:
108
  self.client.delete_object(Bucket=self.bucket, Key=self._k(key))
109
 
 
 
 
 
 
 
 
 
110
  def delete_prefix(self, prefix: str) -> int:
111
  deleted = 0
112
  paginator = self.client.get_paginator("list_objects_v2")
@@ -177,6 +189,13 @@ class LocalStorage:
177
  raise FileNotFoundError(key)
178
  return path.read_bytes()
179
 
 
 
 
 
 
 
 
180
  def head(self, key: str) -> dict | None:
181
  path = self.path(key)
182
  if not path.is_file():
@@ -189,6 +208,13 @@ class LocalStorage:
189
  def delete(self, key: str) -> None:
190
  self.path(key).unlink(missing_ok=True)
191
 
 
 
 
 
 
 
 
192
  def delete_prefix(self, prefix: str) -> int:
193
  base = self.path(prefix.rstrip("/"))
194
  if base.is_file():
 
93
  def get_bytes(self, key: str) -> bytes:
94
  return self.client.get_object(Bucket=self.bucket, Key=self._k(key))["Body"].read()
95
 
96
+ def download_to(self, key: str, fileobj) -> None:
97
+ """Stream an object into an open binary file (large files, e.g. a video to post, never fully in memory)."""
98
+ self.client.download_fileobj(self.bucket, self._k(key), fileobj)
99
+
100
  def head(self, key: str) -> dict | None:
101
  try:
102
  return self.client.head_object(Bucket=self.bucket, Key=self._k(key))
 
111
  def delete(self, key: str) -> None:
112
  self.client.delete_object(Bucket=self.bucket, Key=self._k(key))
113
 
114
+ def list_keys(self, prefix: str) -> list[tuple[str, float]]:
115
+ """(key, last modified epoch seconds) of every object under the prefix."""
116
+ out = []
117
+ paginator = self.client.get_paginator("list_objects_v2")
118
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=self._k(prefix)):
119
+ out += [(o["Key"][len(self.prefix):], o["LastModified"].timestamp()) for o in page.get("Contents", [])]
120
+ return out
121
+
122
  def delete_prefix(self, prefix: str) -> int:
123
  deleted = 0
124
  paginator = self.client.get_paginator("list_objects_v2")
 
189
  raise FileNotFoundError(key)
190
  return path.read_bytes()
191
 
192
+ def download_to(self, key: str, fileobj) -> None:
193
+ path = self.path(key)
194
+ if not path.is_file():
195
+ raise FileNotFoundError(key)
196
+ with path.open("rb") as src:
197
+ shutil.copyfileobj(src, fileobj)
198
+
199
  def head(self, key: str) -> dict | None:
200
  path = self.path(key)
201
  if not path.is_file():
 
208
  def delete(self, key: str) -> None:
209
  self.path(key).unlink(missing_ok=True)
210
 
211
+ def list_keys(self, prefix: str) -> list[tuple[str, float]]:
212
+ base = self.path(prefix.rstrip("/"))
213
+ if not base.is_dir():
214
+ return []
215
+ return [(f"{prefix.rstrip('/')}/{f.relative_to(base).as_posix()}", f.stat().st_mtime)
216
+ for f in base.rglob("*") if f.is_file()]
217
+
218
  def delete_prefix(self, prefix: str) -> int:
219
  base = self.path(prefix.rstrip("/"))
220
  if base.is_file():
backend/app/services/uploads.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Files users upload (ws/<workspace>/uploads/<upload id>/<name>): kept only while their upload record exists.
2
+
3
+ Deleting a record through the app removes its file at commit (app.models.content). The daily sweep catches the rest:
4
+ files whose record is gone (deleted straight in the database, or a delete that failed) and uploads that were started
5
+ but never finished."""
6
+
7
+ import logging
8
+ import time
9
+ from collections.abc import Callable
10
+ from datetime import UTC, datetime, timedelta
11
+
12
+ from sqlalchemy import select
13
+ from sqlalchemy.orm import Session
14
+
15
+ from app.db import SessionLocal
16
+ from app.models import Artifact, Upload, Workspace
17
+ from app.services.storage import storage
18
+
19
+ log = logging.getLogger("notestack.storage")
20
+
21
+ GRACE = timedelta(days=1) # a file or a pending upload younger than this may still be on its way
22
+
23
+
24
+ def remove_upload_artifact(db: Session, a: Artifact) -> None:
25
+ """An `upload` artifact is being deleted: its upload record goes too (and with it the file), unless another
26
+ artifact still posts the same file. The caller commits."""
27
+ if a.type != "upload" or not a.storage_key:
28
+ return
29
+ shared = db.scalar(select(Artifact.id).where(Artifact.storage_key == a.storage_key, Artifact.id != a.id).limit(1))
30
+ if shared:
31
+ return
32
+ for up in db.scalars(select(Upload).where(Upload.key == a.storage_key)):
33
+ db.delete(up)
34
+
35
+
36
+ def sweep_orphan_uploads(session_factory: Callable[[], Session] = SessionLocal) -> int:
37
+ """Delete upload files with no upload record, and uploads still pending after a day. Returns files removed."""
38
+ removed = 0
39
+ cutoff = datetime.now(UTC) - GRACE
40
+ with session_factory() as db:
41
+ # Started but never finished: drop the record (its file, if any, goes at commit).
42
+ for up in db.scalars(select(Upload).where(Upload.status == "pending", Upload.created_at < cutoff)):
43
+ db.delete(up)
44
+ db.commit()
45
+ known = set(db.scalars(select(Upload.key)))
46
+ for ws_id in db.scalars(select(Workspace.id)):
47
+ try:
48
+ files = storage.list_keys(f"ws/{ws_id}/uploads/")
49
+ except Exception:
50
+ log.warning("couldn't list uploads for workspace %s", ws_id, exc_info=True)
51
+ continue
52
+ for key, modified in files:
53
+ if key in known or time.time() - modified < GRACE.total_seconds():
54
+ continue
55
+ try:
56
+ storage.delete(key)
57
+ removed += 1
58
+ except Exception:
59
+ log.warning("couldn't delete orphan upload %s", key, exc_info=True)
60
+ if removed:
61
+ log.info("removed %d upload files with no record", removed)
62
+ return removed
backend/app/services/usage.py CHANGED
@@ -60,8 +60,15 @@ def usage_report(db: Session, workspace: Workspace) -> dict:
60
  "plan": plan.id,
61
  "used": used,
62
  "videos_resets_at": videos["resets_at"],
63
- "limits": {"audio_minutes": plan.audio_minutes, "videos": videos["limit"],
64
- "launch_kits": plan.launch_kits, "reports": plan.reports, "infographics": plan.infographics, "sources": plan.sources, "indexed_posts": plan.indexed_posts},
 
 
 
 
 
 
 
65
  }
66
 
67
 
 
60
  "plan": plan.id,
61
  "used": used,
62
  "videos_resets_at": videos["resets_at"],
63
+ "limits": {
64
+ "audio_minutes": plan.audio_minutes,
65
+ "videos": videos["limit"],
66
+ "launch_kits": plan.launch_kits,
67
+ "reports": plan.reports,
68
+ "infographics": plan.infographics,
69
+ "sources": plan.sources,
70
+ "indexed_posts": plan.indexed_posts,
71
+ },
72
  }
73
 
74
 
backend/app/support/scope.py CHANGED
@@ -47,7 +47,10 @@ _ABOUT_MY_DATA = re.compile(
47
  """
48
  )
49
  # Words that mean something went wrong with their work; the reply also offers the human form.
50
- _BROKEN = re.compile(r"(?i)\b(?:fail\w*|slow|forever|too\s+long|taking\s+(?:so\s+|way\s+)?long|hang\w*|never\s+(?:finish|load|appear)\w*|error|broken|stuck|crash\w*|not\s+working|won'?t|doesn'?t\s+work|missing|lost)\b")
 
 
 
51
 
52
  OUT_OF_SCOPE_REPLY = (
53
  "I can help with how Notestack works, but I can't see your notebooks, posts, files or account, so I can't answer "
 
47
  """
48
  )
49
  # Words that mean something went wrong with their work; the reply also offers the human form.
50
+ _BROKEN = re.compile(
51
+ r"(?i)\b(?:fail\w*|slow|forever|too\s+long|taking\s+(?:so\s+|way\s+)?long|hang\w*"
52
+ r"|never\s+(?:finish|load|appear)\w*|error|broken|stuck|crash\w*|not\s+working|won'?t|doesn'?t\s+work|missing|lost)\b"
53
+ )
54
 
55
  OUT_OF_SCOPE_REPLY = (
56
  "I can help with how Notestack works, but I can't see your notebooks, posts, files or account, so I can't answer "
backend/app/worker.py CHANGED
@@ -64,7 +64,9 @@ from app.services.jobs import (
64
  from app.services.notestack_voices import save_notestack_voice
65
  from app.services.plans import effective_plan
66
  from app.services.renderer import PermanentJobError, request_render
 
67
  from app.services.storage import keys, storage
 
68
 
69
  log = logging.getLogger("notestack.worker")
70
 
@@ -267,11 +269,18 @@ def handle_render(db: Session, job: Job):
267
  return HANDED_OFF
268
 
269
 
 
 
 
 
 
 
270
  HANDLERS: dict[str, Callable[[Session, Job], object]] = {
271
  "ingest": handle_ingest,
272
  "import_url": handle_import_url,
273
  "import_upload": handle_import_upload,
274
  "topics": handle_topics,
 
275
  "ideas": handle_ideas,
276
  "memory_update": handle_memory,
277
  "chat_memory": handle_chat_memory,
@@ -424,6 +433,7 @@ def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) ->
424
  def daily_cleanup(session_factory: Callable[[], Session] = SessionLocal) -> None:
425
  with session_factory() as db:
426
  purge_old_codes(db)
 
427
 
428
 
429
  @dataclass
@@ -432,11 +442,27 @@ class Periodic:
432
  every_seconds: float
433
  fn: Callable[[], object]
434
  next_at: float = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
435
 
 
436
 
437
  PERIODIC = [
438
  Periodic("recover_stale", 60, recover_stale),
439
- Periodic("publish_due", 30, launchpad.publish_due),
 
 
 
440
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
441
  Periodic("update_email_batch", 300, update_email_batch),
442
  Periodic("topics_backfill", 900, topics_backfill),
@@ -451,7 +477,7 @@ def run_periodic(now: float) -> None:
451
  for task in PERIODIC:
452
  if now < task.next_at:
453
  continue
454
- task.next_at = now + task.every_seconds
455
  try:
456
  with single_flight(task.name) as got:
457
  if got:
 
64
  from app.services.notestack_voices import save_notestack_voice
65
  from app.services.plans import effective_plan
66
  from app.services.renderer import PermanentJobError, request_render
67
+ from app.services.social.health import social_health
68
  from app.services.storage import keys, storage
69
+ from app.services.uploads import sweep_orphan_uploads
70
 
71
  log = logging.getLogger("notestack.worker")
72
 
 
269
  return HANDED_OFF
270
 
271
 
272
+ def handle_publish_post(db: Session, job: Job):
273
+ item = launchpad.publish_job(db, job.params["item_id"])
274
+ return Done({"status": item.status if item else "gone"}, "Published" if item and item.status == "posted"
275
+ else f"Post {item.status}" if item else "Post deleted")
276
+
277
+
278
  HANDLERS: dict[str, Callable[[Session, Job], object]] = {
279
  "ingest": handle_ingest,
280
  "import_url": handle_import_url,
281
  "import_upload": handle_import_upload,
282
  "topics": handle_topics,
283
+ "publish_post": handle_publish_post,
284
  "ideas": handle_ideas,
285
  "memory_update": handle_memory,
286
  "chat_memory": handle_chat_memory,
 
433
  def daily_cleanup(session_factory: Callable[[], Session] = SessionLocal) -> None:
434
  with session_factory() as db:
435
  purge_old_codes(db)
436
+ sweep_orphan_uploads(session_factory) # upload files whose record was deleted, and unfinished uploads
437
 
438
 
439
  @dataclass
 
442
  every_seconds: float
443
  fn: Callable[[], object]
444
  next_at: float = 0.0
445
+ # Run on the wall clock's multiples of every_seconds (1800: at :00 and :30 UTC), not every_seconds after the last
446
+ # run. The first run is still at start-up, to catch up on anything already due.
447
+ align: bool = False
448
+
449
+ def schedule_next(self, now: float, wall: float | None = None) -> None:
450
+ if not self.align:
451
+ self.next_at = now + self.every_seconds
452
+ return
453
+ wall = time.time() if wall is None else wall
454
+ # A couple of seconds past the boundary, so a post scheduled exactly on it is already due.
455
+ self.next_at = now + (self.every_seconds - wall % self.every_seconds) + ALIGN_GRACE_SECONDS
456
+
457
 
458
+ ALIGN_GRACE_SECONDS = 2
459
 
460
  PERIODIC = [
461
  Periodic("recover_stale", 60, recover_stale),
462
+ # Posts are scheduled on half hours (in the user's zone), so they go out on the :00 / :30 run. Zones on a :45
463
+ # offset (Nepal) get slots between runs: those go out at the next run, up to 15 minutes late.
464
+ Periodic("publish_due", 1800, launchpad.publish_due, align=True),
465
+ Periodic("social_health", 600, social_health),
466
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
467
  Periodic("update_email_batch", 300, update_email_batch),
468
  Periodic("topics_backfill", 900, topics_backfill),
 
477
  for task in PERIODIC:
478
  if now < task.next_at:
479
  continue
480
+ task.schedule_next(now)
481
  try:
482
  with single_flight(task.name) as got:
483
  if got:
backend/tests/test_auth.py CHANGED
@@ -35,6 +35,42 @@ def test_wrong_code_and_attempt_limit(client):
35
  assert r.json()["detail"]["code"] == "too_many_attempts"
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def test_resend_cooldown(client):
39
  client.post("/api/auth/email/register/start", json={"email": "c@d.co", "password": "longenough"})
40
  r = client.post("/api/auth/email/register/resend", json={"email": "c@d.co"})
 
35
  assert r.json()["detail"]["code"] == "too_many_attempts"
36
 
37
 
38
+ def test_wrong_code_counts_down_and_lockout_allows_resend(client):
39
+ client.post("/api/auth/email/register/start", json={"email": "e@f.co", "password": "longenough"})
40
+ if last_code() == "000000":
41
+ return
42
+ r = client.post("/api/auth/email/register/verify", json={"email": "e@f.co", "code": "000000"})
43
+ assert r.json()["detail"]["message"].endswith("4 tries left.")
44
+ for _ in range(4):
45
+ r = client.post("/api/auth/email/register/verify", json={"email": "e@f.co", "code": "000000"})
46
+ assert r.json()["detail"]["code"] == "too_many_attempts"
47
+ # A dead code does not hold up a new one, and the pending sign up survives the lockout.
48
+ assert client.post("/api/auth/email/register/resend", json={"email": "e@f.co"}).status_code == 200
49
+ r = client.post("/api/auth/email/register/verify", json={"email": "e@f.co", "code": last_code()})
50
+ assert r.status_code == 200
51
+
52
+
53
+ def test_email_send_failure_is_reported(client, monkeypatch):
54
+ from app.services.email import email_service
55
+
56
+ monkeypatch.setattr(email_service, "send_verification_code", lambda to, code: False)
57
+ r = client.post("/api/auth/email/register/start", json={"email": "f@g.co", "password": "longenough"})
58
+ assert r.status_code == 502
59
+ assert r.json()["detail"]["code"] == "email_failed"
60
+
61
+
62
+ def test_forgot_password_unknown_and_google(client, db_session):
63
+ from app.services.auth_identity import resolve_or_create_google_user
64
+
65
+ r = client.post("/api/auth/password/forgot/start", json={"email": "nobody@example.com"})
66
+ assert r.status_code == 404
67
+ resolve_or_create_google_user(db_session, email="g2@example.com", google_id="456", name="G", avatar_url=None)
68
+ db_session.commit()
69
+ r = client.post("/api/auth/password/forgot/start", json={"email": "g2@example.com"})
70
+ assert r.status_code == 409
71
+ assert r.json()["detail"]["code"] == "wrong_provider"
72
+
73
+
74
  def test_resend_cooldown(client):
75
  client.post("/api/auth/email/register/start", json={"email": "c@d.co", "password": "longenough"})
76
  r = client.post("/api/auth/email/register/resend", json={"email": "c@d.co"})
backend/tests/test_buildout.py CHANGED
@@ -542,7 +542,10 @@ def test_launch_kit(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
542
  "PickQuotes": {"quotes": [{"quote": "Charging more made my readers take the work seriously.",
543
  "source": ref}]},
544
  })
 
 
545
  monkeypatch.setattr(media, "request_render", lambda *a, **k: None)
 
546
  art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
547
  headers=auth).json()
548
  run_jobs()
@@ -563,6 +566,15 @@ def test_launch_kit(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
563
 
564
  listing = client.get("/api/artifacts", params={"type": "launch_kit"}, headers=auth).json()
565
  assert listing["total"] == 1
 
 
 
 
 
 
 
 
 
566
  assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
567
 
568
 
@@ -580,14 +592,14 @@ def _bluesky(client, auth, monkeypatch):
580
  return r.json()["accounts"][0]
581
 
582
 
583
- def test_schedule_and_publish_bluesky_thread(client, auth, monkeypatch, session_factory, db_session):
584
  from app.services import launchpad
585
  from app.services.social import bluesky
586
 
587
  account = _bluesky(client, auth, monkeypatch)
588
  published = {}
589
 
590
- def publish(db, acct, posts):
591
  published["posts"] = posts
592
  return "at://did:plc:ada/app.bsky.feed.post/abc", "https://bsky.app/profile/ada.bsky.social/post/abc"
593
 
@@ -602,14 +614,15 @@ def test_schedule_and_publish_bluesky_thread(client, auth, monkeypatch, session_
602
  }, headers=auth).json()
603
  assert item["auto_post"] is True
604
 
605
- assert launchpad.publish_due(session_factory) == 1
 
606
  assert published["posts"] == ["First", "Second"]
607
  got = client.get("/api/calendar", headers=auth).json()[0]
608
  assert got["status"] == "posted" and got["external_url"].startswith("https://bsky.app/")
609
  assert client.patch(f"/api/calendar/{item['id']}", json={"content": "late"}, headers=auth).status_code == 409
610
 
611
 
612
- def test_substack_notes_send_reminder_email(client, auth, session_factory):
613
  from app.services import launchpad
614
 
615
  ConsoleEmailProvider.sent.clear()
@@ -618,17 +631,18 @@ def test_substack_notes_send_reminder_email(client, auth, session_factory):
618
  headers=auth).json()
619
  assert item["remind_by_email"] is True
620
  launchpad.publish_due(session_factory)
 
621
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "reminded"
622
  assert ConsoleEmailProvider.sent[-1].subject == "Time to post on Substack Notes"
623
 
624
 
625
- def test_publish_retries_then_fails(client, auth, monkeypatch, session_factory):
626
  from app.services import launchpad
627
  from app.services.social import SocialError, bluesky
628
 
629
  account = _bluesky(client, auth, monkeypatch)
630
 
631
- def boom(db, acct, posts):
632
  raise SocialError("Bluesky 500: busy")
633
 
634
  monkeypatch.setattr(bluesky, "publish", boom)
@@ -642,6 +656,7 @@ def test_publish_retries_then_fails(client, auth, monkeypatch, session_factory):
642
  db.query(CalendarItem).update({"scheduled_at": datetime.now(UTC) - timedelta(seconds=1)})
643
  db.commit()
644
  launchpad.publish_due(session_factory)
 
645
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "failed"
646
 
647
 
@@ -697,6 +712,226 @@ def test_oauth_callback_redirects_with_ticket(client, monkeypatch):
697
  assert "error=" in bad.headers["location"]
698
 
699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  # Settings and activity
701
 
702
 
@@ -1587,7 +1822,6 @@ def test_infographic_is_a_page_at_once_and_can_be_shared_by_link(client, auth, r
1587
 
1588
 
1589
  def test_infographic_needs_a_source_and_enough_text_and_obeys_its_limit(client, auth, run_jobs, feed, llm, monkeypatch):
1590
- from app.pipeline import infographic
1591
 
1592
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1593
  llm["ExtractIdeas"] = {"ideas": []}
 
542
  "PickQuotes": {"quotes": [{"quote": "Charging more made my readers take the work seriously.",
543
  "source": ref}]},
544
  })
545
+ from app.services import renderer
546
+
547
  monkeypatch.setattr(media, "request_render", lambda *a, **k: None)
548
+ monkeypatch.setattr(renderer, "available", lambda: True)
549
  art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
550
  headers=auth).json()
551
  run_jobs()
 
566
 
567
  listing = client.get("/api/artifacts", params={"type": "launch_kit"}, headers=auth).json()
568
  assert listing["total"] == 1
569
+
570
+ # Renderer down: the kit keeps its quotes as text and makes no quote cards that could only fail.
571
+ monkeypatch.setattr(renderer, "available", lambda: False)
572
+ art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
573
+ headers=auth).json()
574
+ run_jobs()
575
+ kit = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
576
+ assert kit["status"] == "ready"
577
+ assert kit["content"]["quotes"] and kit["content"]["quote_card_ids"] == []
578
  assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
579
 
580
 
 
592
  return r.json()["accounts"][0]
593
 
594
 
595
+ def test_schedule_and_publish_bluesky_thread(client, auth, monkeypatch, session_factory, db_session, run_jobs):
596
  from app.services import launchpad
597
  from app.services.social import bluesky
598
 
599
  account = _bluesky(client, auth, monkeypatch)
600
  published = {}
601
 
602
+ def publish(db, acct, posts, media=None):
603
  published["posts"] = posts
604
  return "at://did:plc:ada/app.bsky.feed.post/abc", "https://bsky.app/profile/ada.bsky.social/post/abc"
605
 
 
614
  }, headers=auth).json()
615
  assert item["auto_post"] is True
616
 
617
+ assert launchpad.publish_due(session_factory) == 1 # queued as a publish_post job
618
+ assert [j.kind for j in run_jobs()] == ["publish_post"]
619
  assert published["posts"] == ["First", "Second"]
620
  got = client.get("/api/calendar", headers=auth).json()[0]
621
  assert got["status"] == "posted" and got["external_url"].startswith("https://bsky.app/")
622
  assert client.patch(f"/api/calendar/{item['id']}", json={"content": "late"}, headers=auth).status_code == 409
623
 
624
 
625
+ def test_substack_notes_send_reminder_email(client, auth, session_factory, run_jobs):
626
  from app.services import launchpad
627
 
628
  ConsoleEmailProvider.sent.clear()
 
631
  headers=auth).json()
632
  assert item["remind_by_email"] is True
633
  launchpad.publish_due(session_factory)
634
+ run_jobs()
635
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "reminded"
636
  assert ConsoleEmailProvider.sent[-1].subject == "Time to post on Substack Notes"
637
 
638
 
639
+ def test_publish_retries_then_fails(client, auth, monkeypatch, session_factory, run_jobs):
640
  from app.services import launchpad
641
  from app.services.social import SocialError, bluesky
642
 
643
  account = _bluesky(client, auth, monkeypatch)
644
 
645
+ def boom(db, acct, posts, media=None):
646
  raise SocialError("Bluesky 500: busy")
647
 
648
  monkeypatch.setattr(bluesky, "publish", boom)
 
656
  db.query(CalendarItem).update({"scheduled_at": datetime.now(UTC) - timedelta(seconds=1)})
657
  db.commit()
658
  launchpad.publish_due(session_factory)
659
+ run_jobs()
660
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "failed"
661
 
662
 
 
712
  assert "error=" in bad.headers["location"]
713
 
714
 
715
+ # Launchpad: posting what was made (images and videos) to X and LinkedIn
716
+
717
+
718
+ def _made(db, ws, type_, *, status="ready", key=None, **content):
719
+ """An artifact as the pipeline leaves it (its file in storage when it has one)."""
720
+ from app.models import Artifact
721
+ from app.services.storage import storage
722
+
723
+ if key:
724
+ storage.put_bytes(key, b"PNG" if key.endswith(".png") else bytes(range(256)) * 400) # a 100 KB "video"
725
+ a = Artifact(workspace_id=ws.id, type=type_, status=status, storage_key=key,
726
+ content_json={"title": f"A {type_}", **content})
727
+ db.add(a)
728
+ db.commit()
729
+ return a
730
+
731
+
732
+ def _account(db, ws, platform, scopes="tweet.read tweet.write users.read offline.access media.write"):
733
+ from app.services.crypto import encrypt
734
+
735
+ acct = SocialAccount(workspace_id=ws.id, platform=platform, handle="ada", external_id="p42",
736
+ access_token=encrypt("tok"), scopes=scopes, status="active")
737
+ db.add(acct)
738
+ db.commit()
739
+ return acct
740
+
741
+
742
+ def _reply(code: int, method: str, url: str, data: dict | None = None, headers: dict | None = None):
743
+ return httpx.Response(code, json=data, headers=headers, request=httpx.Request(method, url))
744
+
745
+
746
+ def _schedule(client, auth, artifact, platform, account=None):
747
+ r = client.post("/api/calendar", json={
748
+ "platform": platform, "content": "Look at this", "artifact_id": str(artifact.id),
749
+ "scheduled_at": (datetime.now(UTC) - timedelta(minutes=1)).isoformat(),
750
+ **({"social_account_id": str(account.id)} if account else {})}, headers=auth)
751
+ return r
752
+
753
+
754
+ def test_postable_lists_what_can_be_posted(client, auth, db_session):
755
+ ws = db_session.query(Workspace).first()
756
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
757
+ _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
758
+ _made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
759
+ done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
760
+ unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
761
+ _made(db_session, ws, "summary", summary="Raise prices once a year [1].")
762
+ got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
763
+ assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary"} # no audio, no unfinished video
764
+ assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
765
+ # Made but not rendered: listed, flagged; the rendered one is not.
766
+ assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
767
+ assert got[str(quote.id)]["prefill"] == {"x": ['"Ship it" (Ada)'], "linkedin": '"Ship it" (Ada)'}
768
+ summary = next(a for a in got.values() if a["type"] == "summary")
769
+ assert summary["prefill"]["linkedin"] == "Raise prices once a year." and summary["media"] == "text"
770
+
771
+
772
+ def test_scheduling_refuses_what_cannot_go_out(client, auth, db_session):
773
+ ws = db_session.query(Workspace).first()
774
+ audio = _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
775
+ long_video = _made(db_session, ws, "video", key="ws/v/audiogram.mp4", duration_s=200)
776
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
777
+ assert _schedule(client, auth, audio, "x").status_code == 400
778
+ r = _schedule(client, auth, long_video, "x")
779
+ assert r.status_code == 400 and "2:20" in r.json()["detail"]
780
+ linkedin_acct = _account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
781
+ assert _schedule(client, auth, long_video, "linkedin", linkedin_acct).status_code == 200 # LinkedIn takes it
782
+ assert _schedule(client, auth, quote, "bluesky").status_code == 400 # images: X and LinkedIn only
783
+
784
+
785
+ def test_x_posts_images_and_a_video(client, auth, db_session, monkeypatch, session_factory, run_jobs):
786
+ from app.services import launchpad
787
+ from app.services.social import x
788
+
789
+ ws = db_session.query(Workspace).first()
790
+ acct = _account(db_session, ws, "x")
791
+ calls = []
792
+
793
+ def call(method, url, **kw):
794
+ path = url.split("/2/", 1)[1]
795
+ calls.append((method, path, kw))
796
+ if path == "media/upload" and method == "POST":
797
+ return _reply(200, method, url, {"data": {"id": f"img{len(calls)}"}})
798
+ if path == "media/upload/initialize":
799
+ return _reply(200, method, url, {"data": {"id": "vid1"}})
800
+ if path.endswith("/append"):
801
+ return _reply(204, method, url)
802
+ if path.endswith("/finalize"):
803
+ return _reply(200, method, url, {"data": {"processing_info": {"state": "pending", "check_after_secs": 1}}})
804
+ if path == "media/upload": # STATUS
805
+ return _reply(200, method, url, {"data": {"processing_info": {"state": "succeeded"}}})
806
+ return _reply(201, method, url, {"data": {"id": "t1"}}) # tweets
807
+
808
+ monkeypatch.setattr(x, "_call", call)
809
+ monkeypatch.setattr(x.time, "sleep", lambda s: None)
810
+ monkeypatch.setattr(x, "CHUNK", 30000)
811
+ carousel = _made(db_session, ws, "carousel", key="ws/c/slide-01.png",
812
+ slide_keys=[f"ws/c/slide-0{i}.png" for i in range(1, 7)])
813
+ for i in range(1, 7):
814
+ from app.services.storage import storage
815
+ storage.put_bytes(f"ws/c/slide-0{i}.png", b"PNG")
816
+ assert _schedule(client, auth, carousel, "x", acct).status_code == 200
817
+ launchpad.publish_due(session_factory)
818
+ run_jobs()
819
+ uploads = [c for c in calls if c[1] == "media/upload"]
820
+ tweet = next(c for c in calls if c[1] == "tweets")
821
+ assert len(uploads) == 4 # X takes 4 images: the first 4 slides
822
+ assert tweet[2]["json"]["media"]["media_ids"] == [f"img{i}" for i in range(1, 5)]
823
+ assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "posted"
824
+
825
+ calls.clear()
826
+ video = _made(db_session, ws, "video", key="ws/v/audiogram.mp4", duration_s=30)
827
+ _schedule(client, auth, video, "x", acct)
828
+ launchpad.publish_due(session_factory)
829
+ run_jobs()
830
+ paths = [c[1] for c in calls]
831
+ assert paths[0] == "media/upload/initialize" and paths.count("media/upload/vid1/append") == 4 # 102400 bytes
832
+ assert paths[-3:] == ["media/upload/vid1/finalize", "media/upload", "tweets"]
833
+ assert calls[-1][2]["json"]["media"] == {"media_ids": ["vid1"]}
834
+
835
+
836
+ def test_x_accounts_from_before_media_must_reconnect(client, auth, db_session, monkeypatch, session_factory,
837
+ run_jobs):
838
+ from app.services import launchpad
839
+ from app.services.social import x
840
+
841
+ ws = db_session.query(Workspace).first()
842
+ acct = _account(db_session, ws, "x", scopes="tweet.read tweet.write users.read offline.access")
843
+ monkeypatch.setattr(x, "_call", lambda *a, **k: pytest.fail("nothing should be sent"))
844
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
845
+ r = _schedule(client, auth, quote, "x", acct) # refused up front now
846
+ assert r.status_code == 409 and "Reconnect X" in r.json()["detail"]["message"]
847
+ got = client.get("/api/social/accounts", headers=auth).json()["accounts"][0]
848
+ assert got["needs_reconnect"] is True and got["can_post_media"] is False
849
+ # Scheduled earlier, the scope gone since: refused at publish time without a call, as a reconnect.
850
+ with session_factory() as db:
851
+ db.add(CalendarItem(workspace_id=ws.id, platform="x", content="Hi", artifact_id=quote.id,
852
+ social_account_id=acct.id, scheduled_at=datetime.now(UTC) - timedelta(minutes=1)))
853
+ db.commit()
854
+ launchpad.publish_due(session_factory)
855
+ run_jobs()
856
+ item = client.get("/api/calendar", headers=auth).json()[0]
857
+ assert item["status"] == "paused" and "Reconnect X" in item["error"]
858
+
859
+
860
+ def test_linkedin_posts_images_and_a_video(client, auth, db_session, monkeypatch, session_factory, run_jobs):
861
+ from app.services import launchpad
862
+ from app.services.social import linkedin
863
+
864
+ ws = db_session.query(Workspace).first()
865
+ acct = _account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
866
+ calls = []
867
+
868
+ def call(method, url, **kw):
869
+ calls.append((method, url, kw))
870
+ if url.endswith("/rest/images"):
871
+ return _reply(200, method, url, {"value": {"uploadUrl": f"https://up/img{len(calls)}",
872
+ "image": f"urn:li:image:{len(calls)}"}})
873
+ if url.endswith("/rest/videos") and kw["params"]["action"] == "initializeUpload":
874
+ return _reply(200, method, url, {"value": {"video": "urn:li:video:9", "uploadToken": "",
875
+ "uploadInstructions": [
876
+ {"uploadUrl": "https://up/v1", "firstByte": 0, "lastByte": 59999},
877
+ {"uploadUrl": "https://up/v2", "firstByte": 60000,
878
+ "lastByte": 102399}]}})
879
+ if url.startswith("https://up/"):
880
+ return _reply(200, method, url, headers={"etag": f'"e-{url[-2:]}"'})
881
+ if "/rest/videos/" in url:
882
+ return _reply(200, method, url, {"status": "AVAILABLE"})
883
+ if url.endswith("/rest/posts"):
884
+ return _reply(201, method, url, headers={"x-restli-id": "urn:li:share:1"})
885
+ return _reply(200, method, url) # finalize
886
+
887
+ monkeypatch.setattr(linkedin, "_call", call)
888
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
889
+ carousel = _made(db_session, ws, "carousel", key="ws/c/slide-01.png",
890
+ slide_keys=["ws/q/quotecard.png"] * 3)
891
+ video = _made(db_session, ws, "video", key="ws/v/audiogram.mp4", duration_s=200)
892
+ bodies = []
893
+ for a in (quote, carousel, video):
894
+ calls.clear()
895
+ _schedule(client, auth, a, "linkedin", acct)
896
+ launchpad.publish_due(session_factory)
897
+ run_jobs()
898
+ bodies.append(next(c for c in calls if c[1].endswith("/rest/posts"))[2]["json"]["content"])
899
+ if a is video:
900
+ puts = [c for c in calls if c[1].startswith("https://up/v")]
901
+ assert [len(c[2]["content"]) for c in puts] == [60000, 42400]
902
+ fin = next(c for c in calls if c[1].endswith("/rest/videos") and c[2]["params"]["action"] == "finalizeUpload")
903
+ assert fin[2]["json"]["finalizeUploadRequest"]["uploadedPartIds"] == ["e-v1", "e-v2"]
904
+ assert bodies[0] == {"media": {"id": "urn:li:image:1"}}
905
+ assert len(bodies[1]["multiImage"]["images"]) == 3
906
+ assert bodies[2] == {"media": {"id": "urn:li:video:9"}}
907
+ assert {i["status"] for i in client.get("/api/calendar", headers=auth).json()} == {"posted"}
908
+
909
+
910
+ def test_publish_now_with_media_goes_through_a_job(client, auth, db_session, monkeypatch, run_jobs):
911
+ from app.services.social import linkedin
912
+
913
+ ws = db_session.query(Workspace).first()
914
+ acct = _account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
915
+ monkeypatch.setattr(linkedin, "publish", lambda db, a, posts, media=None: ("urn:li:share:2", "https://li/2"))
916
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
917
+ item = client.post("/api/calendar", json={"platform": "linkedin", "content": "Hi", "artifact_id": str(quote.id),
918
+ "social_account_id": str(acct.id),
919
+ "scheduled_at": (datetime.now(UTC) + timedelta(days=1)).isoformat()},
920
+ headers=auth).json()
921
+ assert item["artifact"]["type"] == "quote_card"
922
+ r = client.post(f"/api/calendar/{item['id']}/publish", headers=auth).json()
923
+ assert r["status"] == "publishing" # the upload runs in a job
924
+ run_jobs()
925
+ assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "posted"
926
+ # The attachment can be changed or removed before it goes out.
927
+ other = client.post("/api/calendar", json={"platform": "linkedin", "content": "Later", "artifact_id": str(quote.id),
928
+ "social_account_id": str(acct.id),
929
+ "scheduled_at": (datetime.now(UTC) + timedelta(days=1)).isoformat()},
930
+ headers=auth).json()
931
+ r = client.patch(f"/api/calendar/{other['id']}", json={"artifact_id": None}, headers=auth).json()
932
+ assert r["artifact"] is None
933
+
934
+
935
  # Settings and activity
936
 
937
 
 
1822
 
1823
 
1824
  def test_infographic_needs_a_source_and_enough_text_and_obeys_its_limit(client, auth, run_jobs, feed, llm, monkeypatch):
 
1825
 
1826
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1827
  llm["ExtractIdeas"] = {"ideas": []}
backend/tests/test_social_posting.py ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Posting to X and LinkedIn: scheduling needs a live connection, a connection that goes down pauses its posts (never
2
+ attempted) and reconnecting resumes them, connections are kept fresh, posts go out at their time, and uploads stream
3
+ from temporary files with size limits and retries. The platforms are faked at their _call seam."""
4
+
5
+ import os
6
+ import uuid
7
+ from datetime import UTC, datetime, timedelta
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from app.models import Artifact, CalendarItem, SocialAccount, Workspace
13
+ from app.services.crypto import encrypt
14
+ from tests.conftest import last_code
15
+
16
+ X_SCOPES = "tweet.read tweet.write users.read offline.access media.write"
17
+ LI_SCOPES = "openid,profile,w_member_social" # LinkedIn lists scopes with commas
18
+
19
+
20
+ @pytest.fixture()
21
+ def auth(client):
22
+ client.post("/api/auth/email/register/start", json={"email": "ada@example.com", "password": "stardust-42",
23
+ "name": "Ada Lovelace"})
24
+ r = client.post("/api/auth/email/register/verify", json={"email": "ada@example.com", "code": last_code()})
25
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
26
+
27
+
28
+ @pytest.fixture()
29
+ def ws(auth, db_session):
30
+ return db_session.query(Workspace).first()
31
+
32
+
33
+ @pytest.fixture(autouse=True)
34
+ def _fresh_health(monkeypatch):
35
+ from app.services.social import health
36
+
37
+ monkeypatch.setattr(health, "_last_checked", {})
38
+
39
+
40
+ def account(db, ws, platform="x", scopes=None, expires_in=None, refresh=None, external_id="p42"):
41
+ acct = SocialAccount(workspace_id=ws.id, platform=platform, handle="ada", external_id=external_id,
42
+ access_token=encrypt("tok"), refresh_token=encrypt(refresh) if refresh else None,
43
+ scopes=scopes or (X_SCOPES if platform == "x" else LI_SCOPES), status="active",
44
+ expires_at=datetime.now(UTC) + expires_in if expires_in is not None else None)
45
+ db.add(acct)
46
+ db.commit()
47
+ return acct
48
+
49
+
50
+ def item(db, ws, acct, minutes=60, status="scheduled", **kw):
51
+ it = CalendarItem(workspace_id=ws.id, platform=acct.platform, content="Hello", social_account_id=acct.id,
52
+ scheduled_at=datetime.now(UTC) + timedelta(minutes=minutes), status=status, **kw)
53
+ db.add(it)
54
+ db.commit()
55
+ return it
56
+
57
+
58
+ def made(db, ws, type_, key=None, data=b"PNG", **content):
59
+ from app.services.storage import storage
60
+
61
+ if key:
62
+ storage.put_bytes(key, data)
63
+ a = Artifact(workspace_id=ws.id, type=type_, status="ready", storage_key=key,
64
+ content_json={"title": type_, **content})
65
+ db.add(a)
66
+ db.commit()
67
+ return a
68
+
69
+
70
+ def reply(code, method, url, data=None, headers=None):
71
+ return httpx.Response(code, json=data, headers=headers, request=httpx.Request(method, url))
72
+
73
+
74
+ def statuses(db, *items):
75
+ db.expire_all()
76
+ return [db.get(CalendarItem, i.id).status for i in items]
77
+
78
+
79
+ # Scheduling needs a live connection
80
+
81
+
82
+ def schedule(client, auth, platform, acct=None, **extra):
83
+ return client.post("/api/calendar", json={
84
+ "platform": platform, "content": "Hello", "scheduled_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
85
+ **({"social_account_id": str(acct.id)} if acct else {}), **extra}, headers=auth)
86
+
87
+
88
+ def test_x_and_linkedin_need_a_connected_account(client, auth, ws, db_session):
89
+ r = schedule(client, auth, "x")
90
+ assert r.status_code == 409 and r.json()["detail"]["code"] == "not_connected"
91
+ assert schedule(client, auth, "linkedin").status_code == 409
92
+ down = account(db_session, ws, "linkedin")
93
+ down.status = "expired"
94
+ db_session.commit()
95
+ r = schedule(client, auth, "linkedin", down)
96
+ assert r.status_code == 409 and "Reconnect LinkedIn" in r.json()["detail"]["message"]
97
+ assert schedule(client, auth, "x", account(db_session, ws, "x")).status_code == 200
98
+ assert schedule(client, auth, "bluesky").status_code == 200 # no API connection needed: an email reminder
99
+
100
+
101
+ def test_linkedin_scopes_are_read_with_commas_or_spaces(client, auth, ws, db_session):
102
+ acct = account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
103
+ quote = made(db_session, ws, "quote_card", key="ws/q/q.png")
104
+ assert schedule(client, auth, "linkedin", acct, artifact_id=str(quote.id)).status_code == 200
105
+ got = client.get("/api/social/accounts", headers=auth).json()["accounts"][0]
106
+ assert got["can_post_media"] is True and got["needs_reconnect"] is False
107
+
108
+
109
+ # A connection that goes down pauses its posts; reconnecting resumes them
110
+
111
+
112
+ def test_disconnecting_pauses_its_posts_and_nothing_is_sent(client, auth, ws, db_session, session_factory,
113
+ monkeypatch):
114
+ from app.services import launchpad
115
+ from app.services.social import x
116
+
117
+ monkeypatch.setattr(x, "publish", lambda *a, **k: pytest.fail("a paused post was sent"))
118
+ acct = account(db_session, ws, "x")
119
+ later, due = item(db_session, ws, acct), item(db_session, ws, acct, minutes=-1)
120
+ assert client.delete(f"/api/social/accounts/{acct.id}", headers=auth).status_code == 200
121
+ db_session.expire_all()
122
+ assert db_session.get(SocialAccount, acct.id).status == "revoked"
123
+ assert statuses(db_session, later, due) == ["paused", "paused"]
124
+ assert launchpad.publish_due(session_factory) == 0 # paused posts are never claimed
125
+ got = client.get("/api/social/accounts", headers=auth).json()["accounts"][0]
126
+ assert got["needs_reconnect"] is True
127
+
128
+
129
+ def test_a_refused_connection_at_publish_pauses_all_its_posts(client, auth, ws, db_session, session_factory,
130
+ run_jobs, monkeypatch):
131
+ from app.services import launchpad
132
+ from app.services.social import x
133
+
134
+ acct = account(db_session, ws, "x")
135
+ monkeypatch.setattr(x, "_call", lambda m, url, **k: reply(401, m, url, {"title": "Unauthorized"}))
136
+ due, later = item(db_session, ws, acct, minutes=-1), item(db_session, ws, acct, minutes=90)
137
+ launchpad.publish_due(session_factory)
138
+ run_jobs()
139
+ db_session.expire_all()
140
+ assert db_session.get(SocialAccount, acct.id).status == "expired"
141
+ assert statuses(db_session, due, later) == ["paused", "paused"]
142
+ assert "Reconnect X" in db_session.get(CalendarItem, due.id).error
143
+
144
+
145
+ def test_reconnecting_resumes_future_posts_and_flags_missed_ones(client, auth, ws, db_session):
146
+ import json
147
+
148
+ from app.auth import encode_signed
149
+
150
+ acct = account(db_session, ws, "x")
151
+ ahead, missed = item(db_session, ws, acct, minutes=60), item(db_session, ws, acct, minutes=-30)
152
+ client.delete(f"/api/social/accounts/{acct.id}", headers=auth)
153
+ info = {"access_token": "new", "refresh_token": "r", "expires_in": 7200, "external_id": "p42", "handle": "ada",
154
+ "scope": X_SCOPES}
155
+ ticket = encode_signed({"typ": "social_link", "ws": str(ws.id), "platform": "x",
156
+ "data": encrypt(json.dumps(info))}, timedelta(minutes=5))
157
+ assert client.post("/api/social/complete", json={"ticket": ticket}, headers=auth).status_code == 200
158
+ db_session.expire_all()
159
+ assert db_session.get(SocialAccount, acct.id).status == "active" # the same account, back
160
+ assert statuses(db_session, ahead, missed) == ["scheduled", "paused"]
161
+ assert "pick a new time" in db_session.get(CalendarItem, missed.id).error
162
+ # The missed one goes back out once given a time still ahead.
163
+ r = client.patch(f"/api/calendar/{missed.id}", headers=auth,
164
+ json={"scheduled_at": (datetime.now(UTC) + timedelta(hours=2)).isoformat()})
165
+ assert r.status_code == 200 and r.json()["status"] == "scheduled"
166
+ r = client.patch(f"/api/calendar/{ahead.id}", json={"status": "paused"}, headers=auth) # held by hand
167
+ assert r.json()["status"] == "paused"
168
+
169
+
170
+ # Keeping connections fresh
171
+
172
+
173
+ def test_x_tokens_are_refreshed_before_they_expire(ws, db_session, session_factory, monkeypatch):
174
+ from app.services.crypto import decrypt
175
+ from app.services.social import health, x
176
+
177
+ acct = account(db_session, ws, "x", expires_in=timedelta(minutes=5), refresh="old-refresh")
178
+ sent = []
179
+ monkeypatch.setattr(x, "_token_request", lambda data: sent.append(data) or {
180
+ "access_token": "fresh", "refresh_token": "rotated", "expires_in": 7200})
181
+ monkeypatch.setattr(x, "_call", lambda m, url, **k: reply(200, m, url, {"data": {"id": "p42"}}))
182
+ assert health.social_health(session_factory) == 1
183
+ db_session.expire_all()
184
+ acct = db_session.get(SocialAccount, acct.id)
185
+ assert sent[0]["grant_type"] == "refresh_token" and decrypt(acct.access_token) == "fresh"
186
+ assert decrypt(acct.refresh_token) == "rotated" and acct.status == "active"
187
+
188
+
189
+ def test_a_refused_refresh_or_expiry_takes_the_connection_down(ws, db_session, session_factory, monkeypatch):
190
+ from app.services.social import SocialError, health, x
191
+
192
+ def refused(data):
193
+ raise SocialError("invalid_grant", permanent=True)
194
+
195
+ monkeypatch.setattr(x, "_token_request", refused)
196
+ xa = account(db_session, ws, "x", expires_in=timedelta(minutes=5), refresh="old")
197
+ li = account(db_session, ws, "linkedin", expires_in=timedelta(minutes=-1), external_id="li1") # no refresh token
198
+ posts = [item(db_session, ws, xa), item(db_session, ws, li)]
199
+ health.social_health(session_factory)
200
+ db_session.expire_all()
201
+ assert [db_session.get(SocialAccount, a.id).status for a in (xa, li)] == ["expired", "expired"]
202
+ assert statuses(db_session, *posts) == ["paused", "paused"]
203
+
204
+
205
+ def test_access_revoked_on_the_platform_is_noticed(ws, db_session, session_factory, monkeypatch):
206
+ from app.services.social import health, linkedin
207
+
208
+ acct = account(db_session, ws, "linkedin")
209
+ post = item(db_session, ws, acct)
210
+ monkeypatch.setattr(linkedin, "_call", lambda m, url, **k: reply(401, m, url, {}))
211
+ health.social_health(session_factory)
212
+ db_session.expire_all()
213
+ assert db_session.get(SocialAccount, acct.id).status == "revoked"
214
+ assert statuses(db_session, post) == ["paused"]
215
+
216
+
217
+ def test_liveness_is_asked_at_most_every_six_hours(ws, db_session, session_factory, monkeypatch):
218
+ from app.services.social import health, x
219
+
220
+ account(db_session, ws, "x")
221
+ calls = []
222
+ monkeypatch.setattr(x, "_call", lambda m, url, **k: calls.append(url) or reply(200, m, url, {"data": {}}))
223
+ health.social_health(session_factory)
224
+ health.social_health(session_factory)
225
+ assert len(calls) == 1
226
+
227
+
228
+ # On time
229
+
230
+
231
+ def test_a_due_post_goes_out_through_its_job(client, auth, ws, db_session, session_factory, run_jobs, monkeypatch):
232
+ from app.services import launchpad
233
+ from app.services.social import x
234
+
235
+ acct = account(db_session, ws, "x")
236
+ started = []
237
+ monkeypatch.setattr(x, "publish", lambda db, a, posts, media=None: started.append(
238
+ db.get(CalendarItem, due.id).updated_at) or ("t1", "https://x.com/ada/status/t1"))
239
+ due = item(db_session, ws, acct, minutes=0)
240
+ claimed_at = datetime.now(UTC)
241
+ assert launchpad.publish_due(session_factory) == 1
242
+ run_jobs()
243
+ assert statuses(db_session, due) == ["posted"]
244
+ stamp = started[0] if started[0].tzinfo else started[0].replace(tzinfo=UTC)
245
+ assert stamp >= claimed_at - timedelta(seconds=1) # the job touched it as it started
246
+
247
+
248
+ # Uploads
249
+
250
+
251
+ def test_a_video_is_streamed_to_a_temp_file_that_is_removed(ws, db_session, monkeypatch):
252
+ from app.services.social import media
253
+
254
+ video = made(db_session, ws, "video", key="ws/v/a.mp4", data=os.urandom(100_000), duration_s=30)
255
+ with media.media_for(video, "linkedin") as files:
256
+ path = files[0].path
257
+ assert os.path.exists(path) and files[0].size == 100_000 and files[0].data is None
258
+ assert files[0].read(10, 20) == files[0].read()[10:20]
259
+ assert not os.path.exists(path)
260
+ with pytest.raises(RuntimeError), media.media_for(video, "x") as files:
261
+ path = files[0].path
262
+ raise RuntimeError("upload broke")
263
+ assert not os.path.exists(path) # also on errors
264
+
265
+
266
+ def test_size_limits_fail_before_any_call(ws, db_session, monkeypatch):
267
+ from app.services.social import SocialError, media
268
+
269
+ tiny = made(db_session, ws, "video", key="ws/v/tiny.mp4", data=b"x" * 1000, duration_s=5)
270
+ with pytest.raises(SocialError, match="too small for LinkedIn"), media.media_for(tiny, "linkedin"):
271
+ pass
272
+ monkeypatch.setitem(media.SIZE_LIMITS, ("x", "video"), (1, 500))
273
+ with pytest.raises(SocialError, match="X takes up to"), media.media_for(tiny, "x"):
274
+ pass
275
+
276
+
277
+ def _linkedin(monkeypatch, part_codes: list[int]):
278
+ """A fake LinkedIn: initializeUpload with 2 parts, part PUTs answering from part_codes, then AVAILABLE."""
279
+ from app.services.social import linkedin
280
+
281
+ calls, received = [], {}
282
+ codes = iter(part_codes)
283
+
284
+ def call(method, url, **kw):
285
+ calls.append((method, url, kw))
286
+ if url.endswith("/rest/videos") and kw.get("params", {}).get("action") == "initializeUpload":
287
+ size = kw["json"]["initializeUploadRequest"]["fileSizeBytes"]
288
+ return reply(200, method, url, {"value": {"video": "urn:li:video:1", "uploadToken": "", "uploadInstructions": [
289
+ {"uploadUrl": "https://up/1", "firstByte": 0, "lastByte": 49_999},
290
+ {"uploadUrl": "https://up/2", "firstByte": 50_000, "lastByte": size - 1}]}})
291
+ if url.startswith("https://up/"):
292
+ code = next(codes, 200)
293
+ if code < 400:
294
+ received[url] = kw["content"]
295
+ return reply(code, method, url, {} if code >= 400 else None, headers={"etag": f'"e{url[-1]}"'})
296
+ if "/rest/videos/" in url:
297
+ return reply(200, method, url, {"status": "AVAILABLE"})
298
+ if url.endswith("/rest/posts"):
299
+ return reply(201, method, url, headers={"x-restli-id": "urn:li:share:9"})
300
+ return reply(200, method, url)
301
+
302
+ monkeypatch.setattr(linkedin, "_call", call)
303
+ monkeypatch.setattr(linkedin.time, "sleep", lambda s: None)
304
+ return calls, received
305
+
306
+
307
+ def test_linkedin_video_parts_retry_and_arrive_byte_identical(ws, db_session, monkeypatch):
308
+ from app.services.social import linkedin, media
309
+
310
+ data = os.urandom(90_000)
311
+ acct = account(db_session, ws, "linkedin")
312
+ video = made(db_session, ws, "video", key="ws/v/b.mp4", data=data, duration_s=30)
313
+ calls, received = _linkedin(monkeypatch, [503, 200, 200]) # the first part fails once
314
+ with media.media_for(video, "linkedin") as files:
315
+ linkedin.publish(db_session, acct, ["Watch"], files)
316
+ assert received["https://up/1"] + received["https://up/2"] == data
317
+ part_puts = [c for c in calls if c[1].startswith("https://up/")]
318
+ assert len(part_puts) == 3 and all("Authorization" not in c[2].get("headers", {}) for c in part_puts)
319
+
320
+
321
+ def test_linkedin_retired_api_version_is_permanent(ws, db_session, monkeypatch):
322
+ from app.services.social import SocialError, linkedin
323
+
324
+ acct = account(db_session, ws, "linkedin")
325
+ monkeypatch.setattr(linkedin, "_call", lambda m, url, **k: reply(426, m, url, {"code": "NONEXISTENT_VERSION"}))
326
+ with pytest.raises(SocialError, match="retired API version") as err:
327
+ linkedin.publish(db_session, acct, ["Hi"], [])
328
+ assert err.value.permanent and not err.value.reconnect
329
+
330
+
331
+ def test_x_video_processing_failure_is_permanent(ws, db_session, monkeypatch):
332
+ from app.services.social import SocialError, media, x
333
+
334
+ acct = account(db_session, ws, "x")
335
+ video = made(db_session, ws, "video", key="ws/v/c.mp4", data=os.urandom(100_000), duration_s=30)
336
+
337
+ def call(method, url, **kw):
338
+ path = url.split("/2/", 1)[1]
339
+ if path == "media/upload/initialize":
340
+ return reply(200, method, url, {"data": {"id": "v1"}})
341
+ if path.endswith("/append"):
342
+ return reply(204, method, url)
343
+ if path.endswith("/finalize"):
344
+ return reply(200, method, url, {"data": {"processing_info": {"state": "pending", "check_after_secs": 1}}})
345
+ return reply(200, method, url, {"data": {"processing_info": {"state": "failed",
346
+ "error": {"message": "Unsupported video"}}}})
347
+
348
+ monkeypatch.setattr(x, "_call", call)
349
+ monkeypatch.setattr(x.time, "sleep", lambda s: None)
350
+ with pytest.raises(SocialError, match="Unsupported video") as err, media.media_for(video, "x") as files:
351
+ x.publish(db_session, acct, ["Watch"], files)
352
+ assert err.value.permanent
353
+
354
+
355
+ def test_unfinished_blog2video_video_is_refused_when_scheduled(client, auth, ws, db_session):
356
+ acct = account(db_session, ws, "x")
357
+ video = Artifact(workspace_id=ws.id, type="video", status="processing",
358
+ content_json={"title": "Draft", "provider": "blog2video"})
359
+ db_session.add(video)
360
+ db_session.commit()
361
+ r = schedule(client, auth, "x", acct, artifact_id=str(video.id))
362
+ assert r.status_code == 400 and "isn't finished" in r.json()["detail"]
363
+ assert uuid.UUID(str(video.id))
364
+
365
+
366
+ def test_a_video_being_rendered_stays_listed_as_rendering(client, auth, ws, db_session):
367
+ video = Artifact(workspace_id=ws.id, type="video", status="rendering",
368
+ content_json={"title": "Mid render", "provider": "blog2video"})
369
+ db_session.add(video)
370
+ db_session.commit()
371
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
372
+ item = listed[str(video.id)]
373
+ assert item["needs_render"] and item["rendering"]
374
+
375
+
376
+ def test_a_kit_is_tracked_apart_and_never_attached(client, auth, ws, db_session):
377
+ acct = account(db_session, ws, "x")
378
+ kit = made(db_session, ws, "launch_kit", post_title="The Boring Portfolio", posts={"x_thread": ["One", "Two"]})
379
+ video = made(db_session, ws, "video", key="ws/v/k.mp4", data=os.urandom(100_000), duration_s=30)
380
+ r = schedule(client, auth, "x", acct, artifact_id=str(kit.id))
381
+ assert r.status_code == 400 and "can't be attached" in r.json()["detail"]
382
+ r = schedule(client, auth, "x", acct, kit_id=str(kit.id), artifact_id=str(video.id))
383
+ assert r.status_code == 200
384
+ got = r.json()
385
+ assert got["kit_id"] == str(kit.id) and got["kit_title"] == "The Boring Portfolio"
386
+ assert got["artifact"]["type"] == "video" # the attachment is the video, not the kit
387
+ assert schedule(client, auth, "x", acct, kit_id=str(video.id)).status_code == 404 # only a Launch Kit is a kit
388
+
389
+
390
+ def test_a_time_picked_in_the_users_zone_is_stored_in_utc(client, auth, ws, db_session):
391
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
392
+ year = datetime.now(UTC).year + 1
393
+ r = schedule(client, auth, "linkedin", acct, local_time=f"{year}-10-07T11:00", timezone="Asia/Karachi")
394
+ assert r.status_code == 200, r.text
395
+ assert datetime.fromisoformat(r.json()["scheduled_at"]) == datetime(year, 10, 7, 6, 0, tzinfo=UTC)
396
+ # New York is UTC-4 in summer and UTC-5 in winter: the same wall time lands an hour apart.
397
+ summer = schedule(client, auth, "linkedin", acct, local_time=f"{year}-07-01T09:30", timezone="America/New_York")
398
+ winter = schedule(client, auth, "linkedin", acct, local_time=f"{year}-12-01T09:30", timezone="America/New_York")
399
+ assert datetime.fromisoformat(summer.json()["scheduled_at"]) == datetime(year, 7, 1, 13, 30, tzinfo=UTC)
400
+ assert datetime.fromisoformat(winter.json()["scheduled_at"]) == datetime(year, 12, 1, 14, 30, tzinfo=UTC)
401
+ # Moving it keeps to the user's zone too.
402
+ item_id = r.json()["id"]
403
+ moved = client.patch(f"/api/calendar/{item_id}", json={"local_time": f"{year}-10-08T00:30", "timezone": "Asia/Karachi"},
404
+ headers=auth)
405
+ assert datetime.fromisoformat(moved.json()["scheduled_at"]) == datetime(year, 10, 7, 19, 30, tzinfo=UTC)
406
+
407
+
408
+ def test_only_half_hours_in_a_known_zone_are_taken(client, auth, ws, db_session):
409
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
410
+ year = datetime.now(UTC).year + 1
411
+ off = schedule(client, auth, "linkedin", acct, local_time=f"{year}-10-07T11:15", timezone="Asia/Karachi")
412
+ assert off.status_code == 400 and off.json()["detail"]["code"] == "half_hour_only"
413
+ bad = schedule(client, auth, "linkedin", acct, local_time=f"{year}-10-07T11:00", timezone="Mars/Olympus")
414
+ assert bad.status_code == 400 and bad.json()["detail"]["code"] == "bad_timezone"
415
+ assert schedule(client, auth, "linkedin", acct).status_code == 200 # an exact scheduled_at (Post now) still works
416
+
417
+
418
+ def test_the_publisher_runs_on_the_half_hour():
419
+ from app.worker import ALIGN_GRACE_SECONDS, Periodic
420
+
421
+ task = Periodic("publish_due", 1800, lambda: None, align=True)
422
+ wall = datetime(2026, 10, 7, 6, 10, tzinfo=UTC).timestamp() # 06:10 UTC: the next run is at 06:30
423
+ task.schedule_next(100.0, wall)
424
+ assert task.next_at == 100.0 + 20 * 60 + ALIGN_GRACE_SECONDS
425
+
426
+
427
+ def uploaded(db, ws, filename, content_type, data, status="complete"):
428
+ """A file the user uploaded through /api/storage/uploads, in storage and recorded."""
429
+ from app.models import Upload, User
430
+ from app.services.storage import keys, storage
431
+
432
+ upload_id = uuid.uuid4()
433
+ key = keys.upload(ws.id, upload_id, filename)
434
+ storage.put_bytes(key, data)
435
+ up = Upload(id=upload_id, workspace_id=ws.id, user_id=db.query(User).first().id, key=key, filename=filename,
436
+ content_type=content_type, size_bytes=len(data), status=status)
437
+ db.add(up)
438
+ db.commit()
439
+ return up
440
+
441
+
442
+ def test_a_file_from_the_computer_is_attached_and_posted(client, auth, ws, db_session):
443
+ from app.services.social import media
444
+
445
+ acct = account(db_session, ws, "linkedin")
446
+ png = uploaded(db_session, ws, "chart.png", "image/png", b"\x89PNG-chart")
447
+ r = client.post("/api/launchpad/uploads", json={"upload_id": str(png.id)}, headers=auth)
448
+ assert r.status_code == 200, r.text
449
+ brief = r.json()
450
+ assert brief["type"] == "upload" and brief["media"] == "image" and brief["title"] == "chart"
451
+ assert brief["thumb_url"] and brief["prefill"]["linkedin"] == ""
452
+ listed = {a["id"] for a in client.get("/api/launchpad/postable", headers=auth).json()}
453
+ assert brief["id"] in listed
454
+ assert schedule(client, auth, "linkedin", acct, artifact_id=brief["id"]).status_code == 200
455
+ art = db_session.get(Artifact, uuid.UUID(brief["id"]))
456
+ with media.media_for(art, "linkedin") as files:
457
+ assert [(f.kind, f.content_type, f.read()) for f in files] == [("image", "image/png", b"\x89PNG-chart")]
458
+ # Not one of the things Notestack made: kept out of the general artifact list.
459
+ assert brief["id"] not in {a["id"] for a in client.get("/api/artifacts", headers=auth).json()["items"]}
460
+
461
+
462
+ def test_an_uploaded_video_goes_out_from_a_temporary_file(client, auth, ws, db_session):
463
+ from app.services.social import media
464
+
465
+ mp4 = uploaded(db_session, ws, "clip.mp4", "video/mp4", b"v" * (80 * 1024))
466
+ r = client.post("/api/launchpad/uploads", json={"upload_id": str(mp4.id), "duration_s": 42}, headers=auth)
467
+ assert r.status_code == 200 and r.json()["media"] == "video" and r.json()["duration_s"] == 42
468
+ art = db_session.get(Artifact, uuid.UUID(r.json()["id"]))
469
+ with media.media_for(art, "linkedin") as files:
470
+ assert files[0].kind == "video" and files[0].path and files[0].size == 80 * 1024
471
+
472
+
473
+ def test_only_files_linkedin_takes_can_be_uploaded(client, auth, ws, db_session):
474
+ def register(up):
475
+ return client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth)
476
+
477
+ assert register(uploaded(db_session, ws, "doc.pdf", "application/pdf", b"%PDF")).status_code == 415
478
+ assert register(uploaded(db_session, ws, "half.png", "image/png", b"x", status="pending")).status_code == 409
479
+ big = uploaded(db_session, ws, "huge.png", "image/png", b"x")
480
+ big.size_bytes = 40 * 1024 * 1024
481
+ db_session.commit()
482
+ assert register(big).status_code == 413
483
+ assert client.post("/api/launchpad/uploads", json={"upload_id": str(uuid.uuid4())}, headers=auth).status_code == 404
484
+
485
+
486
+ def test_deleting_an_upload_record_removes_its_file(db_session, ws):
487
+ from app.services.storage import storage
488
+
489
+ up = uploaded(db_session, ws, "gone.png", "image/png", b"PNG")
490
+ kept = uploaded(db_session, ws, "kept.png", "image/png", b"PNG")
491
+ db_session.delete(kept)
492
+ db_session.rollback() # a delete that never commits leaves the file alone
493
+ assert storage.exists(kept.key)
494
+ db_session.delete(up)
495
+ db_session.commit()
496
+ assert not storage.exists(up.key)
497
+
498
+
499
+ def test_deleting_an_uploaded_file_from_the_library_removes_record_and_file(client, auth, ws, db_session):
500
+ from app.models import Upload
501
+ from app.services.storage import storage
502
+
503
+ up = uploaded(db_session, ws, "pic.png", "image/png", b"PNG")
504
+ art = client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth).json()
505
+ assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
506
+ db_session.expire_all()
507
+ assert db_session.get(Upload, up.id) is None and not storage.exists(up.key)
508
+
509
+
510
+ def test_the_daily_sweep_removes_files_with_no_record(db_session, ws, session_factory):
511
+ from app.models import Upload
512
+ from app.services.storage import keys, storage
513
+ from app.services.uploads import sweep_orphan_uploads
514
+
515
+ old = datetime.now(UTC).timestamp() - 3 * 24 * 3600
516
+ orphan = keys.upload(ws.id, uuid.uuid4(), "orphan.png")
517
+ fresh = keys.upload(ws.id, uuid.uuid4(), "fresh.png") # may still be on its way: kept
518
+ storage.put_bytes(orphan, b"x")
519
+ storage.put_bytes(fresh, b"x")
520
+ os.utime(storage.path(orphan), (old, old))
521
+ known = uploaded(db_session, ws, "known.png", "image/png", b"x")
522
+ os.utime(storage.path(known.key), (old, old))
523
+ stale = uploaded(db_session, ws, "stale.png", "image/png", b"x", status="pending")
524
+ stale.created_at = datetime.now(UTC) - timedelta(days=2)
525
+ db_session.commit()
526
+ stale_id, stale_key = stale.id, stale.key
527
+
528
+ assert sweep_orphan_uploads(session_factory) == 1
529
+ assert not storage.exists(orphan) and storage.exists(fresh) and storage.exists(known.key)
530
+ db_session.expire_all()
531
+ assert db_session.get(Upload, stale_id) is None and not storage.exists(stale_key)
532
+
533
+
534
+ def test_an_uploaded_file_can_be_renamed_but_not_retyped(client, auth, ws, db_session):
535
+ up = uploaded(db_session, ws, "IMG_2041.png", "image/png", b"PNG")
536
+ art = client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth).json()
537
+ r = client.patch(f"/api/artifacts/{art['id']}", json={"title": " Q3 chart ",
538
+ "content": {"content_type": "video/mp4", "media": "video"}},
539
+ headers=auth)
540
+ assert r.status_code == 200
541
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[art["id"]]
542
+ assert listed["title"] == "Q3 chart" and listed["media"] == "image"
543
+ assert client.patch(f"/api/artifacts/{art['id']}", json={"title": " "}, headers=auth).status_code == 400
544
+
545
+
546
+ def test_uploaded_files_carry_a_preview_link(client, auth, ws, db_session):
547
+ png = uploaded(db_session, ws, "a.png", "image/png", b"PNG")
548
+ mp4 = uploaded(db_session, ws, "b.mp4", "video/mp4", b"v" * (80 * 1024))
549
+ for up in (png, mp4):
550
+ brief = client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth).json()
551
+ assert brief["view_url"]
552
+ kit = made(db_session, ws, "launch_kit", post_title="Kit", posts={"linkedin": ["Hi"]})
553
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
554
+ assert listed[str(kit.id)]["view_url"] is None
backend/tests/test_videos.py CHANGED
@@ -440,7 +440,7 @@ def test_slow_catalog_is_a_clean_error_not_a_500(client, b2v, owner, db_session)
440
  b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
441
  r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
442
  headers=headers)
443
- assert r.status_code == 502 and "not responding" in r.json()["detail"]
444
  assert not creates(b2v) and used(db_session, ws) == 0
445
 
446
 
 
440
  b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
441
  r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
442
  headers=headers)
443
+ assert r.status_code == 502 and "being updated" in r.json()["detail"]
444
  assert not creates(b2v) and used(db_session, ws) == 0
445
 
446
 
frontend/src/App.tsx CHANGED
@@ -8,8 +8,8 @@ import AuthPage from "./pages/AuthPage";
8
  import { Blog, BlogPostPage } from "./pages/Blog";
9
  import Landing from "./pages/Landing";
10
  import Archive from "./pages/Archive";
11
- import Launchpad from "./pages/Launchpad";
12
- import LaunchKit from "./pages/LaunchKit";
13
  import MissionControl from "./pages/MissionControl";
14
  import Notebooks from "./pages/Notebooks";
15
  import NotebookLMAlternative from "./pages/NotebookLMAlternative";
@@ -70,8 +70,11 @@ export default function App() {
70
  <Route path="map" element={<TopicMap />} />
71
  <Route path="voice" element={<VoiceRedirect />} />
72
  <Route path="studio" element={<Studio />} />
73
- <Route path="launch-kit" element={<LaunchKit />} />
74
  <Route path="launchpad" element={<Launchpad />} />
 
 
 
75
  <Route path="archive" element={<Archive />} />
76
  <Route path="resurface" element={<Resurface />} />
77
  <Route path="settings" element={<Settings />} />
 
8
  import { Blog, BlogPostPage } from "./pages/Blog";
9
  import Landing from "./pages/Landing";
10
  import Archive from "./pages/Archive";
11
+ import Launchpad, { ScheduleLaunch } from "./pages/Launchpad";
12
+ import LaunchKitRedirect, { LaunchKitPage, LaunchKits } from "./pages/LaunchKit";
13
  import MissionControl from "./pages/MissionControl";
14
  import Notebooks from "./pages/Notebooks";
15
  import NotebookLMAlternative from "./pages/NotebookLMAlternative";
 
70
  <Route path="map" element={<TopicMap />} />
71
  <Route path="voice" element={<VoiceRedirect />} />
72
  <Route path="studio" element={<Studio />} />
73
+ <Route path="launch-kit" element={<LaunchKitRedirect />} />
74
  <Route path="launchpad" element={<Launchpad />} />
75
+ <Route path="launchpad/new" element={<ScheduleLaunch />} />
76
+ <Route path="launchpad/kits" element={<LaunchKits />} />
77
+ <Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
78
  <Route path="archive" element={<Archive />} />
79
  <Route path="resurface" element={<Resurface />} />
80
  <Route path="settings" element={<Settings />} />
frontend/src/api/auth.ts CHANGED
@@ -34,6 +34,8 @@ export const authApi = {
34
  forgotStart: (email: string) => post<{ ok: true }>("/api/auth/password/forgot/start", { email }),
35
  forgotComplete: (email: string, code: string, password: string) =>
36
  post<LoginResult>("/api/auth/password/forgot/complete", { email, code, password }),
37
- logout: () => post<{ ok: true }>("/api/auth/logout"),
 
 
38
  deleteAccount: () => post<{ ok: true }>("/api/auth/delete-account"),
39
  };
 
34
  forgotStart: (email: string) => post<{ ok: true }>("/api/auth/password/forgot/start", { email }),
35
  forgotComplete: (email: string, code: string, password: string) =>
36
  post<LoginResult>("/api/auth/password/forgot/complete", { email, code, password }),
37
+ /** Takes the token explicitly: sign out clears local tokens before revoking on the server. */
38
+ logout: (access: string) =>
39
+ api<{ ok: true }>("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${access}` } }, false),
40
  deleteAccount: () => post<{ ok: true }>("/api/auth/delete-account"),
41
  };
frontend/src/api/client.ts CHANGED
@@ -17,6 +17,9 @@ export class ApiError extends Error {
17
  /** Fired on every 402 plan_limit so the upgrade popup opens wherever the request came from. */
18
  export const PLAN_LIMIT_EVENT = "ns:plan-limit";
19
 
 
 
 
20
  export type PlanLimitDetail = { message: string; kind?: string; plan?: string; upgrade_to?: string | null };
21
 
22
  function read(key: string): string | null {
@@ -93,9 +96,14 @@ async function request(path: string, init: RequestInit, retry: boolean): Promise
93
  if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
94
  headers.set("Content-Type", "application/json");
95
  }
96
- if (tokens.access) headers.set("Authorization", `Bearer ${tokens.access}`);
 
97
  const res = await fetch(`${BASE}${path}`, { ...init, headers });
98
  if (res.status === 401 && retry && (await tryRefresh())) return request(path, init, false);
 
 
 
 
99
  if (!res.ok) throw await parseError(res);
100
  return res;
101
  }
@@ -147,7 +155,9 @@ const TYPES_BY_EXT: Record<string, string> = {
147
  jpg: "image/jpeg",
148
  jpeg: "image/jpeg",
149
  webp: "image/webp",
 
150
  svg: "image/svg+xml",
 
151
  };
152
 
153
  /** Browsers leave File.type empty for some extensions (.md on Windows) and use vendor names for others. */
 
17
  /** Fired on every 402 plan_limit so the upgrade popup opens wherever the request came from. */
18
  export const PLAN_LIMIT_EVENT = "ns:plan-limit";
19
 
20
+ /** Fired when a signed in request gets a 401 that a refresh cannot fix, so the app signs the user out. */
21
+ export const SESSION_EXPIRED_EVENT = "ns:session-expired";
22
+
23
  export type PlanLimitDetail = { message: string; kind?: string; plan?: string; upgrade_to?: string | null };
24
 
25
  function read(key: string): string | null {
 
96
  if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
97
  headers.set("Content-Type", "application/json");
98
  }
99
+ const signedIn = Boolean(tokens.access);
100
+ if (signedIn) headers.set("Authorization", `Bearer ${tokens.access}`);
101
  const res = await fetch(`${BASE}${path}`, { ...init, headers });
102
  if (res.status === 401 && retry && (await tryRefresh())) return request(path, init, false);
103
+ // Auth endpoints answer 401 for a wrong password and similar; only a dead session elsewhere signs out.
104
+ if (res.status === 401 && signedIn && retry && !path.startsWith("/api/auth/")) {
105
+ window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
106
+ }
107
  if (!res.ok) throw await parseError(res);
108
  return res;
109
  }
 
155
  jpg: "image/jpeg",
156
  jpeg: "image/jpeg",
157
  webp: "image/webp",
158
+ gif: "image/gif",
159
  svg: "image/svg+xml",
160
+ mp4: "video/mp4",
161
  };
162
 
163
  /** Browsers leave File.type empty for some extensions (.md on Windows) and use vendor names for others. */
frontend/src/api/endpoints.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
  WorkspaceChat,
23
  Page,
24
  Platform,
 
25
  Quota,
26
  ResurfaceItem,
27
  Settings,
@@ -202,10 +203,16 @@ export const voiceApi = {
202
 
203
  export type ItemBody = {
204
  platform: Platform;
205
- scheduled_at: string;
 
 
 
 
206
  content: string;
207
  thread?: string[];
208
  artifact_id?: string | null;
 
 
209
  document_id?: string | null;
210
  social_account_id?: string | null;
211
  remind_by_email?: boolean;
@@ -225,6 +232,13 @@ export const launchpadApi = {
225
  startOAuth: (platform: "x" | "linkedin") => post<{ url: string }>(`/api/social/${platform}/start`),
226
  completeOAuth: (ticket: string) => post<SocialAccounts>("/api/social/complete", { ticket }),
227
  disconnect: (id: string) => del(`/api/social/accounts/${id}`),
 
 
 
 
 
 
 
228
  };
229
 
230
  export const resurfaceApi = {
 
22
  WorkspaceChat,
23
  Page,
24
  Platform,
25
+ PostableArtifact,
26
  Quota,
27
  ResurfaceItem,
28
  Settings,
 
203
 
204
  export type ItemBody = {
205
  platform: Platform;
206
+ /** An exact instant (Post now). Scheduled posts send local_time + timezone instead. */
207
+ scheduled_at?: string;
208
+ /** The half hour picked on the user's own clock, "2026-10-07T11:00", read in `timezone` (IANA) by the backend. */
209
+ local_time?: string;
210
+ timezone?: string;
211
  content: string;
212
  thread?: string[];
213
  artifact_id?: string | null;
214
+ /** The Launch Kit the post was written from (tracked, not attached). */
215
+ kit_id?: string | null;
216
  document_id?: string | null;
217
  social_account_id?: string | null;
218
  remind_by_email?: boolean;
 
232
  startOAuth: (platform: "x" | "linkedin") => post<{ url: string }>(`/api/social/${platform}/start`),
233
  completeOAuth: (ticket: string) => post<SocialAccounts>("/api/social/complete", { ticket }),
234
  disconnect: (id: string) => del(`/api/social/accounts/${id}`),
235
+ /** What can be attached to a post (finished videos, quote cards, carousels, summaries, launch kits; not audio). */
236
+ postable: (q = "") => api<PostableArtifact[]>(`/api/launchpad/postable${qs({ q: q || undefined })}`),
237
+ /** A file from the user's computer, sent to storage: make it postable (an `upload` in the picker). */
238
+ uploadToPost: async (file: File, duration_s?: number) => {
239
+ const { upload_id } = await uploadFile(file);
240
+ return post<PostableArtifact>("/api/launchpad/uploads", { upload_id, duration_s });
241
+ },
242
  };
243
 
244
  export const resurfaceApi = {
frontend/src/api/types.ts CHANGED
@@ -89,7 +89,7 @@ export type AnswerFeedback = { rating: "up" | "down"; reasons: string[]; comment
89
  export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
90
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
91
 
92
- export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map" | "quiz" | "flashcards" | "report" | "infographic";
93
 
94
  export type QuizQuestionType = "multiple_choice" | "multiple_select" | "fill_blank" | "short_answer";
95
  export type QuizQuestion = {
@@ -314,15 +314,44 @@ export type Quota = {
314
 
315
  export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  export type CalendarItem = {
318
  id: string;
319
  platform: Platform;
320
  platform_label: string;
321
  scheduled_at: string;
322
- status: "scheduled" | "publishing" | "posted" | "reminded" | "failed" | "draft";
 
323
  content: string;
324
  thread: string[];
325
  artifact_id: string | null;
 
 
 
 
 
326
  document_id: string | null;
327
  social_account_id: string | null;
328
  account_handle: string | null;
@@ -343,6 +372,12 @@ export type SocialAccount = {
343
  status: string;
344
  avatar_url: string | null;
345
  connected_at: string | null;
 
 
 
 
 
 
346
  };
347
 
348
  export type SocialAccounts = {
 
89
  export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
90
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
91
 
92
+ export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map" | "quiz" | "flashcards" | "report" | "infographic"| "upload";
93
 
94
  export type QuizQuestionType = "multiple_choice" | "multiple_select" | "fill_blank" | "short_answer";
95
  export type QuizQuestion = {
 
314
 
315
  export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
316
 
317
+ /** Something made that a post can carry: images (quote card, carousel slides), a video, or text only. */
318
+ export type PostableArtifact = {
319
+ id: string;
320
+ type: ArtifactType;
321
+ type_label: string;
322
+ title: string;
323
+ media: "image" | "video" | "text";
324
+ media_count: number;
325
+ duration_s: number | null;
326
+ thumb_url: string | null;
327
+ /** A video made but not rendered to an MP4 yet: it can be posted once rendered (in the video editor). */
328
+ needs_render: boolean;
329
+ /** Its MP4 is being rendered: it can be scheduled once that's done. */
330
+ rendering: boolean;
331
+ /** A Notestack (blog2video) video: it opens in the video editor. */
332
+ editable_video: boolean;
333
+ /** An uploaded file's link, to preview it (null for anything else). */
334
+ view_url: string | null;
335
+ created_at: string | null;
336
+ /** A starting text: X as a thread (list of posts), LinkedIn as one post. */
337
+ prefill: { x: string[]; linkedin: string };
338
+ };
339
+
340
  export type CalendarItem = {
341
  id: string;
342
  platform: Platform;
343
  platform_label: string;
344
  scheduled_at: string;
345
+ /** paused: its account's connection is down (or held by hand); never attempted until resumed. */
346
+ status: "scheduled" | "publishing" | "posted" | "reminded" | "failed" | "draft" | "paused";
347
  content: string;
348
  thread: string[];
349
  artifact_id: string | null;
350
+ /** What the post carries (its images or video go out with it). */
351
+ artifact: PostableArtifact | null;
352
+ /** The Launch Kit the post was written from (tracked apart; never an attachment). */
353
+ kit_id: string | null;
354
+ kit_title: string | null;
355
  document_id: string | null;
356
  social_account_id: string | null;
357
  account_handle: string | null;
 
372
  status: string;
373
  avatar_url: string | null;
374
  connected_at: string | null;
375
+ /** Down (expired, revoked, disconnected), or not allowed to post images and videos. */
376
+ needs_reconnect: boolean;
377
+ can_post_media: boolean;
378
+ /** No refresh token and the connection ends within a week (LinkedIn's 60 day tokens). */
379
+ expires_soon: boolean;
380
+ expires_at: string | null;
381
  };
382
 
383
  export type SocialAccounts = {
frontend/src/components/AppShell.tsx CHANGED
@@ -19,7 +19,7 @@ const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean;
19
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
20
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
21
  { to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
22
- { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
23
  ];
24
 
25
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
@@ -33,7 +33,7 @@ function readCollapsed(): boolean {
33
  }
34
 
35
  export default function AppShell() {
36
- const { user, loading, logout } = useAuth();
37
  const location = useLocation();
38
  const notebookFocus = /^\/app\/notebooks\/[^/]+$/.test(location.pathname);
39
  const [collapsed, setCollapsed] = useState(readCollapsed);
@@ -47,10 +47,7 @@ export default function AppShell() {
47
  return !c;
48
  });
49
  if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
50
- if (!user) {
51
- const next = new URLSearchParams({ next: location.pathname + location.search });
52
- return <Navigate to={`/auth?${next}`} replace />;
53
- }
54
 
55
  return (
56
  <UpgradeProvider>
@@ -103,7 +100,7 @@ export default function AppShell() {
103
  </NavLink>
104
  <div className="sidebar-user">
105
  <span className="mono muted side-label">{user.email}</span>
106
- <button className="link-btn" onClick={logout} title="Sign out">
107
  Sign out
108
  </button>
109
  </div>
 
19
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
20
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
21
  { to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
22
+ { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface", "/app/launch-kit"] },
23
  ];
24
 
25
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
 
33
  }
34
 
35
  export default function AppShell() {
36
+ const { user, loading, signingOut, logout } = useAuth();
37
  const location = useLocation();
38
  const notebookFocus = /^\/app\/notebooks\/[^/]+$/.test(location.pathname);
39
  const [collapsed, setCollapsed] = useState(readCollapsed);
 
47
  return !c;
48
  });
49
  if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
50
+ if (!user) return <Navigate to="/" replace />;
 
 
 
51
 
52
  return (
53
  <UpgradeProvider>
 
100
  </NavLink>
101
  <div className="sidebar-user">
102
  <span className="mono muted side-label">{user.email}</span>
103
+ <button className="link-btn" onClick={logout} disabled={signingOut} title="Sign out">
104
  Sign out
105
  </button>
106
  </div>
frontend/src/components/ArtifactCard.tsx CHANGED
@@ -202,7 +202,7 @@ export function Body({ artifact, onCite }: { artifact: Artifact; onCite?: (c: Ci
202
  <p className="muted">
203
  {c.claims?.length ?? 0} claims, {c.hooks?.length ?? 0} hooks, posts for 4 platforms, SEO pack and carousel.
204
  </p>
205
- <Link className="btn btn-small" to={`/app/launch-kit?kit=${artifact.id}`}>
206
  Open Launch Kit
207
  </Link>
208
  </div>
 
202
  <p className="muted">
203
  {c.claims?.length ?? 0} claims, {c.hooks?.length ?? 0} hooks, posts for 4 platforms, SEO pack and carousel.
204
  </p>
205
+ <Link className="btn btn-small" to={`/app/launchpad/kits/${artifact.id}`}>
206
  Open Launch Kit
207
  </Link>
208
  </div>
frontend/src/components/ScheduleModal.tsx CHANGED
@@ -1,6 +1,9 @@
1
  import { useEffect, useState, type FormEvent } from "react";
 
2
  import { launchpadApi } from "../api/endpoints";
3
- import type { CalendarItem, Platform, SocialAccount } from "../api/types";
 
 
4
  import { errorMessage, Modal } from "./ui";
5
 
6
  export const PLATFORMS: { id: Platform; label: string; limit: number; auto: boolean }[] = [
@@ -10,22 +13,27 @@ export const PLATFORMS: { id: Platform; label: string; limit: number; auto: bool
10
  { id: "substack_notes", label: "Substack Notes", limit: 2000, auto: false },
11
  ];
12
 
13
- export function toLocalInput(date: Date): string {
14
- const pad = (n: number) => String(n).padStart(2, "0");
15
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
16
- }
 
 
 
17
 
18
- export function nextSlot(): Date {
19
- const d = new Date(Date.now() + 60 * 60 * 1000);
20
- d.setMinutes(0, 0, 0);
21
- return d;
22
- }
23
 
24
- /** Schedules one post (or a thread) on the Launchpad. Posts auto publish when an account is connected. */
 
25
  export function ScheduleModal({
26
  platform: initialPlatform,
27
  posts,
28
  artifactId,
 
 
 
29
  documentId,
30
  onClose,
31
  onScheduled,
@@ -33,134 +41,253 @@ export function ScheduleModal({
33
  platform: Platform;
34
  posts: string[];
35
  artifactId?: string | null;
 
 
 
 
 
36
  documentId?: string | null;
37
  onClose: () => void;
38
  onScheduled?: (item: CalendarItem) => void;
39
  }) {
40
- const [platform, setPlatform] = useState<Platform>(initialPlatform);
41
- const [texts, setTexts] = useState<string[]>(posts.length ? posts : [""]);
42
- const [when, setWhen] = useState(toLocalInput(nextSlot()));
43
- const [accounts, setAccounts] = useState<SocialAccount[]>([]);
44
- const [accountId, setAccountId] = useState<string>("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  const [remind, setRemind] = useState(false);
46
- const [busy, setBusy] = useState(false);
47
  const [error, setError] = useState<string | null>(null);
48
- const meta = PLATFORMS.find((p) => p.id === platform)!;
49
- const threadable = platform === "x" || platform === "bluesky";
50
 
51
  useEffect(() => {
52
- launchpadApi.accounts().then((a) => setAccounts(a.accounts), () => setAccounts([]));
 
 
 
 
 
 
 
53
  }, []);
54
 
55
- const options = accounts.filter((a) => a.platform === platform && a.status === "active");
56
- useEffect(() => {
57
- setAccountId(options[0]?.id ?? "");
58
- // eslint-disable-next-line react-hooks/exhaustive-deps
59
- }, [platform, accounts.length]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- const submit = async (e: FormEvent) => {
62
- e.preventDefault();
63
- setBusy(true);
 
 
 
64
  setError(null);
65
- const clean = texts.map((t) => t.trim()).filter(Boolean);
66
- const merged = threadable ? clean : [clean.join("\n\n")];
67
  try {
68
- const item = await launchpadApi.create({
69
- platform,
70
- scheduled_at: new Date(when).toISOString(),
71
- content: merged[0] ?? "",
72
- thread: merged.slice(1),
73
- artifact_id: artifactId ?? null,
74
- document_id: documentId ?? null,
75
- social_account_id: meta.auto && accountId ? accountId : null,
76
- remind_by_email: remind || !meta.auto || !accountId,
77
- });
78
- onScheduled?.(item);
 
 
 
 
 
 
 
 
 
 
 
79
  onClose();
80
  } catch (err) {
81
- setError(errorMessage(err));
82
  } finally {
83
- setBusy(false);
84
  }
85
  };
86
 
87
  return (
88
- <Modal title="Schedule on the Launchpad" onClose={onClose} wide>
89
- <form onSubmit={submit} className="stack">
90
- <div className="row">
91
- <label className="field">
92
- <span>Platform</span>
93
- <select className="input input-sm" value={platform} onChange={(e) => setPlatform(e.target.value as Platform)}>
94
- {PLATFORMS.map((p) => (
95
- <option key={p.id} value={p.id}>
 
 
96
  {p.label}
97
- </option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  ))}
99
- </select>
100
- </label>
101
- <label className="field">
102
- <span>When</span>
103
- <input className="input input-sm" type="datetime-local" required value={when} onChange={(e) => setWhen(e.target.value)} />
104
- </label>
105
- {meta.auto && (
106
- <label className="field">
107
- <span>Account</span>
108
- <select className="input input-sm" value={accountId} onChange={(e) => setAccountId(e.target.value)}>
109
- <option value="">Email me a reminder instead</option>
110
- {options.map((a) => (
111
- <option key={a.id} value={a.id}>
112
- {a.handle}
113
- </option>
114
- ))}
115
- </select>
 
 
 
 
 
 
 
 
116
  </label>
117
  )}
118
  </div>
119
- {!meta.auto && <p className="muted">Substack has no posting API, so we email you the note at send time, ready to paste.</p>}
120
- {meta.auto && !options.length && (
121
- <p className="muted">
122
- No {meta.label} account connected. Connect one on the Launchpad to auto post; until then you get an email reminder.
123
- </p>
124
- )}
125
- {texts.map((t, i) => (
126
- <label key={i} className="field">
127
- <span className="field-row">
128
- <span>{threadable && texts.length > 1 ? `Post ${i + 1}` : "Post"}</span>
129
- <span className={`mono ${t.length > meta.limit ? "over" : "muted"}`}>
130
- {t.length} / {meta.limit}
131
- </span>
132
- </span>
133
- <textarea className="textarea" rows={threadable ? 3 : 8} value={t} onChange={(e) => setTexts(texts.map((x, j) => (j === i ? e.target.value : x)))} />
134
- </label>
135
- ))}
136
- {threadable && (
137
- <div className="row">
138
- <button type="button" className="btn btn-small" onClick={() => setTexts([...texts, ""])}>
139
- Add post to thread
140
- </button>
141
- {texts.length > 1 && (
142
- <button type="button" className="btn btn-small" onClick={() => setTexts(texts.slice(0, -1))}>
143
- Remove last
144
- </button>
145
- )}
146
- </div>
147
- )}
148
- {meta.auto && accountId && (
149
- <label className="check">
150
- <input type="checkbox" checked={remind} onChange={(e) => setRemind(e.target.checked)} />
151
- Email me instead of posting automatically
152
- </label>
153
- )}
154
  {error && <p className="error-text">{error}</p>}
155
  <div className="row end">
156
- <button type="button" className="btn" onClick={onClose}>
157
- Cancel
 
 
158
  </button>
159
- <button className="btn btn-primary" disabled={busy}>
160
- {busy ? "Scheduling..." : "Schedule"}
161
  </button>
162
  </div>
163
  </form>
 
 
 
 
 
 
 
164
  </Modal>
165
  );
166
  }
 
1
  import { useEffect, useState, type FormEvent } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
  import { launchpadApi } from "../api/endpoints";
4
+ import type { CalendarItem, Platform, PostableArtifact, SocialAccounts } from "../api/types";
5
+ import { AttachmentChip, AttachPicker } from "./launchpad/Attachment";
6
+ import { nextSlot, SlotPicker, toWall, userTimeZone, wallToDate } from "./launchpad/SlotPicker";
7
  import { errorMessage, Modal } from "./ui";
8
 
9
  export const PLATFORMS: { id: Platform; label: string; limit: number; auto: boolean }[] = [
 
13
  { id: "substack_notes", label: "Substack Notes", limit: 2000, auto: false },
14
  ];
15
 
16
+ /** Only X and LinkedIn take images and videos; the others get the text. */
17
+ const MEDIA_PLATFORMS: Platform[] = ["x", "linkedin"];
18
+ const THREADED: Platform[] = ["x", "bluesky"];
19
+ /** These post only through a live connection: no email fallback. */
20
+ const CONNECTED_ONLY: Platform[] = ["x", "linkedin"];
21
+ /** What the composer offers for now: LinkedIn only (X posting is switched off). */
22
+ const COMPOSER_PLATFORMS: Platform[] = ["linkedin"];
23
 
24
+ /** The attachment's starting text for a platform: a thread for X and Bluesky, one post otherwise. */
25
+ const prefillFor = (p: Platform, a: PostableArtifact): string[] =>
26
+ THREADED.includes(p) ? [...a.prefill.x] : [a.prefill.linkedin];
 
 
27
 
28
+ /** The composer: one post to any of X, LinkedIn, Bluesky and Substack Notes (one calendar item each), with
29
+ * something from the Library attached (its images or video go to X and LinkedIn). Scheduled or posted now. */
30
  export function ScheduleModal({
31
  platform: initialPlatform,
32
  posts,
33
  artifactId,
34
+ artifact,
35
+ kitId,
36
+ kitTitle,
37
  documentId,
38
  onClose,
39
  onScheduled,
 
41
  platform: Platform;
42
  posts: string[];
43
  artifactId?: string | null;
44
+ /** The attachment, when the caller already has it (else it is looked up from artifactId). */
45
+ artifact?: PostableArtifact | null;
46
+ /** The Launch Kit the post is written from: tracked with it, never attached. */
47
+ kitId?: string | null;
48
+ kitTitle?: string | null;
49
  documentId?: string | null;
50
  onClose: () => void;
51
  onScheduled?: (item: CalendarItem) => void;
52
  }) {
53
+ // One platform per post. A caller asking for one the composer doesn't offer starts on LinkedIn with that text.
54
+ const first: Platform = COMPOSER_PLATFORMS.includes(initialPlatform) ? initialPlatform : "linkedin";
55
+ const [selected, setSelected] = useState<Platform[]>([first]);
56
+ const [texts, setTexts] = useState<Partial<Record<Platform, string[]>>>(() => ({
57
+ [first]: posts.length ? posts : artifact ? prefillFor(first, artifact) : [""],
58
+ }));
59
+ const [attached, setAttached] = useState<PostableArtifact | null>(artifact?.type === "launch_kit" ? null : artifact ?? null);
60
+ const navigate = useNavigate();
61
+ const [picking, setPicking] = useState(false);
62
+ const [when, setWhen] = useState(() => toWall(nextSlot()));
63
+ const [accounts, setAccounts] = useState<SocialAccounts | null>(null);
64
+ // The connections are checked before anything says one is missing: loading isn't "not connected".
65
+ const [accountsState, setAccountsState] = useState<"loading" | "ready" | "error">("loading");
66
+ const loadAccounts = () => {
67
+ setAccountsState("loading");
68
+ launchpadApi.accounts().then((a) => {
69
+ setAccounts(a);
70
+ setAccountsState("ready");
71
+ }, () => setAccountsState("error"));
72
+ };
73
  const [remind, setRemind] = useState(false);
74
+ const [busy, setBusy] = useState<"schedule" | "now" | null>(null);
75
  const [error, setError] = useState<string | null>(null);
 
 
76
 
77
  useEffect(() => {
78
+ loadAccounts();
79
+ if (artifactId && !artifact) {
80
+ launchpadApi.postable().then((list) => {
81
+ const found = list.find((a) => a.id === artifactId);
82
+ if (found && found.type !== "launch_kit") attach(found); // a kit is never an attachment
83
+ }, () => undefined);
84
+ }
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
  }, []);
87
 
88
+ const accountFor = (p: Platform) =>
89
+ accounts?.accounts.find((a) => a.platform === p && a.status === "active") ?? null;
90
+ const carriesMedia = !!attached && attached.media !== "text";
91
+ /** Why a ticked X / LinkedIn can't be scheduled yet (no connection, connection down, can't post media). */
92
+ const blocker = (p: Platform): string | null => {
93
+ if (!CONNECTED_ONLY.includes(p) || accountsState !== "ready") return null;
94
+ const label = PLATFORMS.find((x) => x.id === p)!.label;
95
+ const any = accounts?.accounts.find((a) => a.platform === p);
96
+ const live = accountFor(p);
97
+ if (!live) return any ? `Your ${label} connection is down. Reconnect ${label}.` : `Connect ${label} to post there.`;
98
+ if (carriesMedia && !live.can_post_media) return `Reconnect ${label} to post images and videos.`;
99
+ return null;
100
+ };
101
+ const blocked = selected.map(blocker).filter(Boolean) as string[];
102
+ const checking = accountsState !== "ready" && selected.some((p) => CONNECTED_ONLY.includes(p));
103
+ const remindable = selected.some((p) => !CONNECTED_ONLY.includes(p));
104
+
105
+ /** Switch to another platform (one per post); it starts from the attachment's text, or the text written so far. */
106
+ const pick = (p: Platform) => {
107
+ if (selected.includes(p)) return;
108
+ setSelected([p]);
109
+ if (!texts[p]?.some((t) => t.trim())) {
110
+ const from = selected.map((s) => texts[s] ?? []).find((t) => t.some((x) => x.trim())) ?? [""];
111
+ const start = attached ? prefillFor(p, attached) : THREADED.includes(p) ? from : [from.join("\n\n")];
112
+ setTexts((cur) => ({ ...cur, [p]: start }));
113
+ }
114
+ };
115
+
116
+ /** Attach: empty texts take its suggested text. */
117
+ const attach = (a: PostableArtifact) => {
118
+ setAttached(a);
119
+ setPicking(false);
120
+ setTexts((cur) => {
121
+ const next = { ...cur };
122
+ for (const p of selected) if (!next[p]?.some((t) => t.trim())) next[p] = prefillFor(p, a);
123
+ return next;
124
+ });
125
+ };
126
+
127
+ const setText = (p: Platform, i: number, value: string) =>
128
+ setTexts((cur) => ({ ...cur, [p]: (cur[p] ?? [""]).map((t, j) => (j === i ? value : t)) }));
129
+
130
+ const connect = async (p: "x" | "linkedin") => {
131
+ try {
132
+ window.location.href = (await launchpadApi.startOAuth(p)).url;
133
+ } catch (e) {
134
+ setError(errorMessage(e));
135
+ }
136
+ };
137
 
138
+ const submit = async (now: boolean, e?: FormEvent) => {
139
+ e?.preventDefault();
140
+ if (!selected.length) return setError("Pick where to post.");
141
+ if (blocked.length) return setError(blocked[0]);
142
+ if (!now && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
143
+ setBusy(now ? "now" : "schedule");
144
  setError(null);
145
+ const done: string[] = [];
 
146
  try {
147
+ for (const p of PLATFORMS.map((x) => x.id).filter((id) => selected.includes(id))) {
148
+ const meta = PLATFORMS.find((x) => x.id === p)!;
149
+ const clean = (texts[p] ?? []).map((t) => t.trim()).filter(Boolean);
150
+ const merged = THREADED.includes(p) ? clean : [clean.join("\n\n")];
151
+ const account = meta.auto ? accountFor(p) : null;
152
+ const item = await launchpadApi.create({
153
+ platform: p,
154
+ // Post now: this instant. Scheduled: the half hour picked, in the user's zone (the backend makes it UTC).
155
+ ...(now ? { scheduled_at: new Date().toISOString() } : { local_time: when, timezone: userTimeZone() }),
156
+ content: merged[0] ?? "",
157
+ thread: merged.slice(1),
158
+ // Images and videos only go to X and LinkedIn; text attachments go anywhere.
159
+ artifact_id: attached && (!carriesMedia || MEDIA_PLATFORMS.includes(p)) ? attached.id : null,
160
+ document_id: documentId ?? null,
161
+ kit_id: kitId ?? null,
162
+ social_account_id: account?.id ?? null,
163
+ remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account,
164
+ });
165
+ const out = now && item.auto_post ? await launchpadApi.publishNow(item.id) : item;
166
+ onScheduled?.(out);
167
+ done.push(meta.label);
168
+ }
169
  onClose();
170
  } catch (err) {
171
+ setError(done.length ? `${done.join(" and ")} done; then: ${errorMessage(err)}` : errorMessage(err));
172
  } finally {
173
+ setBusy(null);
174
  }
175
  };
176
 
177
  return (
178
+ <Modal title="New post" onClose={onClose} wide>
179
+ <form onSubmit={(e) => submit(false, e)} className="stack lp-compose">
180
+ <div className="field">
181
+ <span className="vw-label">Post to</span>
182
+ <div className="gen-chips lp-platforms" role="radiogroup" aria-label="Platform">
183
+ {PLATFORMS.filter((p) => COMPOSER_PLATFORMS.includes(p.id)).map((p) => {
184
+ const on = selected.includes(p.id);
185
+ return (
186
+ <button key={p.id} type="button" role="radio" className={`chip${on ? " on" : ""}`} aria-checked={on}
187
+ onClick={() => pick(p.id)}>
188
  {p.label}
189
+ </button>
190
+ );
191
+ })}
192
+ </div>
193
+ </div>
194
+
195
+ {kitId && <p className="muted small lp-from-kit">Written from the launch kit: {kitTitle ?? "this kit"}</p>}
196
+ <div className="field">
197
+ <span className="vw-label">Attachment</span>
198
+ {attached ? (
199
+ <AttachmentChip a={attached} onChange={() => setPicking(true)} onRemove={() => setAttached(null)} />
200
+ ) : (
201
+ <button type="button" className="btn lp-attach-btn" onClick={() => setPicking(true)}>
202
+ + Attach from your Library
203
+ </button>
204
+ )}
205
+ </div>
206
+
207
+ {PLATFORMS.filter((p) => selected.includes(p.id)).map((meta) => {
208
+ const p = meta.id;
209
+ const list = texts[p] ?? [""];
210
+ const account = meta.auto ? accountFor(p) : null;
211
+ const canConnect = (p === "x" || p === "linkedin") && accounts?.available[p];
212
+ const problem = blocker(p);
213
+ return (
214
+ <section key={p} className="lp-platform">
215
+ <div className="lp-platform-head">
216
+ <strong>{meta.label}</strong>
217
+ <span className={`small ${problem || (accountsState === "error" && CONNECTED_ONLY.includes(p)) ? "lp-warn" : "muted"}`}>
218
+ {problem ?? (!meta.auto ? "No posting API: we email you the text at send time."
219
+ : accountsState === "loading" ? "Checking your connection..."
220
+ : accountsState === "error" ? "Couldn't check your connections."
221
+ : account ? `Posting as ${account.handle}` : "Not connected: we email you a reminder.")}
222
+ </span>
223
+ {accountsState === "error" && (
224
+ <button type="button" className="btn btn-small" onClick={loadAccounts}>Retry</button>
225
+ )}
226
+ {problem && canConnect && (
227
+ <button type="button" className="btn btn-small btn-primary" onClick={() => connect(p as "x" | "linkedin")}>
228
+ {accounts?.accounts.some((a) => a.platform === p) ? "Reconnect" : "Connect"} {meta.label}
229
+ </button>
230
+ )}
231
+ {problem && !canConnect && <span className="muted small">{meta.label} posting isn't set up yet.</span>}
232
+ </div>
233
+ {list.map((t, i) => (
234
+ <label key={i} className="field">
235
+ <span className="field-row">
236
+ <span className="small muted">{THREADED.includes(p) && list.length > 1 ? `Post ${i + 1}` : "Post"}</span>
237
+ <span className={`mono small ${t.length > meta.limit ? "over" : "muted"}`}>{t.length} / {meta.limit}</span>
238
+ </span>
239
+ <textarea className="textarea" rows={THREADED.includes(p) ? 3 : 6} value={t}
240
+ onChange={(e) => setText(p, i, e.target.value)} />
241
+ </label>
242
  ))}
243
+ {THREADED.includes(p) && (
244
+ <div className="row">
245
+ <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: [...list, ""] })}>
246
+ + Add to thread
247
+ </button>
248
+ {list.length > 1 && (
249
+ <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: list.slice(0, -1) })}>
250
+ Remove last
251
+ </button>
252
+ )}
253
+ </div>
254
+ )}
255
+ </section>
256
+ );
257
+ })}
258
+
259
+ <div className="row wrap lp-when">
260
+ <div className="field">
261
+ <span className="vw-label">When</span>
262
+ <SlotPicker value={when} onChange={setWhen} />
263
+ </div>
264
+ {remindable && (
265
+ <label className="check">
266
+ <input type="checkbox" checked={remind} onChange={(e) => setRemind(e.target.checked)} />
267
+ Email me instead of posting automatically (Bluesky, Substack)
268
  </label>
269
  )}
270
  </div>
271
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  {error && <p className="error-text">{error}</p>}
273
  <div className="row end">
274
+ <button type="button" className="btn" onClick={onClose}>Cancel</button>
275
+ <button type="button" className="btn" disabled={busy !== null || !selected.length || blocked.length > 0 || checking}
276
+ onClick={() => submit(true)}>
277
+ {busy === "now" ? "Posting..." : "Post now"}
278
  </button>
279
+ <button className="btn btn-primary" disabled={busy !== null || !selected.length || blocked.length > 0 || checking}>
280
+ {busy === "schedule" ? "Scheduling..." : "Schedule"}
281
  </button>
282
  </div>
283
  </form>
284
+ {picking && (
285
+ <AttachPicker onPick={attach} onClose={() => setPicking(false)} onCreateKit={() => {
286
+ setPicking(false);
287
+ onClose();
288
+ navigate("/app/launchpad/kits");
289
+ }} />
290
+ )}
291
  </Modal>
292
  );
293
  }
frontend/src/components/UpgradeModal.tsx CHANGED
@@ -161,7 +161,7 @@ export default function UpgradeModal() {
161
  ))}
162
  </ul>
163
  <div className="row end">
164
- <Link to="/app/launch-kit" className="btn btn-primary" onClick={close}>
165
  Launch something
166
  </Link>
167
  </div>
 
161
  ))}
162
  </ul>
163
  <div className="row end">
164
+ <Link to="/app/launchpad/kits" className="btn btn-primary" onClick={close}>
165
  Launch something
166
  </Link>
167
  </div>
frontend/src/components/launchpad/Attachment.tsx ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { useNavigate } from "react-router-dom";
4
+ import { ApiError } from "../../api/client";
5
+ import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
6
+ import type { PostableArtifact } from "../../api/types";
7
+ import { ArrowRightIcon, FilmIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
8
+ import { errorMessage, formatDate, Loading, Modal } from "../ui";
9
+ import { finished, jobPercent } from "../video/jobState";
10
+
11
+ /** X's video limit for standard accounts (2:20): longer videos go to LinkedIn only. */
12
+ export const X_VIDEO_SECONDS = 140;
13
+
14
+ export const fmtDuration = (s: number) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
15
+
16
+ /** "4 images", "Video 1:12", "Text": what the post carries. */
17
+ export function mediaLabel(a: PostableArtifact): string {
18
+ if (a.type === "upload") {
19
+ return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
20
+ }
21
+ if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
22
+ if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
23
+ if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
24
+ if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
25
+ return "Text";
26
+ }
27
+
28
+ function Thumb({ a }: { a: PostableArtifact }) {
29
+ if (a.thumb_url) return <img className="lp-thumb" src={a.thumb_url} alt="" />;
30
+ return (
31
+ <span className="lp-thumb lp-thumb-icon" aria-hidden="true">
32
+ {a.media === "video" ? <FilmIcon size={20} /> : <SparkleIcon size={20} />}
33
+ </span>
34
+ );
35
+ }
36
+
37
+ /** Look at an uploaded image or video before posting it. */
38
+ export function MediaPreviewModal({ a, onClose }: { a: PostableArtifact; onClose: () => void }) {
39
+ const [failed, setFailed] = useState(false);
40
+ return (
41
+ <Modal title={a.title} onClose={onClose} wide>
42
+ <div className="stack">
43
+ <div className="lp-preview">
44
+ {failed || !a.view_url ? (
45
+ <p className="muted">Couldn't load this file.</p>
46
+ ) : a.media === "video" ? (
47
+ <video src={a.view_url} controls autoPlay playsInline onError={() => setFailed(true)} />
48
+ ) : (
49
+ <img src={a.view_url} alt={a.title} onError={() => setFailed(true)} />
50
+ )}
51
+ </div>
52
+ <p className="muted small">{mediaLabel(a)}</p>
53
+ </div>
54
+ </Modal>
55
+ );
56
+ }
57
+
58
+ /** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
59
+ * preview. */
60
+ export function AttachmentChip({ a, onChange, onRemove }: {
61
+ a: PostableArtifact;
62
+ onChange?: () => void;
63
+ onRemove?: () => void;
64
+ }) {
65
+ const [previewing, setPreviewing] = useState(false);
66
+ return (
67
+ <div className="lp-attach">
68
+ {a.view_url ? (
69
+ <button type="button" className="lp-thumb-btn" aria-label={`Preview ${a.title}`} title="Preview"
70
+ onClick={() => setPreviewing(true)}>
71
+ <Thumb a={a} />
72
+ </button>
73
+ ) : <Thumb a={a} />}
74
+ {previewing && <MediaPreviewModal a={a} onClose={() => setPreviewing(false)} />}
75
+ <span className="lp-attach-text">
76
+ <strong>{a.title}</strong>
77
+ <span className="muted small">{a.type_label} · {mediaLabel(a)}</span>
78
+ </span>
79
+ {onChange && <button type="button" className="btn btn-small" onClick={onChange}>Change</button>}
80
+ {onRemove && <button type="button" className="btn btn-small" onClick={onRemove}>Remove</button>}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ type RenderStep = "confirm" | "rendering" | "done" | "failed";
86
+
87
+ /** The furthest each video's render has got, kept across opens of the pop-up: blog2video can answer 0% for a while
88
+ * (preparing, or a poll that misses its progress), and reopening must not drop the bar back to 0. */
89
+ const renderPct = new Map<string, number>();
90
+
91
+ /** Render a video that has no MP4 yet, from the Library list: asks first, then shows the render's progress here.
92
+ * Opened on a video already rendering, it says to wait and shows that render's progress. Closing it mid-render
93
+ * leaves the render running (the row shows Rendering). */
94
+ export function RenderVideoModal({ video, onClose, onRendered }: {
95
+ video: PostableArtifact;
96
+ onClose: () => void;
97
+ /** The MP4 is made: schedule it (the picker reloads the item and opens the composer with it). */
98
+ onRendered: (id: string, schedule: boolean) => void;
99
+ }) {
100
+ const waiting = video.rendering; // opened on a render already under way: scheduling waits for it
101
+ const [step, setStep] = useState<RenderStep>(waiting ? "rendering" : "confirm");
102
+ const [pct, setPct] = useState<number | null>(() => renderPct.get(video.id) ?? null);
103
+ const [busy, setBusy] = useState(false);
104
+ const [error, setError] = useState<string | null>(null);
105
+ // The latest callback, so a parent re-render doesn't restart the polling below.
106
+ const onRenderedRef = useRef(onRendered);
107
+ onRenderedRef.current = onRendered;
108
+
109
+ // While rendering: blog2video's progress for the bar. When it says the job stopped (and every few polls anyway,
110
+ // in case blog2video lost track of the render), /status (which also moves the video to ready with its MP4) says
111
+ // how it ended.
112
+ useEffect(() => {
113
+ if (step !== "rendering") return;
114
+ let stopped = false;
115
+ let timer: ReturnType<typeof setTimeout> | undefined;
116
+ let polls = 0;
117
+ const tick = async () => {
118
+ try {
119
+ const s = await videoEditApi.renderStatus(video.id);
120
+ if (stopped) return;
121
+ const now = s?.progress_unknown ? null : jobPercent(s);
122
+ if (now !== null) renderPct.set(video.id, Math.max(renderPct.get(video.id) ?? 0, now));
123
+ setPct(renderPct.get(video.id) ?? null);
124
+ if (finished(s) || ++polls % 3 === 0) {
125
+ const st = await videosApi.status(video.id);
126
+ if (stopped) return;
127
+ if (st.artifact_status !== "rendering") {
128
+ renderPct.delete(video.id);
129
+ if (st.video_url && !st.error) {
130
+ setStep("done");
131
+ onRenderedRef.current(video.id, false); // the list shows it as ready behind the modal
132
+ } else {
133
+ setError(st.error ?? "The render stopped before the MP4 was made.");
134
+ setStep("failed");
135
+ }
136
+ return;
137
+ }
138
+ }
139
+ } catch {
140
+ // a missed poll: try again on the next one
141
+ }
142
+ if (!stopped) timer = setTimeout(tick, 4000);
143
+ };
144
+ void tick();
145
+ return () => {
146
+ stopped = true;
147
+ clearTimeout(timer);
148
+ };
149
+ }, [step, video.id]);
150
+
151
+ const render = async () => {
152
+ setBusy(true);
153
+ setError(null);
154
+ try {
155
+ await videoEditApi.render(video.id);
156
+ renderPct.delete(video.id); // a new run starts from 0
157
+ setPct(null);
158
+ setStep("rendering");
159
+ } catch (e) {
160
+ setError(e instanceof ApiError && e.status === 409 ? "Another change is still running on this video, try again in a moment."
161
+ : errorMessage(e));
162
+ } finally {
163
+ setBusy(false);
164
+ }
165
+ };
166
+
167
+ const title = step === "done" ? "Video rendered" : step === "failed" ? "Render failed"
168
+ : waiting ? "Still rendering" : step === "rendering" ? "Rendering your video" : "Render this video?";
169
+ return (
170
+ <Modal title={title} onClose={onClose}>
171
+ <div className="stack">
172
+ <p><strong>{video.title}</strong></p>
173
+ {step === "confirm" && (
174
+ <p className="muted">It has no MP4 yet, so it can't be posted. Render it now; it takes a few minutes and you can close this while it runs.</p>
175
+ )}
176
+ {step === "rendering" && (
177
+ <>
178
+ <p className="muted">
179
+ {waiting ? "Wait for the render to complete before scheduling it." : "You can close this: the render keeps going and the video shows as Rendering until it's done."}
180
+ </p>
181
+ <span className="vw-render-status lp-render-status" role="status">
182
+ <span className="vw-render-line">
183
+ <span className="vw-spin small" aria-hidden="true" />
184
+ <span>{pct ? "Rendering MP4" : "Preparing the render"}</span>
185
+ {pct ? <span className="mono vw-render-pct">{pct}%</span> : null}
186
+ </span>
187
+ <span className="vw-render-bar" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, pct ?? 0))}%` }} /></span>
188
+ </span>
189
+ </>
190
+ )}
191
+ {step === "done" && <p className="muted">The MP4 is ready. Schedule it now, or pick it from the list later.</p>}
192
+ {error && <p className="error-text">{error}</p>}
193
+ <div className="row end">
194
+ {step === "confirm" && (
195
+ <>
196
+ <button type="button" className="btn" onClick={onClose} disabled={busy}>Cancel</button>
197
+ <button type="button" className="btn btn-primary" onClick={render} disabled={busy}>
198
+ {busy ? "Starting..." : "Render"}
199
+ </button>
200
+ </>
201
+ )}
202
+ {step === "rendering" && <button type="button" className="btn" onClick={onClose}>{waiting ? "OK" : "Close"}</button>}
203
+ {step === "failed" && (
204
+ <>
205
+ <button type="button" className="btn" onClick={onClose}>Close</button>
206
+ <button type="button" className="btn btn-primary" onClick={render} disabled={busy}>
207
+ {busy ? "Starting..." : "Try again"}
208
+ </button>
209
+ </>
210
+ )}
211
+ {step === "done" && (
212
+ <>
213
+ <button type="button" className="btn" onClick={onClose}>Close</button>
214
+ <button type="button" className="btn btn-primary" onClick={() => onRendered(video.id, true)}>Schedule it</button>
215
+ </>
216
+ )}
217
+ </div>
218
+ </div>
219
+ </Modal>
220
+ );
221
+ }
222
+
223
+ type MenuItem = { label: string; onSelect: () => void; danger?: boolean };
224
+
225
+ /** A row's ⋮ button and its menu. The menu is drawn over everything (a portal, placed from the button), so the
226
+ * scrolling list and the pop-up around it never cut it off; it opens upwards near the bottom of the screen. */
227
+ function RowMenu({ label, items }: { label: string; items: MenuItem[] }) {
228
+ const [open, setOpen] = useState(false);
229
+ const [at, setAt] = useState<{ top: number; left: number; up: boolean } | null>(null);
230
+ const button = useRef<HTMLButtonElement>(null);
231
+ const menu = useRef<HTMLDivElement>(null);
232
+
233
+ useLayoutEffect(() => {
234
+ if (!open || !button.current) return;
235
+ const r = button.current.getBoundingClientRect();
236
+ const up = r.bottom + 8 + items.length * 40 > window.innerHeight;
237
+ setAt({ top: up ? r.top - 6 : r.bottom + 6, left: r.right, up });
238
+ }, [open, items.length]);
239
+
240
+ useEffect(() => {
241
+ if (!open) return;
242
+ const close = () => setOpen(false);
243
+ const outside = (e: MouseEvent) => {
244
+ const t = e.target as Node;
245
+ if (!menu.current?.contains(t) && !button.current?.contains(t)) close();
246
+ };
247
+ // Escape closes only the menu, not the pop-up the picker sits in.
248
+ const key = (e: KeyboardEvent) => {
249
+ if (e.key !== "Escape") return;
250
+ e.stopPropagation();
251
+ close();
252
+ button.current?.focus();
253
+ };
254
+ document.addEventListener("mousedown", outside);
255
+ document.addEventListener("keydown", key, true);
256
+ window.addEventListener("scroll", close, true);
257
+ window.addEventListener("resize", close);
258
+ menu.current?.querySelector<HTMLElement>("[role=menuitem]")?.focus();
259
+ return () => {
260
+ document.removeEventListener("mousedown", outside);
261
+ document.removeEventListener("keydown", key, true);
262
+ window.removeEventListener("scroll", close, true);
263
+ window.removeEventListener("resize", close);
264
+ };
265
+ }, [open, at]);
266
+
267
+ return (
268
+ <>
269
+ <button ref={button} type="button" className="btn btn-small lp-row-menu-btn" aria-label={`Options for ${label}`}
270
+ aria-haspopup="menu" aria-expanded={open} title="Options" onClick={() => setOpen(!open)}>
271
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
272
+ <circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" />
273
+ </svg>
274
+ </button>
275
+ {open && at && createPortal(
276
+ <div ref={menu} className="lp-menu" role="menu" aria-label={`Options for ${label}`}
277
+ style={{ top: at.top, left: at.left, transform: `translate(-100%, ${at.up ? "-100%" : "0"})` }}>
278
+ {items.map((it) => (
279
+ <button key={it.label} type="button" role="menuitem" className={`lp-menu-item${it.danger ? " danger" : ""}`}
280
+ onClick={() => {
281
+ setOpen(false);
282
+ it.onSelect();
283
+ }}>
284
+ {it.label}
285
+ </button>
286
+ ))}
287
+ </div>,
288
+ document.body,
289
+ )}
290
+ </>
291
+ );
292
+ }
293
+
294
+ /** The warning before deleting, by kind: what goes, and what happens to the posts that carry it. */
295
+ function deleteWording(a: PostableArtifact): { title: string; body: string } {
296
+ if (a.type === "upload") {
297
+ return { title: "Delete this upload?", body: "The file is deleted for good. Posts on your calendar that carry it are removed too." };
298
+ }
299
+ if (a.type === "launch_kit") {
300
+ return { title: "Delete this launch kit?",
301
+ body: "Its LinkedIn post and hooks are deleted for good. Posts already scheduled from it stay on your calendar." };
302
+ }
303
+ if (a.media === "video") {
304
+ return { title: "Delete this video?",
305
+ body: "It's deleted here and on the video service, for good. Posts on your calendar that carry it are removed too." };
306
+ }
307
+ return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
308
+ }
309
+
310
+ type Kind = "all" | "kits" | "video" | "uploads";
311
+ const KINDS: { id: Kind; label: string }[] = [
312
+ { id: "all", label: "All" },
313
+ { id: "kits", label: "Launch kits" },
314
+ { id: "video", label: "Videos" },
315
+ { id: "uploads", label: "Uploads" },
316
+ ];
317
+ /** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
318
+ const matches = (a: PostableArtifact, kind: Kind) =>
319
+ kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload" : a.editable_video);
320
+
321
+ /** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
322
+ const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
323
+ const MB = 1024 * 1024;
324
+ function uploadProblem(file: File): string | null {
325
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
326
+ const video = file.type === "video/mp4" || ext === "mp4";
327
+ const image = ["image/png", "image/jpeg", "image/gif"].includes(file.type) || ["png", "jpg", "jpeg", "gif"].includes(ext);
328
+ if (!video && !image) return "LinkedIn takes JPG, PNG or GIF images and MP4 videos.";
329
+ if (image && file.size > 36 * MB) return `This image is ${Math.round(file.size / MB)} MB; LinkedIn takes up to 36 MB.`;
330
+ if (video && file.size > 300 * MB) return `This video is ${Math.round(file.size / MB)} MB; uploads are limited to 300 MB.`;
331
+ if (video && file.size < 75 * 1024) return "This video is too small for LinkedIn (at least 75 KB).";
332
+ return null;
333
+ }
334
+
335
+ /** A video file's length in seconds, read by the browser (null if it can't tell). */
336
+ function videoDuration(file: File): Promise<number | null> {
337
+ return new Promise((resolve) => {
338
+ const url = URL.createObjectURL(file);
339
+ const v = document.createElement("video");
340
+ v.preload = "metadata";
341
+ const done = (d: number | null) => {
342
+ URL.revokeObjectURL(url);
343
+ resolve(d);
344
+ };
345
+ v.onloadedmetadata = () => done(Number.isFinite(v.duration) ? v.duration : null);
346
+ v.onerror = () => done(null);
347
+ v.src = url;
348
+ });
349
+ }
350
+
351
+ /** Pick something made to attach to a post (audio is never offered: X and LinkedIn take no audio files), in a
352
+ * pop-up (the composer, editing a scheduled post). */
353
+ export function AttachPicker({ onPick, onCreateKit, onClose }: {
354
+ onPick: (a: PostableArtifact) => void;
355
+ onCreateKit?: () => void;
356
+ onClose: () => void;
357
+ }) {
358
+ return (
359
+ <Modal title="Attach from your Library" onClose={onClose} wide>
360
+ <LibraryPicker onPick={onPick} onCreateKit={onCreateKit} />
361
+ </Modal>
362
+ );
363
+ }
364
+
365
+ /** What can go out with a post: everything postable in the Library, your Launch Kits, or your videos (tabs), and
366
+ * searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
367
+ * Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
368
+ * with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
369
+ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }: {
370
+ onPick: (a: PostableArtifact) => void;
371
+ onPickKit?: (a: PostableArtifact) => void;
372
+ onCreateKit?: () => void;
373
+ fill?: boolean;
374
+ }) {
375
+ const [items, setItems] = useState<PostableArtifact[] | null>(null);
376
+ const [kind, setKind] = useState<Kind>("all");
377
+ const [q, setQ] = useState("");
378
+ const [error, setError] = useState<string | null>(null);
379
+ const [rendering, setRendering] = useState<PostableArtifact | null>(null); // the video in the render pop-up
380
+ const navigate = useNavigate();
381
+ const [uploading, setUploading] = useState<string | null>(null); // the file name being uploaded
382
+ const [dragging, setDragging] = useState(false); // a file is held over the drop area
383
+ const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
384
+ const [previewing, setPreviewing] = useState<PostableArtifact | null>(null); // the upload being looked at
385
+ const [deleting, setDeleting] = useState<PostableArtifact | null>(null); // the item in the delete pop-up
386
+ const [deleteBusy, setDeleteBusy] = useState(false);
387
+ const [deleteError, setDeleteError] = useState<string | null>(null);
388
+
389
+ /** Delete an item after its warning: an upload (record and stored file too), a video (also on blog2video), a kit... */
390
+ const deleteItem = async () => {
391
+ if (!deleting) return;
392
+ setDeleteBusy(true);
393
+ setDeleteError(null);
394
+ try {
395
+ await artifactsApi.remove(deleting.id);
396
+ setItems((list) => list?.filter((x) => x.id !== deleting.id) ?? list);
397
+ setDeleting(null);
398
+ } catch (e) {
399
+ setDeleteError(errorMessage(e));
400
+ } finally {
401
+ setDeleteBusy(false);
402
+ }
403
+ };
404
+
405
+ /** An uploaded file's name, as the picker and the composer show it (the file itself keeps its name). */
406
+ const saveName = async () => {
407
+ if (!renaming) return;
408
+ const title = renaming.title.trim();
409
+ if (!title) return setError("Give it a name.");
410
+ setRenaming({ ...renaming, busy: true });
411
+ try {
412
+ await artifactsApi.update(renaming.id, { title });
413
+ setItems((list) => list?.map((x) => (x.id === renaming.id ? { ...x, title } : x)) ?? list);
414
+ setRenaming(null);
415
+ setError(null);
416
+ } catch (e) {
417
+ setError(errorMessage(e));
418
+ setRenaming((cur) => cur && { ...cur, busy: false });
419
+ }
420
+ };
421
+ const fileInput = useRef<HTMLInputElement>(null);
422
+
423
+ /** A file from the computer: uploaded, made postable, then picked like anything else. */
424
+ const upload = async (file: File) => {
425
+ const problem = uploadProblem(file);
426
+ if (problem) return setError(problem);
427
+ setError(null);
428
+ setUploading(file.name);
429
+ try {
430
+ const seconds = file.type === "video/mp4" || file.name.toLowerCase().endsWith(".mp4") ? await videoDuration(file) : null;
431
+ const item = await launchpadApi.uploadToPost(file, seconds ?? undefined);
432
+ void load().catch(() => undefined);
433
+ onPick(item);
434
+ } catch (e) {
435
+ setError(errorMessage(e));
436
+ } finally {
437
+ setUploading(null);
438
+ }
439
+ };
440
+
441
+ const load = useCallback(() => launchpadApi.postable(q).then((list) => {
442
+ setItems(list);
443
+ return list;
444
+ }), [q]);
445
+
446
+ useEffect(() => {
447
+ const t = setTimeout(() => {
448
+ load().catch((e) => setError(errorMessage(e)));
449
+ }, 200);
450
+ return () => clearTimeout(t);
451
+ }, [load]);
452
+
453
+ // Videos rendering (from here, the editor, or another tab): check on them, and reload once one is done.
454
+ const renderingIds = useMemo(() => (items ?? []).filter((a) => a.rendering).map((a) => a.id).join(","), [items]);
455
+ useEffect(() => {
456
+ if (!renderingIds) return;
457
+ const t = setInterval(async () => {
458
+ const ids = renderingIds.split(",");
459
+ const states = await Promise.all(ids.map((id) => videosApi.status(id).catch(() => null)));
460
+ if (states.some((s) => s && s.artifact_status !== "rendering")) void load().catch(() => undefined);
461
+ }, 8000);
462
+ return () => clearInterval(t);
463
+ }, [renderingIds, load]);
464
+
465
+ const rendered = useCallback((id: string, schedule: boolean) => {
466
+ void load().then((list) => {
467
+ if (!schedule) return;
468
+ const found = list.find((a) => a.id === id);
469
+ setRendering(null);
470
+ if (found && !found.needs_render) onPick(found);
471
+ }).catch((e) => setError(errorMessage(e)));
472
+ }, [load, onPick]);
473
+
474
+ // Kits only on the Schedule a launch page (they open the kit); as an attachment they are never offered.
475
+ const kits = Boolean(onPickKit);
476
+ const shown = useMemo(() => (items ?? []).filter((a) => (kits || a.type !== "launch_kit") && matches(a, kind)),
477
+ [items, kind, kits]);
478
+
479
+ return (
480
+ <div className="stack">
481
+ <div className="row between wrap">
482
+ <div className="gen-chips" role="group" aria-label="Kind">
483
+ {KINDS.filter((k) => kits || k.id !== "kits").map((k) => (
484
+ <button key={k.id} type="button" className={`chip${kind === k.id ? " on" : ""}`} aria-pressed={kind === k.id}
485
+ onClick={() => setKind(k.id)}>
486
+ {k.label}
487
+ </button>
488
+ ))}
489
+ </div>
490
+ {/* A LinkedIn post and hooks from one of your posts: its own pages */}
491
+ {onCreateKit && (
492
+ <button type="button" className="lp-kit-link" onClick={onCreateKit}
493
+ title="A LinkedIn post and hooks from one of your posts">
494
+ Create a new launch kit <ArrowRightIcon size={16} />
495
+ </button>
496
+ )}
497
+ </div>
498
+ {/* Uploads: a drop area for a file from the computer, above the files uploaded before */}
499
+ {kind === "uploads" && (
500
+ <div className={`lp-drop${dragging ? " over" : ""}${uploading ? " busy" : ""}`} role="button" tabIndex={0}
501
+ aria-label="Upload an image or video from your computer" aria-disabled={uploading !== null}
502
+ onClick={() => !uploading && fileInput.current?.click()}
503
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && !uploading && fileInput.current?.click()}
504
+ onDragOver={(e) => {
505
+ if (!e.dataTransfer.types.includes("Files")) return;
506
+ e.preventDefault();
507
+ setDragging(true);
508
+ }}
509
+ onDragLeave={(e) => e.currentTarget === e.target && setDragging(false)}
510
+ onDrop={(e) => {
511
+ e.preventDefault();
512
+ setDragging(false);
513
+ const file = e.dataTransfer.files[0];
514
+ if (file && !uploading) void upload(file);
515
+ }}>
516
+ <span className="lp-drop-icon" aria-hidden="true">
517
+ {uploading ? <span className="vw-spin" /> : <UploadIcon size={22} />}
518
+ </span>
519
+ {uploading ? (
520
+ <strong>Uploading {uploading}...</strong>
521
+ ) : (
522
+ <>
523
+ <strong>Drop a file here, or <span className="lp-drop-link">browse your computer</span></strong>
524
+ <span className="muted small">JPG, PNG or GIF up to 36 MB · MP4 video up to 300 MB</span>
525
+ </>
526
+ )}
527
+ </div>
528
+ )}
529
+ {/* Outside the drop area, so its click doesn't bubble back into the area's own click */}
530
+ <input ref={fileInput} type="file" accept={UPLOAD_ACCEPT} hidden onChange={(e) => {
531
+ const file = e.target.files?.[0];
532
+ e.target.value = ""; // the same file can be picked again
533
+ if (file) void upload(file);
534
+ }} />
535
+ <input className="input input-sm search" placeholder="Search by title" value={q}
536
+ onChange={(e) => setQ(e.target.value)} aria-label="Search" />
537
+ {error && <p className="error-text">{error}</p>}
538
+ {!items && !error && (
539
+ <div className={`lp-loading${fill ? " fill" : ""}`}><Loading label="Loading your Library" /></div>
540
+ )}
541
+ {items && shown.length === 0 && (
542
+ <p className="muted lp-pick-empty">{kind === "kits" ? "No launch kits yet: create a new one."
543
+ : kind === "uploads" ? "Nothing uploaded yet." : "Nothing to attach here yet."}</p>
544
+ )}
545
+ <ul className={`lk-rows lp-pick-rows${fill ? " lp-pick-rows-fill" : ""}`}>
546
+ {shown.map((a) => {
547
+ const kit = a.type === "launch_kit";
548
+ const go = () => (a.needs_render || a.rendering ? setRendering(a) : kit && onPickKit ? onPickKit(a) : onPick(a));
549
+ if (renaming?.id === a.id) {
550
+ return (
551
+ <li key={a.id}>
552
+ <form className="lk-row lp-rename-row" onSubmit={(e) => {
553
+ e.preventDefault();
554
+ void saveName();
555
+ }}>
556
+ <Thumb a={a} />
557
+ <input className="input input-sm" autoFocus value={renaming.title} maxLength={200}
558
+ aria-label="Name" disabled={renaming.busy}
559
+ onChange={(e) => setRenaming({ ...renaming, title: e.target.value })}
560
+ onKeyDown={(e) => e.key === "Escape" && (e.stopPropagation(), setRenaming(null))} />
561
+ <button type="submit" className="btn btn-small btn-primary" disabled={renaming.busy}>
562
+ {renaming.busy ? "Saving..." : "Save"}
563
+ </button>
564
+ <button type="button" className="btn btn-small" disabled={renaming.busy} onClick={() => setRenaming(null)}>
565
+ Cancel
566
+ </button>
567
+ </form>
568
+ </li>
569
+ );
570
+ }
571
+ return (
572
+ <li key={a.id} className="lp-pick-item">
573
+ <button type="button" className="lk-row lp-pick-row" onClick={go}
574
+ title={a.rendering ? "Rendering: it can be scheduled once the MP4 is made"
575
+ : a.needs_render ? "Render it, then post it" : undefined}>
576
+ <Thumb a={a} />
577
+ <span className="lk-title">
578
+ <strong>{a.title}</strong>
579
+ <span className="muted small">
580
+ {kit ? "LinkedIn post and hooks ready to schedule" : mediaLabel(a)}
581
+ {a.created_at ? ` · ${formatDate(a.created_at)}` : ""}
582
+ </span>
583
+ </span>
584
+ <span className={`lp-tag${a.media === "video" ? " video" : kit ? " kit" : ""}`}>
585
+ {a.type === "upload" ? "Upload" : a.media === "video" ? "Video" : kit ? "Launch Kit" : a.type_label}
586
+ </span>
587
+ <span className="lp-pick-go">
588
+ {a.rendering ? (
589
+ <><span className="vw-spin small" aria-hidden="true" /> Rendering</>
590
+ ) : (
591
+ <>{a.needs_render ? "Render it" : kit && onPickKit ? "Open kit" : "Schedule"} <ArrowRightIcon size={14} /></>
592
+ )}
593
+ </span>
594
+ </button>
595
+ <RowMenu label={a.title} items={[
596
+ ...(a.view_url ? [{ label: "Preview", onSelect: () => setPreviewing(a) }] : []),
597
+ ...(a.type === "upload" ? [{ label: "Rename", onSelect: () => setRenaming({ id: a.id, title: a.title }) }] : []),
598
+ ...(kit ? [{ label: "Open kit", onSelect: () => (onPickKit ? onPickKit(a) : navigate(`/app/launchpad/kits/${a.id}`)) }]
599
+ : a.editable_video ? [{ label: "Open", onSelect: () => navigate(`/app/videos/${a.id}`) }] : []),
600
+ { label: kit ? "Delete kit" : "Delete", danger: true, onSelect: () => {
601
+ setDeleteError(null);
602
+ setDeleting(a);
603
+ } },
604
+ ]} />
605
+ </li>
606
+ );
607
+ })}
608
+ </ul>
609
+ {rendering && <RenderVideoModal video={rendering} onClose={() => setRendering(null)} onRendered={rendered} />}
610
+ {previewing && <MediaPreviewModal a={previewing} onClose={() => setPreviewing(null)} />}
611
+ {deleting && (
612
+ <Modal title={deleteWording(deleting).title} onClose={() => !deleteBusy && setDeleting(null)}>
613
+ <div className="stack">
614
+ <p><strong>{deleting.title}</strong></p>
615
+ <p className="muted">{deleteWording(deleting).body}</p>
616
+ {deleteError && <p className="error-text">{deleteError}</p>}
617
+ <div className="row end">
618
+ <button type="button" className="btn" disabled={deleteBusy} onClick={() => setDeleting(null)}>Cancel</button>
619
+ <button type="button" className="btn btn-danger" disabled={deleteBusy} onClick={deleteItem}>
620
+ {deleteBusy ? "Deleting..." : "Delete"}
621
+ </button>
622
+ </div>
623
+ </div>
624
+ </Modal>
625
+ )}
626
+ </div>
627
+ );
628
+ }
frontend/src/components/launchpad/PostButton.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { Artifact } from "../../api/types";
3
+ import { ScheduleModal } from "../ScheduleModal";
4
+
5
+ /** What can go out on X / LinkedIn: images, videos and text. Not audio (neither platform takes audio files). */
6
+ const POSTABLE = new Set(["video", "quote_card", "carousel", "summary", "launch_kit", "mind_map"]);
7
+
8
+ export function canPost(a: Artifact): boolean {
9
+ if (!POSTABLE.has(a.type)) return false;
10
+ if (a.provider === "blog2video") return Boolean((a.content as { video_url?: string } | null)?.video_url);
11
+ return a.status === "ready";
12
+ }
13
+
14
+ /** "Post": opens the composer with this item attached and its text filled in. */
15
+ export function PostButton({ artifactId, className = "btn btn-small", label = "Post" }: {
16
+ artifactId: string;
17
+ className?: string;
18
+ label?: string;
19
+ }) {
20
+ const [open, setOpen] = useState(false);
21
+ return (
22
+ <>
23
+ <button type="button" className={className} onClick={() => setOpen(true)}>{label}</button>
24
+ {open && <ScheduleModal platform="linkedin" posts={[]} artifactId={artifactId} onClose={() => setOpen(false)} />}
25
+ </>
26
+ );
27
+ }
frontend/src/components/launchpad/SlotPicker.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ /** A post's time as the user picked it: their own wall clock, "2026-10-07T11:00", always on :00 or :30. The backend
4
+ * reads it in their time zone (userTimeZone) and stores UTC. */
5
+ export type Wall = string;
6
+
7
+ const pad = (n: number) => String(n).padStart(2, "0");
8
+ const dayKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
9
+
10
+ export const userTimeZone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
11
+
12
+ export function toWall(d: Date): Wall {
13
+ return `${dayKey(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
14
+ }
15
+
16
+ /** The wall time as a Date in this browser (which is in the user's zone). */
17
+ export function wallToDate(w: Wall): Date {
18
+ const [day, time] = w.split("T");
19
+ const [y, m, d] = day.split("-").map(Number);
20
+ const [h, min] = time.split(":").map(Number);
21
+ return new Date(y, m - 1, d, h, min);
22
+ }
23
+
24
+ /** The next half hour from now (11:07 -> 11:30). */
25
+ export function nextSlot(from = new Date()): Date {
26
+ const d = new Date(from);
27
+ d.setSeconds(0, 0);
28
+ d.setMinutes(d.getMinutes() < 30 ? 30 : 60);
29
+ return d;
30
+ }
31
+
32
+ export const onSlot = (d: Date) => d.getMinutes() % 30 === 0 && d.getSeconds() === 0;
33
+
34
+ /** Weeks start on Monday: the 6 weeks around the month. */
35
+ export function monthGrid(month: Date): Date[] {
36
+ const first = new Date(month.getFullYear(), month.getMonth(), 1);
37
+ const offset = (first.getDay() + 6) % 7;
38
+ return Array.from({ length: 42 }, (_, i) => new Date(first.getFullYear(), first.getMonth(), 1 - offset + i));
39
+ }
40
+
41
+ /** "GMT+5" for the zone right now. */
42
+ function zoneOffset(): string {
43
+ const part = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }).formatToParts(new Date())
44
+ .find((p) => p.type === "timeZoneName");
45
+ return part?.value ?? "";
46
+ }
47
+
48
+ const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
49
+ const SLOTS = Array.from({ length: 48 }, (_, i) => `${pad(Math.floor(i / 2))}:${i % 2 ? "30" : "00"}`);
50
+
51
+ /** When a post goes out: a day on a small month calendar and one of the day's 48 half hours, in the user's own time
52
+ * zone (named under it). Past days and past slots are disabled. */
53
+ export function SlotPicker({ value, onChange }: { value: Wall; onChange: (w: Wall) => void }) {
54
+ const picked = wallToDate(value);
55
+ const [open, setOpen] = useState(false);
56
+ const [month, setMonth] = useState(() => new Date(picked.getFullYear(), picked.getMonth(), 1));
57
+ const days = useMemo(() => monthGrid(month), [month]);
58
+ const now = new Date();
59
+ const today = dayKey(now);
60
+ const pickedDay = value.split("T")[0];
61
+ const pickedTime = value.split("T")[1];
62
+ const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
63
+ const times = useRef<HTMLDivElement>(null);
64
+ // Opened: the time list scrolls (itself only) to the picked slot.
65
+ useEffect(() => {
66
+ const list = times.current;
67
+ const on = list?.querySelector<HTMLElement>(".slot-time.on");
68
+ if (list && on) list.scrollTop = on.offsetTop - list.clientHeight / 2 + on.clientHeight / 2;
69
+ }, [open, pickedDay]);
70
+
71
+ const slotDate = (day: string, time: string) => wallToDate(`${day}T${time}`);
72
+ /** A day keeps its slot when it's still ahead there, else takes the day's first one still ahead. */
73
+ const pickDay = (d: Date) => {
74
+ const day = dayKey(d);
75
+ const time = slotDate(day, pickedTime) > now ? pickedTime : SLOTS.find((t) => slotDate(day, t) > now);
76
+ if (time) onChange(`${day}T${time}`);
77
+ };
78
+
79
+ const label = picked.toLocaleString(undefined, { weekday: "short", month: "short", day: "numeric", hour: "numeric",
80
+ minute: "2-digit" });
81
+ return (
82
+ <div className="slot-picker">
83
+ <button type="button" className="input input-sm slot-trigger" aria-expanded={open} onClick={() => setOpen(!open)}>
84
+ {label}
85
+ </button>
86
+ <span className="muted small">Your time zone: {userTimeZone()}{zoneOffset() ? ` (${zoneOffset()})` : ""}</span>
87
+ {open && (
88
+ <div className="slot-panel">
89
+ <div className="slot-cal">
90
+ <div className="slot-cal-head">
91
+ <button type="button" className="btn btn-small" aria-label="Previous month"
92
+ disabled={month <= thisMonth}
93
+ onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}>‹</button>
94
+ <strong>{month.toLocaleDateString(undefined, { month: "long", year: "numeric" })}</strong>
95
+ <button type="button" className="btn btn-small" aria-label="Next month"
96
+ onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}>›</button>
97
+ </div>
98
+ <div className="slot-days" role="grid">
99
+ {WEEKDAYS.map((w, i) => <span key={i} className="slot-weekday muted small">{w}</span>)}
100
+ {days.map((d) => {
101
+ const key = dayKey(d);
102
+ const past = key < today;
103
+ return (
104
+ <button key={key} type="button" disabled={past}
105
+ className={`slot-day${d.getMonth() !== month.getMonth() ? " out" : ""}${key === today ? " today" : ""}${key === pickedDay ? " on" : ""}`}
106
+ aria-pressed={key === pickedDay} onClick={() => pickDay(d)}>
107
+ {d.getDate()}
108
+ </button>
109
+ );
110
+ })}
111
+ </div>
112
+ </div>
113
+ <div className="slot-times" role="listbox" aria-label="Time" ref={times}>
114
+ {SLOTS.map((t) => {
115
+ const at = slotDate(pickedDay, t);
116
+ const on = t === pickedTime;
117
+ return (
118
+ <button key={t} type="button" role="option" aria-selected={on} disabled={at <= now}
119
+ className={`slot-time${on ? " on" : ""}`}
120
+ onClick={() => {
121
+ onChange(`${pickedDay}T${t}`);
122
+ setOpen(false);
123
+ }}>
124
+ {at.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
125
+ </button>
126
+ );
127
+ })}
128
+ </div>
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
frontend/src/components/ui.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
  import { createPortal } from "react-dom";
 
3
  import { ApiError } from "../api/client";
4
  import type { Job } from "../api/types";
5
 
@@ -7,6 +8,24 @@ export function errorMessage(e: unknown, fallback = "Something went wrong. Try a
7
  return e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : fallback;
8
  }
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  /** Launch progress bar for a job. */
11
  export function JobProgress({ job, compact = false }: { job: Job; compact?: boolean }) {
12
  const pct = Math.round((job.status === "done" ? 1 : job.progress) * 100);
@@ -51,7 +70,12 @@ export function Modal({
51
  }) {
52
  const ref = useRef<HTMLDivElement>(null);
53
  useEffect(() => {
54
- const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
 
 
 
 
 
55
  document.addEventListener("keydown", onKey);
56
  ref.current?.focus();
57
  return () => document.removeEventListener("keydown", onKey);
@@ -213,8 +237,12 @@ export function EmptyState({ title, body, action }: { title: string; body?: stri
213
  );
214
  }
215
 
216
- export function Loading({ label = "Loading" }: { label?: string }) {
217
- return (
 
 
 
 
218
  <div className="loading mono muted" role="status">
219
  <div className="orbit-loader">
220
  <span />
@@ -222,17 +250,36 @@ export function Loading({ label = "Loading" }: { label?: string }) {
222
  {label}...
223
  </div>
224
  );
 
225
  }
226
 
227
  export function StatusPill({ status }: { status: string }) {
228
- const tone = ["ready", "ok", "done", "posted", "reminded"].includes(status)
229
  ? "ok"
230
  : ["failed", "error"].includes(status)
231
  ? "bad"
232
- : "busy";
 
 
233
  return <span className={`pill pill-${tone} mono`}>{status}</span>;
234
  }
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  export function PageHeader({ eyebrow, title, children }: { eyebrow: string; title: string; children?: ReactNode }) {
237
  return (
238
  <header className="page-head">
 
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
  import { createPortal } from "react-dom";
3
+ import { useLocation, useNavigate } from "react-router-dom";
4
  import { ApiError } from "../api/client";
5
  import type { Job } from "../api/types";
6
 
 
8
  return e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : fallback;
9
  }
10
 
11
+ /** Matches blog2video.NOT_RESPONDING on the backend: the video service is down or redeploying. */
12
+ export const VIDEO_UPDATING = "The video service is being updated";
13
+
14
+ /**
15
+ * An inline error. The "video service is being updated" message is a status, not a mistake: it is shown centered
16
+ * and in bold instead, and with `page` it fills the page under the header.
17
+ */
18
+ export function ErrorText({ message, page = false }: { message: string; page?: boolean }) {
19
+ if (message.startsWith(VIDEO_UPDATING)) {
20
+ return (
21
+ <div className={`service-notice${page ? " page" : ""}`} role="status">
22
+ <strong>{message}</strong>
23
+ </div>
24
+ );
25
+ }
26
+ return <p className="error-text">{message}</p>;
27
+ }
28
+
29
  /** Launch progress bar for a job. */
30
  export function JobProgress({ job, compact = false }: { job: Job; compact?: boolean }) {
31
  const pct = Math.round((job.status === "done" ? 1 : job.progress) * 100);
 
70
  }) {
71
  const ref = useRef<HTMLDivElement>(null);
72
  useEffect(() => {
73
+ const onKey = (e: KeyboardEvent) => {
74
+ if (e.key !== "Escape") return;
75
+ // A modal opened from a modal (a picker over the composer): Escape closes only the top one.
76
+ const open = document.querySelectorAll(".modal-backdrop");
77
+ if (open[open.length - 1] === ref.current?.parentElement) onClose();
78
+ };
79
  document.addEventListener("keydown", onKey);
80
  ref.current?.focus();
81
  return () => document.removeEventListener("keydown", onKey);
 
237
  );
238
  }
239
 
240
+ /**
241
+ * Spinner with its label underneath, centered. `center="page"` puts it in the middle of the space under a page
242
+ * header; `center="full"` in the middle of the window, for a page that has no header yet.
243
+ */
244
+ export function Loading({ label = "Loading", center }: { label?: string; center?: "page" | "full" }) {
245
+ const loader = (
246
  <div className="loading mono muted" role="status">
247
  <div className="orbit-loader">
248
  <span />
 
250
  {label}...
251
  </div>
252
  );
253
+ return center ? <div className={`loading-center${center === "full" ? " full" : ""}`}>{loader}</div> : loader;
254
  }
255
 
256
  export function StatusPill({ status }: { status: string }) {
257
+ const tone = ["ready", "ok", "done", "posted", "reminded", "connected"].includes(status)
258
  ? "ok"
259
  : ["failed", "error"].includes(status)
260
  ? "bad"
261
+ : ["paused", "expired", "revoked", "disconnected"].includes(status)
262
+ ? "warn"
263
+ : "busy";
264
  return <span className={`pill pill-${tone} mono`}>{status}</span>;
265
  }
266
 
267
+ /** An arrow back to exactly the page before (browser history); `fallback` when the page was opened directly. */
268
+ export function BackArrow({ fallback, label = "Back" }: { fallback: string; label?: string }) {
269
+ const navigate = useNavigate();
270
+ const location = useLocation();
271
+ return (
272
+ <button type="button" className="back-link page-back"
273
+ onClick={() => (location.key !== "default" ? navigate(-1) : navigate(fallback))}>
274
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"
275
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
276
+ <path d="M19 12H5m6-6-6 6 6 6" />
277
+ </svg>
278
+ {label}
279
+ </button>
280
+ );
281
+ }
282
+
283
  export function PageHeader({ eyebrow, title, children }: { eyebrow: string; title: string; children?: ReactNode }) {
284
  return (
285
  <header className="page-head">
frontend/src/components/video/EditorPanels.tsx CHANGED
@@ -7,7 +7,7 @@ import type {
7
  VideoScene,
8
  } from "../../api/types";
9
  import { ChevronIcon, TrashIcon } from "../icons/Icons";
10
- import { ConfirmButton, errorMessage } from "../ui";
11
  import { Premium } from "./parts";
12
  import { DeleteAssetModal, RemoveMediaModal } from "./SceneMedia";
13
  import { Voiceovers } from "./Voiceovers";
@@ -77,7 +77,7 @@ export function ScriptPanel(p: PanelProps) {
77
  Try again
78
  </button>
79
  </div>
80
- {error && <p className="error-text">{error}</p>}
81
  {preview && <pre className="vw-pre">{JSON.stringify(preview, null, 2)}</pre>}
82
  </section>
83
  );
@@ -137,7 +137,7 @@ export function ChatPanel(p: PanelProps) {
137
  {lines.length === 0 && <p className="muted">Ask for changes, like "make scene 2 shorter" or "use warmer colors".</p>}
138
  {lines.map((l, i) => <p key={i} className={`vw-chat-line ${l.role}`}>{l.text}</p>)}
139
  </div>
140
- {error && <p className="error-text">{error}</p>}
141
  <div className="row">
142
  <input className="input" value={message} maxLength={4000} disabled={busy || p.disabled} placeholder="Ask for a change"
143
  onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
@@ -247,7 +247,7 @@ export function FramesPanel(p: PanelProps) {
247
  <button className="btn btn-small" disabled={busy} onClick={load}>{busy ? "Rendering..." : "Show frame"}</button>
248
  {src && <a className="btn btn-small" href={src} download={`frame-${frame}.png`}>Download</a>}
249
  </div>
250
- {error && <p className="error-text">{error}</p>}
251
  {src && <img className="vw-still" src={src} alt={`Frame ${frame}`} />}
252
  </section>
253
  );
 
7
  VideoScene,
8
  } from "../../api/types";
9
  import { ChevronIcon, TrashIcon } from "../icons/Icons";
10
+ import { ConfirmButton, ErrorText, errorMessage } from "../ui";
11
  import { Premium } from "./parts";
12
  import { DeleteAssetModal, RemoveMediaModal } from "./SceneMedia";
13
  import { Voiceovers } from "./Voiceovers";
 
77
  Try again
78
  </button>
79
  </div>
80
+ {error && <ErrorText message={error} />}
81
  {preview && <pre className="vw-pre">{JSON.stringify(preview, null, 2)}</pre>}
82
  </section>
83
  );
 
137
  {lines.length === 0 && <p className="muted">Ask for changes, like "make scene 2 shorter" or "use warmer colors".</p>}
138
  {lines.map((l, i) => <p key={i} className={`vw-chat-line ${l.role}`}>{l.text}</p>)}
139
  </div>
140
+ {error && <ErrorText message={error} />}
141
  <div className="row">
142
  <input className="input" value={message} maxLength={4000} disabled={busy || p.disabled} placeholder="Ask for a change"
143
  onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
 
247
  <button className="btn btn-small" disabled={busy} onClick={load}>{busy ? "Rendering..." : "Show frame"}</button>
248
  {src && <a className="btn btn-small" href={src} download={`frame-${frame}.png`}>Download</a>}
249
  </div>
250
+ {error && <ErrorText message={error} />}
251
  {src && <img className="vw-still" src={src} alt={`Frame ${frame}`} />}
252
  </section>
253
  );