This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""secretary-api application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routing package."""
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes.access import router as access_router
|
||||
from app.api.routes.health import router as health_router
|
||||
from app.api.routes.internal import router as internal_router
|
||||
from app.api.routes.tickets import router as workspace_ticket_router, ticket_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health_router, prefix="/api")
|
||||
api_router.include_router(access_router, prefix="/api")
|
||||
api_router.include_router(internal_router, prefix="/api")
|
||||
api_router.include_router(workspace_ticket_router, prefix="/api")
|
||||
api_router.include_router(ticket_router, prefix="/api")
|
||||
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Core configuration and shared services."""
|
||||
@@ -0,0 +1,92 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import InvalidTokenError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SsoPrincipal:
|
||||
user_id: str
|
||||
tenant_id: str
|
||||
local_user_id: int | None
|
||||
email: str | None
|
||||
name: str | None
|
||||
department: str | None
|
||||
phone_number: str | None
|
||||
user_type: str | None
|
||||
tenant_ids: list[str]
|
||||
|
||||
|
||||
def get_principal(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> SsoPrincipal:
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="BARON-SSO 로그인 세션이 필요합니다.",
|
||||
)
|
||||
if not settings.jwt_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="JWT_SECRET이 secretary-api에 설정되지 않았습니다.",
|
||||
)
|
||||
|
||||
try:
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
# Older ABC-issued sessions encode the local mirror id as a JSON
|
||||
# number. Secretary authorization uses sso_sub + tenant_id, so
|
||||
# accepting either numeric or string local `sub` keeps those
|
||||
# sessions valid without weakening signature/expiry validation.
|
||||
options={"require": ["exp", "sub"], "verify_sub": False},
|
||||
)
|
||||
except InvalidTokenError as error:
|
||||
# Never log the bearer token itself. The exception type is enough to
|
||||
# distinguish expiry, signature, format, and claim validation errors.
|
||||
logger.warning("JWT validation failed: %s", type(error).__name__)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 로그인 세션입니다.",
|
||||
) from error
|
||||
|
||||
sso_subject = payload.get("sso_sub")
|
||||
tenant_id = payload.get("tenant_id")
|
||||
if not isinstance(sso_subject, str) or not sso_subject:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="SSO 사용자 식별자가 세션에 없습니다.",
|
||||
)
|
||||
if not isinstance(tenant_id, str) or not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="SSO tenant_id가 세션에 없습니다.",
|
||||
)
|
||||
|
||||
local_user_id = payload.get("sub")
|
||||
raw_tenant_ids = payload.get("tenant_ids")
|
||||
return SsoPrincipal(
|
||||
user_id=sso_subject,
|
||||
tenant_id=tenant_id,
|
||||
local_user_id=int(local_user_id) if str(local_user_id).isdigit() else None,
|
||||
email=payload.get("email") if isinstance(payload.get("email"), str) else None,
|
||||
name=payload.get("name") if isinstance(payload.get("name"), str) else None,
|
||||
department=payload.get("department") if isinstance(payload.get("department"), str) else None,
|
||||
phone_number=payload.get("phone_number") if isinstance(payload.get("phone_number"), str) else None,
|
||||
user_type=payload.get("type") if isinstance(payload.get("type"), str) else None,
|
||||
tenant_ids=[
|
||||
tenant_id
|
||||
for tenant_id in (raw_tenant_ids if isinstance(raw_tenant_ids, list) else [])
|
||||
if isinstance(tenant_id, str) and tenant_id
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
DEFAULT_UPLOAD_ROOT = REPO_ROOT / "uploads"
|
||||
|
||||
|
||||
class ABCProjectTarget(BaseModel):
|
||||
project_id: int = Field(gt=0)
|
||||
channel_id: int = Field(gt=0)
|
||||
project_name: str | None = None
|
||||
channel_name: str | None = None
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "secretary-api"
|
||||
app_env: str = "local"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8010
|
||||
docs_root_path: str = ""
|
||||
database_url: str = "mysql+pymysql://baron_support:baron_support@127.0.0.1:13308/baron_support"
|
||||
abc_api_base_url: str = "http://localhost:4000"
|
||||
abc_api_key: str = ""
|
||||
master_api_key: str = ""
|
||||
upload_root_dir: str = str(DEFAULT_UPLOAD_ROOT)
|
||||
upload_max_file_size_mb: int = 30
|
||||
storage_provider: str = "LOCAL"
|
||||
r2_endpoint: str = ""
|
||||
r2_access_key_id: str = ""
|
||||
r2_secret_access_key: str = ""
|
||||
r2_bucket: str = ""
|
||||
r2_region: str = "auto"
|
||||
r2_download_url_expiry_seconds: int = 300
|
||||
sso_issuer: str = ""
|
||||
sso_client_id: str = ""
|
||||
sso_client_secret: str = ""
|
||||
jwt_secret: str = ""
|
||||
admin_candidate_tenant_id: str = ""
|
||||
initial_super_admin_phone_number: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
@property
|
||||
def is_r2_enabled(self) -> bool:
|
||||
# LOCAL remains the safe local-development default. Once R2 is
|
||||
# explicitly selected, incomplete credentials must fail the upload
|
||||
# instead of silently writing a new binary to disk.
|
||||
return self.storage_provider.upper() == "R2"
|
||||
|
||||
@property
|
||||
def has_r2_config(self) -> bool:
|
||||
return (
|
||||
bool(self.r2_endpoint)
|
||||
and bool(self.r2_access_key_id)
|
||||
and bool(self.r2_secret_access_key)
|
||||
and bool(self.r2_bucket)
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1 @@
|
||||
"""Database package."""
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, ForeignKey, String, Text, TIMESTAMP, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SupportUser(Base):
|
||||
"""Local support identity keyed by the BARON-SSO subject and tenant."""
|
||||
|
||||
__tablename__ = "support_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("sso_subject", "tenant_id", name="uq_support_users_subject_tenant"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
sso_subject: Mapped[str] = mapped_column(String(100))
|
||||
tenant_id: Mapped[str] = mapped_column(String(100))
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
department: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
phone_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
tenant_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), server_default=text("'ACTIVE'"))
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
server_onupdate=text("CURRENT_TIMESTAMP"),
|
||||
)
|
||||
|
||||
role_assignments: Mapped[list[SupportRoleAssignment]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class SupportRole(Base):
|
||||
__tablename__ = "support_roles"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
assignments: Mapped[list[SupportRoleAssignment]] = relationship(back_populates="role")
|
||||
|
||||
|
||||
class SupportRoleAssignment(Base):
|
||||
__tablename__ = "support_role_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"support_user_id",
|
||||
"role_code",
|
||||
"workspace_id",
|
||||
name="uq_support_role_assignment_user_role_workspace",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
support_user_id: Mapped[int] = mapped_column(ForeignKey("support_users.id"))
|
||||
role_code: Mapped[str] = mapped_column(ForeignKey("support_roles.code"))
|
||||
workspace_id: Mapped[int | None] = mapped_column(ForeignKey("workspaces.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
user: Mapped[SupportUser] = relationship(back_populates="role_assignments")
|
||||
role: Mapped[SupportRole] = relationship(back_populates="assignments")
|
||||
workspace: Mapped[Workspace | None] = relationship()
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_type: Mapped[str] = mapped_column(String(20))
|
||||
software_app_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
service_type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
workspace_code: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
workspace_name: Mapped[str] = mapped_column(String(100))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
tickets: Mapped[list[SupportTicket]] = relationship(back_populates="workspace")
|
||||
approval_policies: Mapped[list[WorkspaceApprovalPolicy]] = relationship(back_populates="workspace")
|
||||
attachments: Mapped[list[Attachment]] = relationship(back_populates="workspace")
|
||||
access_entries: Mapped[list[UserWorkspaceAccess]] = relationship(back_populates="workspace")
|
||||
role_assignments: Mapped[list[SupportRoleAssignment]] = relationship(back_populates="workspace")
|
||||
channel_mappings: Mapped[list[WorkspaceChannelMapping]] = relationship(
|
||||
"WorkspaceChannelMapping",
|
||||
back_populates="workspace",
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceChannelMapping(Base):
|
||||
__tablename__ = "workspace_channel_mappings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"workspace_id",
|
||||
"abc_channel_id",
|
||||
name="uq_workspace_channel_mappings_workspace_channel",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
|
||||
abc_project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
abc_channel_id: Mapped[str] = mapped_column(String(100))
|
||||
abc_channel_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
form_template_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
)
|
||||
|
||||
workspace: Mapped[Workspace] = relationship(
|
||||
"Workspace",
|
||||
back_populates="channel_mappings",
|
||||
)
|
||||
|
||||
|
||||
class UserWorkspaceAccess(Base):
|
||||
__tablename__ = "user_workspace_access"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[str] = mapped_column(String(100))
|
||||
tenant_id: Mapped[str] = mapped_column(String(100))
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
|
||||
workspace_role: Mapped[str] = mapped_column(String(30), server_default=text("'END_USER'"))
|
||||
can_read: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
can_write: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
|
||||
can_manage: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
|
||||
can_approve: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
|
||||
page_scope: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
workspace: Mapped[Workspace] = relationship(back_populates="access_entries")
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
|
||||
requester_id: Mapped[str] = mapped_column(String(100))
|
||||
requester_tenant_id: Mapped[str] = mapped_column(String(100))
|
||||
requester_contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
requester_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
requester_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
requester_department: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
requester_phone_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
ticket_type: Mapped[str] = mapped_column(String(30))
|
||||
source_system: Mapped[str] = mapped_column(String(30), server_default=text("'ABC'"))
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
category_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
status_code: Mapped[str] = mapped_column(String(20))
|
||||
feedback_status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
server_default=text("'NEW'"),
|
||||
)
|
||||
approval_status: Mapped[str] = mapped_column(String(20), server_default=text("'NOT_REQUIRED'"))
|
||||
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'PENDING'"))
|
||||
issue_link_status: Mapped[str] = mapped_column(String(20), server_default=text("'NOT_REQUIRED'"))
|
||||
external_issue_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
is_secret: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
|
||||
requires_approval: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
|
||||
current_assignee_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
current_assignee_tenant_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
requested_start_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
|
||||
requested_end_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), server_default=text("'NORMAL'"))
|
||||
extra_fields: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
server_onupdate=text("CURRENT_TIMESTAMP"),
|
||||
)
|
||||
|
||||
workspace: Mapped[Workspace] = relationship(back_populates="tickets")
|
||||
feedback_mapping: Mapped[ABCFeedbackMapping | None] = relationship(back_populates="ticket", uselist=False)
|
||||
approvals: Mapped[list[RequestApproval]] = relationship(back_populates="ticket")
|
||||
comments: Mapped[list[TicketComment]] = relationship(back_populates="ticket")
|
||||
attachments: Mapped[list[Attachment]] = relationship(back_populates="ticket")
|
||||
|
||||
|
||||
class ABCFeedbackMapping(Base):
|
||||
__tablename__ = "abc_feedback_mappings"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"), unique=True)
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
|
||||
abc_channel_id: Mapped[str] = mapped_column(String(100))
|
||||
abc_feedback_id: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
abc_feedback_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'SYNCED'"))
|
||||
last_synced_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
ticket: Mapped[SupportTicket] = relationship(back_populates="feedback_mapping")
|
||||
|
||||
|
||||
class RequestApproval(Base):
|
||||
__tablename__ = "request_approvals"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
|
||||
workflow_policy_id: Mapped[int | None] = mapped_column(ForeignKey("workspace_approval_policies.id"), nullable=True)
|
||||
workflow_step_id: Mapped[int | None] = mapped_column(ForeignKey("workspace_approval_steps.id"), nullable=True)
|
||||
step_order: Mapped[int | None] = mapped_column(nullable=True)
|
||||
approver_id: Mapped[str] = mapped_column(String(100))
|
||||
approver_tenant_id: Mapped[str] = mapped_column(String(100))
|
||||
approval_status: Mapped[str] = mapped_column(String(20))
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
ticket: Mapped[SupportTicket] = relationship(back_populates="approvals")
|
||||
workflow_policy: Mapped[WorkspaceApprovalPolicy | None] = relationship(back_populates="approval_history")
|
||||
workflow_step: Mapped[WorkspaceApprovalStep | None] = relationship(back_populates="approval_history")
|
||||
|
||||
|
||||
class WorkspaceApprovalPolicy(Base):
|
||||
__tablename__ = "workspace_approval_policies"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
|
||||
request_category_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
ticket_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
rule_name: Mapped[str] = mapped_column(String(100))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conditions: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
version: Mapped[int] = mapped_column(server_default=text("1"))
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
server_onupdate=text("CURRENT_TIMESTAMP"),
|
||||
)
|
||||
|
||||
workspace: Mapped[Workspace] = relationship(back_populates="approval_policies")
|
||||
steps: Mapped[list[WorkspaceApprovalStep]] = relationship(back_populates="policy")
|
||||
approval_history: Mapped[list[RequestApproval]] = relationship(back_populates="workflow_policy")
|
||||
|
||||
|
||||
class WorkspaceApprovalStep(Base):
|
||||
__tablename__ = "workspace_approval_steps"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
policy_id: Mapped[int] = mapped_column(ForeignKey("workspace_approval_policies.id"))
|
||||
step_order: Mapped[int] = mapped_column()
|
||||
approver_type: Mapped[str] = mapped_column(String(30))
|
||||
approver_key: Mapped[str] = mapped_column(String(100))
|
||||
approver_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
approval_mode: Mapped[str] = mapped_column(String(20), server_default=text("'ALL'"))
|
||||
is_required: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
sla_hours: Mapped[int | None] = mapped_column(nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
policy: Mapped[WorkspaceApprovalPolicy] = relationship(back_populates="steps")
|
||||
approval_history: Mapped[list[RequestApproval]] = relationship(back_populates="workflow_step")
|
||||
|
||||
|
||||
class TicketComment(Base):
|
||||
__tablename__ = "ticket_comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
|
||||
parent_comment_id: Mapped[int | None] = mapped_column(ForeignKey("ticket_comments.id"), nullable=True)
|
||||
author_id: Mapped[str] = mapped_column(String(100))
|
||||
author_tenant_id: Mapped[str] = mapped_column(String(100))
|
||||
author_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
comment_type: Mapped[str] = mapped_column(String(20), server_default=text("'COMMENT'"))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
is_internal: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
|
||||
abc_comment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'PENDING'"))
|
||||
edited_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
server_onupdate=text("CURRENT_TIMESTAMP"),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
|
||||
|
||||
ticket: Mapped[SupportTicket] = relationship(back_populates="comments")
|
||||
parent_comment: Mapped[TicketComment | None] = relationship(remote_side=[id])
|
||||
attachments: Mapped[list[Attachment]] = relationship(back_populates="comment")
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
|
||||
comment_id: Mapped[int | None] = mapped_column(ForeignKey("ticket_comments.id"), nullable=True)
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
|
||||
uploader_id: Mapped[str] = mapped_column(String(100))
|
||||
uploader_tenant_id: Mapped[str] = mapped_column(String(100))
|
||||
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||
stored_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
storage_provider: Mapped[str] = mapped_column(String(30), server_default=text("'LOCAL'"))
|
||||
storage_bucket: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
storage_key: Mapped[str] = mapped_column(String(500))
|
||||
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
file_extension: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
file_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
abc_attachment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
attachment_status: Mapped[str] = mapped_column(String(20), server_default=text("'ACTIVE'"))
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
server_onupdate=text("CURRENT_TIMESTAMP"),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
|
||||
|
||||
ticket: Mapped[SupportTicket] = relationship(back_populates="attachments")
|
||||
comment: Mapped[TicketComment | None] = relationship(back_populates="attachments")
|
||||
workspace: Mapped[Workspace] = relationship(back_populates="attachments")
|
||||
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
database = SessionLocal()
|
||||
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
@@ -0,0 +1,103 @@
|
||||
from http import HTTPStatus
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.schemas.errors import ApiErrorResponse
|
||||
|
||||
|
||||
def _error_name(status_code: int) -> str:
|
||||
try:
|
||||
phrase = HTTPStatus(status_code).phrase
|
||||
except ValueError:
|
||||
return "HTTP_ERROR"
|
||||
return re.sub(r"[^A-Z0-9]+", "_", phrase.upper()).strip("_")
|
||||
|
||||
|
||||
def _error_message(detail: Any) -> str | list[str]:
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
if isinstance(detail, list):
|
||||
messages = [
|
||||
str(item.get("msg", item)) if isinstance(item, dict) else str(item)
|
||||
for item in detail
|
||||
]
|
||||
return messages or "Request validation failed."
|
||||
return str(detail)
|
||||
|
||||
|
||||
def _error_payload(
|
||||
request: Request,
|
||||
status_code: int,
|
||||
message: str | list[str],
|
||||
error: str,
|
||||
) -> dict[str, Any]:
|
||||
return ApiErrorResponse(
|
||||
code=error,
|
||||
message=message,
|
||||
error=error,
|
||||
statusCode=status_code,
|
||||
path=request.url.path,
|
||||
).model_dump(by_alias=True)
|
||||
|
||||
|
||||
async def _http_exception_handler(
|
||||
request: Request,
|
||||
exception: HTTPException,
|
||||
) -> JSONResponse:
|
||||
error = _error_name(exception.status_code)
|
||||
return JSONResponse(
|
||||
status_code=exception.status_code,
|
||||
content=_error_payload(
|
||||
request,
|
||||
exception.status_code,
|
||||
_error_message(exception.detail),
|
||||
error,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _validation_exception_handler(
|
||||
request: Request,
|
||||
exception: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=_error_payload(
|
||||
request,
|
||||
422,
|
||||
_error_message(exception.errors()),
|
||||
"VALIDATION_ERROR",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _register_exception_handlers(application: FastAPI) -> None:
|
||||
application.add_exception_handler(HTTPException, _http_exception_handler)
|
||||
application.add_exception_handler(
|
||||
RequestValidationError,
|
||||
_validation_exception_handler,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
error_responses = {
|
||||
status_code: {"model": ApiErrorResponse}
|
||||
for status_code in (400, 401, 403, 404, 422, 500)
|
||||
}
|
||||
application = FastAPI(
|
||||
title=settings.app_name,
|
||||
root_path=settings.docs_root_path,
|
||||
responses=error_responses,
|
||||
)
|
||||
_register_exception_handlers(application)
|
||||
application.include_router(api_router)
|
||||
return application
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas for secretary-api."""
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared error response schemas for the public Secretary API contract."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ApiErrorResponse(BaseModel):
|
||||
"""Stable error shape shared with the NestJS API."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
code: str = Field(description="Machine-readable error code", examples=["NOT_FOUND"])
|
||||
message: str | list[str] = Field(description="Human-readable error message")
|
||||
error: str = Field(description="Error category")
|
||||
status_code: int = Field(
|
||||
alias="statusCode",
|
||||
description="HTTP status code",
|
||||
examples=[404],
|
||||
)
|
||||
path: str = Field(description="Request path", examples=["/api/tickets/1"])
|
||||
@@ -0,0 +1,222 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TicketCreateRequest(BaseModel):
|
||||
workspace_code: str = Field(default="", max_length=50)
|
||||
requester_id: str = Field(..., min_length=1, max_length=100)
|
||||
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
|
||||
requester_contact: str | None = Field(default=None, max_length=100)
|
||||
requester_email: str | None = Field(default=None, max_length=320)
|
||||
requester_name: str | None = Field(default=None, max_length=100)
|
||||
requester_department: str | None = Field(default=None, max_length=100)
|
||||
requester_phone_number: str | None = Field(default=None, max_length=50)
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field(..., min_length=1)
|
||||
category_code: str | None = Field(default=None, max_length=20)
|
||||
ticket_type: str = Field(default="GENERAL")
|
||||
is_secret: bool = False
|
||||
requires_approval: bool = False
|
||||
extra_fields: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TicketUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, min_length=1)
|
||||
requester_contact: str | None = Field(default=None, max_length=100)
|
||||
is_secret: bool | None = None
|
||||
extra_fields: dict[str, str] | None = None
|
||||
|
||||
|
||||
class TicketCreateResponse(BaseModel):
|
||||
ticket_id: int
|
||||
workspace_code: str
|
||||
status_code: str
|
||||
feedback_status: str
|
||||
sync_status: str
|
||||
message: str
|
||||
|
||||
|
||||
FeedbackStatus = Literal["INIT", "ON_REVIEW", "DETAILED_REVIEW", "IN_PROGRESS", "RESOLVED", "PENDING"]
|
||||
|
||||
|
||||
class FeedbackStatusUpdateRequest(BaseModel):
|
||||
feedback_status: FeedbackStatus
|
||||
|
||||
|
||||
class SupportFeedbackStatusRecord(BaseModel):
|
||||
feedback_id: int
|
||||
feedback_status: FeedbackStatus
|
||||
updated_at: str
|
||||
|
||||
|
||||
class SupportFeedbackMetrics(BaseModel):
|
||||
project_id: int
|
||||
channel_id: int | None = None
|
||||
today_first_count: int
|
||||
answer_waiting_count: int
|
||||
issue_linked_count: int
|
||||
issue_link_rate: float
|
||||
average_processing_minutes: float | None = None
|
||||
|
||||
|
||||
class WorkspaceSummary(BaseModel):
|
||||
workspace_code: str
|
||||
workspace_name: str
|
||||
workspace_type: str
|
||||
|
||||
|
||||
class WorkspaceFormField(BaseModel):
|
||||
field_code: str
|
||||
label: str
|
||||
field_type: str
|
||||
required: bool = False
|
||||
|
||||
|
||||
class WorkspaceFormTemplateResponse(BaseModel):
|
||||
workspace_code: str
|
||||
workspace_name: str
|
||||
requires_approval: bool
|
||||
fields: list[WorkspaceFormField]
|
||||
|
||||
|
||||
class SupportActivityItem(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
detail: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class SupportAttachmentRecord(BaseModel):
|
||||
attachment_id: int = Field(description="첨부파일 식별자")
|
||||
original_file_name: str = Field(description="사용자가 업로드한 원본 파일명")
|
||||
mime_type: str | None = Field(default=None, description="MIME 타입")
|
||||
file_size: int | None = Field(default=None, description="파일 크기(bytes)")
|
||||
created_at: str = Field(description="첨부파일 생성 시각")
|
||||
|
||||
|
||||
class TicketCommentCreateRequest(BaseModel):
|
||||
requester_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="인증된 작성자 식별자. API 서버가 로그인 사용자 정보로 덮어쓴다.",
|
||||
)
|
||||
requester_tenant_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="인증된 작성자 테넌트 식별자. API 서버가 로그인 사용자 정보로 덮어쓴다.",
|
||||
)
|
||||
author_name: str | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="작성자 표시명. 인증 사용자 정보로 서버가 결정한다.",
|
||||
)
|
||||
content: str = Field(default="", description="댓글 본문")
|
||||
is_internal: bool = Field(
|
||||
default=False,
|
||||
description="서버 내부용 필드. 일반 댓글 API에서는 무시되며 내부 메모 API가 true로 설정한다.",
|
||||
)
|
||||
|
||||
|
||||
class TicketCommentUpdateRequest(BaseModel):
|
||||
requester_id: str = Field(..., min_length=1, max_length=100)
|
||||
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
|
||||
content: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class InternalMemoCreateRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, description="관리자만 조회할 내부 메모 본문")
|
||||
|
||||
|
||||
class InternalMemoUpdateRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, description="수정할 내부 메모 본문")
|
||||
|
||||
|
||||
class TicketIssueLinkStatusSyncRequest(BaseModel):
|
||||
linked: bool
|
||||
|
||||
|
||||
class TicketAssigneeCandidate(BaseModel):
|
||||
user_id: str
|
||||
tenant_id: str
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
department: str | None = None
|
||||
phone_number: str | None = None
|
||||
role_code: str
|
||||
|
||||
|
||||
class TicketAssigneeUpdateRequest(BaseModel):
|
||||
assignee_id: str | None = None
|
||||
assignee_tenant_id: str | None = None
|
||||
|
||||
|
||||
class SupportTicketCommentRecord(BaseModel):
|
||||
comment_id: int = Field(description="댓글 또는 내부 메모 식별자")
|
||||
ticket_id: int = Field(description="지원 요청 식별자")
|
||||
author_id: str = Field(description="작성자 식별자")
|
||||
author_tenant_id: str = Field(description="작성자 테넌트 식별자")
|
||||
author_name: str = Field(description="작성자 표시명")
|
||||
content: str = Field(description="댓글 또는 내부 메모 본문")
|
||||
is_internal: bool = Field(description="관리자 전용 내부 메모 여부")
|
||||
comment_type: str = Field(default="COMMENT", description="COMMENT 또는 INTERNAL_MEMO")
|
||||
created_at: str = Field(description="작성 시각")
|
||||
updated_at: str = Field(description="수정 시각")
|
||||
edited_at: str | None = Field(default=None, description="마지막 수정 시각")
|
||||
attachments: list[SupportAttachmentRecord] = Field(default_factory=list)
|
||||
can_edit: bool = False
|
||||
can_delete: bool = False
|
||||
|
||||
|
||||
class TicketDeleteResponse(BaseModel):
|
||||
deleted: bool = Field(description="삭제 처리 여부")
|
||||
ticket_id: int = Field(description="지원 요청 식별자")
|
||||
comment_id: int | None = Field(default=None, description="삭제된 댓글 또는 내부 메모 식별자")
|
||||
feedback_id: int | None = Field(default=None, description="연결된 ABC 피드백 식별자")
|
||||
|
||||
|
||||
class SupportTicketRecord(BaseModel):
|
||||
ticket_id: int
|
||||
workspace_code: str
|
||||
workspace_name: str
|
||||
title: str
|
||||
description: str
|
||||
requester_id: str
|
||||
requester_tenant_id: str
|
||||
requester_contact: str
|
||||
requester_email: str | None
|
||||
requester_name: str | None
|
||||
requester_department: str | None
|
||||
requester_phone_number: str | None
|
||||
category_code: str
|
||||
ticket_type: str
|
||||
requires_approval: bool
|
||||
status_code: str
|
||||
feedback_status: str
|
||||
approval_status: str
|
||||
sync_status: str
|
||||
issue_link_status: str
|
||||
external_issue_status: str | None = None
|
||||
assignee_id: str | None = None
|
||||
assignee_tenant_id: str | None = None
|
||||
assignee_name: str | None = None
|
||||
assignee_email: str | None = None
|
||||
is_secret: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
extra_fields: dict[str, str]
|
||||
attachments: list[SupportAttachmentRecord] = Field(default_factory=list)
|
||||
activity: list[SupportActivityItem]
|
||||
|
||||
|
||||
class TicketMutationResponse(BaseModel):
|
||||
ticket_id: int
|
||||
status_code: str
|
||||
feedback_status: str
|
||||
approval_status: str
|
||||
issue_link_status: str
|
||||
external_issue_status: str | None = None
|
||||
updated_at: str
|
||||
@@ -0,0 +1 @@
|
||||
"""Service layer for secretary-api."""
|
||||
@@ -0,0 +1,376 @@
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import SsoPrincipal
|
||||
from app.db.models import (
|
||||
SupportRoleAssignment,
|
||||
SupportUser,
|
||||
UserWorkspaceAccess,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
|
||||
class AccessService:
|
||||
ROLE_PRIORITY = {
|
||||
"SYSTEM_ADMIN": 5,
|
||||
"SUPER_ADMIN": 4,
|
||||
"PROJECT_MANAGER": 3,
|
||||
"FEEDBACK_PROVIDER": 2,
|
||||
"END_USER": 1,
|
||||
}
|
||||
ROLE_PERMISSIONS = {
|
||||
"SYSTEM_ADMIN": (True, True, True, True),
|
||||
"SUPER_ADMIN": (True, True, True, True),
|
||||
"PROJECT_MANAGER": (True, True, True, True),
|
||||
"FEEDBACK_PROVIDER": (True, True, False, False),
|
||||
"END_USER": (True, True, False, False),
|
||||
}
|
||||
GLOBAL_ADMIN_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN"}
|
||||
MANAGER_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
SUPPORTED_WORKSPACE_ROLES = {
|
||||
"PROJECT_MANAGER",
|
||||
"END_USER",
|
||||
"FEEDBACK_PROVIDER",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def default_user_workspace_code(db: Session) -> str | None:
|
||||
"""Return the first active ABC project workspace.
|
||||
|
||||
The old Q&A_Platform seed workspace must not determine the first SSO
|
||||
redirect after ABC projects are synchronized.
|
||||
"""
|
||||
return db.execute(
|
||||
select(Workspace.workspace_code)
|
||||
.where(
|
||||
Workspace.is_active.is_(True),
|
||||
Workspace.workspace_type == "SOFTWARE_APP",
|
||||
)
|
||||
.order_by(Workspace.id.asc()),
|
||||
).scalar()
|
||||
|
||||
@staticmethod
|
||||
def _upsert_support_user(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
email: str | None = None,
|
||||
name: str | None = None,
|
||||
department: str | None = None,
|
||||
phone_number: str | None = None,
|
||||
tenant_ids: list[str] | None = None,
|
||||
) -> SupportUser:
|
||||
"""Create or refresh an SSO identity without concurrent insert races.
|
||||
|
||||
The access endpoint is called several times in parallel during the
|
||||
first SSO redirect. A select-then-insert sequence can deadlock on the
|
||||
unique (sso_subject, tenant_id) index, so use MySQL's atomic upsert.
|
||||
Empty profile fields never erase values already synchronized.
|
||||
"""
|
||||
table = SupportUser.__table__
|
||||
statement = mysql_insert(table).values(
|
||||
sso_subject=user_id,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
department=department,
|
||||
phone_number=phone_number,
|
||||
tenant_ids=tenant_ids or None,
|
||||
)
|
||||
statement = statement.on_duplicate_key_update(
|
||||
email=func.coalesce(statement.inserted.email, table.c.email),
|
||||
name=func.coalesce(statement.inserted.name, table.c.name),
|
||||
department=func.coalesce(
|
||||
statement.inserted.department,
|
||||
table.c.department,
|
||||
),
|
||||
phone_number=func.coalesce(
|
||||
statement.inserted.phone_number,
|
||||
table.c.phone_number,
|
||||
),
|
||||
tenant_ids=func.coalesce(
|
||||
statement.inserted.tenant_ids,
|
||||
table.c.tenant_ids,
|
||||
),
|
||||
)
|
||||
db.execute(statement)
|
||||
return db.execute(
|
||||
select(SupportUser).where(
|
||||
SupportUser.sso_subject == user_id,
|
||||
SupportUser.tenant_id == tenant_id,
|
||||
),
|
||||
).scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def ensure_support_user(db: Session, principal: SsoPrincipal) -> SupportUser:
|
||||
return AccessService._upsert_support_user(
|
||||
db,
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
email=principal.email,
|
||||
name=principal.name,
|
||||
department=principal.department,
|
||||
phone_number=principal.phone_number,
|
||||
tenant_ids=principal.tenant_ids,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ensure_support_user_identity(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
email: str | None = None,
|
||||
name: str | None = None,
|
||||
department: str | None = None,
|
||||
phone_number: str | None = None,
|
||||
tenant_ids: list[str] | None = None,
|
||||
) -> SupportUser:
|
||||
return AccessService._upsert_support_user(
|
||||
db,
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
department=department,
|
||||
phone_number=phone_number,
|
||||
tenant_ids=tenant_ids,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _role_codes(
|
||||
db: Session,
|
||||
user: SupportUser,
|
||||
workspace_id: int | None = None,
|
||||
*,
|
||||
global_only: bool = False,
|
||||
) -> list[str]:
|
||||
conditions = [SupportRoleAssignment.support_user_id == user.id]
|
||||
if global_only:
|
||||
# Global admin roles are global by role code. Older seed data may
|
||||
# still contain a workspace_id on these assignments.
|
||||
conditions.append(
|
||||
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
|
||||
)
|
||||
elif workspace_id is not None:
|
||||
# Only global administrator roles apply to every workspace. A
|
||||
# project-scoped role with a NULL workspace is not a shortcut to
|
||||
# global access.
|
||||
conditions.append(
|
||||
or_(
|
||||
SupportRoleAssignment.workspace_id == workspace_id,
|
||||
and_(
|
||||
SupportRoleAssignment.workspace_id.is_(None),
|
||||
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
|
||||
),
|
||||
),
|
||||
)
|
||||
return list(
|
||||
db.execute(
|
||||
select(SupportRoleAssignment.role_code).where(*conditions),
|
||||
).scalars().all(),
|
||||
)
|
||||
|
||||
def is_system_admin(self, db: Session, principal: SsoPrincipal) -> bool:
|
||||
"""Check only Secretary's internal global role assignments.
|
||||
|
||||
The ABC `users.type` claim is intentionally not consulted here. ABC's
|
||||
users/roles/members tables remain responsible for ABC's own console API.
|
||||
"""
|
||||
user = self.ensure_support_user(db, principal)
|
||||
return bool(
|
||||
set(self._role_codes(db, user, global_only=True))
|
||||
& AccessService.GLOBAL_ADMIN_ROLES,
|
||||
)
|
||||
|
||||
def _workspace_role(
|
||||
self,
|
||||
db: Session,
|
||||
user: SupportUser,
|
||||
workspace_id: int,
|
||||
) -> str | None:
|
||||
roles = self._role_codes(db, user, workspace_id)
|
||||
if not roles:
|
||||
return None
|
||||
return max(roles, key=lambda role: self.ROLE_PRIORITY.get(role, 0))
|
||||
|
||||
def _apply_role_permissions(
|
||||
self,
|
||||
access: UserWorkspaceAccess,
|
||||
role: str,
|
||||
) -> UserWorkspaceAccess:
|
||||
can_read, can_write, can_manage, can_approve = self.ROLE_PERMISSIONS.get(
|
||||
role,
|
||||
(False, False, False, False),
|
||||
)
|
||||
access.workspace_role = role
|
||||
access.can_read = can_read
|
||||
access.can_write = can_write
|
||||
access.can_manage = can_manage
|
||||
access.can_approve = can_approve
|
||||
return access
|
||||
|
||||
@staticmethod
|
||||
def _synthetic_access(
|
||||
principal: SsoPrincipal,
|
||||
workspace: Workspace,
|
||||
role: str,
|
||||
) -> UserWorkspaceAccess:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
workspace=workspace,
|
||||
)
|
||||
return AccessService()._apply_role_permissions(access, role)
|
||||
|
||||
def find_workspace_access(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
workspace_code: str,
|
||||
default_workspace_code: str | None = None,
|
||||
) -> UserWorkspaceAccess | None:
|
||||
user = self.ensure_support_user(db, principal)
|
||||
workspace = db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.workspace_code == workspace_code,
|
||||
Workspace.is_active.is_(True),
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if workspace is None or user.status != "ACTIVE":
|
||||
return None
|
||||
|
||||
global_roles = set(self._role_codes(db, user, global_only=True))
|
||||
if global_roles & AccessService.GLOBAL_ADMIN_ROLES:
|
||||
return self._synthetic_access(principal, workspace, "SYSTEM_ADMIN")
|
||||
|
||||
role = self._workspace_role(db, user, workspace.id)
|
||||
if role is None:
|
||||
# A first-time user can submit feedback in the default workspace
|
||||
# without creating an END_USER role assignment. Role assignments
|
||||
# are reserved for explicit workspace administration.
|
||||
if workspace.workspace_code != (
|
||||
default_workspace_code or self.default_user_workspace_code(db)
|
||||
):
|
||||
return None
|
||||
role = "END_USER"
|
||||
|
||||
access = db.execute(
|
||||
select(UserWorkspaceAccess).where(
|
||||
UserWorkspaceAccess.user_id == principal.user_id,
|
||||
UserWorkspaceAccess.tenant_id == principal.tenant_id,
|
||||
UserWorkspaceAccess.workspace_id == workspace.id,
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if access is None:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
workspace=workspace,
|
||||
)
|
||||
else:
|
||||
access.workspace = workspace
|
||||
return self._apply_role_permissions(access, role)
|
||||
|
||||
def assert_workspace_access(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
workspace_code: str,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> UserWorkspaceAccess:
|
||||
access = self.find_workspace_access(db, principal, workspace_code)
|
||||
if access is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 workspace에 대한 권한이 없습니다.")
|
||||
|
||||
if write and not access.can_write:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 workspace에 작성 권한이 없습니다.")
|
||||
if not write and not access.can_read:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 workspace를 조회할 권한이 없습니다.")
|
||||
return access
|
||||
|
||||
def list_accessible_workspaces(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
default_workspace_code: str | None = None,
|
||||
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
|
||||
default_workspace_code = (
|
||||
default_workspace_code or self.default_user_workspace_code(db)
|
||||
)
|
||||
workspaces = db.execute(
|
||||
select(Workspace)
|
||||
.where(Workspace.is_active.is_(True))
|
||||
.order_by(Workspace.id.asc()),
|
||||
).scalars().all()
|
||||
entries: list[tuple[Workspace, UserWorkspaceAccess]] = []
|
||||
for workspace in workspaces:
|
||||
access = self.find_workspace_access(
|
||||
db,
|
||||
principal,
|
||||
workspace.workspace_code,
|
||||
default_workspace_code=default_workspace_code,
|
||||
)
|
||||
if access is not None and (access.can_read or access.can_manage):
|
||||
entries.append((workspace, access))
|
||||
return entries
|
||||
|
||||
def ensure_default_support_access(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
default_workspace_code: str | None = None,
|
||||
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
|
||||
user = self.ensure_support_user(db, principal)
|
||||
if self.is_system_admin(db, principal):
|
||||
return self.list_accessible_workspaces(
|
||||
db,
|
||||
principal,
|
||||
default_workspace_code=default_workspace_code,
|
||||
)
|
||||
|
||||
default_workspace_code = (
|
||||
default_workspace_code or self.default_user_workspace_code(db)
|
||||
)
|
||||
if default_workspace_code is None:
|
||||
return []
|
||||
|
||||
workspace = db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.workspace_code == default_workspace_code,
|
||||
Workspace.is_active.is_(True),
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if workspace is None:
|
||||
return []
|
||||
|
||||
role = self._workspace_role(db, user, workspace.id) or "END_USER"
|
||||
|
||||
access = db.execute(
|
||||
select(UserWorkspaceAccess).where(
|
||||
UserWorkspaceAccess.user_id == principal.user_id,
|
||||
UserWorkspaceAccess.tenant_id == principal.tenant_id,
|
||||
UserWorkspaceAccess.workspace_id == workspace.id,
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if access is None:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
db.add(access)
|
||||
access.workspace = workspace
|
||||
self._apply_role_permissions(access, role)
|
||||
db.commit()
|
||||
db.refresh(access)
|
||||
access.workspace = workspace
|
||||
return [(workspace, access)]
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class StorageService:
|
||||
"""Small S3-compatible adapter used for R2-backed support attachments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: Any | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return settings.is_r2_enabled
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
if not self.enabled or not settings.has_r2_config:
|
||||
raise RuntimeError("R2 storage is not fully configured.")
|
||||
|
||||
if self._client is None:
|
||||
try:
|
||||
import boto3
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"R2 storage requires the boto3 dependency."
|
||||
) from exc
|
||||
|
||||
self._client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.r2_endpoint,
|
||||
aws_access_key_id=settings.r2_access_key_id,
|
||||
aws_secret_access_key=settings.r2_secret_access_key,
|
||||
region_name=settings.r2_region,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def put_bytes(self, key: str, body: bytes, content_type: str | None) -> None:
|
||||
self._get_client().put_object(
|
||||
Bucket=settings.r2_bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType=content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
def get_bytes(self, key: str, bucket: str | None = None) -> bytes:
|
||||
response = self._get_client().get_object(
|
||||
Bucket=bucket or settings.r2_bucket,
|
||||
Key=key,
|
||||
)
|
||||
return response["Body"].read()
|
||||
|
||||
def delete(self, key: str, bucket: str | None = None) -> None:
|
||||
self._get_client().delete_object(
|
||||
Bucket=bucket or settings.r2_bucket,
|
||||
Key=key,
|
||||
)
|
||||
|
||||
|
||||
storage_service = StorageService()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib import error, parse, request
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Workspace, WorkspaceChannelMapping
|
||||
|
||||
|
||||
class WorkspaceMappingService:
|
||||
def _fetch_remote_mappings(
|
||||
self,
|
||||
workspace_codes: list[str],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
if not workspace_codes or not settings.master_api_key:
|
||||
return {}
|
||||
|
||||
query = parse.urlencode({"workspaceCodes": ",".join(workspace_codes)})
|
||||
endpoint = (
|
||||
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/workspace-mappings"
|
||||
f"?{query}"
|
||||
)
|
||||
req = request.Request(
|
||||
endpoint,
|
||||
headers={"x-api-key": settings.master_api_key},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(req, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
raw_items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw_items, list):
|
||||
return {}
|
||||
|
||||
mappings: dict[str, dict[str, object]] = {}
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
workspace_code = item.get("workspace_code")
|
||||
project_id = item.get("project_id")
|
||||
channel_id = item.get("channel_id")
|
||||
if (
|
||||
not isinstance(workspace_code, str)
|
||||
or not isinstance(project_id, int)
|
||||
or not isinstance(channel_id, int)
|
||||
or project_id <= 0
|
||||
or channel_id <= 0
|
||||
):
|
||||
continue
|
||||
mappings[workspace_code] = item
|
||||
return mappings
|
||||
|
||||
def _fetch_remote_projects(self) -> list[dict[str, object]] | None:
|
||||
if not settings.master_api_key:
|
||||
return None
|
||||
|
||||
endpoint = (
|
||||
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/projects"
|
||||
)
|
||||
req = request.Request(
|
||||
endpoint,
|
||||
headers={"x-api-key": settings.master_api_key},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(req, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
raw_items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw_items, list):
|
||||
return None
|
||||
|
||||
projects: list[dict[str, object]] = []
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
project_id = item.get("project_id")
|
||||
project_name = item.get("project_name")
|
||||
channel_id = item.get("channel_id")
|
||||
channel_name = item.get("channel_name")
|
||||
if (
|
||||
not isinstance(project_id, int)
|
||||
or project_id <= 0
|
||||
or not isinstance(project_name, str)
|
||||
or not project_name.strip()
|
||||
or len(project_name) > 50
|
||||
):
|
||||
continue
|
||||
projects.append(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"project_name": project_name.strip(),
|
||||
"channel_id": channel_id if isinstance(channel_id, int) else None,
|
||||
"channel_name": channel_name if isinstance(channel_name, str) else None,
|
||||
},
|
||||
)
|
||||
return projects
|
||||
|
||||
def sync_project_workspaces(self, db: Session) -> list[str] | None:
|
||||
"""Mirror ABC projects as selectable Secretary workspaces.
|
||||
|
||||
Project creation remains owned by ABC. The management/access APIs call
|
||||
this lightweight synchronization before returning workspace options, so
|
||||
newly created projects become assignable without hard-coded seed data.
|
||||
|
||||
``None`` means the ABC project list could not be fetched. An empty
|
||||
list is a successful response with no projects.
|
||||
"""
|
||||
projects = self._fetch_remote_projects()
|
||||
if projects is None:
|
||||
return None
|
||||
|
||||
project_codes = list(
|
||||
dict.fromkeys(str(project["project_name"]) for project in projects),
|
||||
)
|
||||
|
||||
# Keep the Secretary workspace catalog aligned with ABC. Older seed
|
||||
# rows such as Q&A_Platform must not become the default SSO target
|
||||
# after the corresponding ABC project no longer exists.
|
||||
stale_query = db.query(Workspace).filter(
|
||||
Workspace.workspace_type == "SOFTWARE_APP",
|
||||
Workspace.is_active.is_(True),
|
||||
)
|
||||
if project_codes:
|
||||
stale_query = stale_query.filter(
|
||||
~Workspace.workspace_code.in_(project_codes),
|
||||
)
|
||||
stale_count = stale_query.update(
|
||||
{Workspace.is_active: False},
|
||||
synchronize_session=False,
|
||||
)
|
||||
if not projects:
|
||||
db.commit()
|
||||
return project_codes
|
||||
|
||||
changed = 0
|
||||
for project in projects:
|
||||
project_name = str(project["project_name"])
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO software_apps
|
||||
(app_code, app_name, description, is_active)
|
||||
VALUES (:app_code, :app_name, :description, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
app_name = VALUES(app_name),
|
||||
description = VALUES(description),
|
||||
is_active = 1
|
||||
""",
|
||||
),
|
||||
{
|
||||
"app_code": project_name,
|
||||
"app_name": project_name,
|
||||
"description": f"{project_name} Q&A",
|
||||
},
|
||||
)
|
||||
software_app_id = db.execute(
|
||||
text("SELECT id FROM software_apps WHERE app_code = :app_code"),
|
||||
{"app_code": project_name},
|
||||
).scalar_one()
|
||||
|
||||
statement = mysql_insert(Workspace.__table__).values(
|
||||
workspace_type="SOFTWARE_APP",
|
||||
software_app_id=int(software_app_id),
|
||||
service_type_id=None,
|
||||
workspace_code=project_name,
|
||||
workspace_name=project_name,
|
||||
is_active=True,
|
||||
)
|
||||
statement = statement.on_duplicate_key_update(
|
||||
software_app_id=statement.inserted.software_app_id,
|
||||
workspace_name=statement.inserted.workspace_name,
|
||||
is_active=True,
|
||||
)
|
||||
db.execute(statement)
|
||||
workspace = db.execute(
|
||||
select(Workspace).where(Workspace.workspace_code == project_name),
|
||||
).scalar_one()
|
||||
changed += 1
|
||||
|
||||
channel_id = project["channel_id"]
|
||||
if not isinstance(channel_id, int) or channel_id <= 0:
|
||||
continue
|
||||
|
||||
existing = db.execute(
|
||||
select(WorkspaceChannelMapping)
|
||||
.where(
|
||||
WorkspaceChannelMapping.workspace_id == workspace.id,
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.asc()),
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
existing.abc_project_id = int(project["project_id"])
|
||||
existing.abc_channel_id = str(channel_id)
|
||||
existing.abc_channel_key = str(project.get("channel_name") or "")
|
||||
continue
|
||||
|
||||
mapping_statement = mysql_insert(WorkspaceChannelMapping.__table__).values(
|
||||
workspace_id=workspace.id,
|
||||
abc_project_id=int(project["project_id"]),
|
||||
abc_channel_id=str(channel_id),
|
||||
abc_channel_key=str(project.get("channel_name") or ""),
|
||||
is_active=True,
|
||||
)
|
||||
mapping_statement = mapping_statement.on_duplicate_key_update(
|
||||
abc_project_id=mapping_statement.inserted.abc_project_id,
|
||||
abc_channel_key=mapping_statement.inserted.abc_channel_key,
|
||||
is_active=True,
|
||||
)
|
||||
db.execute(mapping_statement)
|
||||
|
||||
if changed or stale_count:
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another access request may have synchronized the same
|
||||
# project concurrently. Its committed row is authoritative.
|
||||
db.rollback()
|
||||
|
||||
return project_codes
|
||||
|
||||
def sync_missing_mappings(
|
||||
self,
|
||||
db: Session,
|
||||
workspace_codes: list[str] | None = None,
|
||||
) -> int:
|
||||
statement = select(Workspace).where(Workspace.is_active.is_(True))
|
||||
if workspace_codes:
|
||||
statement = statement.where(Workspace.workspace_code.in_(workspace_codes))
|
||||
workspaces = db.execute(statement.order_by(Workspace.id.asc())).scalars().all()
|
||||
if not workspaces:
|
||||
return 0
|
||||
|
||||
missing_workspaces: list[Workspace] = []
|
||||
for workspace in workspaces:
|
||||
mappings = db.execute(
|
||||
select(WorkspaceChannelMapping)
|
||||
.where(
|
||||
WorkspaceChannelMapping.workspace_id == workspace.id,
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.asc()),
|
||||
).scalars().all()
|
||||
has_valid_mapping = any(
|
||||
mapping.abc_project_id is not None
|
||||
and int(mapping.abc_project_id) > 0
|
||||
and str(mapping.abc_channel_id).isdigit()
|
||||
and int(mapping.abc_channel_id) > 0
|
||||
for mapping in mappings
|
||||
)
|
||||
if not has_valid_mapping:
|
||||
missing_workspaces.append(workspace)
|
||||
|
||||
if not missing_workspaces:
|
||||
return 0
|
||||
|
||||
remote_mappings = self._fetch_remote_mappings(
|
||||
[workspace.workspace_code for workspace in missing_workspaces],
|
||||
)
|
||||
changed = 0
|
||||
|
||||
for workspace in missing_workspaces:
|
||||
remote = remote_mappings.get(workspace.workspace_code)
|
||||
if remote is None:
|
||||
continue
|
||||
|
||||
project_id = int(remote["project_id"])
|
||||
channel_id = str(remote["channel_id"])
|
||||
existing = db.execute(
|
||||
select(WorkspaceChannelMapping)
|
||||
.where(
|
||||
WorkspaceChannelMapping.workspace_id == workspace.id,
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.asc()),
|
||||
).scalars().first()
|
||||
|
||||
if existing is None:
|
||||
existing = WorkspaceChannelMapping(
|
||||
workspace_id=workspace.id,
|
||||
abc_project_id=project_id,
|
||||
abc_channel_id=channel_id,
|
||||
abc_channel_key=str(remote.get("channel_name") or ""),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(existing)
|
||||
else:
|
||||
existing.abc_project_id = project_id
|
||||
existing.abc_channel_id = channel_id
|
||||
existing.abc_channel_key = str(remote.get("channel_name") or "")
|
||||
existing.is_active = True
|
||||
changed += 1
|
||||
|
||||
if changed:
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another request may have synchronized the same workspace
|
||||
# between our read and insert. Keep the request successful.
|
||||
db.rollback()
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
workspace_mapping_service = WorkspaceMappingService()
|
||||
Reference in New Issue
Block a user