From 81ea67654691b17ced5c3d878ac5f5a82bed9a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 22 Sep 2026 08:37:38 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20=EB=AF=B8=EB=8B=88?= =?UTF-8?q?=EB=B8=94=EB=A1=9C=EA=B7=B8=20=EB=B9=8C=EB=8D=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=99=95=EC=9E=A5=20=E2=80=94=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=B6=84=EB=A6=AC=C2=B7?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=C2=B7=EC=A6=89=EC=8B=9C=EB=B0=9C=EC=86=A1?= =?UTF-8?q?=C2=B7=EA=B3=B5=EA=B8=89=EC=9E=90=20=EB=AC=B4=EA=B4=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌더 앱에서 미니블로그를 운영하는 데 필요했던 몇 가지를 묶었다. - notify_email: 업장별 승인 메일 수신자를 계정 로그인 이메일과 분리(places.notify_email, migrations/0021, PlaceCRUD·protocol.py·place_service.py 검증, BlogPostsPage.tsx 설정 UI) - 글 삭제(soft delete): 상태 제한 없이 지우고, 이미 게재된 글이면 재발행 잡까지 큐에 넣는다 (post_crud.py, router/v1/site/post.py DELETE, BlogPostsPage.tsx 삭제 버튼) - 지금 발송하기: 아침 9시 스윕을 안 기다리고 바로 발송(post_crud.next_due_for_mail, BlogPostsPage.tsx 버튼) - blog_service.generate_one: 하드코딩된 Gemini 대신 services/llm/provider.py(LLM_PROVIDER, 기본 openai)를 타도록 전환, 업종별 분기 구조(현재 숙소만 구현) 추가 - site_payload.publish_url(place, site): 발행 주소 조합을 한 곳에 모은 헬퍼 - 프론트: orval 로 재생성한 API 클라이언트(notify_email·삭제·즉시발송·social 엔드포인트 반영) 관련 스위트는 별도 커밋(쓰레드 연동 작업)에서 이미 PASS 확인함 --- postgres-init/init-data/init.sql | 1 + .../migrations/0021_places_notify_email.sql | 7 + .../backend/common/database/model/models.py | 3 + solution/backend/crud/post_crud.py | 30 +- solution/backend/router/v1/place/protocol.py | 3 + solution/backend/router/v1/site/post.py | 33 +- solution/backend/services/blog_service.py | 63 +- solution/backend/services/place_service.py | 7 + solution/backend/services/site_payload.py | 6 + .../src/api/generated/model/approvalParams.ts | 14 + .../src/api/generated/model/callbackParams.ts | 21 + .../src/api/generated/model/decision.ts | 10 + .../frontend/src/api/generated/model/index.ts | 7 + .../src/api/generated/model/jobType.ts | 2 + .../src/api/generated/model/linkDecision.ts | 15 + .../src/api/generated/model/placeData.ts | 2 + .../generated/model/placeDataNotifyEmail.ts | 8 + .../src/api/generated/model/reqUpdatePlace.ts | 2 + .../model/reqUpdatePlaceNotifyEmail.ts | 8 + .../src/api/generated/model/testPost.ts | 14 + .../frontend/src/api/generated/site/site.ts | 178 ++- .../src/api/generated/social/social.ts | 1216 +++++++++++++++++ solution/frontend/src/pages/BlogPostsPage.tsx | 176 ++- solution/shared/src/types/site-payload.ts | 31 +- 24 files changed, 1813 insertions(+), 44 deletions(-) create mode 100644 postgres-init/migrations/0021_places_notify_email.sql create mode 100644 solution/frontend/src/api/generated/model/approvalParams.ts create mode 100644 solution/frontend/src/api/generated/model/callbackParams.ts create mode 100644 solution/frontend/src/api/generated/model/decision.ts create mode 100644 solution/frontend/src/api/generated/model/linkDecision.ts create mode 100644 solution/frontend/src/api/generated/model/placeDataNotifyEmail.ts create mode 100644 solution/frontend/src/api/generated/model/reqUpdatePlaceNotifyEmail.ts create mode 100644 solution/frontend/src/api/generated/model/testPost.ts create mode 100644 solution/frontend/src/api/generated/social/social.ts diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index e525167..13b706c 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -134,6 +134,7 @@ CREATE TABLE IF NOT EXISTS public.places ( verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다 verified_by uuid NULL, content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다 + notify_email VARCHAR(255) NULL, -- 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE diff --git a/postgres-init/migrations/0021_places_notify_email.sql b/postgres-init/migrations/0021_places_notify_email.sql new file mode 100644 index 0000000..9ddd6c5 --- /dev/null +++ b/postgres-init/migrations/0021_places_notify_email.sql @@ -0,0 +1,7 @@ +-- 0021 · places.notify_email — 미니 블로그 승인 메일을 받을 주소를 계정 이메일과 분리한다. +-- +-- ★ 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일(users.email) 하나로는 +-- "이 업장 글은 다른 담당자에게 보낸다" 같은 경우를 못 받는다. 비어 있으면(NULL) +-- 지금처럼 users.email 로 보낸다 — 값이 없는 기존 업장은 동작이 그대로다. + +ALTER TABLE public.places ADD COLUMN IF NOT EXISTS notify_email VARCHAR(255) NULL; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index e033f6b..1609caf 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -130,6 +130,9 @@ class places(MainTableMixin, MAIN_BASE): # ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 — # site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다. content_updated_at = Column(DateTime(timezone=True), nullable=True) + # 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) — + # 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다. + notify_email = Column(String(255), nullable=True) diff --git a/solution/backend/crud/post_crud.py b/solution/backend/crud/post_crud.py index 18c961c..5cebb9f 100644 --- a/solution/backend/crud/post_crud.py +++ b/solution/backend/crud/post_crud.py @@ -62,6 +62,21 @@ class PostCRUD: rows = sorted(result.scalars(), key=lambda row: row.scheduled_date) return ErrorType.SUCCESS, rows[:limit] + async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today): + """이 업장의 오늘 몫 글 하나 — 사장님이 '지금 발송하기'를 눌렀을 때 쓴다. 없으면 None. + due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다.""" + result = await cdb.execute( + select(place_posts) + .where( + place_posts.place_id == place_id, place_posts.status == status, + place_posts.deleted == False, # noqa: E712 + place_posts.scheduled_date <= today, + ) + .order_by(place_posts.scheduled_date) + .limit(1) + ) + return result.scalars().first() + async def list_for_place(self, cdb: AsyncSession, place_id, since, until): """사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순.""" result = await cdb.execute( @@ -173,8 +188,21 @@ class PostCRUD: """재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다.""" await cdb.execute( update(place_posts) - .where(place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value) + .where( + place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value, + place_posts.deleted == False, # noqa: E712 — 승인 후 삭제된 글까지 게재로 옮기지 않는다 + ) .values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(), published_version_id=version_id, updated_at=GTime.UTC()) ) return ErrorType.SUCCESS + + async def delete(self, cdb: AsyncSession, post_id) -> ErrorType: + """소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false + 행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다.""" + await cdb.execute( + update(place_posts) + .where(place_posts.post_id == post_id) + .values(deleted=True, updated_at=GTime.UTC()) + ) + return ErrorType.SUCCESS diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index 4ed64be..ce5a843 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -75,6 +75,8 @@ class Req_UpdatePlace(PlaceProtocol): # ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다. name: Optional[str] = None status: Optional[PlaceStatus] = None + # 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다). + notify_email: Optional[str] = None class Req_CreateUnit(PlaceProtocol): @@ -107,6 +109,7 @@ class PlaceData(WebPacketProtocol): region_code: Optional[str] = None verified_at: Optional[datetime] = None content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별 + notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용 created_at: Optional[datetime] = None diff --git a/solution/backend/router/v1/site/post.py b/solution/backend/router/v1/site/post.py index 7a8f5be..1a55b61 100644 --- a/solution/backend/router/v1/site/post.py +++ b/solution/backend/router/v1/site/post.py @@ -9,6 +9,10 @@ 실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번 글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다. ★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면. +★★ 2026-09-21, 사장님 지시: 게재는 두 경로 다 열려 있다 — 이 파일 위쪽의 /approve + (이메일 토큰, 로그인 없음)와, 아래 owner_router 의 POST .../approve(로그인 세션, + "바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 — + 승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다. """ from datetime import date from uuid import UUID @@ -94,7 +98,7 @@ async def get_my_post( return RemoveNoneResponse(await service.get_post(user_info, str(place_id), str(post_id))) -@owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정·승인") +@owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로") async def edit_my_post( place_id: UUID, post_id: UUID, @@ -105,7 +109,10 @@ async def edit_my_post( return RemoveNoneResponse(await service.edit_by_owner(user_info, str(place_id), str(post_id), req.body)) -@owner_router.post(path="/{post_id}/approve", response_model=Res_WebPacketProtocol, summary="바로 발행 — 고치지 않고 그대로") +@owner_router.post( + path="/{post_id}/approve", response_model=Res_WebPacketProtocol, + summary="바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인", +) async def approve_my_post( place_id: UUID, post_id: UUID, @@ -115,6 +122,28 @@ async def approve_my_post( return RemoveNoneResponse(await service.approve_by_owner(user_info, str(place_id), str(post_id))) +@owner_router.delete(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다") +async def delete_my_post( + place_id: UUID, + post_id: UUID, + service: PostService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse(await service.delete_by_owner(user_info, str(place_id), str(post_id))) + + +@owner_router.post( + path="/send-now", response_model=Res_WebPacketProtocol, + summary="지금 발송하기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다", +) +async def send_my_posts_now( + place_id: UUID, + service: PostService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse(await service.send_now(user_info, str(place_id))) + + @owner_router.post( path="/generate", response_model=Res_GenerateNow, summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다", diff --git a/solution/backend/services/blog_service.py b/solution/backend/services/blog_service.py index dceb82c..d96bbfc 100644 --- a/solution/backend/services/blog_service.py +++ b/solution/backend/services/blog_service.py @@ -10,7 +10,7 @@ import re import secrets from datetime import datetime, timedelta, timezone -from common.enums import PostStatus, PostTopicKind +from common.enums import PlaceCategory, PostStatus, PostTopicKind from common.logger import LOG # 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다. @@ -64,6 +64,7 @@ def issue_token() -> tuple[str, str, object]: return token, hash_token(token), expires +# 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다. TOPIC_RULES: dict[int, str] = { PostTopicKind.WEATHER.value: "오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.", @@ -77,6 +78,23 @@ TOPIC_RULES: dict[int, str] = { "확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.", } +# 업종별 분기 — 지금은 숙소만 채워져 있다. 새 업종을 넣으려면 여기 두 딕셔너리에만 항목을 더한다. +_BUSINESS_NOUN_BY_CATEGORY: dict[int, str] = { + PlaceCategory.LODGING.value: "숙소", +} +_TOPIC_RULES_BY_CATEGORY: dict[int, dict[int, str]] = { + PlaceCategory.LODGING.value: TOPIC_RULES, +} + + +def _business_noun(place_category: int) -> str: + return _BUSINESS_NOUN_BY_CATEGORY.get(place_category, "숙소") + + +def _topic_rules(place_category: int) -> dict[int, str]: + return _TOPIC_RULES_BY_CATEGORY.get(place_category, TOPIC_RULES) + + _RULES = ( "규칙\n" f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n" @@ -87,12 +105,15 @@ _RULES = ( ) -def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str]) -> str: +def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], + place_category: int = PlaceCategory.LODGING.value) -> str: """갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다.""" used = ", ".join(used_topics[:40]) or "없음" + noun = _business_noun(place_category) + rules = _topic_rules(place_category) return ( - f"{region}에 있는 숙소 '{place_name}'의 짧은 홍보 글을 쓴다.\n" - f"갈래: {TOPIC_RULES.get(topic_kind, '')}\n" + f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n" + f"갈래: {rules.get(topic_kind, '')}\n" f"소재: {material}\n" f"이미 쓴 주제: {used}\n\n" f"{_RULES}\n본문만 출력한다." @@ -124,29 +145,37 @@ def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]: async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str, - used_topics: list[str], client=None) -> tuple[str, str] | None: + used_topics: list[str], place_category: int = PlaceCategory.LODGING.value, + client=None) -> tuple[str, str] | None: """(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다. - 모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시).""" - from services.llm.gemini import DEFAULT_MODEL, GeminiError, call, extract_text, is_configured + 모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시). - if not is_configured(): + ★ 발행 링크는 여기서 붙이지 않는다 — 호출부가 길이 게이트(is_publishable_body/ + filter_drafts, MIN_LEN~MAX_LEN)를 이 반환값 그대로에 건다. 링크까지 포함해서 + 길이를 재면 정상 문구도 게이트에 걸려 버려진다. 링크는 게이트를 통과한 뒤 호출부가 + 붙인다. + + ★ 공급자는 LLM_PROVIDER 설정을 따른다(services/llm/provider.py) — Gemini 로 고정하지 + 않는다. generate_social_post(services/external/gemini_text.py)와 달리 구조화 출력 + 재시도 루프가 없는 단순 텍스트 생성이라 공급자를 가려도 된다.""" + from services.llm import provider + from services.llm.errors import LlmError + + llm = provider.active() + if not llm.is_configured(): return None prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind, - material=material, used_topics=used_topics) - body = { - "contents": [{"role": "user", "parts": [{"text": prompt}]}], - "generationConfig": {"temperature": 0.9}, - } + material=material, used_topics=used_topics, place_category=place_category) owns = client is None if owns: import httpx client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) try: - payload = await call(client, DEFAULT_MODEL, body) - text = extract_text(payload).strip() - return (text, DEFAULT_MODEL) if text else None - except GeminiError as error: + result = await llm.generate(client, llm.DEFAULT_MODEL, prompt=prompt, temperature=0.9) + text = result.text.strip() + return (text, llm.DEFAULT_MODEL) if text else None + except LlmError as error: LOG.w(f"[blog] 생성 실패: {error}") return None finally: diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index c796431..a058aa1 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -13,6 +13,7 @@ from common.models.gmodel import PageParams, UserInfo from common.utils.gtime import GTime from crud.job_crud import JobQueue from crud.place_crud import IPlaceCRUD, PlaceCRUD +from services import mail_service from router.v1.place.protocol import ( Req_VerifyPlaceByUrl, LinkData, @@ -150,6 +151,12 @@ class PlaceService: data["status"] = req.status.value if "name" in data: data["name"] = str(data["name"]).strip() + if "notify_email" in data: + data["notify_email"] = str(data["notify_email"]).strip() + if data["notify_email"] and not mail_service.is_valid_address(data["notify_email"]): + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + data["notify_email"] = data["notify_email"] or None if data: err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index d690097..b85095a 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -794,6 +794,12 @@ def publish_origin() -> str: return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}" +def publish_url(place, site) -> str: + """이 사업장 사이트의 전체 발행 주소. 미니 블로그·SNS 초안이 문구 끝에 붙이는 링크가 + 이 값과 갈리면 안 되므로 origin·slug 조합을 여기 한 곳에서만 한다.""" + return f"{publish_origin()}/s/{publish_slug(place, site)}" + + def primary_media(snapshot: dict) -> dict | None: """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None. diff --git a/solution/frontend/src/api/generated/model/approvalParams.ts b/solution/frontend/src/api/generated/model/approvalParams.ts new file mode 100644 index 0000000..d72f4f9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/approvalParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ApprovalParams = { + /** + * @minLength 40 + * @maxLength 100 + */ + t: string; +}; diff --git a/solution/frontend/src/api/generated/model/callbackParams.ts b/solution/frontend/src/api/generated/model/callbackParams.ts new file mode 100644 index 0000000..4e39e44 --- /dev/null +++ b/solution/frontend/src/api/generated/model/callbackParams.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type CallbackParams = { + /** + * @maxLength 2048 + */ + state?: string; + /** + * @maxLength 4096 + */ + code?: string; + /** + * @maxLength 200 + */ + error?: string; +}; diff --git a/solution/frontend/src/api/generated/model/decision.ts b/solution/frontend/src/api/generated/model/decision.ts new file mode 100644 index 0000000..c9e8793 --- /dev/null +++ b/solution/frontend/src/api/generated/model/decision.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export interface Decision { + approve: boolean; +} diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index ac5a458..4c79567 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -5,12 +5,15 @@ * OpenAPI spec version: 0.1.0 */ +export * from "./approvalParams"; export * from "./approvePageParams"; export * from "./auditCheckData"; export * from "./auditCheckDataRecommendation"; export * from "./authProvider"; export * from "./buildStatus"; +export * from "./callbackParams"; export * from "./checkSlugParams"; +export * from "./decision"; export * from "./errorInfo"; export * from "./errorInfoCode"; export * from "./errorInfoDesc"; @@ -61,6 +64,7 @@ export * from "./linkData"; export * from "./linkDataConfirmedAt"; export * from "./linkDataDiscoveredAt"; export * from "./linkDataTitle"; +export * from "./linkDecision"; export * from "./listContentsParams"; export * from "./listFactsParams"; export * from "./listFaqsParams"; @@ -126,6 +130,7 @@ export * from "./placeDataExternalPlaceId"; export * from "./placeDataExternalSource"; export * from "./placeDataLatitude"; export * from "./placeDataLongitude"; +export * from "./placeDataNotifyEmail"; export * from "./placeDataOwnerUserId"; export * from "./placeDataPhone"; export * from "./placeDataRegionCode"; @@ -208,6 +213,7 @@ export * from "./reqUpdateMeName"; export * from "./reqUpdateMePassword"; export * from "./reqUpdatePlace"; export * from "./reqUpdatePlaceName"; +export * from "./reqUpdatePlaceNotifyEmail"; export * from "./reqUpdatePlaceStatus"; export * from "./reqUpsertFact"; export * from "./reqUpsertFactExpiresAt"; @@ -372,6 +378,7 @@ export * from "./siteVersionDataBuildError"; export * from "./siteVersionDataBuiltAt"; export * from "./siteVersionDataCreatedAt"; export * from "./sourceType"; +export * from "./testPost"; export * from "./unitData"; export * from "./userRole"; export * from "./validationError"; diff --git a/solution/frontend/src/api/generated/model/jobType.ts b/solution/frontend/src/api/generated/model/jobType.ts index 76e87bd..0fee41a 100644 --- a/solution/frontend/src/api/generated/model/jobType.ts +++ b/solution/frontend/src/api/generated/model/jobType.ts @@ -23,4 +23,6 @@ export const JobType = { AI_CHECK: 6, SONG: 7, ROLLBACK: 8, + SOCIAL_DRAFT: 9, + SOCIAL_POST: 10, } as const; diff --git a/solution/frontend/src/api/generated/model/linkDecision.ts b/solution/frontend/src/api/generated/model/linkDecision.ts new file mode 100644 index 0000000..ac41181 --- /dev/null +++ b/solution/frontend/src/api/generated/model/linkDecision.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export interface LinkDecision { + approve: boolean; + /** + * @minLength 40 + * @maxLength 100 + */ + t: string; +} diff --git a/solution/frontend/src/api/generated/model/placeData.ts b/solution/frontend/src/api/generated/model/placeData.ts index ca5cad6..794a12f 100644 --- a/solution/frontend/src/api/generated/model/placeData.ts +++ b/solution/frontend/src/api/generated/model/placeData.ts @@ -17,6 +17,7 @@ import type { PlaceDataLongitude } from "./placeDataLongitude"; import type { PlaceDataRegionCode } from "./placeDataRegionCode"; import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt"; import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt"; +import type { PlaceDataNotifyEmail } from "./placeDataNotifyEmail"; import type { PlaceDataCreatedAt } from "./placeDataCreatedAt"; export interface PlaceData { @@ -35,5 +36,6 @@ export interface PlaceData { region_code?: PlaceDataRegionCode; verified_at?: PlaceDataVerifiedAt; content_updated_at?: PlaceDataContentUpdatedAt; + notify_email?: PlaceDataNotifyEmail; created_at?: PlaceDataCreatedAt; } diff --git a/solution/frontend/src/api/generated/model/placeDataNotifyEmail.ts b/solution/frontend/src/api/generated/model/placeDataNotifyEmail.ts new file mode 100644 index 0000000..248eb62 --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeDataNotifyEmail.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 PlaceDataNotifyEmail = string | null; diff --git a/solution/frontend/src/api/generated/model/reqUpdatePlace.ts b/solution/frontend/src/api/generated/model/reqUpdatePlace.ts index 5b7a7a0..1e3224b 100644 --- a/solution/frontend/src/api/generated/model/reqUpdatePlace.ts +++ b/solution/frontend/src/api/generated/model/reqUpdatePlace.ts @@ -6,8 +6,10 @@ */ import type { ReqUpdatePlaceName } from "./reqUpdatePlaceName"; import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus"; +import type { ReqUpdatePlaceNotifyEmail } from "./reqUpdatePlaceNotifyEmail"; export interface ReqUpdatePlace { name?: ReqUpdatePlaceName; status?: ReqUpdatePlaceStatus; + notify_email?: ReqUpdatePlaceNotifyEmail; } diff --git a/solution/frontend/src/api/generated/model/reqUpdatePlaceNotifyEmail.ts b/solution/frontend/src/api/generated/model/reqUpdatePlaceNotifyEmail.ts new file mode 100644 index 0000000..a583065 --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqUpdatePlaceNotifyEmail.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 ReqUpdatePlaceNotifyEmail = string | null; diff --git a/solution/frontend/src/api/generated/model/testPost.ts b/solution/frontend/src/api/generated/model/testPost.ts new file mode 100644 index 0000000..9b9e991 --- /dev/null +++ b/solution/frontend/src/api/generated/model/testPost.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export interface TestPost { + /** + * @minLength 1 + * @maxLength 500 + */ + text: string; +} diff --git a/solution/frontend/src/api/generated/site/site.ts b/solution/frontend/src/api/generated/site/site.ts index 44e711f..41f3376 100644 --- a/solution/frontend/src/api/generated/site/site.ts +++ b/solution/frontend/src/api/generated/site/site.ts @@ -2562,7 +2562,7 @@ export function useGetMyPost< } /** - * @summary 로그인 세션으로 직접 수정·승인 + * @summary 로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로 */ export const editMyPost = ( placeId: string, @@ -2626,7 +2626,7 @@ export type EditMyPostMutationBody = ReqEditPost; export type EditMyPostMutationError = HTTPValidationError; /** - * @summary 로그인 세션으로 직접 수정·승인 + * @summary 로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로 */ export const useEditMyPost = ( options?: { @@ -2650,7 +2650,92 @@ export const useEditMyPost = ( return useMutation(mutationOptions, queryClient); }; /** - * @summary 바로 발행 — 고치지 않고 그대로 + * @summary 글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다 + */ +export const deleteMyPost = ( + placeId: string, + postId: string, + options?: SecondParameter, +) => { + return customFetch( + { url: `/v1/place/${placeId}/post/${postId}`, method: "DELETE" }, + options, + ); +}; + +export const getDeleteMyPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string; postId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { placeId: string; postId: string }, + TContext +> => { + const mutationKey = ["deleteMyPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { placeId: string; postId: string } + > = (props) => { + const { placeId, postId } = props ?? {}; + + return deleteMyPost(placeId, postId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteMyPostMutationResult = NonNullable< + Awaited> +>; + +export type DeleteMyPostMutationError = HTTPValidationError; + +/** + * @summary 글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다 + */ +export const useDeleteMyPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string; postId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { placeId: string; postId: string }, + TContext +> => { + const mutationOptions = getDeleteMyPostMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary 바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인 */ export const approveMyPost = ( placeId: string, @@ -2713,7 +2798,7 @@ export type ApproveMyPostMutationResult = NonNullable< export type ApproveMyPostMutationError = HTTPValidationError; /** - * @summary 바로 발행 — 고치지 않고 그대로 + * @summary 바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인 */ export const useApproveMyPost = < TError = HTTPValidationError, @@ -2739,6 +2824,91 @@ export const useApproveMyPost = < return useMutation(mutationOptions, queryClient); }; +/** + * @summary 지금 발송하기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다 + */ +export const sendMyPostsNow = ( + placeId: string, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/place/${placeId}/post/send-now`, method: "POST", signal }, + options, + ); +}; + +export const getSendMyPostsNowMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { placeId: string }, + TContext +> => { + const mutationKey = ["sendMyPostsNow"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { placeId: string } + > = (props) => { + const { placeId } = props ?? {}; + + return sendMyPostsNow(placeId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SendMyPostsNowMutationResult = NonNullable< + Awaited> +>; + +export type SendMyPostsNowMutationError = HTTPValidationError; + +/** + * @summary 지금 발송하기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다 + */ +export const useSendMyPostsNow = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { placeId: string }, + TContext +> => { + const mutationOptions = getSendMyPostsNowMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; /** * @summary 지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다 */ diff --git a/solution/frontend/src/api/generated/social/social.ts b/solution/frontend/src/api/generated/social/social.ts new file mode 100644 index 0000000..7d19f19 --- /dev/null +++ b/solution/frontend/src/api/generated/social/social.ts @@ -0,0 +1,1216 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import { useCallback } from "react"; + +import type { + ApprovalParams, + CallbackParams, + Decision, + HTTPValidationError, + LinkDecision, + TestPost, +} from ".././model"; + +import { customFetch } from "../../mutator/custom-fetch"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * 연결 상태만 준다 — 사업장을 고르지 않아도 답할 수 있어야 하는 값이다. + * @summary Account + */ +export const account = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/social/account`, method: "GET", signal }, + options, + ); +}; + +export const getAccountQueryKey = () => { + return [`/v1/social/account`] as const; +}; + +export const getAccountQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getAccountQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => account(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type AccountQueryResult = NonNullable< + Awaited> +>; +export type AccountQueryError = unknown; + +export function useAccount< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useAccount< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useAccount< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Account + */ + +export function useAccount< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getAccountQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * 연동 확인용 즉시 게시 — 승인 없이 바로 연결된 계정으로 올라간다. + * @summary Test Post + */ +export const testPost = ( + testPost: TestPost, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { + url: `/v1/social/test-post`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: testPost, + signal, + }, + options, + ); +}; + +export const getTestPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: TestPost }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: TestPost }, + TContext +> => { + const mutationKey = ["testPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: TestPost } + > = (props) => { + const { data } = props ?? {}; + + return testPost(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type TestPostMutationResult = NonNullable< + Awaited> +>; +export type TestPostMutationBody = TestPost; +export type TestPostMutationError = HTTPValidationError; + +/** + * @summary Test Post + */ +export const useTestPost = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: TestPost }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: TestPost }, + TContext +> => { + const mutationOptions = getTestPostMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary List Posts + */ +export const listPosts = ( + placeId: string, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/social/place/${placeId}`, method: "GET", signal }, + options, + ); +}; + +export const getListPostsQueryKey = (placeId?: string) => { + return [`/v1/social/place/${placeId}`] as const; +}; + +export const getListPostsQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListPostsQueryKey(placeId); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listPosts(placeId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ListPostsQueryResult = NonNullable< + Awaited> +>; +export type ListPostsQueryError = HTTPValidationError; + +export function useListPosts< + TData = Awaited>, + TError = HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListPosts< + TData = Awaited>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListPosts< + TData = Awaited>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Posts + */ + +export function useListPosts< + TData = Awaited>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListPostsQueryOptions(placeId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary Draft + */ +export const draft = ( + placeId: string, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/social/place/${placeId}/draft`, method: "POST", signal }, + options, + ); +}; + +export const getDraftMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { placeId: string }, + TContext +> => { + const mutationKey = ["draft"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { placeId: string } + > = (props) => { + const { placeId } = props ?? {}; + + return draft(placeId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DraftMutationResult = NonNullable< + Awaited> +>; + +export type DraftMutationError = HTTPValidationError; + +/** + * @summary Draft + */ +export const useDraft = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { placeId: string }, + TContext +> => { + const mutationOptions = getDraftMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary Request Approval + */ +export const requestApproval = ( + postId: string, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { + url: `/v1/social/posts/${postId}/request-approval`, + method: "POST", + signal, + }, + options, + ); +}; + +export const getRequestApprovalMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { postId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { postId: string }, + TContext +> => { + const mutationKey = ["requestApproval"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { postId: string } + > = (props) => { + const { postId } = props ?? {}; + + return requestApproval(postId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RequestApprovalMutationResult = NonNullable< + Awaited> +>; + +export type RequestApprovalMutationError = HTTPValidationError; + +/** + * @summary Request Approval + */ +export const useRequestApproval = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { postId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { postId: string }, + TContext +> => { + const mutationOptions = getRequestApprovalMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary Owner Decision + */ +export const ownerDecision = ( + postId: string, + decision: Decision, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { + url: `/v1/social/posts/${postId}/decision`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: decision, + signal, + }, + options, + ); +}; + +export const getOwnerDecisionMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { postId: string; data: Decision }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { postId: string; data: Decision }, + TContext +> => { + const mutationKey = ["ownerDecision"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { postId: string; data: Decision } + > = (props) => { + const { postId, data } = props ?? {}; + + return ownerDecision(postId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type OwnerDecisionMutationResult = NonNullable< + Awaited> +>; +export type OwnerDecisionMutationBody = Decision; +export type OwnerDecisionMutationError = HTTPValidationError; + +/** + * @summary Owner Decision + */ +export const useOwnerDecision = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { postId: string; data: Decision }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { postId: string; data: Decision }, + TContext +> => { + const mutationOptions = getOwnerDecisionMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary Approval + */ +export const approval = ( + postId: string, + params: ApprovalParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/social/approval/${postId}`, method: "GET", params, signal }, + options, + ); +}; + +export const getApprovalQueryKey = ( + postId?: string, + params?: ApprovalParams, +) => { + return [ + `/v1/social/approval/${postId}`, + ...(params ? [params] : []), + ] as const; +}; + +export const getApprovalQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + postId: string, + params: ApprovalParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getApprovalQueryKey(postId, params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => approval(postId, params, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: !!postId, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ApprovalQueryResult = NonNullable< + Awaited> +>; +export type ApprovalQueryError = HTTPValidationError; + +export function useApproval< + TData = Awaited>, + TError = HTTPValidationError, +>( + postId: string, + params: ApprovalParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApproval< + TData = Awaited>, + TError = HTTPValidationError, +>( + postId: string, + params: ApprovalParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApproval< + TData = Awaited>, + TError = HTTPValidationError, +>( + postId: string, + params: ApprovalParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Approval + */ + +export function useApproval< + TData = Awaited>, + TError = HTTPValidationError, +>( + postId: string, + params: ApprovalParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getApprovalQueryOptions(postId, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary Decision + */ +export const decision = ( + postId: string, + linkDecision: LinkDecision, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { + url: `/v1/social/approval/${postId}/decision`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: linkDecision, + signal, + }, + options, + ); +}; + +export const getDecisionMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { postId: string; data: LinkDecision }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { postId: string; data: LinkDecision }, + TContext +> => { + const mutationKey = ["decision"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { postId: string; data: LinkDecision } + > = (props) => { + const { postId, data } = props ?? {}; + + return decision(postId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DecisionMutationResult = NonNullable< + Awaited> +>; +export type DecisionMutationBody = LinkDecision; +export type DecisionMutationError = HTTPValidationError; + +/** + * @summary Decision + */ +export const useDecision = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { postId: string; data: LinkDecision }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { postId: string; data: LinkDecision }, + TContext +> => { + const mutationOptions = getDecisionMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary Connect + */ +export const connect = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/social/oauth/connect`, method: "POST", signal }, + options, + ); +}; + +export const getConnectMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["connect"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return connect(requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ConnectMutationResult = NonNullable< + Awaited> +>; + +export type ConnectMutationError = unknown; + +/** + * @summary Connect + */ +export const useConnect = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + const mutationOptions = getConnectMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary Callback + */ +export const callback = ( + params?: CallbackParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/social/oauth/callback`, method: "GET", params, signal }, + options, + ); +}; + +export const getCallbackQueryKey = (params?: CallbackParams) => { + return [`/v1/social/oauth/callback`, ...(params ? [params] : [])] as const; +}; + +export const getCallbackQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: CallbackParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getCallbackQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => callback(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type CallbackQueryResult = NonNullable< + Awaited> +>; +export type CallbackQueryError = HTTPValidationError; + +export function useCallback< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | CallbackParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useCallback< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: CallbackParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useCallback< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: CallbackParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Callback + */ + +export function useCallback< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: CallbackParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getCallbackQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary Disconnect + */ +export const disconnect = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/social/oauth/disconnect`, method: "POST", signal }, + options, + ); +}; + +export const getDisconnectMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["disconnect"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return disconnect(requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DisconnectMutationResult = NonNullable< + Awaited> +>; + +export type DisconnectMutationError = unknown; + +/** + * @summary Disconnect + */ +export const useDisconnect = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + const mutationOptions = getDisconnectMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/pages/BlogPostsPage.tsx b/solution/frontend/src/pages/BlogPostsPage.tsx index 208a5dd..cd0c3a5 100644 --- a/solution/frontend/src/pages/BlogPostsPage.tsx +++ b/solution/frontend/src/pages/BlogPostsPage.tsx @@ -1,15 +1,19 @@ import {useEffect, useMemo, useState} from 'react'; import {useNavigate, useSearchParams} from 'react-router'; -import {Loader2, Pencil, Plus, Save, Send, Sparkles, X} from 'lucide-react'; +import {Loader2, Pencil, Plus, Save, Send, Sparkles, Trash2, X} from 'lucide-react'; import { useApproveMyPost, + useDeleteMyPost, useEditMyPost, useGenerateMyPostForDate, useGenerateMyPosts, useGetGenerationHistory, useGetMyPost, + useGetPlace, useListMyPosts, useListUpcomingPosts, + useSendMyPostsNow, + useUpdatePlace, type PostData, } from '@/api'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; @@ -141,7 +145,10 @@ function publishBadge(post: PostData): {label: string; className: string} | null return null; } -/** 수정 폼 — 저장하면 그대로 승인된다(메일의 "수정해서 올리기"와 같은 규칙). */ +/** + * 수정 폼 — 저장만 한다. 승인은 오직 이메일 링크로만 일어난다(2026-09-21, 사장님 지시: + * "승인되야 올라가도록 해야 한다") — 예전엔 저장이 곧 승인이었지만 그 지름길을 없앴다. + */ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) { const [body, setBody] = useState(post.body); const editMutation = useEditMyPost(); @@ -153,7 +160,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.'); return; } - notify.success('저장하고 승인했습니다. 사이트에 반영되기까지 몇 분 걸립니다.'); + notify.success('저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다.'); onDone(); } catch (error) { notifyApiError(error, '저장하지 못했습니다.'); @@ -171,7 +178,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
- + )} + {editable && ( + + )} +
)} @@ -442,6 +487,78 @@ function GenerationHistoryList({placeId}: {placeId: string}) { ); } +/** + * 승인 메일을 받을 주소 — 계정 로그인 이메일(users.email)과 분리한 업장별 설정 + * (2026-09-21, 사장님 요청: "그 이메일은 변경 가능하도록"). 비워서 저장하면 계정 + * 이메일로 되돌아간다. + */ +function NotifyEmailSetting({placeId}: {placeId: string}) { + const {data: placeRes} = useGetPlace(placeId, {query: {enabled: !!placeId}}); + const savedEmail = placeRes?.place?.notify_email ?? ''; + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(savedEmail); + const updateMutation = useUpdatePlace(); + + useEffect(() => { + setValue(savedEmail); + }, [savedEmail]); + + const handleSave = async () => { + try { + const res = await updateMutation.mutateAsync({placeId, data: {notify_email: value.trim()}}); + if (!res.result?.success) { + notify.error(res.msg || '저장하지 못했습니다. 이메일 형식을 확인해 주세요.'); + return; + } + notify.success('알림 이메일을 저장했습니다.'); + setIsEditing(false); + } catch (error) { + notifyApiError(error, '저장하지 못했습니다.'); + } + }; + + return ( +
+ 승인 메일 받을 주소 + {isEditing ? ( + <> + setValue(event.target.value)} + placeholder="비우면 계정 이메일로 받습니다" + className="min-w-52 flex-1 rounded-md border border-border bg-card px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring" + /> + + + + ) : ( + <> + {savedEmail || '(계정 이메일 사용)'} + + + )} +
+ ); +} + /** * 이번 달(또는 고른 달) 생성된 미니 블로그 글. 기획: docs/MINI_BLOG.md * @@ -506,6 +623,7 @@ export function BlogPostsPage() { const generateMutation = useGenerateMyPosts(); const generateOneMutation = useGenerateMyPostForDate(); const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null; + const sendNowMutation = useSendMyPostsNow(); const [rangeDialogOpen, setRangeDialogOpen] = useState(false); const [rangeStart, setRangeStart] = useState(todayIso()); @@ -546,11 +664,31 @@ export function BlogPostsPage() { } }; + const handleSendNow = async () => { + try { + const res = await sendNowMutation.mutateAsync({placeId}); + if (!res.result?.success) { + notify.error(res.msg || '발송하지 못했습니다.'); + return; + } + notify.success(res.msg ?? '보냈습니다.'); + refetchAll(); + } catch (sendNowError) { + notifyApiError(sendNowError, '발송하지 못했습니다.'); + } + }; + const generateButton = ( - +
+ + +
); const maxGenerateDate = lastDayOfMonthIso(maxMonth); @@ -595,6 +733,8 @@ export function BlogPostsPage() { ))} + + {tab === 'main' && ( <> {upcomingPosts.length > 0 ? ( diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 9b3ca61..a04902b 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -291,8 +291,35 @@ export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움'; /** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */ export type WeatherMood = '맑음' | '흐림' | '비' | '눈'; -/** 그림은 WeatherMood 넷으로 그리고, 문구는 이 아홉으로 고른다. */ -export type WeatherSky = WeatherMood | '구름많음' | '안개' | '이슬비' | '소나기' | '뇌우'; +/** 그림은 WeatherMood 넷으로 그리고, 문구는 WMO weather_code 하나마다 고유한 이 28종으로 고른다. */ +export type WeatherSky = + | WeatherMood + | '대체로 맑음' + | '구름 조금' + | '안개' + | '착빙성 안개' + | '가벼운 이슬비' + | '보통 이슬비' + | '강한 이슬비' + | '가벼운 착빙성 이슬비' + | '강한 착빙성 이슬비' + | '약한 비' + | '보통 비' + | '강한 비' + | '약한 착빙성 비' + | '강한 착빙성 비' + | '약한 눈' + | '보통 눈' + | '강한 눈' + | '싸라기눈' + | '약한 소나기' + | '보통 소나기' + | '강한 소나기' + | '약한 소나기눈' + | '강한 소나기눈' + | '뇌우' + | '약한 우박 뇌우' + | '강한 우박 뇌우'; export interface WeatherSnapshot { temperature: number;