[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_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

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)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
# 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)

View File

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

View File

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

View File

@ -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)을 기다리지 않고, 고른 구간을 채운다",

View File

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

View File

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

View File

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

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
*/
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";

View File

@ -23,4 +23,6 @@ export const JobType = {
AI_CHECK: 6,
SONG: 7,
ROLLBACK: 8,
SOCIAL_DRAFT: 9,
SOCIAL_POST: 10,
} 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 { 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;
}

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 { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus";
import type { ReqUpdatePlaceNotifyEmail } from "./reqUpdatePlaceNotifyEmail";
export interface ReqUpdatePlace {
name?: ReqUpdatePlaceName;
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 = (
placeId: string,
@ -2626,7 +2626,7 @@ export type EditMyPostMutationBody = ReqEditPost;
export type EditMyPostMutationError = HTTPValidationError;
/**
* @summary ·
* @summary ,
*/
export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
options?: {
@ -2650,7 +2650,92 @@ export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
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 = (
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<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) ,
*/

File diff suppressed because it is too large Load Diff

View File

@ -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
<div className="flex items-center gap-2">
<Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}>
<Save />
</Button>
<Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}>
<X />
@ -186,10 +193,11 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
const [isEditing, setIsEditing] = useState(false);
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
const editable = EDITABLE_STATUSES.has(post.status);
const approveMutation = useApproveMyPost();
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 {
const res = await approveMutation.mutateAsync({placeId, postId: post.post_id});
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 (
<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">
@ -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">
{editable && (
<Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}>
<Pencil />
</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 />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="flex-1 text-destructive hover:text-destructive"
isLoading={deleteMutation.isPending}
onClick={handleDelete}
>
<Trash2 />
</Button>
</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
*
@ -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 = (
<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)}>
<Sparkles />
</Button>
</div>
);
const maxGenerateDate = lastDayOfMonthIso(maxMonth);
@ -595,6 +733,8 @@ export function BlogPostsPage() {
))}
</div>
<NotifyEmailSetting placeId={placeId} />
{tab === 'main' && (
<>
{upcomingPosts.length > 0 ? (

View File

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