- 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:
@@ -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
|
||||
|
||||
Generated
+148
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user