feat: Social page — aggregated post queues + X/video history (#345)

* feat(api): x/video post history endpoints

Approved or rejected drafts vanished from both queues permanently --
the listers exclude terminal statuses and no history surface existed,
so a posted tweet or video was only findable in the raw task list.
GET /x/posts/history and GET /video/posts/history (CEO-gated, bounded)
return acted-on drafts newest-first with the posted platform ids and
reject reasons from the draft markers. Route tests assert by identity,
not emptiness: approve/reject commits the whole session, so prior
tests' rows legitimately persist in the shared test DB.

* feat(panel): Social page aggregating post queues and history

New dashboard page composing the X and video post queues with one
unified history section beneath them -- both platforms interleaved
newest-first, kind and outcome badges, posted X ids linking to the
live tweet, reject reasons shown. The command center's two full queue
cards become a compact pending-counts card linking to the page, so the
queues have one home instead of duplicated surfaces.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-09 00:44:51 +02:00
committed by GitHub
co-authored by Renn F
parent 5886336259
commit f0b6390189
26 changed files with 1289 additions and 30 deletions
@@ -577,6 +577,98 @@ async def test_list_held_video_posts_excludes_terminal(
assert rejected_task.id not in ids
@pytest.mark.asyncio
async def test_list_video_post_history_excludes_open_drafts(
db_session: AsyncSession, _test_database_url: str
) -> None:
"""approve() commits the whole session, so open_task's still-uncommitted
seed insert becomes durable too (same class of leak documented on
test_approve_partial_failure_keeps_task_open_and_persists_the_success) —
clean it up explicitly so it doesn't pollute list_open_video_posts()/
list_open_video_post_drafts() assertions elsewhere in the suite."""
open_task = await _seed_video_post(db_session)
open_task_id = _id(open_task)
open_project_id = open_task.project_id
posted_task = await _seed_video_post(db_session, platforms=["x"])
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
try:
with _LOCKED[0], _LOCKED[1]:
await svc.approve(_id(posted_task))
history = await svc.list_video_post_history()
ids = {t.id for t in history}
assert posted_task.id in ids
assert open_task.id not in ids
finally:
cleanup, cleanup_engine = await _fresh_session(_test_database_url)
try:
await cleanup.execute(delete(TaskTable).where(TaskTable.id == open_task_id))
await cleanup.execute(
delete(ProjectTable).where(ProjectTable.id == open_project_id)
)
await cleanup.commit()
finally:
await _dispose(cleanup, cleanup_engine)
@pytest.mark.asyncio
async def test_list_video_post_history_newest_acted_first(
db_session: AsyncSession,
) -> None:
rejected_task = await _seed_video_post(db_session)
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
with _LOCKED[0], _LOCKED[1]:
await svc.reject(_id(rejected_task), "wrong occasion")
posted_task = await _seed_video_post(db_session, platforms=["x"])
with _LOCKED[0], _LOCKED[1]:
await svc.approve(_id(posted_task))
history = await svc.list_video_post_history()
ids = [t.id for t in history]
assert ids.index(posted_task.id) < ids.index(rejected_task.id)
@pytest.mark.asyncio
async def test_list_video_post_history_includes_marker_fields(
db_session: AsyncSession,
) -> None:
posted_task = await _seed_video_post(db_session, platforms=["x"])
svc = _svc(
db_session,
x_poster=_StubXPoster(video_id="xid9"),
tiktok_poster=_StubTikTokPoster(),
)
with _LOCKED[0], _LOCKED[1]:
await svc.approve(_id(posted_task))
rejected_task = await _seed_video_post(db_session)
with _LOCKED[0], _LOCKED[1]:
await svc.reject(_id(rejected_task), "off-brand")
history = await svc.list_video_post_history()
by_id = {t.id: t for t in history}
posted_draft = markers.get_video_draft(by_id[posted_task.id])
assert posted_draft is not None
assert posted_draft["x_posted_id"] == "xid9"
assert markers.get_video_reject_reason(by_id[rejected_task.id]) == "off-brand"
@pytest.mark.asyncio
async def test_list_video_post_history_respects_limit(
db_session: AsyncSession,
) -> None:
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
tasks = []
for _ in range(3):
t = await _seed_video_post(db_session)
with _LOCKED[0], _LOCKED[1]:
await svc.reject(_id(t), "not relevant")
tasks.append(t)
history = await svc.list_video_post_history(limit=2)
assert len(history) == TWO
ids = {t.id for t in history}
assert tasks[2].id in ids
assert tasks[1].id in ids
assert tasks[0].id not in ids
@pytest.mark.asyncio
async def test_approve_commits_before_releasing_the_lock(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
@@ -42,6 +42,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
ONE = 1
TWO = 2
class _StubClient(XClient):
@@ -364,6 +365,74 @@ async def test_reject_completed_raises(db_session: AsyncSession) -> None:
await _svc(db_session).reject(_id(task), "nope")
@pytest.mark.asyncio
async def test_list_post_history_excludes_open_drafts(
db_session: AsyncSession,
) -> None:
open_task = await _seed_draft(db_session)
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
await _svc(db_session).reject(_id(rejected_task), "not relevant")
history = await _svc(db_session).list_post_history()
ids = {t.id for t in history}
assert rejected_task.id in ids
assert open_task.id not in ids
@pytest.mark.asyncio
async def test_list_post_history_newest_acted_first(
db_session: AsyncSession,
) -> None:
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
await _svc(db_session).reject(_id(rejected_task), "not relevant")
posted_task = await _seed_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(posted_task))
history = await _svc(db_session).list_post_history()
ids = [t.id for t in history]
assert ids.index(posted_task.id) < ids.index(rejected_task.id)
@pytest.mark.asyncio
async def test_list_post_history_includes_marker_fields(
db_session: AsyncSession,
) -> None:
posted_task = await _seed_draft(db_session)
client = _StubClient(tweet_id="777")
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(posted_task))
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
await _svc(db_session).reject(_id(rejected_task), "off-brand tone")
history = await _svc(db_session).list_post_history()
by_id = {t.id: t for t in history}
assert markers.get_x_posted_tweet_id(by_id[posted_task.id]) == "777"
assert markers.get_x_reject_reason(by_id[rejected_task.id]) == "off-brand tone"
@pytest.mark.asyncio
async def test_list_post_history_respects_limit(db_session: AsyncSession) -> None:
tasks = []
for _ in range(3):
t = await _seed_draft(db_session, source=X_REPLY_SOURCE)
await _svc(db_session).reject(_id(t), "not relevant")
tasks.append(t)
history = await _svc(db_session).list_post_history(limit=2)
assert len(history) == TWO
ids = {t.id for t in history}
assert tasks[2].id in ids
assert tasks[1].id in ids
assert tasks[0].id not in ids
@pytest.mark.asyncio
async def test_approve_does_not_flush_edited_body_before_lock(
db_session: AsyncSession,