feat(push): 품목 판매 일정 등록/변경 시 일반 사용자 전체에 알림
Deploy momo-erp / deploy (push) Successful in 2m1s

요구 정정 — 트리거는 품목 마스터 저장(items/save) 이며, '지금 출고 가능'
전환뿐 아니라 미래 판매예정(시작일이 오늘 이후)도 알림 대상.
- getSaleInfo(): 판매 일정 유무 + 마감 미경과(sellable) + 현재 출고가능(orderableNow).
- 등록: 판매 일정이 잡혀 있으면 알림. 수정: 판매 시작/마감일이 바뀌고
  그 일정이 아직 유효(오늘/미래)할 때만 알림 (단가 등 단순수정·과거날짜 제외).
- 메시지: 지금 가능 → "지금 출고요청 가능", 미래 → "{시작일} 판매 예정".
- 수신 대상: sendPush(generalOnly) — 관리자(user_type='A') 제외, 일반 거래처만.
This commit is contained in:
chpark
2026-05-27 00:31:13 +09:00
parent 34b64a5a17
commit 85ac9db997
2 changed files with 89 additions and 36 deletions
+71 -34
View File
@@ -4,39 +4,70 @@ import { createObjectId } from "@/lib/utils";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { sendPush } from "@/lib/push";
// 거래처가 지금 출고요청 가능한 품목인지 (KST 기준, ACTIVE/비숨김/미삭제 + 판매기간 내)
// items/list 의 노출 규칙과 동일. objid 한 건에 대해 boolean 반환.
async function isOrderableNow(objid: string): Promise<boolean> {
const row = await queryOne<{ ok: boolean }>(
`SELECT (
COALESCE(is_del,'N') != 'Y'
AND UPPER(COALESCE(status,'')) = 'ACTIVE'
AND COALESCE(is_hidden,'N') != 'Y'
AND (sale_start_date IS NULL OR (NOW() AT TIME ZONE 'Asia/Seoul') >= sale_start_date)
AND (
sale_end_date IS NULL
OR (NOW() AT TIME ZONE 'Asia/Seoul') <= CASE
WHEN sale_end_date = date_trunc('day', sale_end_date)
THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second'
ELSE sale_end_date
END
)
) AS ok
interface SaleInfo {
startTxt: string | null; // 'YYYY-MM-DD HH:MM'
endTxt: string | null;
sellable: boolean; // 판매 일정이 있고 마감이 안 지남(=지금 또는 미래에 판매) + ACTIVE/비숨김/미삭제
orderableNow: boolean; // 지금 출고요청 가능 (판매기간 내)
}
// 품목의 판매 일정/상태 정보 (KST 기준). items/list 노출 규칙과 동일한 마감 해석.
async function getSaleInfo(objid: string): Promise<SaleInfo | null> {
const row = await queryOne<{
start_txt: string | null; end_txt: string | null; sellable: boolean; orderable_now: boolean;
}>(
`SELECT
TO_CHAR(sale_start_date, 'YYYY-MM-DD HH24:MI') AS start_txt,
TO_CHAR(sale_end_date, 'YYYY-MM-DD HH24:MI') AS end_txt,
(
COALESCE(is_del,'N') != 'Y'
AND UPPER(COALESCE(status,'')) = 'ACTIVE'
AND COALESCE(is_hidden,'N') != 'Y'
AND (sale_start_date IS NOT NULL OR sale_end_date IS NOT NULL)
AND (
sale_end_date IS NULL
OR (NOW() AT TIME ZONE 'Asia/Seoul') <= CASE
WHEN sale_end_date = date_trunc('day', sale_end_date)
THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second'
ELSE sale_end_date
END
)
) AS sellable,
(
COALESCE(is_del,'N') != 'Y'
AND UPPER(COALESCE(status,'')) = 'ACTIVE'
AND COALESCE(is_hidden,'N') != 'Y'
AND (sale_start_date IS NULL OR (NOW() AT TIME ZONE 'Asia/Seoul') >= sale_start_date)
AND (
sale_end_date IS NULL
OR (NOW() AT TIME ZONE 'Asia/Seoul') <= CASE
WHEN sale_end_date = date_trunc('day', sale_end_date)
THEN sale_end_date + INTERVAL '1 day' - INTERVAL '1 second'
ELSE sale_end_date
END
)
) AS orderable_now
FROM momo_items WHERE objid = $1`,
[objid]
);
return !!row?.ok;
if (!row) return null;
return {
startTxt: row.start_txt, endTxt: row.end_txt,
sellable: !!row.sellable, orderableNow: !!row.orderable_now,
};
}
// 출고요청 가능 전환 시 PWA 구독자에게 푸시 (실패해도 저장에는 영향 없음)
async function notifyItemAvailable(itemName: string, objid: string) {
// 판매 일정 등록/변경 시 일반 사용자(거래처) 전체에게 푸시 (실패해도 저장에는 영향 없음)
async function notifyItemSale(itemName: string, objid: string, info: SaleInfo) {
try {
await sendPush({
title: "새 품목 출고요청 가능",
body: `${itemName}지금 출고요청할 수 있어요.`,
url: "/m/orders/new",
tag: `item-${objid}`,
});
const body = info.orderableNow
? `${itemName} — 지금 출고요청할 수 있어요.`
: `${itemName}${info.startTxt ?? "곧"} 판매 예정입니다.`;
await sendPush(
{ title: "판매 품목 안내", body, url: "/m/orders/new", tag: `item-${objid}` },
undefined,
{ generalOnly: true }
);
} catch (err) {
console.error("[items/save notify]", err);
}
@@ -103,14 +134,15 @@ export async function POST(req: NextRequest) {
maxQty, hidden, reqDelivery,
userId, saleStart, saleEnd]
);
// 신규 등록 품목이 지금 출고요청 가능하면 구독자에게 알림
if (await isOrderableNow(newId)) await notifyItemAvailable(cleanName, newId);
// 신규 등록 품목에 판매 일정(현재/미래)이 잡혀 있으면 일반 사용자에게 알림
const newInfo = await getSaleInfo(newId);
if (newInfo?.sellable) await notifyItemSale(cleanName, newId, newInfo);
return NextResponse.json({ success: true, objId: newId, itemCode });
}
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
// 수정 전 '출고요청 가능' 여부 — 변경 후 불가→가능 으로 바뀐 경우에만 알림
const wasOrderable = await isOrderableNow(objid);
// 수정 전 판매 일정 — 날짜가 바뀐 경우에만 알림 판단
const beforeInfo = await getSaleInfo(objid);
await execute(
`UPDATE momo_items SET
item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
@@ -130,9 +162,14 @@ export async function POST(req: NextRequest) {
vendorObjid ?? null,
userId, saleStart, saleEnd]
);
// 불가 → 가능 전환 시에만 알림 (이미 가능했던 품목의 단순 수정은 알림 안 함)
if (!wasOrderable && (await isOrderableNow(objid))) {
await notifyItemAvailable(cleanName, objid);
// 판매 일정(시작/마감)이 바뀌었고, 바뀐 일정이 아직 유효(오늘 또는 미래)면 일반 사용자에게 알림.
// 단가/이미지 등만 수정한 경우(날짜 동일)는 알림 안 함. 이미 지난 날짜로 바꾼 경우도 제외.
const afterInfo = await getSaleInfo(objid);
const datesChanged =
(beforeInfo?.startTxt ?? null) !== (afterInfo?.startTxt ?? null) ||
(beforeInfo?.endTxt ?? null) !== (afterInfo?.endTxt ?? null);
if (datesChanged && afterInfo?.sellable) {
await notifyItemSale(cleanName, objid, afterInfo);
}
return NextResponse.json({ success: true, objId: objid });
}
+18 -2
View File
@@ -59,8 +59,15 @@ interface SubRow {
auth: string;
}
// 구독 목록(전체 또는 특정 user) 에게 발송. 만료(404/410) 구독은 자동 삭제.
export async function sendPush(payload: PushPayload, userIds?: string[]): Promise<{ sent: number; failed: number }> {
// 구독 목록에게 발송. 만료(404/410) 구독은 자동 삭제.
// - userIds 지정 시 해당 사용자만.
// - 미지정 + generalOnly 시 일반 사용자(관리자 user_type='A' 제외) 전체.
// - 미지정 + !generalOnly 시 전체 구독자.
export async function sendPush(
payload: PushPayload,
userIds?: string[],
opts?: { generalOnly?: boolean }
): Promise<{ sent: number; failed: number }> {
ensureConfigured();
await ensurePushTable();
@@ -72,6 +79,15 @@ export async function sendPush(payload: PushPayload, userIds?: string[]): Promis
userIds
);
rows = res.rows;
} else if (opts?.generalOnly) {
// 일반 사용자만 — 관리자(user_type='A') 구독은 제외
const res = await pool.query<SubRow>(
`SELECT s.objid, s.endpoint, s.p256dh, s.auth
FROM momo_push_subscriptions s
LEFT JOIN user_info u ON u.user_id = s.user_id
WHERE UPPER(COALESCE(u.user_type, '')) <> 'A'`
);
rows = res.rows;
} else {
const res = await pool.query<SubRow>(`SELECT objid, endpoint, p256dh, auth FROM momo_push_subscriptions`);
rows = res.rows;