File size: 32,083 Bytes
34b0226
 
 
bdd1640
34b0226
 
 
 
 
bdd1640
34b0226
 
 
 
 
 
 
 
 
 
 
bdd1640
34b0226
fa6e4a3
34b0226
 
bdd1640
fa6e4a3
 
 
 
 
 
 
 
 
 
 
bdd1640
34b0226
 
 
 
 
 
 
 
 
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
fa6e4a3
34b0226
bdd1640
 
34b0226
 
 
 
fa6e4a3
 
34b0226
 
bdd1640
 
fa6e4a3
34b0226
 
 
bdd1640
34b0226
 
 
 
 
 
bdd1640
fa6e4a3
 
 
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa6e4a3
bdd1640
 
 
 
fa6e4a3
 
 
 
 
 
 
 
 
 
bdd1640
 
 
 
 
 
fa6e4a3
 
 
 
bdd1640
 
 
 
fa6e4a3
 
 
bdd1640
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
bdd1640
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa6e4a3
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa6e4a3
bdd1640
 
34b0226
 
bdd1640
fa6e4a3
bdd1640
 
 
 
 
34b0226
bdd1640
34b0226
bdd1640
34b0226
 
fa6e4a3
34b0226
 
 
fa6e4a3
 
34b0226
 
 
fa6e4a3
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
bdd1640
 
 
34b0226
 
 
 
bdd1640
 
34b0226
 
 
 
 
 
 
bdd1640
 
 
 
 
fa6e4a3
 
bdd1640
fa6e4a3
bdd1640
 
 
 
34b0226
 
fa6e4a3
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
fa6e4a3
 
 
34b0226
 
 
 
 
 
fa6e4a3
34b0226
 
 
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa6e4a3
 
 
 
bdd1640
fa6e4a3
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
fa6e4a3
 
 
 
 
 
 
 
 
 
 
 
bdd1640
 
 
34b0226
 
 
 
 
 
bdd1640
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdd1640
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdd1640
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
"""Launchpad: the calendar, connected social accounts (OAuth / app password) and tracked short links."""

import json
import re
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from typing import Literal
from urllib.parse import urlencode
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.auth import Ctx, decode_token_full, encode_signed, get_ctx
from app.config import settings
from app.db import get_db
from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
from app.services import launchpad as lp
from app.services.artifacts import TYPE_LABELS, download_name
from app.services.crypto import decrypt, encrypt
from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
from app.services.social.health import pause_account_posts, resume_account_posts
from app.services.social.media import (
    MB,
    SIZE_LIMITS,
    UPLOAD_TYPES,
    carries_media,
    check_postable,
    duration,
    is_blog2video,
    media_count,
    upload_kind,
)
from app.services.storage import storage

router = APIRouter(prefix="/api", tags=["launchpad"])

Platform = Literal["x", "linkedin", "bluesky", "substack_notes"]


# Calendar


class When(BaseModel):
    """When a post goes out: the wall time the user picked in their own time zone (local_time "2026-10-07T11:00" +
    timezone "Asia/Karachi", on a half hour), converted to UTC here; or an exact scheduled_at (Post now)."""
    scheduled_at: datetime | None = None
    local_time: str | None = Field(None, max_length=32)
    timezone: str | None = Field(None, max_length=64)

    def utc(self) -> datetime | None:
        if self.local_time:
            try:
                zone = ZoneInfo(self.timezone or "")
            except (ZoneInfoNotFoundError, ValueError) as exc:
                raise HTTPException(400, {"code": "bad_timezone", "message": "Unknown time zone."}) from exc
            try:
                wall = datetime.fromisoformat(self.local_time)
            except ValueError as exc:
                raise HTTPException(400, {"code": "bad_time", "message": "Pick a date and time."}) from exc
            if wall.tzinfo or wall.minute not in (0, 30) or wall.second or wall.microsecond:
                raise HTTPException(400, {"code": "half_hour_only", "message": "Pick a time on the hour or half hour."})
            return wall.replace(tzinfo=zone).astimezone(UTC)
        return _aware(self.scheduled_at) if self.scheduled_at else None


class ItemIn(When):
    platform: Platform
    content: str = Field("", max_length=10000)  # may be empty when the post carries images or a video
    thread: list[str] = Field(default_factory=list, max_length=25)
    artifact_id: uuid.UUID | None = None  # the attachment (a video, quote card, carousel...), never a Launch Kit
    kit_id: uuid.UUID | None = None  # the Launch Kit the post was written from (tracked, not attached)
    document_id: uuid.UUID | None = None
    social_account_id: uuid.UUID | None = None
    remind_by_email: bool = False
    draft: bool = False
    # Post now: published (or, by email, reminded) in this request, never left scheduled; no time needed
    publish_now: bool = False


class ItemPatch(When):
    artifact_id: uuid.UUID | None = None  # set or (null) clear what the post carries
    content: str | None = Field(None, max_length=10000)
    thread: list[str] | None = None
    social_account_id: uuid.UUID | None = None
    remind_by_email: bool | None = None
    status: Literal["scheduled", "draft", "paused"] | None = None


def _aware(dt: datetime) -> datetime:
    return dt if dt.tzinfo else dt.replace(tzinfo=UTC)


# audio can't be posted; "upload" is a file the user brought from their computer
POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload", "slide_deck")
# Listed on the Launchpad too, but only to download: X and LinkedIn take no audio files (check_postable refuses it)
LISTED = (*POSTABLE, "audio_overview")


def _cite_free(text: str) -> str:
    return re.sub(r"\s*\[\d+\]", "", text or "").strip()


def _prefill(a: Artifact) -> dict:
    """A starting text per platform for a post of this artifact: X as a list of posts (a thread), LinkedIn as one."""
    c = a.content_json or {}
    title = c.get("title") or TYPE_LABELS.get(a.type, a.type)
    if a.type == "upload":  # their own file: the words are theirs to write
        return {"x": [""], "linkedin": ""}
    if a.type == "launch_kit":
        posts = c.get("posts") or {}
        x_posts = posts.get("x_thread") or posts.get("x") or [title]
        return {"x": x_posts, "linkedin": "\n\n".join(posts.get("linkedin") or x_posts)}
    text = {
        "summary": _cite_free(c.get("summary") or title),
        "quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
                      else title,
        "carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
        "slide_deck": _deck_prefill(c) or title,
    }.get(a.type, title)
    return {"x": [text], "linkedin": text}


def _deck_prefill(c: dict) -> str:
    """A slide deck's post: its title, its subtitle, and the takeaways of its closing slide."""
    deck = c.get("deck") or {}
    slides = deck.get("slides") or []
    closing = next((s for s in reversed(slides) if s.get("layout") == "closing"), {})
    head = "\n\n".join(x for x in (deck.get("title"), deck.get("subtitle")) if x)
    takeaways = "\n".join(f"- {t}" for t in closing.get("takeaways") or [])
    return "\n\n".join(x for x in (head, takeaways) if x)


def artifact_brief(a: Artifact) -> dict:
    """What the composer and the calendar show of the attached artifact."""
    c = a.content_json or {}
    thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
             else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
    return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
            # A deck's title starts "Slide deck: ", which its tag already says
            "title": (c.get("title") or TYPE_LABELS.get(a.type, a.type)).removeprefix("Slide deck: " if a.type == "slide_deck" else ""),
            "media": upload_kind(a) or ("video" if a.type == "video" else "audio" if a.type == "audio_overview"
                                        else "image" if media_count(a) else "text"),
            "media_count": media_count(a), "duration_s": duration(a),
            "thumb_url": storage.presign_get(thumb) if thumb else None,
            # The user's own file, to preview before posting it.
            "view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
            # An audio overview can't be posted: it is offered to download instead.
            "download_url": (storage.presign_get(a.storage_key, download_name=download_name(a))
                             if a.type == "audio_overview" and a.storage_key else None),
            # A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
            # Made by the video service (blog2video): it opens in the video editor.
            "editable_video": is_blog2video(a),
            "needs_render": is_blog2video(a) and not (a.content_json or {}).get("video_url"),
            # Its MP4 is being made right now: it can't be scheduled until the render finishes.
            "rendering": is_blog2video(a) and a.status == "rendering",
            "created_at": a.created_at.isoformat() if a.created_at else None, "prefill": _prefill(a)}


def serialize_item(item: CalendarItem, account: SocialAccount | None = None, clicks: int = 0,
                   artifact: Artifact | None = None, kit: Artifact | None = None) -> dict:
    return {
        "id": str(item.id),
        "platform": item.platform,
        "platform_label": PLATFORM_LABELS.get(item.platform, item.platform),
        "scheduled_at": _aware(item.scheduled_at).isoformat(),
        "status": item.status,
        "content": item.content,
        "thread": item.thread or [],
        "artifact_id": str(item.artifact_id) if item.artifact_id else None,
        "artifact": artifact_brief(artifact) if artifact else None,
        "kit_id": str(item.kit_id) if item.kit_id else None,
        "kit_title": ((kit.content_json or {}).get("post_title") or (kit.content_json or {}).get("title")) if kit else None,
        "document_id": str(item.document_id) if item.document_id else None,
        "social_account_id": str(item.social_account_id) if item.social_account_id else None,
        "account_handle": account.handle if account else None,
        "remind_by_email": item.remind_by_email,
        "auto_post": item.platform in AUTO_POST and bool(item.social_account_id) and not item.remind_by_email,
        "external_url": item.external_url,
        "posted_at": _aware(item.posted_at).isoformat() if item.posted_at else None,
        "error": item.error,
        "metrics": item.metrics or {},
        "clicks": clicks,
    }


def _item(ctx: Ctx, item_id: uuid.UUID) -> CalendarItem:
    item = ctx.db.scalar(select(CalendarItem).where(CalendarItem.id == item_id,
                                                    CalendarItem.workspace_id == ctx.workspace.id))
    if not item:
        raise HTTPException(404, "Calendar item not found")
    return item


def _account(ctx: Ctx, account_id: uuid.UUID | None, platform: str) -> SocialAccount | None:
    if not account_id:
        return None
    account = ctx.db.scalar(select(SocialAccount).where(SocialAccount.id == account_id,
                                                        SocialAccount.workspace_id == ctx.workspace.id))
    if not account or account.platform != platform:
        raise HTTPException(400, "That account does not match the platform")
    return account


CONNECTED_ONLY = ("x", "linkedin")  # these post only through a live connection (no email fallback)


def can_post_media(a: SocialAccount) -> bool:
    return {"x": x.can_post_media, "linkedin": linkedin.can_post_media}.get(a.platform, lambda _: False)(a)


def _require_connected(platform: str, account: SocialAccount | None, artifact: Artifact | None) -> None:
    """X and LinkedIn: scheduling needs a live connection (and one allowed to post media, when media is attached)."""
    if platform not in CONNECTED_ONLY:
        return
    label = PLATFORM_LABELS[platform]
    if not account or account.status != "active":
        raise HTTPException(409, {"code": "not_connected",
                                  "message": f"Connect {label} to schedule posts there."
                                  if not account else f"Your {label} connection is down. Reconnect {label} first."})
    if artifact and media_count(artifact) and not can_post_media(account):
        raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})


def _require_words(posts: list[str], artifact: Artifact | None) -> None:
    """A post needs words, unless it carries images or a video (those can go out without a caption)."""
    if not any(p.strip() for p in posts) and not carries_media(artifact):
        raise HTTPException(422, {"code": "no_text", "message": "Write something to post, or attach an image or video."})


def _validate_lengths(platform: str, posts: list[str]) -> None:
    limit = LIMITS.get(platform)
    if platform in ("x", "bluesky") and limit:
        for i, p in enumerate(posts, start=1):
            if len(p) > limit:
                raise HTTPException(422, {"code": "too_long",
                                          "message": f"Post {i} is {len(p)} characters; {PLATFORM_LABELS[platform]} "
                                                     f"allows {limit}."})


def _enrich(ctx: Ctx, items: list[CalendarItem]) -> list[dict]:
    accounts = {a.id: a for a in ctx.db.scalars(
        select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id))}
    clicks = lp.clicks_by_item(ctx.db, [i.id for i in items])
    ids = {i.artifact_id for i in items if i.artifact_id} | {i.kit_id for i in items if i.kit_id}
    artifacts = {a.id: a for a in ctx.db.scalars(select(Artifact).where(Artifact.id.in_(ids)))} if ids else {}
    return [serialize_item(i, accounts.get(i.social_account_id), clicks.get(i.id, 0), artifacts.get(i.artifact_id),
                           artifacts.get(i.kit_id)) for i in items]


def _kit(ctx: Ctx, kit_id: uuid.UUID) -> Artifact:
    """This workspace's Launch Kit (what a post was written from)."""
    kit = ctx.db.scalar(select(Artifact).where(Artifact.id == kit_id, Artifact.workspace_id == ctx.workspace.id,
                                               Artifact.type == "launch_kit"))
    if not kit:
        raise HTTPException(404, "Launch Kit not found")
    return kit


def _postable(ctx: Ctx, artifact_id: uuid.UUID, platform: str) -> Artifact:
    """This workspace's artifact, checked for the platform (audio, unfinished, too long for X: 400)."""
    a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
    if not a:
        raise HTTPException(404, "Artifact not found")
    check_postable(a, platform)
    return a


@router.get("/calendar")
def list_items(ctx: Ctx = Depends(get_ctx), start: datetime | None = None, end: datetime | None = None,
               status: str | None = None, limit: int = 500):
    q = select(CalendarItem).where(CalendarItem.workspace_id == ctx.workspace.id)
    if start:
        q = q.where(CalendarItem.scheduled_at >= start)
    if end:
        q = q.where(CalendarItem.scheduled_at < end)
    if status:
        q = q.where(CalendarItem.status.in_(status.split(",")))
    items = list(ctx.db.scalars(q.order_by(CalendarItem.scheduled_at).limit(min(limit, 1000))).all())
    return _enrich(ctx, items)


@router.post("/calendar")
def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
    when = datetime.now(UTC) if body.publish_now else body.utc()
    if when is None:
        raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
    account = _account(ctx, body.social_account_id, body.platform)
    _validate_lengths(body.platform, [body.content, *body.thread])
    artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
    _require_words([body.content, *body.thread], artifact)
    kit = _kit(ctx, body.kit_id) if body.kit_id else None
    _require_connected(body.platform, account, artifact)
    if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
                                                                        Document.workspace_id == ctx.workspace.id)):
        raise HTTPException(404, "Document not found")
    item = CalendarItem(
        workspace_id=ctx.workspace.id, platform=body.platform, scheduled_at=when,
        content=body.content, thread=[t for t in body.thread if t.strip()], artifact_id=body.artifact_id,
        kit_id=kit.id if kit else None,
        document_id=body.document_id, social_account_id=account.id if account else None,
        remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
        status="draft" if body.draft else "publishing" if body.publish_now else "scheduled",
    )
    ctx.db.add(item)
    ctx.db.commit()
    if body.publish_now:
        _go_now(ctx, item)
    return _enrich(ctx, [item])[0]


def _go_now(ctx: Ctx, item: CalendarItem) -> None:
    """Publish an item claimed for it ("publishing") now: with images or a video through a job (uploading takes a
    while; the page follows its status), anything else right here."""
    if lp.has_media(ctx.db, item):
        lp.queue_publish(ctx.db, item)
    else:
        lp.publish_item(ctx.db, item)


@router.patch("/calendar/{item_id}")
def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
    item = _item(ctx, item_id)
    if item.status in ("posted", "publishing"):
        raise HTTPException(409, "This post already went out.")
    when = body.utc()
    if when is not None:
        item.scheduled_at = when
    if body.content is not None:
        item.content = body.content
    if body.thread is not None:
        item.thread = [t for t in body.thread if t.strip()]
    if "artifact_id" in body.model_fields_set:
        item.artifact_id = _postable(ctx, body.artifact_id, item.platform).id if body.artifact_id else None
    if "social_account_id" in body.model_fields_set:
        item.social_account_id = (_account(ctx, body.social_account_id, item.platform).id
                                  if body.social_account_id else None)
    if body.remind_by_email is not None:
        item.remind_by_email = body.remind_by_email
    if not item.social_account_id or item.platform not in AUTO_POST:
        item.remind_by_email = True
    account = ctx.db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
    artifact = ctx.db.get(Artifact, item.artifact_id) if item.artifact_id else None
    if body.status == "paused":
        item.status = "paused"  # held by hand: never attempted until resumed
    elif body.status is not None or item.status in ("failed", "reminded", "paused"):
        # Going out (again), rescheduled: only through a live connection, and only at a time still ahead (a failed or
        # reminded post's old time has passed, and it would otherwise go out at the very next run).
        _require_connected(item.platform, account, artifact)
        if (body.status or "scheduled") == "scheduled" and _aware(item.scheduled_at) <= datetime.now(UTC):
            raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
        item.status = body.status or "scheduled"
    elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
        _require_connected(item.platform, account, artifact)
    item.error = None
    item.attempts = 0
    _require_words([item.content or "", *(item.thread or [])], artifact)  # e.g. the caption emptied, or the video removed
    _validate_lengths(item.platform, [item.content, *(item.thread or [])])
    ctx.db.commit()
    return _enrich(ctx, [item])[0]


@router.delete("/calendar/{item_id}")
def delete_item(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
    item = _item(ctx, item_id)
    ctx.db.delete(item)
    ctx.db.commit()
    return {"ok": True}


@router.post("/calendar/{item_id}/publish")
def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
    """Publish a scheduled post now. A plain (threadpool) endpoint: its database calls and an inline publish must
    never hold up the event loop, and with it every other request."""
    item = _item(ctx, item_id)
    if item.status in ("posted", "publishing"):
        raise HTTPException(409, "This post already went out.")
    item.status = "publishing"
    item.attempts = 0
    ctx.db.commit()
    _go_now(ctx, item)
    return _enrich(ctx, [item])[0]


class UploadIn(BaseModel):
    upload_id: uuid.UUID
    duration_s: float | None = Field(None, ge=0, le=24 * 3600)  # a video's length, read by the browser


@router.post("/launchpad/uploads")
def register_upload(body: UploadIn, ctx: Ctx = Depends(get_ctx)):
    """A file the user uploaded from their computer (through /api/storage/uploads), made postable: it becomes an
    `upload` artifact, attached and posted like anything Notestack made, and offered again in the picker later."""
    upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
    if not upload:
        raise HTTPException(404, "Upload not found")
    if upload.status != "complete":
        raise HTTPException(409, "This file hasn't finished uploading.")
    kind = UPLOAD_TYPES.get(upload.content_type)
    if not kind:
        raise HTTPException(415, "LinkedIn takes JPG, PNG or GIF images and MP4 videos.")
    low, high = SIZE_LIMITS[("linkedin", kind)]
    size = upload.size_bytes or 0
    if size > high:
        raise HTTPException(413, f"This {kind} is {size / MB:.0f} MB; LinkedIn takes up to {high // MB} MB.")
    if size < low:
        raise HTTPException(400, f"This {kind} is too small for LinkedIn (at least {low // 1024} KB).")
    title = upload.filename.rsplit(".", 1)[0] if "." in upload.filename else upload.filename
    a = Artifact(workspace_id=ctx.workspace.id, type="upload", status="ready", storage_key=upload.key,
                 content_json={"title": title[:200] or "Upload", "filename": upload.filename,
                               "content_type": upload.content_type, "media": kind, "size_bytes": size,
                               **({"duration_s": body.duration_s} if body.duration_s else {})})
    ctx.db.add(a)
    ctx.db.commit()
    return artifact_brief(a)


@router.get("/launchpad/postable")
def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
    """What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps,
    slide decks, newest first, and audio overviews (listed to download: they can't be posted). Videos made but not
    rendered yet are listed too, flagged needs_render (they can only be posted once rendered), and so are videos being
    rendered, flagged rendering."""
    rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
                                                 Artifact.type.in_(LISTED))
                          .order_by(Artifact.created_at.desc()).limit(400))
    needle = q.strip().lower()
    out = []
    for a in rows:
        # A blog2video video counts once blog2video made it (ready), rendered or not; anything else once it is ready.
        # A blog2video video mid-render stays listed (flagged rendering).
        ready = a.status == "ready" or (is_blog2video(a) and (bool((a.content_json or {}).get("video_url"))
                                                              or a.status == "rendering"))
        title = ((a.content_json or {}).get("title") or "").lower()
        if ready and (not needle or needle in title):
            out.append(artifact_brief(a))
        if len(out) >= min(limit, 200):
            break
    # What each one already has coming up (scheduled, still ahead), soonest first: the list offers to reschedule it
    upcoming = ctx.db.scalars(select(CalendarItem).where(
        CalendarItem.workspace_id == ctx.workspace.id, CalendarItem.status == "scheduled",
        CalendarItem.scheduled_at > datetime.now(UTC),
        CalendarItem.artifact_id.in_([uuid.UUID(b["id"]) for b in out])).order_by(CalendarItem.scheduled_at))
    by_artifact: dict[str, list[dict]] = {}
    for it in upcoming:
        by_artifact.setdefault(str(it.artifact_id), []).append(
            {"id": str(it.id), "scheduled_at": _aware(it.scheduled_at).isoformat(),
             "platform_label": PLATFORM_LABELS.get(it.platform, it.platform)})
    for b in out:
        b["scheduled"] = by_artifact.get(b["id"], [])
    return out


# Social accounts


def serialize_account(a: SocialAccount) -> dict:
    return {"id": str(a.id), "platform": a.platform, "platform_label": PLATFORM_LABELS[a.platform],
            "handle": a.handle, "status": a.status, "avatar_url": a.avatar_url,
            "can_post_media": can_post_media(a),
            # No refresh token and the connection ends within a week (LinkedIn's 60 day tokens).
            "expires_soon": bool(a.expires_at and not a.refresh_token
                                 and _aware(a.expires_at) - datetime.now(UTC) < timedelta(days=7)),
            "expires_at": _aware(a.expires_at).isoformat() if a.expires_at else None,
            # Down (expired, revoked, disconnected), or unable to post images and videos.
            "needs_reconnect": a.status != "active" or (a.platform in CONNECTED_ONLY and not can_post_media(a)),
            "connected_at": a.created_at.isoformat() if a.created_at else None}


@router.get("/social/accounts")
def list_accounts(ctx: Ctx = Depends(get_ctx)):
    accounts = ctx.db.scalars(select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id)
                              .order_by(SocialAccount.created_at)).all()
    return {
        "accounts": [serialize_account(a) for a in accounts],
        "available": {"x": x.configured(), "linkedin": linkedin.configured(), "bluesky": True},
        "redirect_uris": {"x": x.redirect_uri(), "linkedin": linkedin.redirect_uri()},
    }


@router.delete("/social/accounts/{account_id}")
def disconnect(account_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
    account = ctx.db.scalar(select(SocialAccount).where(SocialAccount.id == account_id,
                                                        SocialAccount.workspace_id == ctx.workspace.id))
    if not account:
        raise HTTPException(404, "Account not found")
    # Kept (its posts stay linked to it), tokens gone, posts paused: reconnecting the same account resumes them.
    account.status = "revoked"
    account.access_token = ""  # no usable token kept
    account.refresh_token = None
    label = PLATFORM_LABELS[account.platform]
    pause_account_posts(ctx.db, account, f"{label} disconnected. Reconnect {label} to resume.")
    ctx.db.commit()
    return {"ok": True}


def _upsert_account(db: Session, workspace_id: uuid.UUID, platform: str, info: dict, secret: str,
                    refresh: str | None = None, expires_in: int | None = None, scopes: str | None = None) -> None:
    account = db.scalar(select(SocialAccount).where(SocialAccount.workspace_id == workspace_id,
                                                    SocialAccount.platform == platform,
                                                    SocialAccount.external_id == info["external_id"]))
    if not account:
        account = SocialAccount(workspace_id=workspace_id, platform=platform, external_id=info["external_id"],
                                handle=info["handle"], access_token="")
        db.add(account)
    account.handle = info["handle"]
    account.avatar_url = info.get("avatar_url")
    account.access_token = encrypt(secret)
    account.refresh_token = encrypt(refresh) if refresh else account.refresh_token
    account.expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in)) if expires_in else None
    account.scopes = scopes
    account.status = "active"
    resume_account_posts(db, account)  # its paused posts still ahead go back to scheduled
    db.commit()


class BlueskyIn(BaseModel):
    handle: str = Field(min_length=3, max_length=200)
    app_password: str = Field(min_length=8, max_length=100)


@router.post("/social/bluesky")
async def connect_bluesky(body: BlueskyIn, ctx: Ctx = Depends(get_ctx)):
    try:
        session = await run_in_threadpool(bluesky.create_session, body.handle, body.app_password)
    except SocialError as exc:
        raise HTTPException(400, {"code": "bluesky_auth", "message": str(exc)}) from exc
    _upsert_account(ctx.db, ctx.workspace.id, "bluesky",
                    {"external_id": session["did"], "handle": session.get("handle", body.handle)}, body.app_password)
    return list_accounts(ctx)


# OAuth (X, LinkedIn). The signed state carries the workspace and PKCE verifier; the callback hands a
# short lived encrypted ticket back to the SPA, which completes the link as the signed in user.

STATE_TTL = timedelta(minutes=10)


def _launchpad_redirect(**params: str) -> RedirectResponse:
    return RedirectResponse(f"{settings.frontend_url.rstrip('/')}/app/launchpad?{urlencode(params)}", 302)


@router.post("/social/{platform}/start")
def oauth_start(platform: Literal["x", "linkedin"], ctx: Ctx = Depends(get_ctx)):
    """Returns the provider URL. The SPA calls this with its bearer token, then navigates to `url`."""
    mod = x if platform == "x" else linkedin
    if not mod.configured():
        raise HTTPException(503, {"code": "not_configured",
                                  "message": f"Set {platform.upper()}_CLIENT_ID and {platform.upper()}_CLIENT_SECRET."})
    nonce = secrets.token_urlsafe(24)
    payload = {"typ": "social_state", "nonce": nonce, "ws": str(ctx.workspace.id), "platform": platform}
    if platform == "x":
        verifier, challenge = x.pkce_pair()
        payload["verifier"] = verifier
        state = encode_signed(payload, STATE_TTL)
        url = x.authorize_url(state, challenge)
    else:
        state = encode_signed(payload, STATE_TTL)
        url = linkedin.authorize_url(state)
    return {"url": url, "nonce": nonce}


@router.get("/social/{platform}/callback")
def oauth_callback(platform: Literal["x", "linkedin"], code: str | None = None, state: str | None = None,
                   error: str | None = None, db: Session = Depends(get_db)):
    label = PLATFORM_LABELS[platform]
    if error or not code or not state:
        return _launchpad_redirect(error=f"{label} connection was cancelled.")
    try:
        saved = decode_token_full(state, expected_type="social_state")
    except HTTPException:
        return _launchpad_redirect(error=f"{label} connection expired. Try again.")
    if saved.get("platform") != platform:
        return _launchpad_redirect(error=f"{label} connection failed.")
    try:
        if platform == "x":
            info = x.exchange_code(code, saved["verifier"])
        else:
            info = linkedin.exchange_code(code)
    except Exception:
        return _launchpad_redirect(error=f"{label} connection failed. Try again.")
    # Do not link here: this request carries no session. The signed in SPA finishes the link, and only
    # for the workspace that started it, so a forwarded authorize link cannot attach someone's account
    # to another workspace.
    ticket = encode_signed({"typ": "social_link", "ws": saved["ws"], "platform": platform,
                            "data": encrypt(json.dumps(info))}, timedelta(minutes=10))
    return _launchpad_redirect(link=ticket, platform=platform)


class LinkIn(BaseModel):
    ticket: str


@router.post("/social/complete")
def oauth_complete(body: LinkIn, ctx: Ctx = Depends(get_ctx)):
    try:
        saved = decode_token_full(body.ticket, expected_type="social_link")
    except HTTPException as exc:
        raise HTTPException(400, {"code": "expired", "message": "That connection expired. Try again."}) from exc
    if saved.get("ws") != str(ctx.workspace.id):
        raise HTTPException(403, {"code": "wrong_workspace", "message": "This connection was started elsewhere."})
    info = json.loads(decrypt(saved["data"]) or "{}")
    if not info.get("access_token"):
        raise HTTPException(400, {"code": "expired", "message": "That connection expired. Try again."})
    _upsert_account(ctx.db, ctx.workspace.id, saved["platform"], info, info["access_token"],
                    info.get("refresh_token"), info.get("expires_in"), info.get("scope"))
    return list_accounts(ctx)


# Tracked links (public)

links_router = APIRouter(tags=["links"])


@links_router.get("/l/{slug}")
def follow_link(slug: str, request: Request, db: Session = Depends(get_db)):
    link = db.scalar(select(TrackedLink).where(TrackedLink.slug == slug))
    if not link:
        raise HTTPException(404, "Link not found")
    db.add(EngagementEvent(workspace_id=link.workspace_id, tracked_link_id=link.id, kind="click",
                           metadata_json={"referer": request.headers.get("referer", "")[:300],
                                          "ua": request.headers.get("user-agent", "")[:200]}))
    db.commit()
    return RedirectResponse(link.target_url, status_code=302)