diff --git a/.env.momo.example b/.env.momo.example index aa14326..d72cc19 100644 --- a/.env.momo.example +++ b/.env.momo.example @@ -17,3 +17,10 @@ SMTP_FROM=모모유통 # ============ 거래명세표에 표시될 공급자 정보 ============ 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 diff --git a/package-lock.json b/package-lock.json index 8176e08..b300540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c71fe29..159db5e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index b46112e..7b17628 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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); + }) + ); +}); diff --git a/src/app/(main)/m/orders/new/page.tsx b/src/app/(main)/m/orders/new/page.tsx index e1ca55b..c3687a5 100644 --- a/src/app/(main)/m/orders/new/page.tsx +++ b/src/app/(main)/m/orders/new/page.tsx @@ -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() {
-
-

출고 요청

-

현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.

+
+
+

출고 요청

+

현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.

+
+
{isAdmin && onBehalfOfCustomer && ( diff --git a/src/app/api/m/items/save/route.ts b/src/app/api/m/items/save/route.ts index 2ffecc0..106d32b 100644 --- a/src/app/api/m/items/save/route.ts +++ b/src/app/api/m/items/save/route.ts @@ -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 { + 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 }); } diff --git a/src/app/api/m/push/subscribe/route.ts b/src/app/api/m/push/subscribe/route.ts new file mode 100644 index 0000000..82bba8d --- /dev/null +++ b/src/app/api/m/push/subscribe/route.ts @@ -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 }); +} diff --git a/src/app/api/m/push/vapid/route.ts b/src/app/api/m/push/vapid/route.ts new file mode 100644 index 0000000..39b499b --- /dev/null +++ b/src/app/api/m/push/vapid/route.ts @@ -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() }); +} diff --git a/src/components/push-optin.tsx b/src/components/push-optin.tsx new file mode 100644 index 0000000..4aa303f --- /dev/null +++ b/src/components/push-optin.tsx @@ -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("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 => { + 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 ( + + 새 품목 알림 켜짐 + + ); + } + if (perm === "denied") { + return ( + + 알림 차단됨 + + ); + } + return ( + + ); +} diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..6984095 --- /dev/null +++ b/src/lib/push.ts @@ -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( + `SELECT objid, endpoint, p256dh, auth FROM momo_push_subscriptions WHERE user_id IN (${ph})`, + userIds + ); + rows = res.rows; + } else { + const res = await pool.query(`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 }; +}