Initial deployment setup
Deploy staging / deploy (push) Failing after 6s

This commit is contained in:
root
2026-08-31 16:45:24 +09:00
commit 33453ecc55
3475 changed files with 850363 additions and 0 deletions
@@ -0,0 +1 @@
"""API route modules."""
+595
View File
@@ -0,0 +1,595 @@
from fastapi import APIRouter, Depends, HTTPException
from urllib.parse import quote
from pydantic import BaseModel, Field
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.auth import SsoPrincipal, get_principal
from app.db.models import (
SupportRole,
SupportRoleAssignment,
SupportUser,
UserWorkspaceAccess,
Workspace,
WorkspaceChannelMapping,
)
from app.db.session import get_db
from app.core.config import ABCProjectTarget, settings
from app.services.access_service import AccessService
from app.services.workspace_mapping_service import workspace_mapping_service
router = APIRouter(prefix="/access", tags=["access"])
service = AccessService()
def abc_project_target(
db: Session,
workspace_id: int,
) -> ABCProjectTarget | None:
mapping = (
db.query(WorkspaceChannelMapping)
.filter(
WorkspaceChannelMapping.workspace_id == workspace_id,
WorkspaceChannelMapping.is_active.is_(True),
WorkspaceChannelMapping.abc_project_id.is_not(None),
)
.order_by(WorkspaceChannelMapping.id.asc())
.first()
)
if mapping is None or mapping.abc_project_id is None:
return None
try:
project_id = int(mapping.abc_project_id)
channel_id = int(mapping.abc_channel_id)
except (TypeError, ValueError):
return None
if project_id <= 0 or channel_id <= 0:
return None
return ABCProjectTarget(project_id=project_id, channel_id=channel_id)
def candidate_tenant_ids(value: str | None = None) -> list[str]:
raw_value = settings.admin_candidate_tenant_id if value is None else value
return [
tenant_id.strip()
for tenant_id in raw_value.split(",")
if tenant_id.strip()
]
def candidate_scope_filter(tenant_ids: str | list[str]):
configured_tenant_ids = (
candidate_tenant_ids(tenant_ids)
if isinstance(tenant_ids, str)
else tenant_ids
)
return or_(
*[
condition
for tenant_id in configured_tenant_ids
for condition in (
SupportUser.tenant_id == tenant_id,
func.json_contains(
func.coalesce(SupportUser.tenant_ids, "[]"),
func.json_quote(tenant_id),
) == 1,
)
],
)
def normalize_phone_number(value: str | None) -> str:
if not value:
return ""
digits = "".join(character for character in value if character.isdigit())
if digits.startswith("82") and len(digits) >= 11:
return "0" + digits[2:]
return digits
def is_initial_super_admin(principal: SsoPrincipal) -> bool:
configured_phone = normalize_phone_number(
settings.initial_super_admin_phone_number,
)
return bool(configured_phone) and normalize_phone_number(
principal.phone_number,
) == configured_phone
def is_candidate_tenant_user(principal: SsoPrincipal) -> bool:
configured_tenant_ids = candidate_tenant_ids()
return bool(configured_tenant_ids) and any(
principal.tenant_id == candidate_tenant_id
or candidate_tenant_id in principal.tenant_ids
for candidate_tenant_id in configured_tenant_ids
)
def has_global_admin(db: Session) -> bool:
return (
db.query(SupportRoleAssignment)
.join(SupportUser)
.filter(
SupportUser.status == "ACTIVE",
SupportRoleAssignment.role_code.in_(service.GLOBAL_ADMIN_ROLES),
)
.first()
is not None
)
def can_manage_admins(db: Session, principal: SsoPrincipal) -> bool:
service.ensure_support_user(db, principal)
db.commit()
if service.is_system_admin(db, principal):
return True
return not has_global_admin(db) and is_initial_super_admin(principal) and is_candidate_tenant_user(principal)
class WorkspaceAccessRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=100)
tenant_id: str = Field(min_length=1, max_length=100)
workspace_role: str = Field(default="END_USER", max_length=30)
can_read: bool = True
can_write: bool = False
can_manage: bool = False
can_approve: bool = False
page_scope: str | None = Field(default=None, max_length=50)
class AdminAssignmentRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=100)
tenant_id: str = Field(min_length=1, max_length=100)
email: str | None = Field(default=None, max_length=320)
name: str | None = Field(default=None, max_length=100)
department: str | None = Field(default=None, max_length=100)
phone_number: str | None = Field(default=None, max_length=50)
tenant_ids: list[str] | None = None
role_code: str = Field(min_length=1, max_length=40)
workspace_code: str | None = Field(default=None, max_length=50)
@router.get("/me")
def get_my_access(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
workspace_mapping_service.sync_project_workspaces(db)
workspace_mapping_service.sync_missing_mappings(db)
entries = service.list_accessible_workspaces(db, principal)
is_system_admin = service.is_system_admin(db, principal)
support_entries = [
(workspace, access)
for workspace, access in entries
if access.can_read
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
]
if not support_entries and not is_system_admin:
entries = service.ensure_default_support_access(db, principal)
manager_entries = [
(workspace, access)
for workspace, access in entries
if access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
or access.can_manage
]
support_entries = [
(workspace, access)
for workspace, access in entries
if access.can_read or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
]
default_admin_path = None
admin_workspace_code = None
for workspace, access in manager_entries:
target = abc_project_target(db, workspace.id)
if admin_workspace_code is None:
admin_workspace_code = workspace.workspace_code
if target is None:
continue
admin_workspace_code = workspace.workspace_code
default_admin_path = (
f"/main/project/{target.project_id}/feedback"
f"?channelId={target.channel_id}"
)
break
default_support_path = None
default_support_create_path = None
if support_entries:
workspace_code = support_entries[0][0].workspace_code
encoded_workspace_code = quote(workspace_code, safe='')
default_support_path = f"/support/{encoded_workspace_code}/list"
default_support_create_path = f"/support/{encoded_workspace_code}/new"
return {
"user_id": principal.user_id,
"tenant_id": principal.tenant_id,
"email": principal.email,
"name": principal.name,
"department": principal.department,
"phone_number": principal.phone_number,
"is_system_admin": is_system_admin,
"is_admin": bool(manager_entries),
"roles": sorted({access.workspace_role for _, access in entries}),
"workspaces": [
{
"workspace_code": workspace.workspace_code,
"workspace_name": workspace.workspace_name,
"workspace_role": access.workspace_role,
"can_read": access.can_read,
"can_write": access.can_write,
"can_manage": access.can_manage,
"can_approve": access.can_approve,
"page_scope": access.page_scope,
"abc_project_id": (
abc_project_target(db, workspace.id).project_id
if abc_project_target(db, workspace.id)
else None
),
"abc_channel_id": (
abc_project_target(db, workspace.id).channel_id
if abc_project_target(db, workspace.id)
else None
),
}
for workspace, access in entries
],
"default_admin_path": default_admin_path,
"admin_workspace_code": admin_workspace_code,
"default_support_path": default_support_path,
"default_support_create_path": default_support_create_path,
}
@router.post("/workspaces/{workspace_code}/users")
def upsert_workspace_access(
workspace_code: str,
payload: WorkspaceAccessRequest,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not service.is_system_admin(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
workspace = db.query(Workspace).filter_by(
workspace_code=workspace_code,
is_active=True,
).one_or_none()
if workspace is None:
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
if payload.workspace_role not in service.SUPPORTED_WORKSPACE_ROLES:
raise HTTPException(status_code=400, detail="지원하지 않는 workspace 역할입니다.")
support_user = db.query(SupportUser).filter_by(
sso_subject=payload.user_id,
tenant_id=payload.tenant_id,
).one_or_none()
if support_user is None:
support_user = SupportUser(
sso_subject=payload.user_id,
tenant_id=payload.tenant_id,
)
db.add(support_user)
db.flush()
assignment = db.query(SupportRoleAssignment).filter_by(
support_user_id=support_user.id,
workspace_id=workspace.id,
).filter(SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES)).first()
if assignment is None:
assignment = SupportRoleAssignment(
support_user_id=support_user.id,
workspace_id=workspace.id,
role_code=payload.workspace_role,
)
db.add(assignment)
else:
assignment.role_code = payload.workspace_role
access = db.query(UserWorkspaceAccess).filter_by(
user_id=payload.user_id,
tenant_id=payload.tenant_id,
workspace_id=workspace.id,
).one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=payload.user_id,
tenant_id=payload.tenant_id,
workspace_id=workspace.id,
)
db.add(access)
service._apply_role_permissions(access, payload.workspace_role)
access.page_scope = payload.page_scope
db.commit()
db.refresh(access)
return {
"id": access.id,
"user_id": access.user_id,
"tenant_id": access.tenant_id,
"workspace_code": workspace.workspace_code,
"workspace_role": access.workspace_role,
}
@router.get("/candidates")
def list_admin_candidates(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not can_manage_admins(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
configured_tenant_ids = candidate_tenant_ids()
users_query = db.query(SupportUser).filter(SupportUser.status == "ACTIVE")
if configured_tenant_ids:
users_query = users_query.filter(candidate_scope_filter(configured_tenant_ids))
else:
# System administrators are already authorized to manage the current
# tenant. Do not make the assignment dialog unusable just because the
# optional initial-admin candidate scope is not configured. The POST
# endpoint applies the same current-tenant boundary when this setting
# is empty.
if not service.is_system_admin(db, principal):
return {"items": []}
users_query = users_query.filter(SupportUser.tenant_id == principal.tenant_id)
users = users_query.order_by(SupportUser.id.asc()).all()
return {
"items": [
{
"email": user.email or user.sso_subject,
"isRegistered": True,
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"department": user.department,
"phone_number": user.phone_number,
"oauthSubject": user.sso_subject,
"oauthTenantId": user.tenant_id,
"tenantIds": user.tenant_ids or [],
},
}
for user in users
],
}
@router.get("/admins")
def list_admin_assignments(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not can_manage_admins(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
project_workspace_codes = workspace_mapping_service.sync_project_workspaces(db)
roles = db.query(SupportRole).order_by(SupportRole.code.asc()).all()
workspace_query = db.query(Workspace).filter(
Workspace.is_active.is_(True),
Workspace.workspace_type == "SOFTWARE_APP",
)
# Limit the selector to projects currently returned by ABC. This avoids
# exposing legacy software_app seed rows that are not ABC projects.
if project_workspace_codes is not None:
workspace_query = workspace_query.filter(
Workspace.workspace_code.in_(project_workspace_codes),
)
workspaces = workspace_query.order_by(Workspace.id.asc()).all()
configured_tenant_ids = candidate_tenant_ids()
assignment_scope = (
candidate_scope_filter(configured_tenant_ids)
if configured_tenant_ids
else SupportUser.tenant_id == principal.tenant_id
)
assignments = (
db.query(SupportRoleAssignment)
.join(SupportUser)
.filter(
SupportUser.status == "ACTIVE",
assignment_scope,
SupportRoleAssignment.role_code.in_(
service.GLOBAL_ADMIN_ROLES | {"PROJECT_MANAGER", "FEEDBACK_PROVIDER"},
),
)
.order_by(SupportRoleAssignment.created_at.asc())
.all()
)
users: dict[int, dict[str, object]] = {}
for assignment in assignments:
user = assignment.user
item = users.setdefault(
user.id,
{
"id": user.id,
"user_id": user.sso_subject,
"tenant_id": user.tenant_id,
"email": user.email,
"name": user.name,
"department": user.department,
"phone_number": user.phone_number,
"assignments": [],
},
)
item["assignments"].append(
{
"id": assignment.id,
"role_code": assignment.role_code,
"role_name": assignment.role.name if assignment.role else assignment.role_code,
"workspace_id": assignment.workspace_id,
"workspace_code": assignment.workspace.workspace_code if assignment.workspace else None,
"workspace_name": assignment.workspace.workspace_name if assignment.workspace else None,
},
)
return {
"roles": [
{
"code": role.code,
"name": role.name,
"description": role.description,
}
for role in roles
],
"workspaces": [
{
"id": workspace.id,
"code": workspace.workspace_code,
"name": workspace.workspace_name,
}
for workspace in workspaces
],
"users": list(users.values()),
}
@router.post("/admins")
def upsert_admin_assignment(
payload: AdminAssignmentRequest,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not can_manage_admins(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
configured_tenant_ids = candidate_tenant_ids()
if configured_tenant_ids:
target_user = db.query(SupportUser).filter_by(
sso_subject=payload.user_id,
tenant_id=payload.tenant_id,
status="ACTIVE",
).one_or_none()
if target_user is None or not (
target_user.tenant_id in configured_tenant_ids
or any(tenant_id in (target_user.tenant_ids or []) for tenant_id in configured_tenant_ids)
):
raise HTTPException(status_code=403, detail="지정된 후보 테넌트의 사용자만 관리할 수 있습니다.")
elif payload.tenant_id != principal.tenant_id:
raise HTTPException(status_code=403, detail="현재 tenant의 사용자만 관리할 수 있습니다.")
role_code = payload.role_code.upper()
if role_code not in service.ROLE_PERMISSIONS:
raise HTTPException(status_code=400, detail="지원하지 않는 역할입니다.")
workspace = None
if role_code in service.GLOBAL_ADMIN_ROLES:
if payload.workspace_code:
raise HTTPException(status_code=400, detail="전체 관리자 역할에는 workspace를 지정할 수 없습니다.")
else:
if not payload.workspace_code:
raise HTTPException(status_code=400, detail="workspace 역할에는 workspace가 필요합니다.")
workspace = db.query(Workspace).filter_by(
workspace_code=payload.workspace_code,
is_active=True,
).one_or_none()
if workspace is None:
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
user = service.ensure_support_user_identity(
db,
user_id=payload.user_id,
tenant_id=payload.tenant_id,
email=payload.email,
name=payload.name,
department=payload.department,
phone_number=payload.phone_number,
tenant_ids=payload.tenant_ids,
)
if workspace is not None:
existing_workspace_assignments = db.query(SupportRoleAssignment).filter(
SupportRoleAssignment.support_user_id == user.id,
SupportRoleAssignment.workspace_id == workspace.id,
SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES),
).all()
for existing in existing_workspace_assignments:
if existing.role_code != role_code:
db.delete(existing)
assignment = db.query(SupportRoleAssignment).filter_by(
support_user_id=user.id,
role_code=role_code,
workspace_id=workspace.id if workspace else None,
).one_or_none()
if assignment is None:
assignment = SupportRoleAssignment(
support_user_id=user.id,
role_code=role_code,
workspace_id=workspace.id if workspace else None,
)
db.add(assignment)
if workspace is not None:
access = db.query(UserWorkspaceAccess).filter_by(
user_id=user.sso_subject,
tenant_id=user.tenant_id,
workspace_id=workspace.id,
).one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=user.sso_subject,
tenant_id=user.tenant_id,
workspace_id=workspace.id,
)
db.add(access)
service._apply_role_permissions(access, role_code)
db.commit()
db.refresh(assignment)
return {
"id": assignment.id,
"user_id": user.sso_subject,
"tenant_id": user.tenant_id,
"role_code": role_code,
"workspace_code": workspace.workspace_code if workspace else None,
}
@router.delete("/admins/{assignment_id}")
def delete_admin_assignment(
assignment_id: int,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, bool]:
if not service.is_system_admin(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
assignment = db.query(SupportRoleAssignment).filter_by(id=assignment_id).one_or_none()
if assignment is None:
raise HTTPException(status_code=404, detail="권한 할당을 찾을 수 없습니다.")
if assignment.role_code in service.GLOBAL_ADMIN_ROLES:
remaining = db.query(SupportRoleAssignment).filter(
SupportRoleAssignment.role_code.in_(service.GLOBAL_ADMIN_ROLES),
SupportRoleAssignment.id != assignment.id,
).count()
if remaining == 0:
raise HTTPException(status_code=400, detail="전체 관리자는 최소 1명 이상 유지해야 합니다.")
workspace_id = assignment.workspace_id
support_user_id = assignment.support_user_id
db.delete(assignment)
if workspace_id is not None:
remaining_role = db.query(SupportRoleAssignment).filter(
SupportRoleAssignment.support_user_id == support_user_id,
SupportRoleAssignment.workspace_id == workspace_id,
SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES),
).order_by(SupportRoleAssignment.created_at.desc()).first()
access = db.query(UserWorkspaceAccess).filter_by(
workspace_id=workspace_id,
user_id=assignment.user.sso_subject,
tenant_id=assignment.user.tenant_id,
).one_or_none()
if access is not None:
if remaining_role is None:
db.delete(access)
else:
service._apply_role_permissions(access, remaining_role.role_code)
db.commit()
return {"deleted": True}
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
@@ -0,0 +1,74 @@
from fastapi import APIRouter, Header, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.models import (
SupportRoleAssignment,
SupportUser,
Workspace,
WorkspaceChannelMapping,
)
from app.db.session import get_db
from fastapi import Depends
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get("/notification-recipients")
def list_notification_recipients(
project_id: int = Query(gt=0),
channel_id: int = Query(gt=0),
api_key: str | None = Header(default=None, alias="x-api-key"),
db: Session = Depends(get_db),
) -> dict[str, object]:
"""Return project-scoped notification recipients to ABC API only.
Secretary is the source of truth for workspace/project administrators.
This endpoint is intentionally protected by the service API key and is
not exposed through the public user-facing API contract.
"""
if not settings.master_api_key or api_key != settings.master_api_key:
raise HTTPException(status_code=401, detail="internal service authentication failed")
mapping = (
db.query(WorkspaceChannelMapping)
.join(Workspace, Workspace.id == WorkspaceChannelMapping.workspace_id)
.filter(
WorkspaceChannelMapping.abc_project_id == project_id,
WorkspaceChannelMapping.abc_channel_id == str(channel_id),
WorkspaceChannelMapping.is_active.is_(True),
Workspace.is_active.is_(True),
Workspace.workspace_type == "SOFTWARE_APP",
)
.order_by(WorkspaceChannelMapping.id.desc())
.first()
)
if mapping is None:
return {"items": []}
assignments = (
db.query(SupportRoleAssignment)
.join(SupportUser)
.filter(
SupportRoleAssignment.workspace_id == mapping.workspace_id,
SupportRoleAssignment.role_code == "PROJECT_MANAGER",
SupportRoleAssignment.support_user_id == SupportUser.id,
SupportUser.status == "ACTIVE",
SupportUser.email.is_not(None),
)
.order_by(SupportRoleAssignment.id.asc())
.all()
)
items = []
seen: set[str] = set()
for assignment in assignments:
email = (assignment.user.email or "").strip()
normalized = email.lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
items.append({"email": email, "role": "PROJECT_ADMIN"})
return {"items": items}
@@ -0,0 +1,803 @@
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,
requester_id=str(form.get("requester_id") or ""),
requester_tenant_id=str(form.get("requester_tenant_id") or ""),
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)