[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:
parent
adb3460c37
commit
81ea676546
@ -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
|
||||
|
||||
7
postgres-init/migrations/0021_places_notify_email.sql
Normal file
7
postgres-init/migrations/0021_places_notify_email.sql
Normal 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;
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)을 기다리지 않고, 고른 구간을 채운다",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
14
solution/frontend/src/api/generated/model/approvalParams.ts
Normal file
14
solution/frontend/src/api/generated/model/approvalParams.ts
Normal 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;
|
||||
};
|
||||
21
solution/frontend/src/api/generated/model/callbackParams.ts
Normal file
21
solution/frontend/src/api/generated/model/callbackParams.ts
Normal 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;
|
||||
};
|
||||
10
solution/frontend/src/api/generated/model/decision.ts
Normal file
10
solution/frontend/src/api/generated/model/decision.ts
Normal 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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -23,4 +23,6 @@ export const JobType = {
|
||||
AI_CHECK: 6,
|
||||
SONG: 7,
|
||||
ROLLBACK: 8,
|
||||
SOCIAL_DRAFT: 9,
|
||||
SOCIAL_POST: 10,
|
||||
} as const;
|
||||
|
||||
15
solution/frontend/src/api/generated/model/linkDecision.ts
Normal file
15
solution/frontend/src/api/generated/model/linkDecision.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
14
solution/frontend/src/api/generated/model/testPost.ts
Normal file
14
solution/frontend/src/api/generated/model/testPost.ts
Normal 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;
|
||||
}
|
||||
@ -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)을 기다리지 않고, 고른 구간을 채운다
|
||||
*/
|
||||
|
||||
1216
solution/frontend/src/api/generated/social/social.ts
Normal file
1216
solution/frontend/src/api/generated/social/social.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,15 +253,35 @@ 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">
|
||||
<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}>
|
||||
<Send />
|
||||
바로 발행
|
||||
{editable && (
|
||||
<Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}>
|
||||
<Pencil />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
@ -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 = (
|
||||
<Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}>
|
||||
<Sparkles />
|
||||
지금 생성하기
|
||||
</Button>
|
||||
<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 ? (
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user