Files
egbim_qa_platform/apps/secretary-api/app/api/routes/tickets.py
T
root aaddfc7bfc
Deploy feedback demo / deploy (push) Failing after 4m26s
API 적용 최초 배포
2026-09-01 17:12:46 +09:00

808 lines
27 KiB
Python

import json
from typing import Annotated, cast
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from urllib.parse import quote
from app.core.auth import SsoPrincipal, get_principal
from app.db.session import get_db
from app.services.access_service import AccessService
from app.schemas.ticket import (
SupportTicketCommentRecord,
SupportTicketRecord,
TicketCommentCreateRequest,
TicketIssueLinkStatusSyncRequest,
TicketAssigneeCandidate,
TicketAssigneeUpdateRequest,
InternalMemoCreateRequest,
InternalMemoUpdateRequest,
TicketCommentUpdateRequest,
TicketCreateRequest,
TicketCreateResponse,
SupportFeedbackMetrics,
SupportFeedbackStatusRecord,
FeedbackStatusUpdateRequest,
TicketUpdateRequest,
TicketMutationResponse,
TicketDeleteResponse,
WorkspaceFormTemplateResponse,
WorkspaceSummary,
)
from app.services.ticket_service import TicketService
from app.services.workspace_mapping_service import workspace_mapping_service
router = APIRouter(prefix="/workspaces", tags=["tickets"])
service = TicketService()
access_service = AccessService()
def can_manage_ticket_comments(
db: Session,
principal: SsoPrincipal,
ticket_id: int,
workspace_code: str | None,
abc_feedback_id: int | None,
) -> bool:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
abc_feedback_id=abc_feedback_id,
)
if ticket is None:
return False
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
)
return access.can_manage or access.workspace_role in {
"SYSTEM_ADMIN",
"SUPER_ADMIN",
"PROJECT_MANAGER",
}
def require_ticket_access(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SsoPrincipal:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspaceCode,
abc_feedback_id=abcFeedbackId,
)
if ticket is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
)
if (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
return principal
if (
ticket.requester_id != principal.user_id
or ticket.requester_tenant_id != principal.tenant_id
):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 지원 요청을 조회할 권한이 없습니다.")
return principal
def require_ticket_write_access(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SsoPrincipal:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspaceCode,
abc_feedback_id=abcFeedbackId,
)
if ticket is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
write=True,
)
if (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
return principal
if (
ticket.requester_id != principal.user_id
or ticket.requester_tenant_id != principal.tenant_id
):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 지원 요청을 변경할 권한이 없습니다.")
return principal
def require_ticket_manager_access(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SsoPrincipal:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspaceCode,
abc_feedback_id=abcFeedbackId,
)
if ticket is None:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
)
if not (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
raise HTTPException(status_code=403, detail="프로젝트 관리자 권한이 필요합니다.")
return principal
@router.get("", response_model=list[WorkspaceSummary])
def list_workspaces(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[WorkspaceSummary]:
workspace_mapping_service.sync_project_workspaces(db)
return [
WorkspaceSummary(
workspace_code=workspace.workspace_code,
workspace_name=workspace.workspace_name,
workspace_type=workspace.workspace_type,
)
for workspace, access in access_service.list_accessible_workspaces(db, principal)
if access.can_read or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
]
@router.get("/{workspace_code}/form-template", response_model=WorkspaceFormTemplateResponse)
def get_workspace_form_template(
workspace_code: str,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> WorkspaceFormTemplateResponse:
access_service.assert_workspace_access(db, principal, workspace_code)
return service.get_workspace_form_template(db, workspace_code)
@router.post(
"/{workspace_code}/tickets",
response_model=TicketCreateResponse,
response_description="생성된 지원 요청의 식별자와 동기화 상태",
summary="지원 요청 등록",
description=(
"application/json 또는 multipart/form-data로 지원 요청을 등록합니다. "
"multipart 요청은 title, description, category_code, ticket_type, is_secret, "
"requires_approval, extra_fields(JSON 문자열), attachments(반복 파일 필드)를 지원합니다. "
"작성자 식별자와 SSO 프로필은 인증 사용자 정보로 서버가 설정합니다."
),
openapi_extra={
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/TicketCreateRequest"},
},
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["title", "description"],
"properties": {
"title": {"type": "string", "minLength": 1, "maxLength": 255},
"description": {"type": "string", "minLength": 1},
"category_code": {"type": "string"},
"ticket_type": {"type": "string", "default": "GENERAL"},
"is_secret": {"type": "boolean", "default": False},
"requires_approval": {"type": "boolean", "default": False},
"extra_fields": {
"type": "string",
"description": "동적 필드 JSON 객체를 문자열로 전달",
},
"attachments": {
"type": "array",
"items": {"type": "string", "format": "binary"},
"description": "반복 가능한 첨부파일 필드",
},
},
},
},
},
}
},
)
async def create_ticket(
workspace_code: str,
request: Request,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> TicketCreateResponse:
access_service.assert_workspace_access(db, principal, workspace_code, write=True)
content_type = request.headers.get("content-type", "")
if content_type.startswith("multipart/form-data"):
form = await request.form()
extra_fields_value = form.get("extra_fields")
extra_fields = (
json.loads(extra_fields_value)
if isinstance(extra_fields_value, str) and extra_fields_value
else {}
)
attachments = [
cast(UploadFile, value)
for key, value in form.multi_items()
if key == "attachments"
and isinstance(value, (UploadFile, StarletteUploadFile))
]
request_payload = TicketCreateRequest(
workspace_code=workspace_code,
# Multipart writers do not send requester identity fields. Use
# the verified SSO principal before Pydantic validates the
# required fields; applying model_copy afterwards is too late
# because empty values fail min_length validation first.
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
requester_contact=str(form.get("requester_contact") or ""),
title=str(form.get("title") or ""),
description=str(form.get("description") or ""),
category_code=str(form.get("category_code") or ""),
ticket_type=str(form.get("ticket_type") or "GENERAL"),
is_secret=str(form.get("is_secret") or "false").lower() == "true",
requires_approval=str(form.get("requires_approval") or "false").lower() == "true",
extra_fields=extra_fields,
)
request_payload = request_payload.model_copy(update={
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"requester_email": principal.email,
"requester_name": principal.name,
"requester_department": principal.department,
"requester_phone_number": principal.phone_number,
})
return service.create_ticket(db, request_payload, attachments)
payload = TicketCreateRequest.model_validate(await request.json())
request_payload = payload.model_copy(update={
"workspace_code": workspace_code,
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"requester_email": principal.email,
"requester_name": principal.name,
"requester_department": principal.department,
"requester_phone_number": principal.phone_number,
})
return service.create_ticket(db, request_payload)
@router.get("/{workspace_code}/tickets", response_model=list[SupportTicketRecord])
def list_workspace_tickets(
workspace_code: str,
requesterId: str | None = None,
requesterTenantId: str | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportTicketRecord]:
access = access_service.assert_workspace_access(db, principal, workspace_code)
if access.can_manage or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}:
requesterId = None
requesterTenantId = None
else:
requesterId = principal.user_id
requesterTenantId = principal.tenant_id
return service.list_workspace_tickets(db, workspace_code, requesterId, requesterTenantId)
@router.get("/../tickets", include_in_schema=False)
def _noop_redirect() -> None:
return None
ticket_router = APIRouter(
prefix="/tickets",
tags=["support-tickets"],
dependencies=[Depends(get_principal)],
)
@ticket_router.get("/statuses", response_model=list[SupportFeedbackStatusRecord])
def get_feedback_statuses(
projectId: int,
channelId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportFeedbackStatusRecord]:
return [
SupportFeedbackStatusRecord.model_validate(item)
for item in service.list_feedback_statuses(db, principal, projectId, channelId)
]
@ticket_router.get("/metrics", response_model=SupportFeedbackMetrics)
def get_feedback_metrics(
projectId: int,
channelId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportFeedbackMetrics:
return SupportFeedbackMetrics.model_validate(
service.get_feedback_metrics(db, principal, projectId, channelId)
)
@ticket_router.get("", response_model=list[SupportTicketRecord])
def list_tickets(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportTicketRecord]:
if not access_service.is_system_admin(db, principal):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
return service.list_tickets(db)
@ticket_router.patch("/{ticket_id}/feedback-status", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_manager_access)])
def update_feedback_status(
ticket_id: int,
payload: FeedbackStatusUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
db: Session = Depends(get_db),
) -> TicketMutationResponse:
return service.update_feedback_status(
db,
ticket_id,
payload.feedback_status,
workspaceCode,
abcFeedbackId,
)
@ticket_router.get("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_access)])
def get_ticket(
ticket_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> SupportTicketRecord:
return service.get_ticket(db, ticket_id, workspaceCode)
@ticket_router.get("/{ticket_id}/attachments/{attachment_id}", dependencies=[Depends(require_ticket_access)])
def download_ticket_attachment(
ticket_id: int,
attachment_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> Response:
attachment = service.get_ticket_attachment(db, ticket_id, attachment_id, workspaceCode)
if attachment.storage_provider.upper() == "R2":
return Response(
content=service.read_attachment(attachment),
media_type=attachment.mime_type or "application/octet-stream",
headers={
"Content-Disposition": (
"attachment; filename*=UTF-8''"
+ quote(attachment.original_file_name)
)
},
)
file_path = service.resolve_attachment_path(attachment)
return FileResponse(
path=file_path,
media_type=attachment.mime_type or "application/octet-stream",
filename=attachment.original_file_name,
)
@ticket_router.put("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_write_access)])
def update_ticket(
ticket_id: int,
payload: TicketUpdateRequest,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> SupportTicketRecord:
return service.update_ticket(db, ticket_id, payload, workspaceCode)
@ticket_router.delete("/{ticket_id}", dependencies=[Depends(require_ticket_write_access)])
def delete_ticket(
ticket_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, int | bool]:
return service.delete_ticket(db, ticket_id, workspaceCode)
@ticket_router.get(
"/{ticket_id}/assignees",
response_model=list[TicketAssigneeCandidate],
dependencies=[Depends(require_ticket_manager_access)],
)
def list_ticket_assignees(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
db: Session = Depends(get_db),
) -> list[TicketAssigneeCandidate]:
return service.list_assignee_candidates(
db,
ticket_id,
workspaceCode,
abcFeedbackId,
)
@ticket_router.patch(
"/{ticket_id}/assignee",
response_model=SupportTicketRecord,
dependencies=[Depends(require_ticket_manager_access)],
)
def update_ticket_assignee(
ticket_id: int,
payload: TicketAssigneeUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
db: Session = Depends(get_db),
) -> SupportTicketRecord:
return service.update_assignee(
db,
ticket_id,
payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.get(
"/{ticket_id}/internal-memos",
response_model=list[SupportTicketCommentRecord],
response_description="관리자 전용 내부 메모 목록",
summary="내부 메모 목록 조회",
description=(
"관리자 권한이 있는 사용자만 조회할 수 있습니다. "
"일반 댓글은 반환하지 않으며, 내부 메모는 사용자용 댓글 응답에 포함되지 않습니다."
),
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def list_internal_memos(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportTicketCommentRecord]:
return service.list_internal_memos(
db,
ticket_id,
workspaceCode,
abcFeedbackId,
principal.user_id,
principal.tenant_id,
True,
)
@ticket_router.post(
"/{ticket_id}/internal-memos",
response_model=SupportTicketCommentRecord,
response_description="생성된 내부 메모",
summary="내부 메모 등록",
description="관리자 전용 메모를 등록합니다. 내부 메모는 고객용 댓글 목록에 노출되지 않습니다.",
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def create_internal_memo(
ticket_id: int,
payload: InternalMemoCreateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = TicketCommentCreateRequest(
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
author_name=principal.name or principal.email or principal.user_id,
content=payload.content,
is_internal=True,
)
return service.create_internal_memo(
db,
ticket_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.get(
"/{ticket_id}/comments",
response_model=list[SupportTicketCommentRecord],
response_description="공개 댓글 목록",
summary="공개 댓글 목록 조회",
description=(
"지원 요청의 공개 댓글만 반환합니다. 내부 메모는 관리자라도 이 경로에서는 반환하지 않고 "
"별도의 내부 메모 API로만 조회합니다."
),
tags=["comments"],
)
def list_ticket_comments(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(require_ticket_access),
db: Session = Depends(get_db),
) -> list[SupportTicketCommentRecord]:
can_manage_comments = can_manage_ticket_comments(
db,
principal,
ticket_id,
workspaceCode,
abcFeedbackId,
)
return service.list_ticket_comments(
db,
ticket_id,
workspaceCode,
abcFeedbackId,
principal.user_id,
principal.tenant_id,
can_manage_comments,
)
@ticket_router.post(
"/{ticket_id}/comments",
response_model=SupportTicketCommentRecord,
response_description="생성된 공개 댓글",
summary="공개 댓글 등록",
description=(
"공개 댓글을 등록합니다. 작성자와 테넌트는 요청 본문을 신뢰하지 않고 "
"SSO 인증 사용자 정보로 결정합니다. 관리자 전용 메모는 내부 메모 API를 사용합니다."
),
tags=["comments"],
dependencies=[Depends(require_ticket_write_access)],
)
def create_ticket_comment(
ticket_id: int,
payload: TicketCommentCreateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = payload.model_copy(update={
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"author_name": principal.name or principal.email or principal.user_id,
"is_internal": False,
})
return service.create_ticket_comment(
db,
ticket_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.post(
"/{ticket_id}/comments/attachments",
response_model=SupportTicketCommentRecord,
response_description="첨부파일이 연결된 생성 댓글",
summary="첨부파일과 함께 공개 댓글 등록",
description=(
"multipart/form-data로 공개 댓글과 첨부파일을 등록합니다. "
"content는 댓글 본문이고 attachments는 반복 가능한 파일 필드입니다. "
"내부 메모 여부와 작성자 정보는 인증 권한으로 서버가 결정합니다."
),
tags=["comments", "attachments"],
dependencies=[Depends(require_ticket_write_access)],
)
async def create_ticket_comment_with_attachments(
ticket_id: int,
content: Annotated[str, Form(description="공개 댓글 본문")] = "",
attachments: Annotated[
list[UploadFile] | None,
File(description="댓글에 연결할 파일. 같은 필드명을 반복해 여러 파일을 첨부할 수 있습니다."),
] = None,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
payload = TicketCommentCreateRequest(
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
author_name=principal.name or principal.email or principal.user_id,
content=content,
is_internal=False,
)
return service.create_ticket_comment(
db,
ticket_id,
payload,
workspaceCode,
abcFeedbackId,
attachments or [],
)
@ticket_router.put(
"/{ticket_id}/internal-memos/{memo_id}",
response_model=SupportTicketCommentRecord,
response_description="수정된 내부 메모",
summary="내부 메모 수정",
description="관리자 전용 내부 메모를 수정합니다.",
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def update_internal_memo(
ticket_id: int,
memo_id: int,
payload: InternalMemoUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = TicketCommentUpdateRequest(
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
content=payload.content,
)
return service.update_internal_memo(
db,
ticket_id,
memo_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.put(
"/{ticket_id}/comments/{comment_id}",
response_model=SupportTicketCommentRecord,
response_description="수정된 공개 댓글",
summary="공개 댓글 수정",
description="댓글 작성자 본인의 공개 댓글을 수정합니다. 내부 메모는 별도 내부 메모 API로 수정합니다.",
tags=["comments"],
dependencies=[Depends(require_ticket_write_access)],
)
def update_ticket_comment(
ticket_id: int,
comment_id: int,
payload: TicketCommentUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = payload.model_copy(update={
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
})
return service.update_ticket_comment(
db,
ticket_id,
comment_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.delete(
"/{ticket_id}/internal-memos/{memo_id}",
response_model=TicketDeleteResponse,
response_description="삭제 결과",
summary="내부 메모 삭제",
description="관리자 전용 내부 메모를 삭제합니다.",
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def delete_internal_memo(
ticket_id: int,
memo_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, int | bool]:
return service.delete_internal_memo(
db,
ticket_id,
memo_id,
principal.user_id,
principal.tenant_id,
workspaceCode,
abcFeedbackId,
True,
)
@ticket_router.delete(
"/{ticket_id}/comments/{comment_id}",
response_model=TicketDeleteResponse,
response_description="삭제 결과",
summary="공개 댓글 삭제",
description="댓글 작성자 본인 또는 관리자 권한으로 공개 댓글을 삭제합니다.",
tags=["comments"],
dependencies=[Depends(require_ticket_write_access)],
)
def delete_ticket_comment(
ticket_id: int,
comment_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, int | bool]:
return service.delete_ticket_comment(
db,
ticket_id,
comment_id,
principal.user_id,
principal.tenant_id,
workspaceCode,
abcFeedbackId,
can_manage_ticket_comments(
db,
principal,
ticket_id,
workspaceCode,
abcFeedbackId,
),
)
@ticket_router.post("/{ticket_id}/approve", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
def approve_ticket(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
return service.approve_ticket(db, ticket_id)
@ticket_router.post("/{ticket_id}/issue", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
def create_issue(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
return service.create_issue(db, ticket_id)
@ticket_router.post("/{ticket_id}/issue-link-status", dependencies=[Depends(require_ticket_write_access)])
def sync_issue_link_status(
ticket_id: int,
payload: TicketIssueLinkStatusSyncRequest,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, int | str | bool]:
return service.sync_issue_link_status(db, ticket_id, payload.linked, workspaceCode)