[feat] solution: 미니블로그 빌더 기능 확장 — 알림 이메일 분리·삭제·즉시발송·공급자 무관 생성

빌더 앱에서 미니블로그를 운영하는 데 필요했던 몇 가지를 묶었다.

- 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 확인함
This commit is contained in:
김성경 2026-09-22 08:37:38 +09:00
parent adb3460c37
commit 81ea676546
24 changed files with 1813 additions and 44 deletions

View File

@ -134,6 +134,7 @@ CREATE TABLE IF NOT EXISTS public.places (
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다 verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
verified_by uuid NULL, verified_by uuid NULL,
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다 content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
notify_email VARCHAR(255) NULL, -- 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE

View File

@ -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;

View File

@ -130,6 +130,9 @@ class places(MainTableMixin, MAIN_BASE):
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 — # ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다. # site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
content_updated_at = Column(DateTime(timezone=True), nullable=True) content_updated_at = Column(DateTime(timezone=True), nullable=True)
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
notify_email = Column(String(255), nullable=True)

View File

@ -62,6 +62,21 @@ class PostCRUD:
rows = sorted(result.scalars(), key=lambda row: row.scheduled_date) rows = sorted(result.scalars(), key=lambda row: row.scheduled_date)
return ErrorType.SUCCESS, rows[:limit] 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): async def list_for_place(self, cdb: AsyncSession, place_id, since, until):
"""사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순.""" """사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순."""
result = await cdb.execute( result = await cdb.execute(
@ -173,8 +188,21 @@ class PostCRUD:
"""재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다.""" """재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다."""
await cdb.execute( await cdb.execute(
update(place_posts) 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(), .values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(),
published_version_id=version_id, updated_at=GTime.UTC()) published_version_id=version_id, updated_at=GTime.UTC())
) )
return ErrorType.SUCCESS 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

View File

@ -75,6 +75,8 @@ class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다. # ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
name: Optional[str] = None name: Optional[str] = None
status: Optional[PlaceStatus] = None status: Optional[PlaceStatus] = None
# 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다).
notify_email: Optional[str] = None
class Req_CreateUnit(PlaceProtocol): class Req_CreateUnit(PlaceProtocol):
@ -107,6 +109,7 @@ class PlaceData(WebPacketProtocol):
region_code: Optional[str] = None region_code: Optional[str] = None
verified_at: Optional[datetime] = None verified_at: Optional[datetime] = None
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별 content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용
created_at: Optional[datetime] = None created_at: Optional[datetime] = None

View File

@ -9,6 +9,10 @@
실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 토큰으로 로그인한 이번 실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 토큰으로 로그인한 이번
편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다. 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
owner_router 로그인 세션이 신원이다 빌더 앱의 "이번 달 생성된 글" 화면. owner_router 로그인 세션이 신원이다 빌더 앱의 "이번 달 생성된 글" 화면.
2026-09-21, 사장님 지시: 게재는 경로 열려 있다 파일 위쪽의 /approve
(이메일 토큰, 로그인 없음), 아래 owner_router POST .../approve(로그인 세션,
"바로 발행" 수정 없이 그대로 승인). PUT(수정) 저장만 하고 자동으로 승인하지 않는다
승인은 경로 하나를 명시적으로 눌러야 한다.
""" """
from datetime import date from datetime import date
from uuid import UUID 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))) 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( async def edit_my_post(
place_id: UUID, place_id: UUID,
post_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)) 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( async def approve_my_post(
place_id: UUID, place_id: UUID,
post_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))) 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( @owner_router.post(
path="/generate", response_model=Res_GenerateNow, path="/generate", response_model=Res_GenerateNow,
summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다", summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다",

View File

@ -10,7 +10,7 @@ import re
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from common.enums import PostStatus, PostTopicKind from common.enums import PlaceCategory, PostStatus, PostTopicKind
from common.logger import LOG from common.logger import LOG
# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다. # 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다.
@ -64,6 +64,7 @@ def issue_token() -> tuple[str, str, object]:
return token, hash_token(token), expires return token, hash_token(token), expires
# 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다.
TOPIC_RULES: dict[int, str] = { TOPIC_RULES: dict[int, str] = {
PostTopicKind.WEATHER.value: 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 = ( _RULES = (
"규칙\n" "규칙\n"
f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\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 "없음" used = ", ".join(used_topics[:40]) or "없음"
noun = _business_noun(place_category)
rules = _topic_rules(place_category)
return ( return (
f"{region}에 있는 숙소 '{place_name}'의 짧은 홍보 글을 쓴다.\n" f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n"
f"갈래: {TOPIC_RULES.get(topic_kind, '')}\n" f"갈래: {rules.get(topic_kind, '')}\n"
f"소재: {material}\n" f"소재: {material}\n"
f"이미 쓴 주제: {used}\n\n" f"이미 쓴 주제: {used}\n\n"
f"{_RULES}\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, 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 — 생성 실패가 잡을 죽이지 않는다. """(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다.
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 쓴다(2026-09-17, 사장님 지시).""" 모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 쓴다(2026-09-17, 사장님 지시).
from services.llm.gemini import DEFAULT_MODEL, GeminiError, call, extract_text, is_configured
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 return None
prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind, prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind,
material=material, used_topics=used_topics) material=material, used_topics=used_topics, place_category=place_category)
body = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.9},
}
owns = client is None owns = client is None
if owns: if owns:
import httpx import httpx
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
try: try:
payload = await call(client, DEFAULT_MODEL, body) result = await llm.generate(client, llm.DEFAULT_MODEL, prompt=prompt, temperature=0.9)
text = extract_text(payload).strip() text = result.text.strip()
return (text, DEFAULT_MODEL) if text else None return (text, llm.DEFAULT_MODEL) if text else None
except GeminiError as error: except LlmError as error:
LOG.w(f"[blog] 생성 실패: {error}") LOG.w(f"[blog] 생성 실패: {error}")
return None return None
finally: finally:

View File

@ -13,6 +13,7 @@ from common.models.gmodel import PageParams, UserInfo
from common.utils.gtime import GTime from common.utils.gtime import GTime
from crud.job_crud import JobQueue from crud.job_crud import JobQueue
from crud.place_crud import IPlaceCRUD, PlaceCRUD from crud.place_crud import IPlaceCRUD, PlaceCRUD
from services import mail_service
from router.v1.place.protocol import ( from router.v1.place.protocol import (
Req_VerifyPlaceByUrl, Req_VerifyPlaceByUrl,
LinkData, LinkData,
@ -150,6 +151,12 @@ class PlaceService:
data["status"] = req.status.value data["status"] = req.status.value
if "name" in data: if "name" in data:
data["name"] = str(data["name"]).strip() 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: if data:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(

View File

@ -794,6 +794,12 @@ def publish_origin() -> str:
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}" 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: def primary_media(snapshot: dict) -> dict | None:
"""대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None. """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None.

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
}

View File

@ -5,12 +5,15 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export * from "./approvalParams";
export * from "./approvePageParams"; export * from "./approvePageParams";
export * from "./auditCheckData"; export * from "./auditCheckData";
export * from "./auditCheckDataRecommendation"; export * from "./auditCheckDataRecommendation";
export * from "./authProvider"; export * from "./authProvider";
export * from "./buildStatus"; export * from "./buildStatus";
export * from "./callbackParams";
export * from "./checkSlugParams"; export * from "./checkSlugParams";
export * from "./decision";
export * from "./errorInfo"; export * from "./errorInfo";
export * from "./errorInfoCode"; export * from "./errorInfoCode";
export * from "./errorInfoDesc"; export * from "./errorInfoDesc";
@ -61,6 +64,7 @@ export * from "./linkData";
export * from "./linkDataConfirmedAt"; export * from "./linkDataConfirmedAt";
export * from "./linkDataDiscoveredAt"; export * from "./linkDataDiscoveredAt";
export * from "./linkDataTitle"; export * from "./linkDataTitle";
export * from "./linkDecision";
export * from "./listContentsParams"; export * from "./listContentsParams";
export * from "./listFactsParams"; export * from "./listFactsParams";
export * from "./listFaqsParams"; export * from "./listFaqsParams";
@ -126,6 +130,7 @@ export * from "./placeDataExternalPlaceId";
export * from "./placeDataExternalSource"; export * from "./placeDataExternalSource";
export * from "./placeDataLatitude"; export * from "./placeDataLatitude";
export * from "./placeDataLongitude"; export * from "./placeDataLongitude";
export * from "./placeDataNotifyEmail";
export * from "./placeDataOwnerUserId"; export * from "./placeDataOwnerUserId";
export * from "./placeDataPhone"; export * from "./placeDataPhone";
export * from "./placeDataRegionCode"; export * from "./placeDataRegionCode";
@ -208,6 +213,7 @@ export * from "./reqUpdateMeName";
export * from "./reqUpdateMePassword"; export * from "./reqUpdateMePassword";
export * from "./reqUpdatePlace"; export * from "./reqUpdatePlace";
export * from "./reqUpdatePlaceName"; export * from "./reqUpdatePlaceName";
export * from "./reqUpdatePlaceNotifyEmail";
export * from "./reqUpdatePlaceStatus"; export * from "./reqUpdatePlaceStatus";
export * from "./reqUpsertFact"; export * from "./reqUpsertFact";
export * from "./reqUpsertFactExpiresAt"; export * from "./reqUpsertFactExpiresAt";
@ -372,6 +378,7 @@ export * from "./siteVersionDataBuildError";
export * from "./siteVersionDataBuiltAt"; export * from "./siteVersionDataBuiltAt";
export * from "./siteVersionDataCreatedAt"; export * from "./siteVersionDataCreatedAt";
export * from "./sourceType"; export * from "./sourceType";
export * from "./testPost";
export * from "./unitData"; export * from "./unitData";
export * from "./userRole"; export * from "./userRole";
export * from "./validationError"; export * from "./validationError";

View File

@ -23,4 +23,6 @@ export const JobType = {
AI_CHECK: 6, AI_CHECK: 6,
SONG: 7, SONG: 7,
ROLLBACK: 8, ROLLBACK: 8,
SOCIAL_DRAFT: 9,
SOCIAL_POST: 10,
} as const; } as const;

View File

@ -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;
}

View File

@ -17,6 +17,7 @@ import type { PlaceDataLongitude } from "./placeDataLongitude";
import type { PlaceDataRegionCode } from "./placeDataRegionCode"; import type { PlaceDataRegionCode } from "./placeDataRegionCode";
import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt"; import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt";
import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt"; import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt";
import type { PlaceDataNotifyEmail } from "./placeDataNotifyEmail";
import type { PlaceDataCreatedAt } from "./placeDataCreatedAt"; import type { PlaceDataCreatedAt } from "./placeDataCreatedAt";
export interface PlaceData { export interface PlaceData {
@ -35,5 +36,6 @@ export interface PlaceData {
region_code?: PlaceDataRegionCode; region_code?: PlaceDataRegionCode;
verified_at?: PlaceDataVerifiedAt; verified_at?: PlaceDataVerifiedAt;
content_updated_at?: PlaceDataContentUpdatedAt; content_updated_at?: PlaceDataContentUpdatedAt;
notify_email?: PlaceDataNotifyEmail;
created_at?: PlaceDataCreatedAt; created_at?: PlaceDataCreatedAt;
} }

View File

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

View File

@ -6,8 +6,10 @@
*/ */
import type { ReqUpdatePlaceName } from "./reqUpdatePlaceName"; import type { ReqUpdatePlaceName } from "./reqUpdatePlaceName";
import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus"; import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus";
import type { ReqUpdatePlaceNotifyEmail } from "./reqUpdatePlaceNotifyEmail";
export interface ReqUpdatePlace { export interface ReqUpdatePlace {
name?: ReqUpdatePlaceName; name?: ReqUpdatePlaceName;
status?: ReqUpdatePlaceStatus; status?: ReqUpdatePlaceStatus;
notify_email?: ReqUpdatePlaceNotifyEmail;
} }

View File

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

View File

@ -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;
}

View File

@ -2562,7 +2562,7 @@ export function useGetMyPost<
} }
/** /**
* @summary · * @summary ,
*/ */
export const editMyPost = ( export const editMyPost = (
placeId: string, placeId: string,
@ -2626,7 +2626,7 @@ export type EditMyPostMutationBody = ReqEditPost;
export type EditMyPostMutationError = HTTPValidationError; export type EditMyPostMutationError = HTTPValidationError;
/** /**
* @summary · * @summary ,
*/ */
export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>( export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
options?: { options?: {
@ -2650,7 +2650,92 @@ export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/** /**
* @summary * @summary
*/
export const deleteMyPost = (
placeId: string,
postId: string,
options?: SecondParameter<typeof customFetch>,
) => {
return customFetch<ResWebPacketProtocol>(
{ url: `/v1/place/${placeId}/post/${postId}`, method: "DELETE" },
options,
);
};
export const getDeleteMyPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMyPost>>,
TError,
{ placeId: string; postId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteMyPost>>,
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<ReturnType<typeof deleteMyPost>>,
{ placeId: string; postId: string }
> = (props) => {
const { placeId, postId } = props ?? {};
return deleteMyPost(placeId, postId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteMyPostMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteMyPost>>
>;
export type DeleteMyPostMutationError = HTTPValidationError;
/**
* @summary
*/
export const useDeleteMyPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMyPost>>,
TError,
{ placeId: string; postId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof deleteMyPost>>,
TError,
{ placeId: string; postId: string },
TContext
> => {
const mutationOptions = getDeleteMyPostMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* @summary ( )
*/ */
export const approveMyPost = ( export const approveMyPost = (
placeId: string, placeId: string,
@ -2713,7 +2798,7 @@ export type ApproveMyPostMutationResult = NonNullable<
export type ApproveMyPostMutationError = HTTPValidationError; export type ApproveMyPostMutationError = HTTPValidationError;
/** /**
* @summary * @summary ( )
*/ */
export const useApproveMyPost = < export const useApproveMyPost = <
TError = HTTPValidationError, TError = HTTPValidationError,
@ -2739,6 +2824,91 @@ export const useApproveMyPost = <
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/**
* @summary 9
*/
export const sendMyPostsNow = (
placeId: string,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<ResWebPacketProtocol>(
{ url: `/v1/place/${placeId}/post/send-now`, method: "POST", signal },
options,
);
};
export const getSendMyPostsNowMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof sendMyPostsNow>>,
TError,
{ placeId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof sendMyPostsNow>>,
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<ReturnType<typeof sendMyPostsNow>>,
{ placeId: string }
> = (props) => {
const { placeId } = props ?? {};
return sendMyPostsNow(placeId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SendMyPostsNowMutationResult = NonNullable<
Awaited<ReturnType<typeof sendMyPostsNow>>
>;
export type SendMyPostsNowMutationError = HTTPValidationError;
/**
* @summary 9
*/
export const useSendMyPostsNow = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof sendMyPostsNow>>,
TError,
{ placeId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof sendMyPostsNow>>,
TError,
{ placeId: string },
TContext
> => {
const mutationOptions = getSendMyPostsNowMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/** /**
* @summary (04:10) , * @summary (04:10) ,
*/ */

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,19 @@
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router'; 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 { import {
useApproveMyPost, useApproveMyPost,
useDeleteMyPost,
useEditMyPost, useEditMyPost,
useGenerateMyPostForDate, useGenerateMyPostForDate,
useGenerateMyPosts, useGenerateMyPosts,
useGetGenerationHistory, useGetGenerationHistory,
useGetMyPost, useGetMyPost,
useGetPlace,
useListMyPosts, useListMyPosts,
useListUpcomingPosts, useListUpcomingPosts,
useSendMyPostsNow,
useUpdatePlace,
type PostData, type PostData,
} from '@/api'; } from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
@ -141,7 +145,10 @@ function publishBadge(post: PostData): {label: string; className: string} | null
return null; return null;
} }
/** 수정 폼 — 저장하면 그대로 승인된다(메일의 "수정해서 올리기"와 같은 규칙). */ /**
* . (2026-09-21, :
* "승인되야 올라가도록 해야 한다") .
*/
function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) { function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) {
const [body, setBody] = useState(post.body); const [body, setBody] = useState(post.body);
const editMutation = useEditMyPost(); const editMutation = useEditMyPost();
@ -153,7 +160,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.'); notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.');
return; return;
} }
notify.success('저장하고 승인했습니다. 사이트에 반영되기까지 몇 분 걸립니다.'); notify.success('저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다.');
onDone(); onDone();
} catch (error) { } catch (error) {
notifyApiError(error, '저장하지 못했습니다.'); notifyApiError(error, '저장하지 못했습니다.');
@ -171,7 +178,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}> <Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}>
<Save /> <Save />
</Button> </Button>
<Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}> <Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}>
<X /> <X />
@ -186,10 +193,11 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1]; const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
const editable = EDITABLE_STATUSES.has(post.status); const editable = EDITABLE_STATUSES.has(post.status);
const approveMutation = useApproveMyPost();
const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null}; const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null};
const deleteMutation = useDeleteMyPost();
const approveMutation = useApproveMyPost();
const handlePublish = async () => { const handleApprove = async () => {
try { try {
const res = await approveMutation.mutateAsync({placeId, postId: post.post_id}); const res = await approveMutation.mutateAsync({placeId, postId: post.post_id});
if (!res.result?.success) { if (!res.result?.success) {
@ -203,6 +211,23 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
} }
}; };
const handleDelete = async () => {
// 이미 게재된 글은 지우면 사이트에서도 빠지기까지 재발행이 걸린다 — 되돌릴 수 없는
// 작업이라 한 번 확인한다(SitesPage.tsx 의 발행 내리기와 같은 관례).
if (!window.confirm('이 글을 삭제할까요? 게재된 글이면 사이트에서도 곧 사라집니다.')) return;
try {
const res = await deleteMutation.mutateAsync({placeId, postId: post.post_id});
if (!res.result?.success) {
notify.error(res.msg || '삭제하지 못했습니다.');
return;
}
notify.success('삭제했습니다.');
onChanged();
} catch (error) {
notifyApiError(error, '삭제하지 못했습니다.');
}
};
return ( return (
<div className="flex h-full flex-col rounded-lg border border-border bg-card p-3.5 shadow-lg"> <div className="flex h-full flex-col rounded-lg border border-border bg-card p-3.5 shadow-lg">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -228,16 +253,36 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
}} }}
/> />
)} )}
{editable && !isEditing && ( {!isEditing && (
<div className="mt-3 flex items-center gap-1.5 border-t border-border pt-3"> <div className="mt-3 flex items-center gap-1.5 border-t border-border pt-3">
{editable && (
<Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}> <Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}>
<Pencil /> <Pencil />
</Button> </Button>
<Button size="sm" variant="primary" className="flex-1" isLoading={approveMutation.isPending} onClick={handlePublish}> )}
{editable && (
<Button
size="sm"
variant="primary"
className="flex-1"
isLoading={approveMutation.isPending}
onClick={handleApprove}
>
<Send /> <Send />
</Button> </Button>
)}
<Button
size="sm"
variant="ghost"
className="flex-1 text-destructive hover:text-destructive"
isLoading={deleteMutation.isPending}
onClick={handleDelete}
>
<Trash2 />
</Button>
</div> </div>
)} )}
</div> </div>
@ -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 (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground"> </span>
{isEditing ? (
<>
<input
type="email"
value={value}
onChange={(event) => 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"
/>
<Button size="sm" variant="primary" isLoading={updateMutation.isPending} onClick={handleSave}>
<Save />
</Button>
<Button
size="sm"
variant="ghost"
disabled={updateMutation.isPending}
onClick={() => {
setIsEditing(false);
setValue(savedEmail);
}}
>
<X />
</Button>
</>
) : (
<>
<span className="font-medium">{savedEmail || '(계정 이메일 사용)'}</span>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(true)}>
<Pencil />
</Button>
</>
)}
</div>
);
}
/** /**
* ( ) . 기획: docs/MINI_BLOG.md * ( ) . 기획: docs/MINI_BLOG.md
* *
@ -506,6 +623,7 @@ export function BlogPostsPage() {
const generateMutation = useGenerateMyPosts(); const generateMutation = useGenerateMyPosts();
const generateOneMutation = useGenerateMyPostForDate(); const generateOneMutation = useGenerateMyPostForDate();
const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null; const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null;
const sendNowMutation = useSendMyPostsNow();
const [rangeDialogOpen, setRangeDialogOpen] = useState(false); const [rangeDialogOpen, setRangeDialogOpen] = useState(false);
const [rangeStart, setRangeStart] = useState(todayIso()); 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 generateButton = (
<div className="flex items-center gap-1.5">
<Button size="sm" variant="ghost" isLoading={sendNowMutation.isPending} onClick={handleSendNow}>
<Send />
</Button>
<Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}> <Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}>
<Sparkles /> <Sparkles />
</Button> </Button>
</div>
); );
const maxGenerateDate = lastDayOfMonthIso(maxMonth); const maxGenerateDate = lastDayOfMonthIso(maxMonth);
@ -595,6 +733,8 @@ export function BlogPostsPage() {
))} ))}
</div> </div>
<NotifyEmailSetting placeId={placeId} />
{tab === 'main' && ( {tab === 'main' && (
<> <>
{upcomingPosts.length > 0 ? ( {upcomingPosts.length > 0 ? (

View File

@ -291,8 +291,35 @@ export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움';
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */ /** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
export type WeatherMood = '맑음' | '흐림' | '비' | '눈'; export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
/** 그림은 WeatherMood 넷으로 그리고, 문구는 이 아홉으로 고른다. */ /** 그림은 WeatherMood 넷으로 그리고, 문구는 WMO weather_code 하나마다 고유한 이 28종으로 고른다. */
export type WeatherSky = WeatherMood | '구름많음' | '안개' | '이슬비' | '소나기' | '뇌우'; export type WeatherSky =
| WeatherMood
| '대체로 맑음'
| '구름 조금'
| '안개'
| '착빙성 안개'
| '가벼운 이슬비'
| '보통 이슬비'
| '강한 이슬비'
| '가벼운 착빙성 이슬비'
| '강한 착빙성 이슬비'
| '약한 비'
| '보통 비'
| '강한 비'
| '약한 착빙성 비'
| '강한 착빙성 비'
| '약한 눈'
| '보통 눈'
| '강한 눈'
| '싸라기눈'
| '약한 소나기'
| '보통 소나기'
| '강한 소나기'
| '약한 소나기눈'
| '강한 소나기눈'
| '뇌우'
| '약한 우박 뇌우'
| '강한 우박 뇌우';
export interface WeatherSnapshot { export interface WeatherSnapshot {
temperature: number; temperature: number;