diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 65ea3f5..0aa391d 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,26 @@ # 개발 일지 +## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로 + +admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안 +갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트 +하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다. + +- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부 + `RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` / + `crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다 + (`UserRole` 주석 원칙을 내부 화면에도 지킨다). +- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`). + `AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다. + ★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐, + 빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다. + 실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다. +- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만. + +**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트· +스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` · +`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요. + ## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다 실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index c76df31..c2fe5fe 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -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 place_photos, places, site_publish_logs, site_versions, sites +from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites, users from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus from common.logger import LOG from common.utils.gtime import GTime @@ -48,6 +48,12 @@ class ISiteCRUD(ABC): """(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).""" pass + @abstractmethod + async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]: + """전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용. + (ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수).""" + pass + @abstractmethod async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: pass @@ -157,6 +163,35 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 + async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]: + """list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다. + (ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수).""" + try: + where = places.deleted == False # 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, _primary_photo_subquery(), users.id, users.email, users.name) + .join(users, users.user_id == places.owner_user_id) + .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: diff --git a/solution/backend/crud/user_crud.py b/solution/backend/crud/user_crud.py index d24088c..be53a47 100644 --- a/solution/backend/crud/user_crud.py +++ b/solution/backend/crud/user_crud.py @@ -1,16 +1,27 @@ from abc import ABC, abstractmethod from typing import Tuple -from sqlalchemy import select, func, update +from sqlalchemy import and_, select, func, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import users +from common.database.model.models import places, users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime +def _place_count_subquery(): + """계정 하나가 가진 사업장 수 — users 에 상관 서브쿼리로 얹는다(N+1 회피).""" + return ( + select(func.count()) + .select_from(places) + .where(places.owner_user_id == users.user_id, places.deleted == False) # noqa: E712 + .correlate(users) + .scalar_subquery() + ) + + # CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다. # - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이). # - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다. @@ -47,6 +58,13 @@ class IUserCRUD(ABC): async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType: pass + @abstractmethod + async def list_users( + self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None + ) -> Tuple[ErrorType, list, int]: + """내부 운영(DEVELOPER) 전용 전체 계정 목록. (ErrorType, [(user, place_count)], 총건수).""" + pass + class UserCRUD(IUserCRUD): async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: @@ -151,3 +169,34 @@ class UserCRUD(IUserCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def list_users( + self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None + ) -> Tuple[ErrorType, list, int]: + """role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다 + (UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다).""" + try: + where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712 + if search: + like = f"%{search.strip()}%" + where = and_(where, (users.email.ilike(like) | users.name.ilike(like) | users.id.ilike(like))) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).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(users, _place_count_subquery()) + .where(where) + .order_by(users.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 diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index cf46e52..bb3a81c 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -25,6 +25,7 @@ import router.v1.site.booking_request import router.v1.site.post import router.v1.site.review import router.v1.local.local +import router.v1.ops.ops import router.v1.social.social import router.v1.social.oauth import router.v1.agent.kakao @@ -138,6 +139,7 @@ app.include_router(router.v1.site.post.owner_router) app.include_router(router.v1.site.review.router) app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.weather_router) +app.include_router(router.v1.ops.ops.router) app.include_router(router.v1.social.social.router) app.include_router(router.v1.social.oauth.router) diff --git a/solution/backend/router/v1/ops/__init__.py b/solution/backend/router/v1/ops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/backend/router/v1/ops/ops.py b/solution/backend/router/v1/ops/ops.py new file mode 100644 index 0000000..16031fa --- /dev/null +++ b/solution/backend/router/v1/ops/ops.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends, Query + +from common.models.gmodel import PageParams +from router.v1.validator.dependencies import RemoveNoneResponse, RequireDeveloper +from services.ops_service import OpsService +from .protocol import Res_OpsSites, Res_OpsUsers + +# 내부 운영(개발자) 전용 조회 — 회사 스코프를 걷어낸 전 계정 사이트·유저 목록. +# admin/frontend(:9801)를 새 도메인으로 키우는 대신 solution 앱(:9800)에 경량으로 얹은 것이다 +# (2026-09-23 기획). RequireDeveloper 가 유일한 문이다 — OWNER(사장님)는 관리할 하위 계정이 +# 없어(2026-09-08 회사/테넌트 걷어냄) 이 화면을 볼 이유가 없다. +router = APIRouter(prefix="/v1/ops", tags=["Ops"]) + + +@router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)") +async def list_sites(service: OpsService = Depends(), pg: PageParams = Depends(), _user=Depends(RequireDeveloper)): + return RemoveNoneResponse(await service.list_sites(pg)) + + +@router.get(path="/users", response_model=Res_OpsUsers, summary="전 계정 목록(개발자 전용)") +async def list_users( + service: OpsService = Depends(), + pg: PageParams = Depends(), + search: str | None = Query(None, description="이메일·이름·로그인 아이디 부분 일치"), + _user=Depends(RequireDeveloper), +): + return RemoveNoneResponse(await service.list_users(pg, search)) diff --git a/solution/backend/router/v1/ops/protocol.py b/solution/backend/router/v1/ops/protocol.py new file mode 100644 index 0000000..361f8d0 --- /dev/null +++ b/solution/backend/router/v1/ops/protocol.py @@ -0,0 +1,53 @@ +import uuid +from datetime import datetime +from typing import Optional + +from common.enums import AuthProvider, PlaceCategory, PlaceStatus, SiteStatus, UserRole, UserStatus +from common.models.gmodel import Res_PageProtocol, WebPacketProtocol + + +class OpsSiteData(WebPacketProtocol): + """전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자. + + ★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다.""" + + place_id: uuid.UUID + name: str + category: PlaceCategory + place_status: PlaceStatus + created_at: Optional[datetime] = None + + site_id: Optional[uuid.UUID] = None + status: Optional[SiteStatus] = None + domain: Optional[str] = None + published_at: Optional[datetime] = None + thumbnail_url: Optional[str] = None + needs_rebuild: bool = False + + owner_login_id: str + owner_email: Optional[str] = None + owner_name: Optional[str] = None + + +class Res_OpsSites(Res_PageProtocol): + sites: list[OpsSiteData] = [] + + +class OpsUserData(WebPacketProtocol): + """전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다.""" + + user_id: uuid.UUID + login_id: str + name: Optional[str] = None + email: Optional[str] = None + contact_number: Optional[str] = None + role: UserRole + status: UserStatus + provider: AuthProvider + created_at: Optional[datetime] = None + last_accessed_at: Optional[datetime] = None + place_count: int = 0 + + +class Res_OpsUsers(Res_PageProtocol): + users: list[OpsUserData] = [] diff --git a/solution/backend/services/ops_service.py b/solution/backend/services/ops_service.py new file mode 100644 index 0000000..3ea5fa0 --- /dev/null +++ b/solution/backend/services/ops_service.py @@ -0,0 +1,89 @@ +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import places, users +from common.enums import DBWRType, ErrorType, UserRole +from common.models.gmodel import PageParams +from crud.site_crud import ISiteCRUD, SiteCRUD +from crud.user_crud import IUserCRUD, UserCRUD +from router.v1.ops.protocol import OpsSiteData, OpsUserData, Res_OpsSites, Res_OpsUsers + + +class OpsService: + """내부 운영(개발자) 전용 조회 — 회사 스코프 없는 전 계정 사이트·유저 목록. + + admin/frontend 의 사업장 화면(사실 검증)과 역할이 다르다 — 여기는 발행 인프라 상태와 + 계정 상태만 본다. 게이트는 라우터가 RequireDeveloper 로 건다(여기서는 다시 확인하지 않는다).""" + + def __init__(self, site_crud: ISiteCRUD = Depends(SiteCRUD), user_crud: IUserCRUD = Depends(UserCRUD)): + self.site_crud = site_crud + self.user_crud = user_crud + + async def list_sites(self, pg: PageParams) -> Res_OpsSites: + res = Res_OpsSites(page=pg.page, size=pg.size) + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, + lambda s: self.site_crud.list_all_sites(s, pg.skip, pg.size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.sites = [ + self._site_row(place, site, built_at, photo, login_id, email, name) + for place, site, built_at, photo, login_id, email, name in rows + ] + res.total = total + return res + + @staticmethod + def _site_row(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name) -> OpsSiteData: + # ★ 재빌드 판별은 site_service._my_site_row 와 같은 규칙이어야 한다 — 다르면 화면마다 다른 답을 한다. + changed = place.content_updated_at + ever_published = site is not None and getattr(site, "published_at", None) is not None + thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None) + return OpsSiteData( + place_id=place.place_id, + name=place.name, + category=place.category, + place_status=place.status, + created_at=place.created_at, + site_id=getattr(site, "site_id", None), + status=getattr(site, "status", None), + domain=getattr(site, "domain", None), + published_at=getattr(site, "published_at", None), + thumbnail_url=thumbnail_url, + needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), + owner_login_id=owner_login_id, + owner_email=owner_email, + owner_name=owner_name, + ) + + async def list_users(self, pg: PageParams, search: str | None = None) -> Res_OpsUsers: + """USER/OWNER 만 — 개발자 계정은 목록에서 뺀다(UserRole 주석: 고객사에 존재를 노출하지 않는다).""" + res = Res_OpsUsers(page=pg.page, size=pg.size) + roles = [UserRole.USER.value, UserRole.OWNER.value] + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + users.DBType(), DBWRType.DB_READ.value, + lambda s: self.user_crud.list_users(s, roles, pg.skip, pg.size, search), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.users = [ + OpsUserData( + user_id=user.user_id, + login_id=user.id, + name=user.name, + email=user.email, + contact_number=user.contact_number, + role=user.role, + status=user.status, + provider=user.provider, + created_at=user.created_at, + last_accessed_at=user.last_accessed_at, + place_count=place_count, + ) + for user, place_count in rows + ] + res.total = total + return res diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index 273a6d6..a0f6ce3 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -74,7 +74,9 @@ export * from "./listMySitesParams"; export * from "./listPlacesParams"; export * from "./listReviewsParams"; export * from "./listShowcaseParams"; +export * from "./listSitesParams"; export * from "./listUpcomingPostsParams"; +export * from "./listUsersParams"; export * from "./localContentData"; export * from "./localContentDataBody"; export * from "./localContentDataCollectedAt"; @@ -107,6 +109,21 @@ export * from "./mySiteDataStatus"; export * from "./mySiteDataTemplateId"; export * from "./mySiteDataThumbnailUrl"; export * from "./oauthCallbackParams"; +export * from "./opsSiteData"; +export * from "./opsSiteDataCreatedAt"; +export * from "./opsSiteDataDomain"; +export * from "./opsSiteDataOwnerEmail"; +export * from "./opsSiteDataOwnerName"; +export * from "./opsSiteDataPublishedAt"; +export * from "./opsSiteDataSiteId"; +export * from "./opsSiteDataStatus"; +export * from "./opsSiteDataThumbnailUrl"; +export * from "./opsUserData"; +export * from "./opsUserDataContactNumber"; +export * from "./opsUserDataCreatedAt"; +export * from "./opsUserDataEmail"; +export * from "./opsUserDataLastAccessedAt"; +export * from "./opsUserDataName"; export * from "./placeCandidate"; export * from "./placeCandidateAddress"; export * from "./placeCandidateCategoryName"; @@ -291,6 +308,10 @@ export * from "./resMyPosts"; export * from "./resMyPostsMsg"; export * from "./resMySites"; export * from "./resMySitesMsg"; +export * from "./resOpsSites"; +export * from "./resOpsSitesMsg"; +export * from "./resOpsUsers"; +export * from "./resOpsUsersMsg"; export * from "./resPlace"; export * from "./resPlaceContentList"; export * from "./resPlaceContentListMsg"; @@ -381,6 +402,7 @@ export * from "./sourceType"; export * from "./testPost"; export * from "./unitData"; export * from "./userRole"; +export * from "./userStatus"; export * from "./validationError"; export * from "./validationErrorCtx"; export * from "./validationErrorLocItem"; diff --git a/solution/frontend/src/api/generated/model/listSitesParams.ts b/solution/frontend/src/api/generated/model/listSitesParams.ts new file mode 100644 index 0000000..536f6cc --- /dev/null +++ b/solution/frontend/src/api/generated/model/listSitesParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ListSitesParams = { + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + size?: number; +}; diff --git a/solution/frontend/src/api/generated/model/listUsersParams.ts b/solution/frontend/src/api/generated/model/listUsersParams.ts new file mode 100644 index 0000000..ac99ff3 --- /dev/null +++ b/solution/frontend/src/api/generated/model/listUsersParams.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ListUsersParams = { + /** + * 이메일·이름·로그인 아이디 부분 일치 + */ + search?: string | null; + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + size?: number; +}; diff --git a/solution/frontend/src/api/generated/model/opsSiteData.ts b/solution/frontend/src/api/generated/model/opsSiteData.ts new file mode 100644 index 0000000..b1abc59 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteData.ts @@ -0,0 +1,38 @@ +/** + * 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 { OpsSiteDataCreatedAt } from "./opsSiteDataCreatedAt"; +import type { OpsSiteDataSiteId } from "./opsSiteDataSiteId"; +import type { OpsSiteDataStatus } from "./opsSiteDataStatus"; +import type { OpsSiteDataDomain } from "./opsSiteDataDomain"; +import type { OpsSiteDataPublishedAt } from "./opsSiteDataPublishedAt"; +import type { OpsSiteDataThumbnailUrl } from "./opsSiteDataThumbnailUrl"; +import type { OpsSiteDataOwnerEmail } from "./opsSiteDataOwnerEmail"; +import type { OpsSiteDataOwnerName } from "./opsSiteDataOwnerName"; + +/** + * 전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자. + +★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다. + */ +export interface OpsSiteData { + place_id: string; + name: string; + category: PlaceCategory; + place_status: PlaceStatus; + created_at?: OpsSiteDataCreatedAt; + site_id?: OpsSiteDataSiteId; + status?: OpsSiteDataStatus; + domain?: OpsSiteDataDomain; + published_at?: OpsSiteDataPublishedAt; + thumbnail_url?: OpsSiteDataThumbnailUrl; + needs_rebuild?: boolean; + owner_login_id: string; + owner_email?: OpsSiteDataOwnerEmail; + owner_name?: OpsSiteDataOwnerName; +} diff --git a/solution/frontend/src/api/generated/model/opsSiteDataCreatedAt.ts b/solution/frontend/src/api/generated/model/opsSiteDataCreatedAt.ts new file mode 100644 index 0000000..2527540 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataCreatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataCreatedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataDomain.ts b/solution/frontend/src/api/generated/model/opsSiteDataDomain.ts new file mode 100644 index 0000000..c78ab42 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataDomain.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataDomain = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataOwnerEmail.ts b/solution/frontend/src/api/generated/model/opsSiteDataOwnerEmail.ts new file mode 100644 index 0000000..5e21485 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataOwnerEmail.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataOwnerEmail = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataOwnerName.ts b/solution/frontend/src/api/generated/model/opsSiteDataOwnerName.ts new file mode 100644 index 0000000..5fb8ab9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataOwnerName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataOwnerName = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataPublishedAt.ts b/solution/frontend/src/api/generated/model/opsSiteDataPublishedAt.ts new file mode 100644 index 0000000..0bb9a55 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataPublishedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataPublishedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataSiteId.ts b/solution/frontend/src/api/generated/model/opsSiteDataSiteId.ts new file mode 100644 index 0000000..aea1a00 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataSiteId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataSiteId = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataStatus.ts b/solution/frontend/src/api/generated/model/opsSiteDataStatus.ts new file mode 100644 index 0000000..a3dbe92 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataStatus.ts @@ -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 OpsSiteDataStatus = SiteStatus | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataThumbnailUrl.ts b/solution/frontend/src/api/generated/model/opsSiteDataThumbnailUrl.ts new file mode 100644 index 0000000..0cd0cbf --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataThumbnailUrl.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataThumbnailUrl = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserData.ts b/solution/frontend/src/api/generated/model/opsUserData.ts new file mode 100644 index 0000000..fd490c8 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserData.ts @@ -0,0 +1,31 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { OpsUserDataName } from "./opsUserDataName"; +import type { OpsUserDataEmail } from "./opsUserDataEmail"; +import type { OpsUserDataContactNumber } from "./opsUserDataContactNumber"; +import type { UserRole } from "./userRole"; +import type { UserStatus } from "./userStatus"; +import type { AuthProvider } from "./authProvider"; +import type { OpsUserDataCreatedAt } from "./opsUserDataCreatedAt"; +import type { OpsUserDataLastAccessedAt } from "./opsUserDataLastAccessedAt"; + +/** + * 전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다. + */ +export interface OpsUserData { + user_id: string; + login_id: string; + name?: OpsUserDataName; + email?: OpsUserDataEmail; + contact_number?: OpsUserDataContactNumber; + role: UserRole; + status: UserStatus; + provider: AuthProvider; + created_at?: OpsUserDataCreatedAt; + last_accessed_at?: OpsUserDataLastAccessedAt; + place_count?: number; +} diff --git a/solution/frontend/src/api/generated/model/opsUserDataContactNumber.ts b/solution/frontend/src/api/generated/model/opsUserDataContactNumber.ts new file mode 100644 index 0000000..5147e11 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataContactNumber.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataContactNumber = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataCreatedAt.ts b/solution/frontend/src/api/generated/model/opsUserDataCreatedAt.ts new file mode 100644 index 0000000..c64c9cf --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataCreatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataCreatedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataEmail.ts b/solution/frontend/src/api/generated/model/opsUserDataEmail.ts new file mode 100644 index 0000000..09ab82f --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataEmail.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataEmail = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataLastAccessedAt.ts b/solution/frontend/src/api/generated/model/opsUserDataLastAccessedAt.ts new file mode 100644 index 0000000..465b1a6 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataLastAccessedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataLastAccessedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataName.ts b/solution/frontend/src/api/generated/model/opsUserDataName.ts new file mode 100644 index 0000000..ffc9740 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataName = string | null; diff --git a/solution/frontend/src/api/generated/model/resOpsSites.ts b/solution/frontend/src/api/generated/model/resOpsSites.ts new file mode 100644 index 0000000..e8e11e2 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsSites.ts @@ -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 { ResOpsSitesMsg } from "./resOpsSitesMsg"; +import type { OpsSiteData } from "./opsSiteData"; + +export interface ResOpsSites { + result?: ErrorInfo; + msg?: ResOpsSitesMsg; + total?: number; + page?: number; + size?: number; + sites?: OpsSiteData[]; +} diff --git a/solution/frontend/src/api/generated/model/resOpsSitesMsg.ts b/solution/frontend/src/api/generated/model/resOpsSitesMsg.ts new file mode 100644 index 0000000..4c4d3f8 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsSitesMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResOpsSitesMsg = string | null; diff --git a/solution/frontend/src/api/generated/model/resOpsUsers.ts b/solution/frontend/src/api/generated/model/resOpsUsers.ts new file mode 100644 index 0000000..1251e6d --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsUsers.ts @@ -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 { ResOpsUsersMsg } from "./resOpsUsersMsg"; +import type { OpsUserData } from "./opsUserData"; + +export interface ResOpsUsers { + result?: ErrorInfo; + msg?: ResOpsUsersMsg; + total?: number; + page?: number; + size?: number; + users?: OpsUserData[]; +} diff --git a/solution/frontend/src/api/generated/model/resOpsUsersMsg.ts b/solution/frontend/src/api/generated/model/resOpsUsersMsg.ts new file mode 100644 index 0000000..35586a8 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsUsersMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResOpsUsersMsg = string | null; diff --git a/solution/frontend/src/api/generated/model/userStatus.ts b/solution/frontend/src/api/generated/model/userStatus.ts new file mode 100644 index 0000000..909aed9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/userStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +/** + * users.status 코드값. + */ +export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const UserStatus = { + ACTIVE: 1, + INACTIVE: 2, +} as const; diff --git a/solution/frontend/src/api/generated/ops/ops.ts b/solution/frontend/src/api/generated/ops/ops.ts new file mode 100644 index 0000000..e2822c4 --- /dev/null +++ b/solution/frontend/src/api/generated/ops/ops.ts @@ -0,0 +1,314 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + HTTPValidationError, + ListSitesParams, + ListUsersParams, + ResOpsSites, + ResOpsUsers, +} from ".././model"; + +import { customFetch } from "../../mutator/custom-fetch"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * @summary 전 계정 사이트 목록(개발자 전용) + */ +export const listSites = ( + params?: ListSitesParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/ops/sites`, method: "GET", params, signal }, + options, + ); +}; + +export const getListSitesQueryKey = (params?: ListSitesParams) => { + return [`/v1/ops/sites`, ...(params ? [params] : [])] as const; +}; + +export const getListSitesQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListSitesQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listSites(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListSitesQueryResult = NonNullable< + Awaited> +>; +export type ListSitesQueryError = HTTPValidationError; + +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | ListSitesParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 전 계정 사이트 목록(개발자 전용) + */ + +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListSitesQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary 전 계정 목록(개발자 전용) + */ +export const listUsers = ( + params?: ListUsersParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/ops/users`, method: "GET", params, signal }, + options, + ); +}; + +export const getListUsersQueryKey = (params?: ListUsersParams) => { + return [`/v1/ops/users`, ...(params ? [params] : [])] as const; +}; + +export const getListUsersQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listUsers(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListUsersQueryResult = NonNullable< + Awaited> +>; +export type ListUsersQueryError = HTTPValidationError; + +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | ListUsersParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 전 계정 목록(개발자 전용) + */ + +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListUsersQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} diff --git a/solution/frontend/src/api/index.ts b/solution/frontend/src/api/index.ts index 22f4cd8..312fc24 100644 --- a/solution/frontend/src/api/index.ts +++ b/solution/frontend/src/api/index.ts @@ -19,6 +19,7 @@ export * from './generated/job/job'; export * from './generated/site/site'; export * from './generated/media/media'; export * from './generated/faq/faq'; +export * from './generated/ops/ops'; export * from './generated/model'; // 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다. diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index da25315..f999814 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,7 @@ import type {ComponentType, ReactNode} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router'; -import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react'; +import {Globe2, LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Users, Wand2} from 'lucide-react'; +import {UserRole} from '@/api'; import {cn} from '@/lib/utils'; import {userLabel, useAuthStore} from '@/stores/auth'; @@ -36,11 +37,23 @@ const OWNER_NAV: NavItem[] = [ {to: '/pricing', match: '/pricing', label: '요금', icon: Receipt}, ]; -export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) { +/** + * 개발자 전용 두 줄(사이트관리·유저관리, 2026-09-23 기획). admin/frontend 를 새 도메인으로 + * 키우는 대신 solution 앱에 경량으로 얹은 것 — 그래서 위 주석의 "메뉴가 섞이면 새어 나간다"가 + * 그대로 적용된다: 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임에만 + * 숨긴다). 실제 데이터 접근은 백엔드 RequireDeveloper 가 막으므로 노출은 경로 이름 정도다. + */ +const DEVELOPER_NAV: NavItem[] = [ + {to: '/ops/sites', match: '/ops/sites', label: '사이트관리', icon: Globe2}, + {to: '/ops/users', match: '/ops/users', label: '유저관리', icon: Users}, +]; + +export function AppShell({children, nav}: {children: ReactNode; nav?: NavItem[]}) { const user = useAuthStore((s) => s.user); const signOut = useAuthStore((s) => s.signOut); const location = useLocation(); const navigate = useNavigate(); + const items = nav ?? (user?.role === UserRole.DEVELOPER ? [...OWNER_NAV, ...DEVELOPER_NAV] : OWNER_NAV); return (
@@ -52,7 +65,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
+ } + > + {isLoading && ( +
+ +
+ )} + + {isError && ( + + )} + + {!isLoading && !isError && rows.length === 0 && ( + + )} + + {!isLoading && !isError && rows.length > 0 && ( + <> +
    + {rows.map((row) => { + const statusBadge = STATUS_BADGE[row.status ?? UserStatus.ACTIVE] ?? STATUS_BADGE[UserStatus.ACTIVE]; + return ( +
  • +
    +
    +

    {row.name || row.login_id}

    + {ROLE_LABEL[row.role] ?? '알 수 없음'} + {statusBadge.label} + {PROVIDER_LABEL[row.provider] ?? '알 수 없음'} +
    +

    + {[row.email, row.contact_number].filter(Boolean).join(' · ') || row.login_id} +

    +
    + + + 사이트 {row.place_count}개 + +
  • + ); + })} +
+ +
+ 총 {total}건 +
+ + + {page} / {lastPage} + + +
+
+ + )} + + + ); +} + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default OpsUsersPage; diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts index b66c80f..972973f 100644 --- a/solution/frontend/src/routes.ts +++ b/solution/frontend/src/routes.ts @@ -29,6 +29,8 @@ export default [ // "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) — // 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다. route('blog', 'pages/BlogPostsPage.tsx'), + route('ops/sites', 'pages/OpsSitesPage.tsx'), + route('ops/users', 'pages/OpsUsersPage.tsx'), ]), /**