[feat] solution/backend: 내 사이트 목록 엔드포인트 — places LEFT JOIN sites 단일 질의

로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서
(site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다.

- site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번.
  사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다
- protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 —
  보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다
- site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다
- GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다

테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed
(기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1)
This commit is contained in:
Mina Choi 2026-09-02 22:37:53 +09:00
parent d376677b86
commit 479edf9403
20 changed files with 473 additions and 10 deletions

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import publish_logs, site_versions, sites
from common.database.model.models import places, publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -21,6 +21,10 @@ class ISiteCRUD(ABC):
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
pass
@abstractmethod
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
pass
@ -85,6 +89,36 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
try:
where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(places, sites, site_versions.built_at)
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
.where(where)
.order_by(places.created_at.desc())
.offset(skip)
.limit(limit)
)
list_err, rows = await DB_SESSION_MNG.execute(cdb, query)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
try:

View File

@ -87,5 +87,6 @@ app.include_router(router.v1.faq.faq.router)
app.include_router(router.v1.media.media.router)
app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router)
app.include_router(router.v1.local.local.router)
app.include_router(router.v1.local.local.weather_router)

View File

@ -4,8 +4,17 @@ from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import BuildStatus, JobStatus, PublishAction, PublishRejectReason, PublishResult, SiteStatus
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
from common.enums import (
BuildStatus,
JobStatus,
PlaceCategory,
PlaceStatus,
PublishAction,
PublishRejectReason,
PublishResult,
SiteStatus,
)
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class SiteProtocol(WebPacketProtocol):
@ -56,6 +65,32 @@ class SiteData(WebPacketProtocol):
published_at: Optional[datetime] = None
class MySiteData(WebPacketProtocol):
"""내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다."""
place_id: uuid.UUID
name: str
category: PlaceCategory
place_status: PlaceStatus
road_address: Optional[str] = None
created_at: Optional[datetime] = None
site_id: Optional[uuid.UUID] = None
status: Optional[SiteStatus] = None
domain: Optional[str] = None
template_id: Optional[str] = None
published_at: Optional[datetime] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False
class Res_MySites(Res_PageProtocol):
sites: list[MySiteData] = []
class PublishLogData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)

View File

@ -2,7 +2,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import UserInfo
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.site_service import SiteService
from .protocol import (
@ -11,6 +11,7 @@ from .protocol import (
Req_SiteTemplate,
Req_SiteTheme,
Req_StartBuild,
Res_MySites,
Res_PublishLogs,
Res_Site,
Res_SeoAudit,
@ -23,6 +24,25 @@ from .protocol import (
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}})
# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어
# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록).
my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}})
@my_router.get(
path="/list",
response_model=Res_MySites,
summary="내 사이트 목록",
description="로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 "
"site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.",
)
async def list_my_sites(
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_my_sites(user_info, pg))
@router.get(
path="",

View File

@ -16,12 +16,13 @@ from common.enums import (
SiteStatus,
)
from common.logger import LOG
from common.models.gmodel import UserInfo
from common.models.gmodel import PageParams, UserInfo
from common.utils.gtime import GTime
from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD
from crud.site_crud import ISiteCRUD, SiteCRUD
from router.v1.site.protocol import (
MySiteData,
PublishLogData,
RenderStatusData,
Req_SiteSlug,
@ -29,6 +30,7 @@ from router.v1.site.protocol import (
Req_SiteTemplate,
Req_SiteTheme,
Req_StartBuild,
Res_MySites,
Res_PublishLogs,
Res_Site,
Res_SeoAudit,
@ -417,6 +419,44 @@ class SiteService:
return _THEME_REJECTED
return cleaned
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
"""로그인한 계정(회사)이 가진 사이트 전부.
사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라
'발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다."""
res = Res_MySites(page=pg.page, size=pg.size)
cid = uuid.UUID(user_info.company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows]
res.total = total
return res
@staticmethod
def _my_site_row(place, site, built_at) -> MySiteData:
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
changed = place.content_updated_at
return MySiteData(
place_id=place.place_id,
name=place.name,
category=place.category,
place_status=place.status,
road_address=place.road_address or place.address,
created_at=place.created_at,
site_id=getattr(site, "site_id", None),
status=getattr(site, "status", None),
domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", None),
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
)
async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site:
res = Res_Site()
err_type, place = await self._load_place(user_info, place_id)

View File

@ -0,0 +1,90 @@
"""내 사이트 목록 — 로그인한 사장님이 자기 사이트 전부를 보는 화면의 뒷단.
이 경로가 절대 하면 안 되는 것:
- 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
- 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
- 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른
답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다.
"""
import uuid
from sqlalchemy import text
from common.enums import ErrorType, SiteStatus
async def _place(client, headers, name):
r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1})
return r.json()["place"]["place_id"]
async def _list(client, headers, **params):
return (await client.get("/v1/site/list", headers=headers, params=params)).json()
async def test_place_without_site_is_still_listed(auth_headers, client):
"""검증: 사이트 행이 없는 사업장(위저드만 걸어온 것)도 목록에 나온다.
기대결과: 줄은 있고 site_id 는 없다 — 화면이 '만드는 중'으로 그릴 근거다."""
h = await auth_headers("my1")
await _place(client, h, "아직펜션")
body = await _list(client, h)
assert body["result"]["code"] == ErrorType.SUCCESS.value
assert body["total"] == 1
row = body["sites"][0]
assert row["name"] == "아직펜션"
assert row.get("site_id") is None
assert row.get("status") is None
async def test_site_row_is_joined_into_the_line(auth_headers, client):
"""검증: 사업장과 사이트가 한 줄로 합쳐져 온다(줄마다 사이트를 다시 묻지 않는다).
기대결과: 템플릿·주소가 목록에 그대로 보인다."""
h = await auth_headers("my2")
pid = await _place(client, h, "합쳐진펜션")
await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "stay-quiet-margin"})
await client.post(f"/v1/place/{pid}/site/slug", headers=h, json={"slug": "joined-stay"})
row = (await _list(client, h))["sites"][0]
assert row["site_id"]
assert row["template_id"] == "stay-quiet-margin"
assert row["domain"] == "joined-stay"
assert row["status"] == SiteStatus.DRAFT.value
async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id):
"""검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다.
기대결과: 각자 자기 것만 1건."""
mine = await auth_headers("my3")
theirs = await auth_headers("my3b", other_company_id)
await _place(client, mine, "내펜션")
await _place(client, theirs, "남의펜션")
assert [r["name"] for r in (await _list(client, mine))["sites"]] == ["내펜션"]
assert [r["name"] for r in (await _list(client, theirs))["sites"]] == ["남의펜션"]
async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client, db_engine):
"""검증: 재빌드 판정이 단건 조회와 같은 답을 낸다.
기대결과: 노출값이 바뀐 사업장은 목록에서도 needs_rebuild=true."""
h = await auth_headers("my4")
pid = await _place(client, h, "고친펜션")
# 템플릿 저장이 사이트 행을 만든다. 그 뒤 노출값이 바뀐 것으로 표시한다.
await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "t"})
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE places SET content_updated_at = now() WHERE place_id = :pid"),
{"pid": uuid.UUID(pid)},
)
single = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()
row = (await _list(client, h))["sites"][0]
assert row["needs_rebuild"] is True
assert row["needs_rebuild"] == single["needs_rebuild"]
async def test_list_requires_login(client):
"""검증: 내 것을 보는 화면이므로 토큰 없이는 열리지 않는다.
기대결과: 401."""
assert (await client.get("/v1/site/list")).status_code == 401

View File

@ -54,6 +54,7 @@ export * from './listFactsParams';
export * from './listFaqsParams';
export * from './listLinksParams';
export * from './listMediaParams';
export * from './listMySitesParams';
export * from './listPlacesParams';
export * from './localContentData';
export * from './localContentDataBody';
@ -77,6 +78,14 @@ export * from './mediaDataUnitId';
export * from './mediaDataVisionConfidence';
export * from './mediaDataWidth';
export * from './mediaStatus';
export * from './mySiteData';
export * from './mySiteDataCreatedAt';
export * from './mySiteDataDomain';
export * from './mySiteDataPublishedAt';
export * from './mySiteDataRoadAddress';
export * from './mySiteDataSiteId';
export * from './mySiteDataStatus';
export * from './mySiteDataTemplateId';
export * from './placeCandidate';
export * from './placeCandidateAddress';
export * from './placeCandidateCategoryName';
@ -215,6 +224,8 @@ export * from './resMeMsg';
export * from './resMeName';
export * from './resMediaList';
export * from './resMediaListMsg';
export * from './resMySites';
export * from './resMySitesMsg';
export * from './resPlace';
export * from './resPlaceList';
export * from './resPlaceListMsg';

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListMySitesParams = {
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -0,0 +1,36 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceCategory } from './placeCategory';
import type { PlaceStatus } from './placeStatus';
import type { MySiteDataRoadAddress } from './mySiteDataRoadAddress';
import type { MySiteDataCreatedAt } from './mySiteDataCreatedAt';
import type { MySiteDataSiteId } from './mySiteDataSiteId';
import type { MySiteDataStatus } from './mySiteDataStatus';
import type { MySiteDataDomain } from './mySiteDataDomain';
import type { MySiteDataTemplateId } from './mySiteDataTemplateId';
import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt';
/**
* 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다.
*/
export interface MySiteData {
place_id: string;
name: string;
category: PlaceCategory;
place_status: PlaceStatus;
road_address?: MySiteDataRoadAddress;
created_at?: MySiteDataCreatedAt;
site_id?: MySiteDataSiteId;
status?: MySiteDataStatus;
domain?: MySiteDataDomain;
template_id?: MySiteDataTemplateId;
published_at?: MySiteDataPublishedAt;
needs_rebuild?: boolean;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataDomain = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataPublishedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataRoadAddress = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataSiteId = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { SiteStatus } from './siteStatus';
export type MySiteDataStatus = SiteStatus | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataTemplateId = string | null;

View File

@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme';
떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다.
{"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다.

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResMySitesMsg } from './resMySitesMsg';
import type { MySiteData } from './mySiteData';
export interface ResMySites {
result?: ErrorInfo;
msg?: ResMySitesMsg;
total?: number;
page?: number;
size?: number;
sites?: MySiteData[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResMySitesMsg = string | null;

View File

@ -26,11 +26,13 @@ import type {
import type {
CheckSlugParams,
HTTPValidationError,
ListMySitesParams,
ReqSiteSlug,
ReqSiteStatus,
ReqSiteTemplate,
ReqSiteTheme,
ReqStartBuild,
ResMySites,
ResPublishLogs,
ResSeoAudit,
ResSite,
@ -467,7 +469,7 @@ export const useSetTemplate = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId","body","data"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).
* @summary 디자인(색·서체·섹션) 저장
*/
export const setTheme = (
@ -849,4 +851,97 @@ export const useChangeStatus = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* 로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.
* @summary 내 사이트 목록
*/
export const listMySites = (
params?: ListMySitesParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResMySites>(
{url: `/v1/site/list`, method: 'GET',
params, signal
},
options);
}
export const getListMySitesQueryKey = (params?: ListMySitesParams,) => {
return [
`/v1/site/list`, ...(params ? [params]: [])
] as const;
}
export const getListMySitesQueryOptions = <TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMySites>>> = ({ signal }) => listMySites(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListMySitesQueryResult = NonNullable<Awaited<ReturnType<typeof listMySites>>>
export type ListMySitesQueryError = void | HTTPValidationError
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params: undefined | ListMySitesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 내 사이트 목록
*/
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListMySitesQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}