feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Admin API for the FAQ manager + help center, split by concern
|
||||
(settings/branding, FAQ content, generation jobs; the custom-domain router
|
||||
joins in the domain-verification unit). Mounted at /api/v1/help-center.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.help_center.branding import router as branding_router
|
||||
from app.api.help_center.domain import router as domain_router
|
||||
from app.api.help_center.faqs import router as faqs_router
|
||||
from app.api.help_center.generation import router as generation_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(branding_router)
|
||||
router.include_router(faqs_router)
|
||||
router.include_router(generation_router)
|
||||
router.include_router(domain_router)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Help-center settings + branding endpoints (settings get/put, logo upload).
|
||||
"""
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.core.config import settings as app_settings
|
||||
from app.core.cors import update_cors_middleware
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.models.schemas.help_center import (
|
||||
DnsRecord,
|
||||
DomainStatusResponse,
|
||||
HelpCenterAgentOption,
|
||||
HelpCenterSettingsResponse,
|
||||
HelpCenterSettingsUpdate,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.repositories.help_center import HelpCenterRepository
|
||||
from app.services.file_storage import resolve_public_url, store_upload
|
||||
from app.services.help_center_access import check_help_center_access, help_center_allowed
|
||||
from app.services.help_center_settings import get_or_create_settings, live_url
|
||||
from app.services.help_center_images import absolute_upload_url
|
||||
from app.services.image_security import sanitize_image
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
# Raster only — SVG is active markup and would be stored XSS on the public help
|
||||
# center, so it is not accepted; the admin cropper rasterises to PNG client-side.
|
||||
_LOGO_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
# Reject anything larger than this on a side up front (decompression-bomb guard);
|
||||
# the stored logo is then downscaled to LOGO_FIT_DIM (header renders it ~30px).
|
||||
MAX_LOGO_DIM = 4000
|
||||
LOGO_FIT_DIM = 512
|
||||
|
||||
# Favicon: same raster-only policy (SVG/ICO rejected — stored XSS / unsupported by
|
||||
# sanitize_image). Stored small and square-ish; the browser scales the PNG.
|
||||
MAX_FAVICON_BYTES = 1 * 1024 * 1024
|
||||
_FAVICON_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
MAX_FAVICON_DIM = 2000
|
||||
FAVICON_FIT_DIM = 128
|
||||
|
||||
|
||||
def domain_status_response(row: HelpCenterSettings) -> DomainStatusResponse:
|
||||
"""DNS-records table for the admin UI, shaped from stored state."""
|
||||
records = []
|
||||
if row.custom_domain:
|
||||
records = [
|
||||
DnsRecord(
|
||||
type="CNAME",
|
||||
host=row.custom_domain,
|
||||
value=app_settings.HELP_CENTER_CNAME_TARGET,
|
||||
verified=row.cname_record_verified,
|
||||
),
|
||||
DnsRecord(
|
||||
type="TXT",
|
||||
host=f"_replypier.{row.custom_domain}",
|
||||
value=f"rp-verify={row.domain_verification_token}",
|
||||
verified=row.txt_record_verified,
|
||||
),
|
||||
]
|
||||
return DomainStatusResponse(
|
||||
custom_domain=row.custom_domain,
|
||||
domain_status=row.domain_status,
|
||||
ssl_status=row.ssl_status,
|
||||
records=records,
|
||||
domain_verified_at=row.domain_verified_at,
|
||||
)
|
||||
|
||||
|
||||
async def settings_response(
|
||||
db: Session, row: HelpCenterSettings, organization_id: UUID
|
||||
) -> HelpCenterSettingsResponse:
|
||||
agents = (
|
||||
db.query(Agent)
|
||||
.options(selectinload(Agent.widgets)) # avoid a lazy-load query per agent
|
||||
.filter(Agent.organization_id == organization_id)
|
||||
.order_by(Agent.name)
|
||||
.all()
|
||||
)
|
||||
response = HelpCenterSettingsResponse.model_validate(row)
|
||||
# Absolute (api-origin) URL: the admin runs on app.replypier.site, whose
|
||||
# nginx serves any *.png path as a static asset — a host-relative
|
||||
# /api/v1/uploads/*.png would 404 there. Anchor to the backend origin.
|
||||
response.logo_url = (
|
||||
absolute_upload_url(await resolve_public_url(row.logo_url)) if row.logo_url else None
|
||||
)
|
||||
response.favicon_url = (
|
||||
absolute_upload_url(await resolve_public_url(row.favicon_url)) if row.favicon_url else None
|
||||
)
|
||||
response.live_url = live_url(row)
|
||||
response.published_count = FAQRepository(db).count_published(organization_id)
|
||||
response.plan_allowed = help_center_allowed(db, organization_id)
|
||||
response.agents = [
|
||||
HelpCenterAgentOption(
|
||||
id=agent.id,
|
||||
name=agent.display_name or agent.name,
|
||||
has_widget=bool(agent.widgets),
|
||||
)
|
||||
for agent in agents
|
||||
]
|
||||
response.domain = domain_status_response(row)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/settings", response_model=HelpCenterSettingsResponse)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get-or-create the org's help center settings. Ungated read: the response
|
||||
carries plan_allowed so the UI can render the upgrade lock."""
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=HelpCenterSettingsResponse)
|
||||
async def update_settings(
|
||||
payload: HelpCenterSettingsUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if updates.get("agent_id") is not None:
|
||||
agent = db.query(Agent).filter(
|
||||
Agent.id == updates["agent_id"],
|
||||
Agent.organization_id == current_user.organization_id,
|
||||
).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
was_enabled = row.enabled
|
||||
for field, value in updates.items():
|
||||
setattr(row, field, value)
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
# Toggling `enabled` changes which {slug}.{base} origins the widget may call,
|
||||
# so refresh the CORS allowlist (and propagate to other workers via Redis).
|
||||
if "enabled" in updates and updates["enabled"] != was_enabled:
|
||||
update_cors_middleware(request.app)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.post("/logo", response_model=HelpCenterSettingsResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
|
||||
content = await file.read()
|
||||
# Validate, bomb-guard, downscale and re-encode to a clean PNG/JPEG — the
|
||||
# stored bytes carry no metadata or non-image payload.
|
||||
safe_bytes, content_type, ext = sanitize_image(
|
||||
content,
|
||||
allowed_content_types=_LOGO_TYPES,
|
||||
max_bytes=MAX_LOGO_BYTES,
|
||||
max_dim=MAX_LOGO_DIM,
|
||||
fit=LOGO_FIT_DIM,
|
||||
)
|
||||
file_name = f"{uuid4()}{ext}"
|
||||
row.logo_url = await store_upload(
|
||||
safe_bytes,
|
||||
f"help_center/{current_user.organization_id}",
|
||||
file_name,
|
||||
content_type=content_type,
|
||||
)
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.delete("/logo", response_model=HelpCenterSettingsResponse)
|
||||
async def remove_logo(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
row.logo_url = None
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.post("/favicon", response_model=HelpCenterSettingsResponse)
|
||||
async def upload_favicon(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
|
||||
content = await file.read()
|
||||
safe_bytes, content_type, ext = sanitize_image(
|
||||
content,
|
||||
allowed_content_types=_FAVICON_TYPES,
|
||||
max_bytes=MAX_FAVICON_BYTES,
|
||||
max_dim=MAX_FAVICON_DIM,
|
||||
fit=FAVICON_FIT_DIM,
|
||||
)
|
||||
file_name = f"{uuid4()}{ext}"
|
||||
row.favicon_url = await store_upload(
|
||||
safe_bytes,
|
||||
f"help_center/{current_user.organization_id}",
|
||||
file_name,
|
||||
content_type=content_type,
|
||||
)
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.delete("/favicon", response_model=HelpCenterSettingsResponse)
|
||||
async def remove_favicon(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
row.favicon_url = None
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Custom-domain lifecycle for the help center: claim, verify (DNS + SSL),
|
||||
inspect status, remove.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.core.cors import update_cors_middleware
|
||||
from app.database import get_db
|
||||
from app.models.schemas.help_center import DomainRequest, DomainStatusResponse
|
||||
from app.models.user import User
|
||||
from app.services.domain_verification import (
|
||||
clear_custom_domain,
|
||||
set_custom_domain,
|
||||
verify_custom_domain,
|
||||
)
|
||||
from app.services.help_center_access import check_help_center_access
|
||||
from app.services.help_center_settings import get_or_create_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _domain_response(row) -> DomainStatusResponse:
|
||||
from app.api.help_center.branding import domain_status_response
|
||||
return domain_status_response(row)
|
||||
|
||||
|
||||
@router.post("/domain", response_model=DomainStatusResponse)
|
||||
async def set_domain(
|
||||
payload: DomainRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
try:
|
||||
row = set_custom_domain(db, row, payload.domain)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="That domain is already in use.")
|
||||
return _domain_response(row)
|
||||
|
||||
|
||||
@router.delete("/domain", response_model=DomainStatusResponse)
|
||||
async def remove_domain(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
return _domain_response(clear_custom_domain(db, row))
|
||||
|
||||
|
||||
@router.post("/domain/verify", response_model=DomainStatusResponse)
|
||||
async def verify_domain(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
if not row.custom_domain:
|
||||
raise HTTPException(status_code=400, detail="Set a custom domain first.")
|
||||
# DNS + HTTPS probes block; keep them off the event loop.
|
||||
row = await asyncio.to_thread(verify_custom_domain, db, row)
|
||||
# Once verified, the custom domain becomes a widget origin — allow it in CORS.
|
||||
if row.domain_verified:
|
||||
update_cors_middleware(request.app)
|
||||
return _domain_response(row)
|
||||
|
||||
|
||||
@router.get("/domain/status", response_model=DomainStatusResponse)
|
||||
async def domain_status(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Stored state only — cheap enough for the UI's pending-domain polling.
|
||||
POST /domain/verify is the endpoint that re-runs live DNS checks."""
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
return _domain_response(row)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.database import get_db
|
||||
from app.models.faq import FAQ, FAQStatus
|
||||
from app.models.schemas.faq import (
|
||||
FAQBulkDeleteRequest,
|
||||
FAQBulkStatusRequest,
|
||||
FAQCreate,
|
||||
FAQListResponse,
|
||||
FAQResponse,
|
||||
FAQUpdate,
|
||||
)
|
||||
from app.models.schemas.pagination import Pagination
|
||||
from app.models.user import User
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.services.help_center_access import check_help_center_access
|
||||
from app.services.image_security import sanitize_image
|
||||
from app.core.params import PageNumber
|
||||
from app.services.help_center_images import (
|
||||
FAQ_IMAGE_FIT_DIM,
|
||||
FAQ_IMAGE_MAX_DIM,
|
||||
FAQ_IMAGE_TYPES,
|
||||
MAX_FAQ_IMAGE_BYTES,
|
||||
store_article_image,
|
||||
)
|
||||
from app.services.help_center_settings import (
|
||||
generate_faq_slug,
|
||||
normalize_url_path,
|
||||
resolve_faq_slug,
|
||||
resolve_faq_url_path,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_owned_faq(faq_id: UUID, current_user: User, db: Session) -> FAQ:
|
||||
faq = FAQRepository(db).get_by_id(faq_id, current_user.organization_id)
|
||||
# 404 for both missing and cross-org ids (don't reveal existence).
|
||||
if not faq:
|
||||
raise HTTPException(status_code=404, detail="FAQ not found")
|
||||
return faq
|
||||
|
||||
|
||||
@router.get("/faqs", response_model=FAQListResponse)
|
||||
async def list_faqs(
|
||||
status: Optional[FAQStatus] = Query(default=None),
|
||||
category: Optional[str] = Query(default=None),
|
||||
q: Optional[str] = Query(default=None, max_length=200),
|
||||
page: PageNumber = 1,
|
||||
page_size: int = Query(default=100, ge=1, le=200),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items, total = FAQRepository(db).list_for_org(
|
||||
current_user.organization_id,
|
||||
status=status,
|
||||
category=category,
|
||||
search=q,
|
||||
skip=(page - 1) * page_size,
|
||||
limit=page_size,
|
||||
)
|
||||
return FAQListResponse(
|
||||
faqs=[FAQResponse.model_validate(item) for item in items],
|
||||
pagination=Pagination.build(total=total, page=page, page_size=page_size),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faqs/categories", response_model=List[str])
|
||||
async def list_categories(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return FAQRepository(db).get_categories(current_user.organization_id)
|
||||
|
||||
|
||||
@router.post("/faqs", response_model=FAQResponse, status_code=201)
|
||||
async def create_faq(
|
||||
payload: FAQCreate,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
org_id = current_user.organization_id
|
||||
faq = FAQ(
|
||||
organization_id=org_id,
|
||||
question=payload.question,
|
||||
answer=payload.answer,
|
||||
category=payload.category,
|
||||
status=payload.status,
|
||||
# A hand-typed slug is normalized and de-duplicated the same way a
|
||||
# generated one is; omitting it derives the slug from the question.
|
||||
slug=(
|
||||
resolve_faq_slug(db, org_id, payload.slug)
|
||||
if payload.slug
|
||||
else generate_faq_slug(db, org_id, payload.question)
|
||||
),
|
||||
url_path=_resolve_url_path(db, org_id, payload.url_path),
|
||||
meta_title=payload.meta_title,
|
||||
meta_description=payload.meta_description,
|
||||
source_label="Added manually",
|
||||
created_by=current_user.id,
|
||||
)
|
||||
return FAQResponse.model_validate(FAQRepository(db).create(faq))
|
||||
|
||||
|
||||
def _resolve_url_path(
|
||||
db: Session, org_id: UUID, requested: Optional[str], exclude_id: Optional[UUID] = None
|
||||
) -> Optional[str]:
|
||||
"""Validate a hand-typed preserved URL path, or raise. None passes through
|
||||
(no path requested / cleared). Unlike the slug this is never auto-corrected:
|
||||
an altered path is no longer the URL the org migrated in, so a bad one is
|
||||
surfaced instead of silently changed."""
|
||||
if not requested:
|
||||
return None
|
||||
if normalize_url_path(requested) is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="That isn't a usable URL path — it can't be empty, or start with a "
|
||||
"reserved prefix like /a/, /ask or /api.",
|
||||
)
|
||||
resolved = resolve_faq_url_path(db, org_id, requested, exclude_id=exclude_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=409, detail="Another article already uses that URL path.")
|
||||
return resolved
|
||||
|
||||
|
||||
@router.post("/faqs/image")
|
||||
async def upload_faq_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload an image for embedding in an article's Markdown. Returns a stable
|
||||
absolute URL the editor inserts as ."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
content = await file.read()
|
||||
# Validate, bomb-guard, downscale and re-encode from decoded pixels — strips
|
||||
# metadata/payloads and normalises to PNG/JPEG (content type is derived here,
|
||||
# never from the client filename).
|
||||
safe_bytes, content_type, _ext = sanitize_image(
|
||||
content,
|
||||
allowed_content_types=set(FAQ_IMAGE_TYPES),
|
||||
max_bytes=MAX_FAQ_IMAGE_BYTES,
|
||||
max_dim=FAQ_IMAGE_MAX_DIM,
|
||||
fit=FAQ_IMAGE_FIT_DIM,
|
||||
)
|
||||
return {"url": await store_article_image(safe_bytes, content_type)}
|
||||
|
||||
|
||||
@router.put("/faqs/{faq_id}", response_model=FAQResponse)
|
||||
async def update_faq(
|
||||
faq_id: UUID,
|
||||
payload: FAQUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
faq = _get_owned_faq(faq_id, current_user, db)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
# The slug is never taken verbatim from the client — normalize and dedupe it
|
||||
# (excluding this row, so re-saving an unchanged slug isn't a self-collision).
|
||||
requested_slug = updates.pop("slug", None)
|
||||
# Note the asymmetry with the slug: a blank slug is a no-op (an article must
|
||||
# always have one), but a blank url_path CLEARS it, moving the article back
|
||||
# to /a/{slug} — so this branches on presence, not truthiness.
|
||||
has_url_path = "url_path" in updates
|
||||
requested_url_path = updates.pop("url_path", None)
|
||||
for field, value in updates.items():
|
||||
setattr(faq, field, value)
|
||||
if requested_slug:
|
||||
faq.slug = resolve_faq_slug(
|
||||
db, current_user.organization_id, requested_slug, exclude_id=faq.id
|
||||
)
|
||||
if has_url_path:
|
||||
faq.url_path = _resolve_url_path(
|
||||
db, current_user.organization_id, requested_url_path, exclude_id=faq.id
|
||||
)
|
||||
# Backfill a slug for legacy/generated FAQs the first time they're edited;
|
||||
# otherwise keep it stable so published article URLs never change on their own.
|
||||
if not faq.slug:
|
||||
faq.slug = generate_faq_slug(db, current_user.organization_id, faq.question)
|
||||
return FAQResponse.model_validate(FAQRepository(db).update(faq))
|
||||
|
||||
|
||||
@router.delete("/faqs/{faq_id}", status_code=204)
|
||||
async def delete_faq(
|
||||
faq_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
faq = _get_owned_faq(faq_id, current_user, db)
|
||||
FAQRepository(db).delete(faq)
|
||||
|
||||
|
||||
@router.post("/faqs/bulk-status")
|
||||
async def bulk_set_status(
|
||||
payload: FAQBulkStatusRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Publish/unpublish one or many FAQs in a single call."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
updated = FAQRepository(db).bulk_set_status(
|
||||
current_user.organization_id, payload.faq_ids, payload.status
|
||||
)
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.post("/faqs/bulk-delete")
|
||||
async def bulk_delete(
|
||||
payload: FAQBulkDeleteRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete many FAQs in one call (org-scoped; foreign ids are ignored)."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
deleted = FAQRepository(db).bulk_delete(current_user.organization_id, payload.faq_ids)
|
||||
return {"deleted": deleted}
|
||||
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.core.config import settings
|
||||
from app.core.file_validation import PDF_MAGIC, read_validated, safe_filename
|
||||
from app.database import get_db
|
||||
from app.knowledge.url_safety import resolves_to_blocked_host
|
||||
from app.models.faq_generation_job import FAQGenerationJob, FAQJobType
|
||||
from app.models.knowledge import Knowledge
|
||||
from app.models.schemas.faq import (
|
||||
GenerateRequest,
|
||||
GenerationEstimateResponse,
|
||||
GenerationJobResponse,
|
||||
GenerationSourceResponse,
|
||||
ImportRequest,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.repositories.ai_config import AIConfigRepository
|
||||
from app.repositories.faq_generation_job import FAQGenerationJobRepository
|
||||
from app.services.faq_usage import (
|
||||
ensure_generation_budget,
|
||||
estimate_generation_calls,
|
||||
generation_is_metered,
|
||||
remaining_message_credits,
|
||||
)
|
||||
from app.services.help_center_access import check_help_center_access
|
||||
from app.core.params import DbIntId
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Budget floor for imports whose call count isn't known up front — blocks only
|
||||
# a fully exhausted plan; the per-call metering happens in the worker.
|
||||
MIN_IMPORT_CALL_ESTIMATE = 1
|
||||
|
||||
MAX_IMPORT_PDF_BYTES = 25 * 1024 * 1024
|
||||
# Stored under its own folder so import buffers never mix with article images.
|
||||
_PDF_IMPORT_FOLDER = "help_center_imports"
|
||||
|
||||
|
||||
def _require_ai_config(db: Session, organization_id) -> None:
|
||||
if not AIConfigRepository(db).get_active_config(organization_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No active AI configuration. Set up an AI model in AI Configuration first.",
|
||||
)
|
||||
|
||||
|
||||
def _enqueue(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
job_type: FAQJobType,
|
||||
estimated_calls: int = MIN_IMPORT_CALL_ESTIMATE,
|
||||
requires_llm: bool = True,
|
||||
**fields,
|
||||
) -> FAQGenerationJob:
|
||||
"""Shared enqueue guard: one active job per type per org (409 on dupes),
|
||||
fail fast when no AI model is configured, 402 when a metered run exceeds
|
||||
the remaining message credits. Stamps the job's `metered` flag from the
|
||||
org's current AI config. requires_llm=False (article import) skips the
|
||||
AI-config and credit checks entirely."""
|
||||
org_id = current_user.organization_id
|
||||
check_help_center_access(db, org_id)
|
||||
if requires_llm:
|
||||
_require_ai_config(db, org_id)
|
||||
ensure_generation_budget(db, org_id, estimated_calls)
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
# Clear jobs a dead worker left in 'processing' so they don't block this
|
||||
# enqueue (the one-active-per-type guard + partial unique index).
|
||||
job_repo.fail_orphaned_processing(
|
||||
"Worker stopped before the job finished — please try again.",
|
||||
older_than_seconds=settings.FAQ_JOB_STALE_SECONDS,
|
||||
organization_id=org_id,
|
||||
)
|
||||
if job_repo.get_active_for_org(org_id, job_type):
|
||||
raise HTTPException(status_code=409, detail="A job of this type is already running.")
|
||||
try:
|
||||
return job_repo.create(
|
||||
FAQGenerationJob(
|
||||
organization_id=org_id,
|
||||
user_id=current_user.id,
|
||||
job_type=job_type.value,
|
||||
metered=requires_llm and generation_is_metered(db, org_id),
|
||||
**fields,
|
||||
)
|
||||
)
|
||||
except IntegrityError:
|
||||
# Concurrent enqueue lost the race against the partial unique index.
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="A job of this type is already running.")
|
||||
|
||||
|
||||
def _validate_org_knowledge_ids(db: Session, organization_id, knowledge_ids) -> None:
|
||||
if not knowledge_ids:
|
||||
return
|
||||
owned = {
|
||||
row[0]
|
||||
for row in db.query(Knowledge.id)
|
||||
.filter(Knowledge.organization_id == organization_id, Knowledge.id.in_(knowledge_ids))
|
||||
.all()
|
||||
}
|
||||
if set(knowledge_ids) - owned:
|
||||
raise HTTPException(status_code=400, detail="Unknown knowledge source in selection.")
|
||||
|
||||
|
||||
@router.get("/generate/estimate", response_model=GenerationEstimateResponse)
|
||||
async def generation_estimate(
|
||||
include_pages: bool = Query(
|
||||
default=True,
|
||||
description="False skips the per-source page scan — cheap counts for button labels",
|
||||
),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Pre-generation numbers for the confirm dialog: new (ungenerated)
|
||||
sources, page count, estimated LLM calls and remaining credits."""
|
||||
org_id = current_user.organization_id
|
||||
check_help_center_access(db, org_id)
|
||||
estimate = estimate_generation_calls(db, org_id, count_pages=include_pages)
|
||||
metered = generation_is_metered(db, org_id)
|
||||
return GenerationEstimateResponse(
|
||||
total_sources=estimate.total_sources,
|
||||
new_sources=estimate.new_sources,
|
||||
pages=estimate.pages,
|
||||
estimated_calls=estimate.estimated_calls,
|
||||
metered=metered,
|
||||
remaining_credits=remaining_message_credits(db, org_id) if metered else None,
|
||||
sources=[
|
||||
GenerationSourceResponse(
|
||||
id=s.id, name=s.name, source_type=s.source_type,
|
||||
has_faqs=s.has_faqs, pages=s.pages, estimated_calls=s.estimated_calls,
|
||||
)
|
||||
for s in estimate.sources
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerationJobResponse, status_code=202)
|
||||
async def start_generation(
|
||||
payload: Optional[GenerateRequest] = None,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Draft FAQs from the knowledge base (additive; the worker dedups against
|
||||
existing questions). By default only sources without FAQs are read;
|
||||
knowledge_ids explicitly targets sources instead."""
|
||||
org_id = current_user.organization_id
|
||||
# Plan gate and AI-config checks first: a locked plan must see 403 and a
|
||||
# missing model its setup hint, not source-count errors.
|
||||
check_help_center_access(db, org_id)
|
||||
_require_ai_config(db, org_id)
|
||||
knowledge_ids = payload.knowledge_ids if payload else None
|
||||
_validate_org_knowledge_ids(db, org_id, knowledge_ids)
|
||||
estimate = estimate_generation_calls(db, org_id, knowledge_ids)
|
||||
if estimate.total_sources == 0:
|
||||
raise HTTPException(status_code=400, detail="No knowledge sources to generate from. Add knowledge first.")
|
||||
if estimate.new_sources == 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="All knowledge sources already have FAQs. Delete a source's FAQs to regenerate it.",
|
||||
)
|
||||
job = _enqueue(
|
||||
db,
|
||||
current_user,
|
||||
FAQJobType.GENERATE_ALL,
|
||||
estimated_calls=estimate.estimated_calls,
|
||||
knowledge_ids=knowledge_ids,
|
||||
)
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
|
||||
|
||||
@router.post("/import", response_model=GenerationJobResponse, status_code=202)
|
||||
async def start_import(
|
||||
payload: ImportRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Migrate an existing help center. mode=qa extracts Q&A pairs from the
|
||||
page with the org's model (uses credits); mode=articles crawls the page's
|
||||
linked article pages and imports each one as-is (Markdown, no LLM)."""
|
||||
# Early SSRF rejection for immediate feedback; the worker re-validates at
|
||||
# fetch time (including every redirect hop).
|
||||
if resolves_to_blocked_host(payload.url):
|
||||
raise HTTPException(status_code=400, detail="That URL points to a blocked or internal host.")
|
||||
if payload.mode == "articles":
|
||||
# No LLM → no AI-config requirement, no credit checks.
|
||||
job = _enqueue(
|
||||
db, current_user, FAQJobType.IMPORT_ARTICLES,
|
||||
requires_llm=False, source_url=payload.url,
|
||||
preserve_source_urls=payload.preserve_urls,
|
||||
)
|
||||
else:
|
||||
job = _enqueue(db, current_user, FAQJobType.IMPORT_URL, source_url=payload.url)
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
|
||||
|
||||
@router.post("/import/pdf", response_model=GenerationJobResponse, status_code=202)
|
||||
async def start_pdf_import(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Extract Q&A pairs from an uploaded PDF with the org's model. The file
|
||||
is persisted via shared storage (the worker runs in another container)
|
||||
and deleted after processing."""
|
||||
from app.services.file_storage import store_upload
|
||||
|
||||
content = await read_validated(
|
||||
file,
|
||||
max_size=MAX_IMPORT_PDF_BYTES,
|
||||
allowed_content_types=("application/pdf",),
|
||||
magic_prefix=PDF_MAGIC,
|
||||
label="PDF",
|
||||
)
|
||||
stored = await store_upload(
|
||||
content=content,
|
||||
folder=_PDF_IMPORT_FOLDER,
|
||||
file_name=f"{uuid4().hex}.pdf",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
# safe_filename strips traversal; keep only the readable tail for the label.
|
||||
original = safe_filename(file.filename or "document.pdf").split("_", 1)[-1][:255]
|
||||
try:
|
||||
job = _enqueue(
|
||||
db, current_user, FAQJobType.IMPORT_PDF,
|
||||
source_url=stored, source_file_name=original,
|
||||
)
|
||||
except HTTPException:
|
||||
# Enqueue refused (plan/config/duplicate/budget) — don't leak the buffer.
|
||||
from app.services.file_storage import delete_upload
|
||||
await delete_upload(stored)
|
||||
raise
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=Optional[GenerationJobResponse])
|
||||
async def get_job(
|
||||
active: bool = Query(default=True, description="Active job only; false = most recent job"),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""The UI's polling endpoint: the running job if any (active=true), or the
|
||||
most recent job of any status (active=false) for showing the last outcome."""
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
if active:
|
||||
job = job_repo.get_active_for_org(current_user.organization_id)
|
||||
else:
|
||||
job = job_repo.get_latest_for_org(current_user.organization_id)
|
||||
return GenerationJobResponse.model_validate(job) if job else None
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=GenerationJobResponse)
|
||||
async def get_job_by_id(
|
||||
job_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
job = FAQGenerationJobRepository(db).get_by_id_for_org(job_id, current_user.organization_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
Reference in new issue
Block a user