카톡·메일 발송이 실패하면 로그 한 줄만 남고 글은 REVIEWED 로 계속 머물렀다. 손님 예약 요청은 사장님께 못 가도 아무도 몰랐다. - blog_jobs: 카톡 실패(메일 대체) · 메일 실패 · 보낼 곳 없음 → blog_send_failed, 사업장별 dedupe, 성공 시 resolve - booking_request_service: 전달 실패 → booking_request_failed, 손님 이름·연락처는 싣지 않는다 - docs/ALERTS.md: 두 kind 추가 테스트 2건 추가. 블로그·예약·알림 93 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
416 lines
21 KiB
Python
416 lines
21 KiB
Python
"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: docs/MINI_BLOG.md
|
|
|
|
★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약).
|
|
★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에
|
|
못 넣어 중복이 막히지 않는다.
|
|
★ 팀 사전검수 없음 — 금칙 필터(blog_service.is_publishable_body)를 통과하면 바로 REVIEWED 로
|
|
쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다.
|
|
★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼
|
|
것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17).
|
|
"""
|
|
import os
|
|
import uuid
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
|
|
from config import agent_config, social_config
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import place_posts, places, sites, users
|
|
from common.enums import DBWRType, PostStatus, SiteStatus
|
|
from common.logger import LOG
|
|
from crud.post_crud import PostCRUD
|
|
from services import alert_service, blog_service, kakao_link_service, mail_service, site_payload
|
|
from services.external import kakao_event
|
|
from services.snapshot import build_snapshot
|
|
|
|
BATCH_SIZE = 30
|
|
# 이 수보다 재고(DRAFT)가 적은 업장만 새로 만든다 — 한 달치(하루 한 통 기준 약 30일)를 채운다.
|
|
REFILL_BELOW = 30
|
|
MAIL_PER_SWEEP = 20
|
|
|
|
_KST = timezone(timedelta(hours=9))
|
|
|
|
_crud = PostCRUD()
|
|
|
|
|
|
def _today_kst() -> date:
|
|
return datetime.now(_KST).date()
|
|
|
|
|
|
async def _published_places() -> list:
|
|
"""(place, user) 쌍 — user 전체를 준다. 메일에 email 뿐 아니라(대상 판정) 로그인
|
|
day-pass 토큰(id·role·token_version)도 만들어야 해서 email 만으로는 부족하다."""
|
|
def query(session):
|
|
return session.execute(
|
|
select(places, users)
|
|
.join(sites, sites.place_id == places.place_id)
|
|
.join(users, users.user_id == places.owner_user_id)
|
|
.where(
|
|
places.deleted == False, # noqa: E712
|
|
sites.deleted == False, # noqa: E712
|
|
sites.status == SiteStatus.PUBLISHED.value,
|
|
sites.domain.isnot(None),
|
|
)
|
|
)
|
|
|
|
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
|
|
return list(result.all()) if result is not None else []
|
|
|
|
|
|
def _auto_owner_allowlist() -> set:
|
|
"""자동 스윕을 받을 사장님 목록(`BLOG_AUTO_OWNER_IDS`, 쉼표 구분 user_id).
|
|
|
|
★ 비면 **전체**다 — 운영 기본값이고, 이 칸이 비었다고 기능이 꺼지면 안 된다.
|
|
★ 왜 필요한가: 발행된 사이트가 전부 대상이라 **테스트로 만든 업장까지** 매일 알림을
|
|
받는다(실측 2026-10-01: 발행 25곳 중 상당수가 중복·테스트 업장이었다).
|
|
시험 운영 중에는 받을 사람을 좁혀 둔다."""
|
|
return {x.strip() for x in os.environ.get("BLOG_AUTO_OWNER_IDS", "").split(",") if x.strip()}
|
|
|
|
|
|
def _for_auto_sweep(pairs: list) -> list:
|
|
"""자동 스윕(생성·발송) 대상만 남긴다.
|
|
|
|
★★ **`_published_places()` 안에서 거르지 않는다.** 그 함수는 빌더의 [지금 생성하기]
|
|
(`generate_range` · `generate_one_for_date`)도 쓰는데, 거기서 걸러 버리면 목록에 없는
|
|
사장님은 **자기 화면에서 자기 글도 못 만든다** — 자동으로 보낼지와 직접 만들지는
|
|
다른 질문이다. 그래서 두 스윕에서만 부른다."""
|
|
allow = _auto_owner_allowlist()
|
|
if not allow:
|
|
return pairs
|
|
return [(place, user) for place, user in pairs if str(user.user_id) in allow]
|
|
|
|
|
|
async def _pending_count(place_id) -> int:
|
|
"""아직 사장님에게 안 나간 재고 — 팀 사전검수가 없어 생성 즉시 REVIEWED 로 쌓인다."""
|
|
def query(session):
|
|
return session.execute(
|
|
select(place_posts.post_id).where(
|
|
place_posts.place_id == place_id,
|
|
place_posts.status.in_((PostStatus.DRAFT.value, PostStatus.REVIEWED.value)),
|
|
place_posts.deleted == False, # noqa: E712
|
|
)
|
|
)
|
|
|
|
result = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, query)
|
|
return len(result.all()) if result is not None else 0
|
|
|
|
|
|
async def _compose_for_dates(place, dates: list[date]) -> list[dict]:
|
|
"""날짜마다 그 날짜에 맞는 소재(blog_service.materials(snapshot, d))로 한 편씩 만든다 —
|
|
세 생성 경로(자동·구간·개별)가 같이 쓴다. 저장은 부르는 쪽이 한다.
|
|
|
|
★ 날짜를 먼저 정하고 소재를 고른다(2026-09-23). 예전에는 소재 목록을 순서대로 뽑아 날짜에
|
|
차례로 붙여서, 글 내용이 배정된 날짜와 무관했다.
|
|
★ 그 날짜에 맞는 소재가 없으면 그 날짜만 비워 두고 다음 날짜로 간다 — 뒤 날짜엔 축제가 걸릴 수 있다.
|
|
★ LLM 이 없거나 실패하면(None) 그 자리에서 멈춘다 — 날짜마다 소재를 전부 돌며 헛호출하지 않는다."""
|
|
used = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
|
|
)
|
|
used_set = set(used or [])
|
|
snapshot = await build_snapshot(place)
|
|
region = site_payload.region_label(place.road_address, place.address)
|
|
|
|
rows = []
|
|
for target in dates:
|
|
for kind, key, material in blog_service.materials(snapshot, target):
|
|
if key in used_set:
|
|
continue
|
|
generated = await blog_service.generate_one(
|
|
place_name=place.name, region=region, topic_kind=kind, material=material,
|
|
used_topics=sorted(used_set), place_category=place.category, post_date=target,
|
|
)
|
|
if not generated:
|
|
return rows
|
|
body, model = generated
|
|
ok, reason = blog_service.is_publishable_body(body)
|
|
used_set.add(key) # 버린 주제도 이번 회차에서 다시 고르지 않는다
|
|
if not ok:
|
|
LOG.i(f"[blog] place={place.place_id} {target} 버림 — {reason}")
|
|
continue
|
|
rows.append({
|
|
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
|
|
"scheduled_date": target, "generation_meta": {"model": model},
|
|
"status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 팀 사전검수 없음
|
|
})
|
|
break
|
|
return rows
|
|
|
|
|
|
async def _generate_for_place(place) -> int:
|
|
"""업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0)."""
|
|
if await _pending_count(place.place_id) >= REFILL_BELOW:
|
|
return 0
|
|
|
|
latest = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid),
|
|
)
|
|
next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst()
|
|
rows = await _compose_for_dates(place, [next_date + timedelta(days=i) for i in range(BATCH_SIZE)])
|
|
if rows:
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
|
|
)
|
|
return len(rows)
|
|
|
|
|
|
async def generate_drafts() -> int:
|
|
"""재고가 모자란 업장마다 최대 BATCH_SIZE 건. 만든 수를 돌려준다."""
|
|
made = 0
|
|
for place, _user in _for_auto_sweep(await _published_places()):
|
|
made += await _generate_for_place(place)
|
|
return made
|
|
|
|
|
|
async def generate_range(place_id: str, start_date: date, end_date: date) -> dict:
|
|
"""사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다
|
|
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").
|
|
재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에
|
|
상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 —
|
|
매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 그 날짜에 맞는 소재가 없으면
|
|
그 날짜는 빈 날짜로 남는다(_compose_for_dates)."""
|
|
place = None
|
|
for p, _user in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place = p
|
|
break
|
|
if place is None:
|
|
return {"requested": 0, "created": 0}
|
|
|
|
requested = (end_date - start_date).days + 1
|
|
dates = [start_date + timedelta(days=i) for i in range(requested)]
|
|
|
|
_err, existing_rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.list_for_place(s, pid, start_date, end_date + timedelta(days=1)),
|
|
)
|
|
taken = {row.scheduled_date for row in existing_rows}
|
|
empty_dates = [d for d in dates if d not in taken]
|
|
if not empty_dates:
|
|
return {"requested": requested, "created": 0}
|
|
|
|
rows = await _compose_for_dates(place, empty_dates)
|
|
if rows:
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
|
|
)
|
|
return {"requested": requested, "created": len(rows)}
|
|
|
|
|
|
async def generate_one_for_date(place_id: str, target_date: date) -> dict | None:
|
|
"""개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다(2026-09-17, 사장님 지시:
|
|
"개별적으로 새로 만들수있게 해줘"). 재고 상한(REFILL_BELOW)을 안 본다 — 특정 날짜를
|
|
지정한 요청이라 상한 로직이 끼어들 자리가 아니다. 그 날짜가 이미 차 있으면 None."""
|
|
place = None
|
|
for p, _user in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place = p
|
|
break
|
|
if place is None:
|
|
return None
|
|
|
|
rows = await _compose_for_dates(place, [target_date])
|
|
if not rows:
|
|
return None
|
|
return await DB_SESSION_MNG.execute_lambda_write(
|
|
place_posts.DBType(), lambda s, r=rows[0]: _crud.add_one(s, r),
|
|
) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
|
|
|
|
|
|
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str, edit_token: str) -> str:
|
|
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다
|
|
(2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에
|
|
만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
|
|
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
|
|
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
|
|
# ★ 두 링크가 같은 모양이다 — 일회용 코드 하나씩. 예전에는 수정 링크만 500자였는데,
|
|
# 길이보다 나쁜 것은 거기 실린 빌더 액세스 토큰이었다(services/post_service.open_editor).
|
|
edit_link = f"{origin}/v1/site/post/edit?t={edit_token}"
|
|
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
|
|
return (
|
|
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
|
|
f"{post.body}\n\n"
|
|
f"이대로 올리려면(누르면 바로 게재됩니다):\n{approve_link}\n\n"
|
|
f"고쳐서 올리려면:\n{edit_link}\n\n"
|
|
f"두 링크 모두 오늘 자정(KST)에 만료됩니다. 그 뒤엔 로그인해서 빌더 앱에서 처리해 주세요.\n"
|
|
f"— 이 메일은 Web4AI 가 자동으로 보냈습니다."
|
|
)
|
|
|
|
|
|
def _notify_address(place, user) -> str:
|
|
# notify_email 이 있으면 그 업장 전용 수신자다 — 없으면 계정 이메일(users.email)로 대체한다
|
|
# (사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다).
|
|
return place.notify_email or user.email
|
|
|
|
|
|
def _app_origin() -> str:
|
|
"""blog_service.app_origin() 을 그대로 쓴다 — 라우터도 같은 값을 써야 해서 거기로 옮겼다."""
|
|
return blog_service.app_origin()
|
|
|
|
|
|
def _kakao_push_ready() -> bool:
|
|
"""카톡 승인 알림을 보낼 수 있는 환경인가. DB 를 안 본다 — 스윕 맨 앞에서 싸게 거른다."""
|
|
return agent_config.approval_push_enabled() and kakao_event.is_configured()
|
|
|
|
|
|
async def _kakao_target(user) -> str | None:
|
|
"""이 사장님에게 카톡으로 보낼 수 있으면 발화자 키, 아니면 None(스위치 꺼짐·미연결·끊김)."""
|
|
if not _kakao_push_ready():
|
|
return None
|
|
return await kakao_link_service.linked_key(user.user_id)
|
|
|
|
|
|
def _can_mail(place, user) -> bool:
|
|
return mail_service.is_configured() and mail_service.is_valid_address(_notify_address(place, user) or "")
|
|
|
|
|
|
async def _push_kakao(channel_user_key: str, post, edit_token: str) -> bool:
|
|
"""승인 알림을 카톡으로. 실패해도 예외를 올리지 않는다 — 친구가 아니거나 차단했으면
|
|
실패하는 게 정상 경로라, 호출부가 메일로 대신할 수 있어야 한다.
|
|
|
|
★ 글 본문·수정 링크는 여기서 만들지 않는다. 오픈빌더 이벤트 블록의 스킬(우리 웹훅)이
|
|
params 의 글 ID 로 **연결된 본인 글인지 다시 확인한 뒤** 그린다. 수정 링크용 일회용
|
|
코드는 평문을 지금밖에 모르므로 params 로 넘긴다(메일과 같은 코드다)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
await kakao_event.send(
|
|
channel_user_key,
|
|
agent_config.get("KAKAO_APPROVAL_EVENT_NAME", "post_approval"),
|
|
params={"post_id": str(post.post_id), "edit_token": edit_token},
|
|
client=client,
|
|
)
|
|
return True
|
|
except kakao_event.KakaoEventError as ex:
|
|
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림 실패: {ex}")
|
|
except httpx.HTTPError as ex:
|
|
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림 실패: {type(ex).__name__}")
|
|
return False
|
|
|
|
|
|
async def _alert_send_failed(place, post, reason: str) -> None:
|
|
await alert_service.send_alert(
|
|
kind="blog_send_failed",
|
|
title=f"미니블로그 승인 알림 발송 실패 — {place.name}",
|
|
detail=f"place_id={place.place_id} post_id={post.post_id}\n{reason}",
|
|
dedupe_key=f"blog_send_failed:{place.place_id}",
|
|
)
|
|
|
|
|
|
async def _send_one(place, user, post) -> bool:
|
|
"""토큰 발급 → 발송 → 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 DB 를 안 건드린다
|
|
(다시 시도할 길을 막지 않는다).
|
|
|
|
★ 카톡이 연결돼 있으면 **카톡으로만** 보내고, 카톡이 **실패했을 때만** 메일로 대체한다
|
|
(2026-09-30). 처음(2026-09-29)엔 '둘 다'였는데 같은 알림이 두 통 가는 게 번거롭다는 지시로
|
|
바꿨고, 카톡만 보내면 채널 친구가 아니거나 차단했거나 Event API 가 죽은 날 알림이 통째로
|
|
사라져서 실패 때만 메일로 받치게 했다. 연결이 없거나 스위치가 꺼져 있으면 예전처럼 메일이다.
|
|
★ 카톡도 메일도 못 보내면 SENT 로 표시하지 않는다 — 다음 스윕이 다시 시도하고 '승인 알림보내기'
|
|
는 실패로 답한다."""
|
|
token, token_hash, expires = blog_service.issue_token()
|
|
# ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고,
|
|
# 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
|
|
edit_token, edit_hash, _edit_expires = blog_service.issue_token()
|
|
|
|
async def mark_sent():
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()],
|
|
[lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)],
|
|
)
|
|
|
|
kakao_key = await _kakao_target(user)
|
|
if kakao_key is not None:
|
|
# 카톡 발송이 성공해야 SENT 로 표시할 수 있어 그 뒤에 한다. 이벤트가 나가자마자 웹훅이 이
|
|
# 글을 읽어도 되는 이유: 웹훅은 REVIEWED 글도 읽고, 사장님이 버튼을 누르기까지의 시간이
|
|
# 이 간격보다 훨씬 길다.
|
|
if await _push_kakao(kakao_key, post, edit_token):
|
|
await mark_sent()
|
|
await alert_service.resolve_alert(f"blog_kakao_failed:{place.place_id}", f"미니블로그 카톡 알림 정상화 — {place.name}")
|
|
await alert_service.resolve_alert(f"blog_send_failed:{place.place_id}", f"미니블로그 승인 알림 정상화 — {place.name}")
|
|
return True
|
|
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림이 안 나가 메일로 대체한다")
|
|
await alert_service.send_alert(
|
|
kind="blog_send_failed",
|
|
title=f"미니블로그 카톡 승인 알림 실패(메일로 대체) — {place.name}",
|
|
detail=f"place_id={place.place_id} post_id={post.post_id}",
|
|
dedupe_key=f"blog_kakao_failed:{place.place_id}",
|
|
)
|
|
|
|
if not _can_mail(place, user):
|
|
await _alert_send_failed(place, post, "보낼 곳이 없다 — 메일 미설정 또는 받는 주소 형식 오류")
|
|
return False
|
|
body = _mail_body(
|
|
place_name=place.name, post=post, user=user,
|
|
origin=_app_origin(), approve_token=token, edit_token=edit_token,
|
|
)
|
|
if not mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body):
|
|
await _alert_send_failed(place, post, "메일 발송 실패")
|
|
return False
|
|
await mark_sent()
|
|
await alert_service.resolve_alert(f"blog_send_failed:{place.place_id}", f"미니블로그 승인 알림 정상화 — {place.name}")
|
|
return True
|
|
|
|
|
|
async def send_reviewed() -> int:
|
|
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
|
|
if not mail_service.is_configured() and not _kakao_push_ready():
|
|
return 0
|
|
|
|
_err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: _crud.due_for_mail(s, PostStatus.REVIEWED.value, _today_kst(), MAIL_PER_SWEEP),
|
|
)
|
|
if not rows:
|
|
return 0
|
|
|
|
# ★ 목록 밖 업장의 글은 건너뛸 뿐 **버리지 않는다** — REVIEWED 로 남아 있어서, 목록을
|
|
# 풀면 그때 나간다. 여기서 SENT 로 찍으면 아무에게도 안 간 글이 보낸 것으로 기록된다.
|
|
places_by_id = {str(place.place_id): (place, user)
|
|
for place, user in _for_auto_sweep(await _published_places())}
|
|
sent = 0
|
|
for post in rows:
|
|
target = places_by_id.get(str(post.place_id))
|
|
if not target:
|
|
continue
|
|
place, user = target
|
|
# 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 어느 쪽으로 보낼지는 _send_one 이 정한다
|
|
# (카톡이 연결돼 있으면 카톡, 실패했을 때만 메일 — 연결이 없으면 메일).
|
|
if not _can_mail(place, user) and await _kakao_target(user) is None:
|
|
await _alert_send_failed(place, post, "보낼 곳이 없다 — 메일 주소도 카톡 연결도 없다")
|
|
continue
|
|
if await _send_one(place, user, post):
|
|
sent += 1
|
|
return sent
|
|
|
|
|
|
async def send_now_for_place(place_id: str) -> dict:
|
|
"""사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의
|
|
오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록").
|
|
'하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(REVIEWED 가 아니면) 보낼 게 없다."""
|
|
if not mail_service.is_configured() and not _kakao_push_ready():
|
|
return {"sent": False, "reason": "MAIL_NOT_CONFIGURED"}
|
|
|
|
place = user = None
|
|
for p, u in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place, user = p, u
|
|
break
|
|
if place is None:
|
|
return {"sent": False, "reason": "NOTHING_DUE"}
|
|
|
|
post = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: _crud.next_due_for_mail(s, place_id, PostStatus.REVIEWED.value, _today_kst()),
|
|
)
|
|
if post is None:
|
|
return {"sent": False, "reason": "NOTHING_DUE"}
|
|
|
|
if not _can_mail(place, user) and await _kakao_target(user) is None:
|
|
return {"sent": False, "reason": "NO_VALID_EMAIL"}
|
|
|
|
if not await _send_one(place, user, post):
|
|
return {"sent": False, "reason": "SEND_FAILED"}
|
|
return {"sent": True, "reason": None}
|