feat(push): PWA 웹 푸시 — 품목이 출고요청 가능해지면 구독자에게 알림
Deploy momo-erp / deploy (push) Successful in 3m34s

- lib/push.ts: web-push + VAPID(env 우선/하드코딩 폴백) + momo_push_subscriptions
  자동 생성. sendPush() 는 만료(404/410) 구독 자동 정리.
- API: GET /api/m/push/vapid (공개키), POST /api/m/push/subscribe (구독 저장).
- sw.js: push / notificationclick 핸들러 추가 (클릭 시 /m/orders/new 열기).
- components/PushOptIn: 출고요청 페이지에 '새 품목 알림 받기' 버튼. 권한 허용 시
  구독 저장, 이미 허용이면 조용히 갱신. iOS<16.4 등 미지원 환경은 자동 숨김.
- items/save: 품목이 '출고요청 불가 → 가능' 으로 전환되면(신규 등록 포함, KST 기준
  판매기간/ACTIVE/비숨김) 구독자에게 푸시 발송. 단순 수정은 알림 안 함.

운영에서 VAPID 키 교체 원하면 .env.production 에 VAPID_* 설정(없으면 기본키 사용).
This commit is contained in:
chpark
2026-05-27 00:17:54 +09:00
parent 1b0d652282
commit b5302c52d2
10 changed files with 501 additions and 6 deletions
+7
View File
@@ -17,3 +17,10 @@ SMTP_FROM=모모유통 <momo8443@daum.net>
# ============ 거래명세표에 표시될 공급자 정보 ============
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
MOMO_PHONE=010-6624-5315
# ============ 웹 푸시(PWA 알림) ============
# 미설정 시 코드 하드코딩 기본 VAPID 키 사용. 운영에서 키를 교체하려면 아래 지정.
# 생성: npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY=__public__
# VAPID_PRIVATE_KEY=__private__
# VAPID_SUBJECT=mailto:admin@momotogether.com
+148 -3
View File
@@ -29,6 +29,7 @@
"recharts": "^3.8.1",
"sweetalert2": "^11.26.24",
"tailwind-merge": "^3.5.0",
"web-push": "^3.6.7",
"xlsx": "^0.18.5",
"zustand": "^5.0.12"
},
@@ -40,6 +41,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
@@ -2202,6 +2204,16 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
@@ -2798,6 +2810,15 @@
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -3008,6 +3029,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -3104,6 +3137,12 @@
"integrity": "sha512-C4FQ1gCLz1YCxmM8HhNPb4D7WQmdrdllkhNReeLwvIVtJKQFKKfwJwmM3yZEBG4P34cLtrgB+FEPr1u553hF7Q==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -3162,6 +3201,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -3649,7 +3694,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3792,6 +3836,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -5052,12 +5105,34 @@
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -5121,6 +5196,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5698,6 +5779,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6123,6 +6225,12 @@
"node": ">=8.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -6140,7 +6248,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6150,7 +6257,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -7297,6 +7403,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8263,6 +8389,25 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -31,6 +31,7 @@
"recharts": "^3.8.1",
"sweetalert2": "^11.26.24",
"tailwind-merge": "^3.5.0",
"web-push": "^3.6.7",
"xlsx": "^0.18.5",
"zustand": "^5.0.12"
},
@@ -42,6 +43,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
+28
View File
@@ -25,3 +25,31 @@ self.addEventListener('fetch', (e) => {
fetch(e.request).catch(() => caches.match(e.request))
);
});
// ===== 웹 푸시 =====
self.addEventListener('push', (e) => {
let data = {};
try { data = e.data ? e.data.json() : {}; } catch (_) { data = {}; }
const title = data.title || '모모유통';
const options = {
body: data.body || '',
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: data.tag || undefined,
data: { url: data.url || '/m/orders/new' },
};
e.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
const target = (e.notification.data && e.notification.data.url) || '/m/orders/new';
e.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((cs) => {
for (const c of cs) {
if ('focus' in c) { c.navigate(target); return c.focus(); }
}
if (self.clients.openWindow) return self.clients.openWindow(target);
})
);
});
+7 -3
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, useMemo, useCallback, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package, LayoutGrid, List as ListIcon, PhoneCall } from "lucide-react";
import Swal from "sweetalert2";
import { PushOptIn } from "@/components/push-optin";
interface Item {
OBJID: string;
@@ -563,9 +564,12 @@ function ItemsBrowse() {
</div>
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p>
<div className="flex items-start justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p>
</div>
<div className="shrink-0 pt-1"><PushOptIn /></div>
</div>
{isAdmin && onBehalfOfCustomer && (
+47
View File
@@ -2,6 +2,45 @@ import { NextRequest, NextResponse } from "next/server";
import { execute, queryOne } from "@/lib/db";
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
FROM momo_items WHERE objid = $1`,
[objid]
);
return !!row?.ok;
}
// 출고요청 가능 전환 시 PWA 구독자에게 푸시 (실패해도 저장에는 영향 없음)
async function notifyItemAvailable(itemName: string, objid: string) {
try {
await sendPush({
title: "새 품목 출고요청 가능",
body: `${itemName} — 지금 출고요청할 수 있어요.`,
url: "/m/orders/new",
tag: `item-${objid}`,
});
} catch (err) {
console.error("[items/save notify]", err);
}
}
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
@@ -64,10 +103,14 @@ export async function POST(req: NextRequest) {
maxQty, hidden, reqDelivery,
userId, saleStart, saleEnd]
);
// 신규 등록 품목이 지금 출고요청 가능하면 구독자에게 알림
if (await isOrderableNow(newId)) await notifyItemAvailable(cleanName, newId);
return NextResponse.json({ success: true, objId: newId, itemCode });
}
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
// 수정 전 '출고요청 가능' 여부 — 변경 후 불가→가능 으로 바뀐 경우에만 알림
const wasOrderable = await isOrderableNow(objid);
await execute(
`UPDATE momo_items SET
item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
@@ -87,6 +130,10 @@ export async function POST(req: NextRequest) {
vendorObjid ?? null,
userId, saleStart, saleEnd]
);
// 불가 → 가능 전환 시에만 알림 (이미 가능했던 품목의 단순 수정은 알림 안 함)
if (!wasOrderable && (await isOrderableNow(objid))) {
await notifyItemAvailable(cleanName, objid);
}
return NextResponse.json({ success: true, objId: objid });
}
+37
View File
@@ -0,0 +1,37 @@
// 현재 로그인 사용자의 푸시 구독 정보 저장 (endpoint 기준 upsert)
import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoUser } from "@/lib/momo-guard";
import { ensurePushTable } from "@/lib/push";
export async function POST(req: NextRequest) {
const r = await requireMomoUser();
if (r instanceof NextResponse) return r;
await ensurePushTable();
const userId = r.user.objid || r.user.userId;
const body = await req.json().catch(() => ({}));
const sub = body?.subscription as
| { endpoint?: string; keys?: { p256dh?: string; auth?: string } }
| undefined;
const endpoint = sub?.endpoint;
const p256dh = sub?.keys?.p256dh;
const auth = sub?.keys?.auth;
const userAgent = String(body?.userAgent ?? req.headers.get("user-agent") ?? "");
if (!endpoint || !p256dh || !auth) {
return NextResponse.json({ success: false, message: "구독 정보가 올바르지 않습니다." }, { status: 400 });
}
await pool.query(
`INSERT INTO momo_push_subscriptions (objid, user_id, endpoint, p256dh, auth, user_agent, regdate, last_seen)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (endpoint) DO UPDATE
SET user_id = EXCLUDED.user_id, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth,
user_agent = EXCLUDED.user_agent, last_seen = NOW()`,
[createObjectId(), userId, endpoint, p256dh, auth, userAgent]
);
return NextResponse.json({ success: true });
}
+10
View File
@@ -0,0 +1,10 @@
// 클라이언트가 푸시 구독 시 사용할 VAPID 공개키 제공
import { NextResponse } from "next/server";
import { getVapidPublicKey } from "@/lib/push";
import { requireMomoUser } from "@/lib/momo-guard";
export async function GET() {
const r = await requireMomoUser();
if (r instanceof NextResponse) return r;
return NextResponse.json({ publicKey: getVapidPublicKey() });
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Bell, BellRing, BellOff } from "lucide-react";
// VAPID 공개키(base64url) → Uint8Array
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
type Perm = "unsupported" | "default" | "granted" | "denied";
// PWA 설치 사용자에게 '새 품목 출고요청 가능' 알림을 받게 해주는 구독 버튼.
export function PushOptIn() {
const [perm, setPerm] = useState<Perm>("unsupported");
const [busy, setBusy] = useState(false);
const supported =
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window;
// 실제 구독 + 서버 저장
const subscribe = useCallback(async (): Promise<boolean> => {
try {
const reg = await navigator.serviceWorker.ready;
const res = await fetch("/api/m/push/vapid");
if (!res.ok) return false;
const { publicKey } = await res.json();
if (!publicKey) return false;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
});
}
const save = await fetch("/api/m/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscription: sub.toJSON(), userAgent: navigator.userAgent }),
});
return save.ok;
} catch {
return false;
}
}, []);
useEffect(() => {
if (!supported) { setPerm("unsupported"); return; }
const p = Notification.permission as "default" | "granted" | "denied";
setPerm(p);
// 이미 허용된 경우 — 구독 정보가 서버에 없을 수 있으니 조용히 저장 갱신
if (p === "granted") void subscribe();
}, [supported, subscribe]);
const enable = async () => {
setBusy(true);
try {
const p = await Notification.requestPermission();
setPerm(p as Perm);
if (p === "granted") await subscribe();
} finally {
setBusy(false);
}
};
if (!supported || perm === "unsupported") return null;
if (perm === "granted") {
return (
<span className="inline-flex items-center gap-1 text-xs text-emerald-700 font-semibold">
<BellRing size={14} />
</span>
);
}
if (perm === "denied") {
return (
<span className="inline-flex items-center gap-1 text-xs text-slate-400" title="브라우저/앱 설정에서 알림을 허용해주세요.">
<BellOff size={14} />
</span>
);
}
return (
<button
type="button"
onClick={enable}
disabled={busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-amber-100 text-amber-800 text-xs font-bold hover:bg-amber-200 disabled:opacity-50"
title="새 품목이 출고요청 가능해지면 푸시 알림을 받습니다."
>
<Bell size={14} /> {busy ? "설정 중…" : "새 품목 알림 받기"}
</button>
);
}
+113
View File
@@ -0,0 +1,113 @@
// 웹 푸시(Web Push) — PWA 설치 사용자에게 알림 발송.
// VAPID 키는 env(VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY) 우선, 없으면 하드코딩 기본값 사용
// (이 프로젝트는 AES_KEY/MASTER_PWD 등 비밀값을 constants 에 하드코딩하는 관례를 따름).
import webpush from "web-push";
import { pool } from "./db";
// 기본 VAPID 키 — 운영에서 .env.production 에 VAPID_* 를 넣으면 그 값이 우선한다.
const VAPID_PUBLIC_KEY =
process.env.VAPID_PUBLIC_KEY ||
"BGTqaBRflu1yaLssXymN9fb2QEudKEI1FwbsvG0h7Vz768AYERVAuTvG2A6ZQ7FAqbx9P6o_fvmwTgCQD6aFhO8";
const VAPID_PRIVATE_KEY =
process.env.VAPID_PRIVATE_KEY || "825izwQffsYwmv1r-my7T-DA0ROLTgZ2yqI2iQYwtV4";
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:admin@momotogether.com";
let configured = false;
function ensureConfigured() {
if (configured) return;
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
configured = true;
}
export function getVapidPublicKey(): string {
return VAPID_PUBLIC_KEY;
}
let tableEnsured = false;
export async function ensurePushTable() {
if (tableEnsured) return;
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS momo_push_subscriptions (
objid TEXT PRIMARY KEY,
user_id TEXT,
endpoint TEXT UNIQUE NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
regdate TIMESTAMP DEFAULT NOW(),
last_seen TIMESTAMP DEFAULT NOW()
);
`);
tableEnsured = true;
} catch (err) {
console.error("[push/ensurePushTable]", err);
}
}
export interface PushPayload {
title: string;
body: string;
url?: string; // 클릭 시 열 경로 (기본 /m/orders/new)
tag?: string; // 같은 tag 알림은 묶임
}
interface SubRow {
objid: string;
endpoint: string;
p256dh: string;
auth: string;
}
// 구독 목록(전체 또는 특정 user) 에게 발송. 만료(404/410) 구독은 자동 삭제.
export async function sendPush(payload: PushPayload, userIds?: string[]): Promise<{ sent: number; failed: number }> {
ensureConfigured();
await ensurePushTable();
let rows: SubRow[];
if (userIds && userIds.length > 0) {
const ph = userIds.map((_, i) => `$${i + 1}`).join(",");
const res = await pool.query<SubRow>(
`SELECT objid, endpoint, p256dh, auth FROM momo_push_subscriptions WHERE user_id IN (${ph})`,
userIds
);
rows = res.rows;
} else {
const res = await pool.query<SubRow>(`SELECT objid, endpoint, p256dh, auth FROM momo_push_subscriptions`);
rows = res.rows;
}
const body = JSON.stringify({
title: payload.title,
body: payload.body,
url: payload.url || "/m/orders/new",
tag: payload.tag,
});
let sent = 0;
let failed = 0;
const stale: string[] = [];
await Promise.all(
rows.map(async (r) => {
try {
await webpush.sendNotification(
{ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } },
body
);
sent++;
} catch (err: unknown) {
failed++;
const status = (err as { statusCode?: number })?.statusCode;
if (status === 404 || status === 410) stale.push(r.objid); // 만료 구독
}
})
);
if (stale.length > 0) {
const ph = stale.map((_, i) => `$${i + 1}`).join(",");
await pool.query(`DELETE FROM momo_push_subscriptions WHERE objid IN (${ph})`, stale).catch(() => {});
}
return { sent, failed };
}