LINKEDIN POST CONFIG DONE AND TESTED
parent
3991bd60a3
commit
c79024160c
|
|
@ -39,6 +39,9 @@ env/
|
||||||
!frontend/.env.development
|
!frontend/.env.development
|
||||||
!frontend/.env.production
|
!frontend/.env.production
|
||||||
|
|
||||||
|
# Postman/Insomnia environments holding real API keys
|
||||||
|
*.postman_environment.local.json
|
||||||
|
|
||||||
# Logs & temp
|
# Logs & temp
|
||||||
*.log
|
*.log
|
||||||
tmp/
|
tmp/
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,7 @@ FRONTEND_URL=http://localhost:5173
|
||||||
CONFIRM_EMAIL_PATH=/auth/confirm-email
|
CONFIRM_EMAIL_PATH=/auth/confirm-email
|
||||||
CONFIRM_TOKEN_TTL_SECONDS=86400
|
CONFIRM_TOKEN_TTL_SECONDS=86400
|
||||||
CONFIRM_TOKEN_RESEND_SECONDS=60
|
CONFIRM_TOKEN_RESEND_SECONDS=60
|
||||||
|
|
||||||
|
BUFFER_API=
|
||||||
|
BUFFER_API_URL=https://api.buffer.com
|
||||||
|
BUFFER_CHANNEL_ID=
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,46 @@
|
||||||
import profile
|
from fastapi import APIRouter,Depends
|
||||||
from fastapi import APIRouter,Depends, Query
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, EmailStr, model_validator
|
from pydantic import BaseModel, model_validator
|
||||||
from users.views import User
|
from users.permissions import PermissionTag, require_permission
|
||||||
from users.permissions import CurrentUser, PermissionTag, require_permission
|
from job.job_post.views import JobPost
|
||||||
from job_post.views import JobPost
|
|
||||||
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class JobPostCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
experience_min: int | None = None
|
||||||
|
experience_max: int | None = None
|
||||||
|
requirements: list[str] = []
|
||||||
|
optional_skills: list[str] = []
|
||||||
|
salary: str = "Anonymous"
|
||||||
|
location: str | None = None
|
||||||
|
employment_type: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
channel_id: str | None = None
|
||||||
|
mode: str = "addToQueue"
|
||||||
|
due_at: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_mode_and_due_at(self):
|
||||||
|
allowed = {"addToQueue", "shareNow", "customScheduled"}
|
||||||
|
if self.mode not in allowed:
|
||||||
|
raise ValueError(f"mode must be one of {sorted(allowed)}")
|
||||||
|
if self.mode == "customScheduled" and not self.due_at:
|
||||||
|
raise ValueError("due_at is required when mode is customScheduled")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
@router.post("/candidate/cv_upload")
|
@router.post("/candidate/cv_upload")
|
||||||
async def cv_upload(session: AsyncSession = Depends(get_session),current_user: Depends(require_permission(PermissionTag.CANDIDATES_MANAGE,PermissionTag.CANDIDATES_VIEW,PermissionTag.CANDIDATES_EDIT,PermissionTag.CANDIDATES_DELETE,PermissionTag.CANDIDATES_CREATE))):
|
async def cv_upload(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
pass
|
pass
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -27,23 +51,30 @@ async def cv_upload(session: AsyncSession = Depends(get_session),current_user: D
|
||||||
|
|
||||||
@router.post("/job/post-job")
|
@router.post("/job/post-job")
|
||||||
async def post_job(
|
async def post_job(
|
||||||
|
payload: JobPostCreate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_CREATE)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: Depends(require_permission(
|
):
|
||||||
PermissionTag.JOB_BOARD_CREATE,
|
|
||||||
PermissionTag.JOB_BOARD_VIEW,
|
|
||||||
PermissionTag.JOB_BOARD_APPROVE,
|
|
||||||
PermissionTag.JOB_BOARD_EDIT,
|
|
||||||
PermissionTag.JOB_BOARD_DELETE,
|
|
||||||
PermissionTag.JOB_BOARD_VIEW_ALL,
|
|
||||||
PermissionTag.JOB_BOARD_APPROVE_ALL,
|
|
||||||
PermissionTag.JOB_BOARD_EDIT_ALL,
|
|
||||||
PermissionTag.JOB_BOARD_DELETE_ALL,
|
|
||||||
PermissionTag.JOB_BOARD_VIEW_ALL,
|
|
||||||
PermissionTag.JOB_BOARD_APPROVE_ALL,
|
|
||||||
PermissionTag.JOB_BOARD_EDIT_ALL,
|
|
||||||
PermissionTag.JOB_BOARD_DELETE_ALL,
|
|
||||||
))):
|
|
||||||
try:
|
try:
|
||||||
service=JobPost(session=session)
|
service=JobPost(session=session)
|
||||||
|
data=await service.post_job(payload.model_dump(),current_user)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/job/buffer/channels")
|
||||||
|
async def buffer_channels(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=JobPost(session=session)
|
||||||
|
data=await service.list_channels()
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,117 @@
|
||||||
from os import setegid
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import DateTime, JSON
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlalchemy.orm.base import state_str
|
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class JobPosts(SQLModel, table=True):
|
class JobPosts(SQLModel, table=True):
|
||||||
__tablename__ = "job_posts"
|
__tablename__ = "job_posts"
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
title: str
|
title: str = Field(index=True)
|
||||||
position:str
|
|
||||||
team:str
|
user: Optional["Users"] = Relationship(
|
||||||
location:str
|
back_populates="job_posts",
|
||||||
requirements:str
|
sa_relationship_kwargs={"lazy": "joined"},
|
||||||
responsibilities:str
|
)
|
||||||
benefits:str
|
|
||||||
salary_range:str
|
platform: str = Field(default="linkedin")
|
||||||
description: str
|
is_active: bool = Field(default=True)
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
|
||||||
updated_at: datetime = Field(default_factory=datetime.now)
|
|
||||||
is_deleted: bool = Field(default=False)
|
is_deleted: bool = Field(default=False)
|
||||||
|
employment_type: str | None = Field(default=None)
|
||||||
|
location: str | None = Field(default=None)
|
||||||
|
experience_min: int | None = Field(default=None)
|
||||||
|
experience_max: int | None = Field(default=None)
|
||||||
|
requirements: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||||
|
optional_skills: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||||
|
salary: str = Field(default="Anonymous")
|
||||||
|
description: str | None = Field(default=None)
|
||||||
|
post_text: str
|
||||||
|
channel_id: str
|
||||||
|
buffer_post_id: str | None = Field(default=None)
|
||||||
|
buffer_external_link: str | None = Field(default=None)
|
||||||
|
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
|
status: str = Field(default="draft")
|
||||||
|
buffer_error: str | None = Field(default=None)
|
||||||
|
created_by: uuid.UUID = Field(foreign_key="users.id")
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_uuid(record_id: str) -> uuid.UUID | None:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(record_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
||||||
|
uid = cls._as_uuid(record_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_job_post(cls, session: AsyncSession, fields: dict):
|
||||||
|
row = cls(**fields)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return await cls.get_job_post_by_id(session, row.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_buffer_result(
|
||||||
|
cls,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id: str,
|
||||||
|
*,
|
||||||
|
buffer_post_id: str,
|
||||||
|
status: str,
|
||||||
|
external_link: str | None = None,
|
||||||
|
sent_at: datetime | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
):
|
||||||
|
"""Record what Buffer reported.
|
||||||
|
`status` is the mapped Buffer PostStatus, not an assumption: a queued post lands
|
||||||
|
here as "scheduled" and only becomes "published" once Buffer says `sent`.
|
||||||
|
"""
|
||||||
|
row = await cls.get_job_post_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.status = status
|
||||||
|
row.buffer_post_id = buffer_post_id
|
||||||
|
row.buffer_external_link = external_link
|
||||||
|
row.buffer_sent_at = sent_at
|
||||||
|
if platform:
|
||||||
|
row.platform = platform
|
||||||
|
row.buffer_error = None
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_failed(cls, session: AsyncSession, record_id: str, error: str):
|
||||||
|
row = await cls.get_job_post_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.status = "failed"
|
||||||
|
row.buffer_error = error
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
import users.models as _users_models
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
"""Buffer GraphQL helpers and LinkedIn job-post copy rendering.
|
||||||
|
|
||||||
|
Pure module: no FastAPI imports and no HTTPException.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BUFFER_API = os.getenv("BUFFER_API")
|
||||||
|
BUFFER_API_URL = os.getenv("BUFFER_API_URL", "https://api.buffer.com")
|
||||||
|
BUFFER_CHANNEL_ID = os.getenv("BUFFER_CHANNEL_ID")
|
||||||
|
LINKEDIN_POST_MAX_CHARS = 3000
|
||||||
|
|
||||||
|
# Buffer's PostStatus -> the job_posts.status lifecycle. Only `sent` means the post is
|
||||||
|
# actually live on the network: the default `addToQueue` mode comes back as `scheduled`,
|
||||||
|
# so treating any successful mutation as "published" would record a post that nobody
|
||||||
|
# outside Buffer can see yet.
|
||||||
|
BUFFER_STATUS_TO_LOCAL = {
|
||||||
|
"sent": "published",
|
||||||
|
"sending": "publishing",
|
||||||
|
"scheduled": "scheduled",
|
||||||
|
"draft": "draft",
|
||||||
|
"needs_approval": "needs_approval",
|
||||||
|
"error": "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def local_status(buffer_status) -> str:
|
||||||
|
"""Map a Buffer PostStatus onto our own. Unknown values stay uncommitted."""
|
||||||
|
return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_buffer_datetime(value):
|
||||||
|
"""Buffer sends ISO 8601 with a trailing `Z`, which fromisoformat wants as +00:00."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BufferError(RuntimeError):
|
||||||
|
def __init__(self, message: str, *, code: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def render_job_post(payload) -> str:
|
||||||
|
title = (payload.get("title") or "").strip() or "Open Role"
|
||||||
|
location = (payload.get("location") or "").strip()
|
||||||
|
employment_type = (payload.get("employment_type") or "").strip()
|
||||||
|
experience_min = payload.get("experience_min")
|
||||||
|
experience_max = payload.get("experience_max")
|
||||||
|
requirements = [str(r).strip() for r in (payload.get("requirements") or []) if str(r).strip()]
|
||||||
|
optional_skills = [str(s).strip() for s in (payload.get("optional_skills") or []) if str(s).strip()]
|
||||||
|
salary = (payload.get("salary") or "Anonymous").strip() or "Anonymous"
|
||||||
|
description = (payload.get("description") or "").strip()
|
||||||
|
|
||||||
|
lines = [f"We're hiring: {title}", ""]
|
||||||
|
|
||||||
|
meta = []
|
||||||
|
if location:
|
||||||
|
meta.append(location)
|
||||||
|
if employment_type:
|
||||||
|
meta.append(employment_type)
|
||||||
|
if meta:
|
||||||
|
lines.append(" · ".join(meta))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if experience_min is not None and experience_max is not None:
|
||||||
|
lines.append(f"Experience: {experience_min}–{experience_max} years")
|
||||||
|
lines.append("")
|
||||||
|
elif experience_min is not None:
|
||||||
|
lines.append(f"Experience: {experience_min}+ years")
|
||||||
|
lines.append("")
|
||||||
|
elif experience_max is not None:
|
||||||
|
lines.append(f"Experience: up to {experience_max} years")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if requirements:
|
||||||
|
lines.append("Requirements:")
|
||||||
|
for item in requirements:
|
||||||
|
lines.append(f"• {item}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if optional_skills:
|
||||||
|
lines.append("Nice to have:")
|
||||||
|
for item in optional_skills:
|
||||||
|
lines.append(f"• {item}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(f"Salary: {salary}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if description:
|
||||||
|
lines.append(description)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("Interested? Apply via our careers page or reply to this post.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
for item in requirements:
|
||||||
|
tag = re.sub(r"[^A-Za-z0-9]+", "", item)
|
||||||
|
if tag:
|
||||||
|
tags.append(f"#{tag}")
|
||||||
|
if tags:
|
||||||
|
lines.append(" ".join(tags))
|
||||||
|
|
||||||
|
text = "\n".join(lines).strip()
|
||||||
|
if len(text) > LINKEDIN_POST_MAX_CHARS:
|
||||||
|
text = text[: LINKEDIN_POST_MAX_CHARS - 1].rstrip() + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def build_create_post_query(text, channel_id, *, mode="addToQueue", due_at=None) -> str:
|
||||||
|
fields = [
|
||||||
|
f"text: {json.dumps(text)}",
|
||||||
|
f"channelId: {json.dumps(channel_id)}",
|
||||||
|
"schedulingType: automatic",
|
||||||
|
f"mode: {mode}",
|
||||||
|
]
|
||||||
|
if mode == "customScheduled" and due_at:
|
||||||
|
fields.append(f"dueAt: {json.dumps(due_at)}")
|
||||||
|
input_block = ",\n ".join(fields)
|
||||||
|
return (
|
||||||
|
"mutation CreatePost {\n"
|
||||||
|
" createPost(input: {\n"
|
||||||
|
f" {input_block}\n"
|
||||||
|
" }) {\n"
|
||||||
|
" ... on PostActionSuccess {\n"
|
||||||
|
" post { id text status sentAt externalLink channelService }\n"
|
||||||
|
" }\n"
|
||||||
|
" ... on MutationError { message }\n"
|
||||||
|
" }\n"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_buffer_post(text, channel_id, *, mode="addToQueue", due_at=None) -> dict:
|
||||||
|
if not BUFFER_API:
|
||||||
|
raise RuntimeError("BUFFER_API is not configured")
|
||||||
|
query = build_create_post_query(text, channel_id, mode=mode, due_at=due_at)
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
BUFFER_API_URL,
|
||||||
|
json={"query": query},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {BUFFER_API}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
errors = body.get("errors")
|
||||||
|
if errors:
|
||||||
|
first = errors[0] if isinstance(errors, list) and errors else {}
|
||||||
|
msg = first.get("message") or "Buffer GraphQL error"
|
||||||
|
code = (first.get("extensions") or {}).get("code")
|
||||||
|
raise BufferError(msg, code=code)
|
||||||
|
create_post = (body.get("data") or {}).get("createPost") or {}
|
||||||
|
if "message" in create_post and "post" not in create_post:
|
||||||
|
raise BufferError(create_post.get("message") or "Buffer mutation error")
|
||||||
|
post = create_post.get("post")
|
||||||
|
if not post or not post.get("id"):
|
||||||
|
raise BufferError("Buffer did not return a post id")
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
async def list_buffer_channels() -> list[dict]:
|
||||||
|
if not BUFFER_API:
|
||||||
|
raise RuntimeError("BUFFER_API is not configured")
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
orgs_response = await client.post(
|
||||||
|
BUFFER_API_URL,
|
||||||
|
json={"query": "query { account { organizations { id name } } }"},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {BUFFER_API}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if orgs_response.status_code != 200:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
orgs_response.text,
|
||||||
|
request=orgs_response.request,
|
||||||
|
response=orgs_response,
|
||||||
|
)
|
||||||
|
orgs_body = orgs_response.json()
|
||||||
|
if orgs_body.get("errors"):
|
||||||
|
first = orgs_body["errors"][0]
|
||||||
|
raise BufferError(
|
||||||
|
first.get("message") or "Buffer GraphQL error",
|
||||||
|
code=(first.get("extensions") or {}).get("code"),
|
||||||
|
)
|
||||||
|
organizations = ((orgs_body.get("data") or {}).get("account") or {}).get("organizations") or []
|
||||||
|
channels: list[dict] = []
|
||||||
|
for org in organizations:
|
||||||
|
org_id = org.get("id")
|
||||||
|
if not org_id:
|
||||||
|
continue
|
||||||
|
channels_query = (
|
||||||
|
"query GetChannels {\n"
|
||||||
|
f' channels(input:{{organizationId:{json.dumps(org_id)}}}) {{\n'
|
||||||
|
" id name displayName service isQueuePaused\n"
|
||||||
|
" }\n"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
channels_response = await client.post(
|
||||||
|
BUFFER_API_URL,
|
||||||
|
json={"query": channels_query},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {BUFFER_API}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if channels_response.status_code != 200:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
channels_response.text,
|
||||||
|
request=channels_response.request,
|
||||||
|
response=channels_response,
|
||||||
|
)
|
||||||
|
channels_body = channels_response.json()
|
||||||
|
if channels_body.get("errors"):
|
||||||
|
first = channels_body["errors"][0]
|
||||||
|
raise BufferError(
|
||||||
|
first.get("message") or "Buffer GraphQL error",
|
||||||
|
code=(first.get("extensions") or {}).get("code"),
|
||||||
|
)
|
||||||
|
for channel in (channels_body.get("data") or {}).get("channels") or []:
|
||||||
|
channels.append({
|
||||||
|
**channel,
|
||||||
|
"organization_id": org_id,
|
||||||
|
"organization_name": org.get("name"),
|
||||||
|
})
|
||||||
|
return channels
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
def serialize_job_post(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"title": row.title,
|
||||||
|
"employment_type": row.employment_type,
|
||||||
|
"location": row.location,
|
||||||
|
"experience_min": row.experience_min,
|
||||||
|
"experience_max": row.experience_max,
|
||||||
|
"requirements": list(row.requirements or []),
|
||||||
|
"optional_skills": list(row.optional_skills or []),
|
||||||
|
"salary": row.salary,
|
||||||
|
"description": row.description,
|
||||||
|
"post_text": row.post_text,
|
||||||
|
"channel_id": row.channel_id,
|
||||||
|
"platform": row.platform,
|
||||||
|
"buffer_post_id": row.buffer_post_id,
|
||||||
|
"buffer_external_link": row.buffer_external_link,
|
||||||
|
"buffer_sent_at": row.buffer_sent_at.isoformat() if row.buffer_sent_at else None,
|
||||||
|
"status": row.status,
|
||||||
|
"buffer_error": row.buffer_error,
|
||||||
|
"created_by": str(row.created_by) if row.created_by else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,79 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from job.job_post.plugins import (
|
||||||
|
BufferError,
|
||||||
|
create_buffer_post,
|
||||||
|
list_buffer_channels,
|
||||||
|
local_status,
|
||||||
|
parse_buffer_datetime,
|
||||||
|
render_job_post,
|
||||||
|
)
|
||||||
|
from job.job_post.serializers import serialize_job_post
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
class JobPost:
|
class JobPost:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
self.session=session
|
||||||
self.buffer_api=os.getenv("BUFFER_API")
|
self.buffer_api=os.getenv("BUFFER_API")
|
||||||
self.client_id=os.getenv("CLIENT_ID")
|
self.channel_id=os.getenv("BUFFER_CHANNEL_ID")
|
||||||
|
|
||||||
|
async def post_job(self,payload,current_user):
|
||||||
|
channel_id=payload.get("channel_id") or self.channel_id
|
||||||
|
if not channel_id:
|
||||||
|
raise HTTPException(status_code=500,detail="BUFFER_CHANNEL_ID is not configured")
|
||||||
|
|
||||||
|
text=render_job_post(payload)
|
||||||
|
row=await JobPosts.insert_job_post(self.session,{
|
||||||
|
"title":payload.get("title"),
|
||||||
|
"employment_type":payload.get("employment_type"),
|
||||||
|
"location":payload.get("location"),
|
||||||
|
"experience_min":payload.get("experience_min"),
|
||||||
|
"experience_max":payload.get("experience_max"),
|
||||||
|
"requirements":list(payload.get("requirements") or []),
|
||||||
|
"optional_skills":list(payload.get("optional_skills") or []),
|
||||||
|
"salary":payload.get("salary") or "Anonymous",
|
||||||
|
"description":payload.get("description"),
|
||||||
|
"post_text":text,
|
||||||
|
"channel_id":channel_id,
|
||||||
|
"status":"draft",
|
||||||
|
"created_by":current_user["id"],
|
||||||
|
})
|
||||||
|
|
||||||
async def post_job(self,payload):
|
|
||||||
try:
|
try:
|
||||||
pass
|
post=await create_buffer_post(
|
||||||
except Exception as e:
|
text,
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
channel_id,
|
||||||
|
mode=payload.get("mode") or "addToQueue",
|
||||||
|
due_at=payload.get("due_at"),
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
||||||
|
await JobPosts.mark_failed(self.session,str(row.id),str(e))
|
||||||
|
raise HTTPException(status_code=502,detail="Failed to publish job post to Buffer") from e
|
||||||
|
|
||||||
|
# Buffer accepting the mutation is not the same as the network publishing it:
|
||||||
|
# the default addToQueue mode returns `scheduled`, so the row only reads
|
||||||
|
# "published" once Buffer reports `sent`.
|
||||||
|
saved=await JobPosts.mark_buffer_result(
|
||||||
|
self.session,
|
||||||
|
str(row.id),
|
||||||
|
buffer_post_id=post["id"],
|
||||||
|
status=local_status(post.get("status")),
|
||||||
|
external_link=post.get("externalLink"),
|
||||||
|
sent_at=parse_buffer_datetime(post.get("sentAt")),
|
||||||
|
platform=post.get("channelService"),
|
||||||
|
)
|
||||||
|
return serialize_job_post(saved)
|
||||||
|
|
||||||
|
async def list_channels(self):
|
||||||
|
try:
|
||||||
|
return await list_buffer_channels()
|
||||||
|
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
||||||
|
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
from role.models import Roles
|
from role.models import Roles
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
class Users(SQLModel, table=True):
|
class Users(SQLModel, table=True):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
@ -17,6 +17,13 @@ class Users(SQLModel, table=True):
|
||||||
email: str = Field(unique=True)
|
email: str = Field(unique=True)
|
||||||
role_id: int | None = Field(nullable=True, foreign_key="roles.id")
|
role_id: int | None = Field(nullable=True, foreign_key="roles.id")
|
||||||
role: Roles | None = Relationship(back_populates="users")
|
role: Roles | None = Relationship(back_populates="users")
|
||||||
|
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
||||||
|
# user row once per post. Without an explicit strategy the default is a lazy load,
|
||||||
|
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
||||||
|
job_posts: list[JobPosts] = Relationship(
|
||||||
|
back_populates="user",
|
||||||
|
sa_relationship_kwargs={"lazy": "selectin"},
|
||||||
|
)
|
||||||
password: str
|
password: str
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
updated_at: datetime = Field(default_factory=datetime.now)
|
updated_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"id": "e0ffe4a1-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
|
||||||
|
"name": "Buffer API (fill in your key)",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"key": "buffer_api_url",
|
||||||
|
"value": "https://api.buffer.com",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "buffer_token",
|
||||||
|
"value": "",
|
||||||
|
"type": "secret",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "org_id",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "channel_id",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "post_id",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sent_post_id",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "queued_post_id",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "idea_id",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "posts_cursor",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_postman_variable_scope": "environment"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Buffer API — working collection
|
||||||
|
|
||||||
|
Buffer's public API is **GraphQL, one endpoint, POST only**:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.buffer.com
|
||||||
|
Authorization: Bearer <BUFFER_API>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
There are no REST paths. The operation is decided entirely by the GraphQL document in the
|
||||||
|
body. Docs: <https://developers.buffer.com/guides> · Explorer: <https://developers.buffer.com/explorer.html>
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `Buffer-API.postman_collection.json` | 38 requests in 9 folders. Import into Postman/Insomnia/Bruno. |
|
||||||
|
| `Buffer-API.postman_environment.json` | Empty environment template — safe to commit. |
|
||||||
|
| `Buffer-API.postman_environment.local.json` | Same, pre-filled with the key + ids from `backend/.env`. **Gitignored — do not commit.** |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Import the collection **and** `Buffer-API.postman_environment.local.json`, then select
|
||||||
|
that environment. (Or import the plain template and paste `BUFFER_API` from
|
||||||
|
`backend/.env` into `buffer_token`.)
|
||||||
|
2. Run **01 · Get Organizations** → fills `{{org_id}}`.
|
||||||
|
3. Run **02 · Get Channels** → fills `{{channel_id}}`.
|
||||||
|
|
||||||
|
Everything else works from there. Test scripts chain the ids for you:
|
||||||
|
|
||||||
|
| Variable | Filled by | Used by |
|
||||||
|
|---|---|---|
|
||||||
|
| `org_id` | 01 · Get Organizations | almost everything |
|
||||||
|
| `channel_id` | 02 · Get Channels | all create requests |
|
||||||
|
| `post_id` | 03 · Get Posts, every create request | Get Post by ID, Edit Post, **Delete Post** |
|
||||||
|
| `sent_post_id` | 03 · Get Sent Posts | 06 · Get Post Metrics |
|
||||||
|
| `queued_post_id` | 03 · Get Scheduled Posts, 04 · Add to Queue | 04 · Move Post in Queue |
|
||||||
|
| `posts_cursor` | 03 · Get Posts | 03 · Get Posts — Next Page |
|
||||||
|
|
||||||
|
So **Delete Post** always targets the last post you touched.
|
||||||
|
|
||||||
|
## The endpoints you asked for
|
||||||
|
|
||||||
|
| Need | Folder / request | Where the value is |
|
||||||
|
|---|---|---|
|
||||||
|
| **org_id** | 01 · Get Organizations | `data.account.organizations[].id` |
|
||||||
|
| **channel_id** | 02 · Get Channels | `data.channels[].id` |
|
||||||
|
| **create a post** | 04 · Create Post · … | `data.createPost` → `PostActionSuccess.post.id` |
|
||||||
|
| **delete a post** | 04 · Delete Post | `data.deletePost` → `DeletePostSuccess.id` |
|
||||||
|
| **list posts** | 03 · Get Posts | `data.posts.edges[].node` |
|
||||||
|
| **one post** | 03 · Get Post by ID | `data.post` |
|
||||||
|
| **edit a post** | 04 · Edit Post | `editPost` (not `updatePost`) |
|
||||||
|
|
||||||
|
### org_id
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.buffer.com \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $BUFFER_API" \
|
||||||
|
-d '{"query":"query { account { id email organizations { id name channelCount } } }"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### channel_id
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.buffer.com \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $BUFFER_API" \
|
||||||
|
-d '{"query":"query GetChannels($input: ChannelsInput!) { channels(input: $input) { id name service type isDisconnected isQueuePaused } }",
|
||||||
|
"variables":{"input":{"organizationId":"'"$BUFFER_ORG_ID"'"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### create post
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.buffer.com \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $BUFFER_API" \
|
||||||
|
-d '{"query":"mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { __typename ... on PostActionSuccess { post { id status dueAt } } ... on MutationError { message } } }",
|
||||||
|
"variables":{"input":{"channelId":"'"$BUFFER_CHANNEL_ID"'","text":"Hello","schedulingType":"automatic","mode":"addToQueue","assets":[]}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### delete post
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.buffer.com \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $BUFFER_API" \
|
||||||
|
-d '{"query":"mutation DeletePost($input: DeletePostInput!) { deletePost(input: $input) { __typename ... on DeletePostSuccess { id } ... on MutationError { message } } }",
|
||||||
|
"variables":{"input":{"id":"POST_ID"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### list posts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.buffer.com \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $BUFFER_API" \
|
||||||
|
-d '{"query":"query GetPosts($first: Int, $after: String, $input: PostsInput!) { posts(first: $first, after: $after, input: $input) { edges { cursor node { id text status dueAt sentAt channelId externalLink } } pageInfo { hasNextPage endCursor } } }",
|
||||||
|
"variables":{"first":20,"input":{"organizationId":"'"$BUFFER_ORG_ID"'","filter":{"status":["scheduled"]}}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.env` mapping
|
||||||
|
|
||||||
|
| `.env` key | Collection variable | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `BUFFER_API` | `buffer_token` | The personal access token. Buffer → Settings → API. |
|
||||||
|
| `BUFFER_API_URL` | `buffer_api_url` | `https://api.buffer.com` — correct as-is. |
|
||||||
|
| `BUFFER_CHANNEL_ID` | `channel_id` | Currently the LinkedIn profile `ahmedmujtababaig`. |
|
||||||
|
| — | `org_id` | **Not in `.env`.** `CLIENT_ID` in `backend/.env` holds this value, but it is the *organization id*, not an OAuth client id — the naming is misleading. Consider renaming it to `BUFFER_ORG_ID`. |
|
||||||
|
|
||||||
|
`plugins.py` re-derives the org id on every `list_buffer_channels()` call, so nothing is
|
||||||
|
broken today; caching it in `BUFFER_ORG_ID` would save one round trip per request.
|
||||||
|
|
||||||
|
## Enums worth memorising
|
||||||
|
|
||||||
|
| Enum | Values |
|
||||||
|
|---|---|
|
||||||
|
| `ShareMode` (`mode`) | `addToQueue` · `shareNext` · `shareNow` · `customScheduled` |
|
||||||
|
| `SchedulingType` | `automatic` (Buffer publishes) · `notification` (Buffer reminds you) |
|
||||||
|
| `PostStatus` | `draft` · `needs_approval` · `scheduled` · `sending` · `sent` · `error` |
|
||||||
|
| `PostSortableKey` | `dueAt` · `createdAt` **only** |
|
||||||
|
| `SortDirection` | `asc` · `desc` |
|
||||||
|
| `QueuePosition` | `top` · `bottom` |
|
||||||
|
| `Service` | `linkedin` `twitter` `facebook` `instagram` `tiktok` `threads` `youtube` `pinterest` `mastodon` `bluesky` `googlebusiness` `startPage` |
|
||||||
|
| `PostMetricType` | `impressions` `reach` `reactions` `likes` `comments` `shares` `reposts` `quotes` `clicks` `saves` `follows` `views` `viewers` `totalTimeWatched` `engagementRate` `postCount` |
|
||||||
|
|
||||||
|
## Gotchas that cost real time
|
||||||
|
|
||||||
|
- **Errors come back as HTTP 200.** Check `errors[]` and `__typename`, not the status code.
|
||||||
|
- **Do not request `totalCount` on `posts`** — API-key auth gets `FORBIDDEN` and the whole
|
||||||
|
query returns `data: null`.
|
||||||
|
- The edit mutation is **`editPost`**, not `updatePost`.
|
||||||
|
- `deletePost` returns **`DeletePostSuccess`**, not `PostActionSuccess`. A blanket
|
||||||
|
`... on PostActionSuccess` fragment silently matches nothing.
|
||||||
|
- `schedulingType` is *not* the queue mode. `automatic` vs `notification` only. The queue
|
||||||
|
mode is `mode`.
|
||||||
|
- `mode: customScheduled` requires `dueAt` (ISO 8601 UTC). `mode: shareNow` publishes
|
||||||
|
immediately with no undo.
|
||||||
|
- `assets` URLs are fetched **server-side** — they must return raw bytes, not an HTML page.
|
||||||
|
- `metadata.<service>.linkAttachment` and a non-empty `assets` array are mutually exclusive.
|
||||||
|
- LinkedIn `linkAttachment` only accepts `{ url }`; there is no title/description override.
|
||||||
|
- There is **no `deleteIdea` mutation** — ideas created via the API must be removed in the UI.
|
||||||
|
- `movePostInQueue` only accepts posts whose `shareMode` is `addToQueue`/`shareNext`. Drafts
|
||||||
|
and `customScheduled` posts give
|
||||||
|
`VoidMutationError: Only queued posts can be moved within the queue`.
|
||||||
|
|
||||||
|
## Free-plan limits hit while testing this
|
||||||
|
|
||||||
|
- **100 requests / 15 min**, 250 / day, 3000 / 30 days. A full Collection Runner pass is
|
||||||
|
~38 calls, so two back-to-back runs trip the 15-minute window
|
||||||
|
(HTTP 429, `RATE_LIMIT_EXCEEDED`, `extensions.window: "15m"`, plus `Retry-After`).
|
||||||
|
Every response carries `ratelimit` / `ratelimit-policy` headers.
|
||||||
|
- **Insights are capped at the last 31 days.** A wider `aggregatedPostMetrics` window
|
||||||
|
returns `BAD_USER_INPUT`.
|
||||||
|
- **LinkedIn `firstComment` is paid-only** — `InvalidInputError` on Free.
|
||||||
|
- **`needsApproval: true`** is rejected unless the channel has an approval posting policy.
|
||||||
|
- Daily posting limit on the connected channel is 50/day (`dailyPostingLimits`).
|
||||||
|
|
||||||
|
## Error codes
|
||||||
|
|
||||||
|
`extensions.code` on top-level `errors[]`: `UNAUTHORIZED` · `FORBIDDEN` · `NOT_FOUND` ·
|
||||||
|
`BAD_USER_INPUT` · `GRAPHQL_VALIDATION_FAILED` · `RATE_LIMIT_EXCEEDED` · `UNEXPECTED`.
|
||||||
|
|
||||||
|
Mutation union error members: `InvalidInputError` · `LimitReachedError` · `NotFoundError` ·
|
||||||
|
`UnauthorizedError` · `RestProxyError` · `UnexpectedError` — all implement the
|
||||||
|
`MutationError` interface, so `... on MutationError { message }` catches every one,
|
||||||
|
including ones Buffer adds later.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Every request in the collection was executed against the live API on 2026-08-05 using the
|
||||||
|
key in `backend/.env`: **38/38 pass.**
|
||||||
|
|
||||||
|
Two of those (**Share Now**, **Create Idea**) were validated document-only — sent with a
|
||||||
|
deliberately invalid id so the server still parses and validates the GraphQL but cannot
|
||||||
|
execute it — because one publishes to the real LinkedIn account and the other creates
|
||||||
|
something the API has no mutation to delete. **Create Post · Needs Approval** returns
|
||||||
|
`InvalidInputError` on this account: the query is correct, the channel just has no approval
|
||||||
|
policy.
|
||||||
|
|
||||||
|
Every post created during verification was deleted; the account is back to the same three
|
||||||
|
posts it had beforehand, and the pre-existing scheduled job ad still holds its original
|
||||||
|
`dueAt` slot.
|
||||||
Loading…
Reference in New Issue