관리자가 공지(제목·본문·이미지+선택적 외부링크)를 작성하고 푸시 구독자 중
원하는 사람에게 발송. 사용자가 알림 탭하면 자체 공지 페이지(/m/notices/[id])
또는 지정 URL 로 이동.
- lib/notices: momo_notices 테이블 자동 생성.
- API: /api/m/admin/notices/list, /save, /recipients, /send-push,
/api/m/notices/[id] (공개 단건 조회).
- Admin UI(/m/admin/notices): 좌 수신자 다중선택+검색+거래처/관리자 필터,
우 제목/본문/이미지 업로드/외부링크. [N명에게 발송] 한 번으로 공지 저장+푸시.
- 공개 페이지(/m/notices/[id]): 이미지+제목+본문 렌더.
- 이미지 업로드는 기존 /api/m/items/upload-image 재사용.
- 사이드바: 마스터 관리 > 푸시알림 게시판 (menu_info 9000299) 신규 등록.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { Send, Upload, X, Bell } from "lucide-react";
|
||||
|
||||
interface Recipient {
|
||||
USER_ID: string;
|
||||
USER_NAME: string;
|
||||
USER_TYPE: string;
|
||||
IS_ADMIN: boolean;
|
||||
DEPT_NAME: string;
|
||||
USER_AGENT: string;
|
||||
LAST_SEEN: string;
|
||||
DEVICE_CNT: number;
|
||||
}
|
||||
interface Notice {
|
||||
OBJID: string; TITLE: string; BODY: string | null;
|
||||
IMAGE_URL: string | null; URL: string | null;
|
||||
REGDATE: string; SENT_COUNT: number;
|
||||
}
|
||||
|
||||
export default function AdminNoticesPage() {
|
||||
const [recipients, setRecipients] = useState<Recipient[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<"" | "U" | "A">("");
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [bodyText, setBodyText] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const [past, setPast] = useState<Notice[]>([]);
|
||||
|
||||
const loadRecipients = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/recipients", { method: "POST" });
|
||||
setRecipients((await r.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
const loadPast = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/list", { method: "POST" });
|
||||
setPast((await r.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
useEffect(() => { loadRecipients(); loadPast(); }, [loadRecipients, loadPast]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return recipients.filter((u) => {
|
||||
if (typeFilter && (typeFilter === "A" ? !u.IS_ADMIN : u.IS_ADMIN)) return false;
|
||||
if (keyword) {
|
||||
const k = keyword.toLowerCase();
|
||||
if (!u.USER_NAME?.toLowerCase().includes(k) && !u.USER_ID?.toLowerCase().includes(k)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [recipients, typeFilter, keyword]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(id)) n.delete(id); else n.add(id);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
const toggleAllFiltered = () => {
|
||||
const allOn = filtered.length > 0 && filtered.every((u) => selected.has(u.USER_ID));
|
||||
setSelected((p) => {
|
||||
const n = new Set(p);
|
||||
if (allOn) filtered.forEach((u) => n.delete(u.USER_ID));
|
||||
else filtered.forEach((u) => n.add(u.USER_ID));
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const onUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const r = await fetch("/api/m/items/upload-image", { method: "POST", body: fd });
|
||||
const j = await r.json();
|
||||
if (j.success) setImageUrl(j.url);
|
||||
else Swal.fire({ icon: "error", title: "업로드 실패", text: j.message });
|
||||
} finally { setUploading(false); }
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!title.trim()) {
|
||||
return Swal.fire({ icon: "warning", title: "제목을 입력하세요" });
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
return Swal.fire({ icon: "warning", title: "수신자를 선택하세요" });
|
||||
}
|
||||
const ok = await Swal.fire({
|
||||
icon: "question",
|
||||
title: `${selected.size}명에게 푸시 발송`,
|
||||
text: `제목: ${title}`,
|
||||
showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
setSending(true);
|
||||
try {
|
||||
// 1) 공지 저장
|
||||
const sv = await fetch("/api/m/admin/notices/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body: bodyText, imageUrl, url: linkUrl }),
|
||||
});
|
||||
const svj = await sv.json();
|
||||
if (!svj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: svj.message }); return; }
|
||||
const noticeObjid = svj.objId;
|
||||
|
||||
// 2) 푸시 발송 (URL = 외부 링크 우선, 없으면 자체 공지 페이지)
|
||||
const send = await fetch("/api/m/admin/notices/send-push", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
noticeObjid, title, message: bodyText,
|
||||
url: linkUrl || `/m/notices/${noticeObjid}`,
|
||||
userIds: Array.from(selected),
|
||||
}),
|
||||
});
|
||||
const sj = await send.json();
|
||||
if (sj.success) {
|
||||
await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false });
|
||||
setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl("");
|
||||
setSelected(new Set());
|
||||
loadPast();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "발송 실패", text: sj.message });
|
||||
}
|
||||
} finally { setSending(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
|
||||
<Bell size={18} className="text-emerald-700" /> 푸시알림 게시판
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">왼쪽에서 수신자를 선택하고, 오른쪽에 제목·본문·이미지를 작성한 뒤 [발송]을 누르세요. 사용자가 알림을 탭하면 작성하신 공지 페이지로 이동합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[420px_1fr] gap-3">
|
||||
{/* 좌: 수신자 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
||||
<span>수신자 (구독자 {recipients.length}명 / 선택 {selected.size})</span>
|
||||
<button onClick={toggleAllFiltered} className="text-[11px] px-2 py-0.5 rounded border border-slate-300 bg-white hover:bg-slate-100">
|
||||
{filtered.length > 0 && filtered.every((u) => selected.has(u.USER_ID)) ? "전체 해제" : "표시된 전체 선택"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 flex gap-1.5 border-b border-slate-100">
|
||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="이름/아이디 검색"
|
||||
className="flex-1 h-8 px-2 rounded border border-slate-200 text-xs" />
|
||||
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as "" | "U" | "A")}
|
||||
className="h-8 px-2 rounded border border-slate-200 bg-white text-xs">
|
||||
<option value="">전체</option>
|
||||
<option value="U">거래처만</option>
|
||||
<option value="A">관리자만</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-[60vh] lg:max-h-[calc(100vh-260px)]">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-10 text-slate-400 text-sm">구독자가 없습니다. 사용자가 출고요청 화면에서 알림을 켜야 목록에 나타납니다.</div>
|
||||
) : filtered.map((u) => {
|
||||
const checked = selected.has(u.USER_ID);
|
||||
const sb = (u.USER_AGENT || "").includes("SamsungBrowser");
|
||||
return (
|
||||
<label key={u.USER_ID}
|
||||
className={`flex items-center gap-2 px-3 py-2 border-b border-slate-100 cursor-pointer ${checked ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(u.USER_ID)} className="accent-emerald-600 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold truncate flex items-center gap-1.5">
|
||||
{u.USER_NAME || u.USER_ID}
|
||||
{u.IS_ADMIN && <span className="text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold">관리자</span>}
|
||||
{sb && <span className="text-[9px] px-1 py-0.5 rounded bg-slate-100 text-slate-500">SamsungBrowser</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 truncate">{u.USER_ID} · 마지막 갱신 {u.LAST_SEEN} {u.DEVICE_CNT > 1 && `(${u.DEVICE_CNT}대)`}</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우: 작성 */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">제목 *</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 오늘의 특가" maxLength={60}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">푸시 알림의 제목으로 표시 (60자 이내)</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">본문</label>
|
||||
<textarea value={bodyText} onChange={(e) => setBodyText(e.target.value)}
|
||||
placeholder="알림 내용 + 공지 페이지 본문이 됩니다." rows={6}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm mt-1 resize-y" />
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">푸시 본문에는 앞 240자만 노출. 전체 본문은 공지 페이지에서 확인.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">이미지 첨부 (선택)</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<label className="inline-flex items-center gap-1 h-9 px-3 rounded-lg border border-slate-300 bg-white text-xs font-semibold cursor-pointer hover:bg-slate-50">
|
||||
<Upload size={14} /> 파일 선택
|
||||
<input type="file" accept="image/*" className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(f); }} />
|
||||
</label>
|
||||
{imageUrl && (
|
||||
<button type="button" onClick={() => setImageUrl("")} className="text-rose-500 hover:text-rose-700" title="이미지 제거"><X size={14} /></button>
|
||||
)}
|
||||
{uploading && <span className="text-xs text-slate-400">업로드 중…</span>}
|
||||
</div>
|
||||
{imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imageUrl} alt="첨부" className="mt-2 max-h-64 rounded border border-slate-200" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">외부 링크 (선택)</label>
|
||||
<input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://… (입력 시 푸시 탭하면 해당 URL 로 이동)"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">비워두면 자체 공지 페이지(이미지+본문)로 이동합니다.</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2 border-t border-slate-100">
|
||||
<button onClick={send} disabled={sending}
|
||||
className="inline-flex items-center gap-2 px-5 h-11 rounded-xl bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Send size={14} /> {sending ? "발송 중…" : `선택한 ${selected.size}명에게 발송`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 발송 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">최근 공지 ({past.length}건)</div>
|
||||
<div className="divide-y divide-slate-100 max-h-[40vh] overflow-y-auto">
|
||||
{past.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400 text-sm">아직 작성된 공지가 없습니다.</div>
|
||||
) : past.map((n) => (
|
||||
<a key={n.OBJID} href={`/m/notices/${n.OBJID}`} target="_blank" rel="noreferrer"
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-slate-50">
|
||||
{n.IMAGE_URL && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={n.IMAGE_URL} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold truncate">{n.TITLE}</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">{n.REGDATE} · 발송 {n.SENT_COUNT ?? 0}건</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, use } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Notice {
|
||||
OBJID: string; TITLE: string; BODY: string | null;
|
||||
IMAGE_URL: string | null; URL: string | null; REGDATE: string;
|
||||
}
|
||||
|
||||
export default function NoticeViewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/m/notices/${id}`)
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.success) setNotice(j.notice);
|
||||
else setErr(j.message || "공지를 찾을 수 없습니다.");
|
||||
})
|
||||
.catch(() => setErr("불러오기 실패"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const back = () => {
|
||||
if (typeof window !== "undefined" && window.history.length > 1) router.back();
|
||||
else router.push("/m/orders/new");
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-12 text-slate-400">불러오는 중…</div>;
|
||||
if (err || !notice) return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-slate-400 mb-3">{err || "공지를 찾을 수 없습니다."}</div>
|
||||
<button onClick={back} className="inline-flex items-center gap-1 h-9 px-3 rounded-lg border border-slate-300 bg-white text-sm font-semibold">
|
||||
<ArrowLeft size={14} /> 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-4">
|
||||
<button onClick={back} className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-emerald-700">
|
||||
<ArrowLeft size={14} /> 돌아가기
|
||||
</button>
|
||||
|
||||
<article className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
{notice.IMAGE_URL && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={notice.IMAGE_URL} alt={notice.TITLE} className="w-full object-contain bg-slate-50" />
|
||||
)}
|
||||
<div className="p-4 sm:p-6 space-y-3">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">{notice.TITLE}</h1>
|
||||
<div className="text-[11px] text-slate-400">{notice.REGDATE}</div>
|
||||
{notice.BODY && (
|
||||
<div className="text-sm sm:text-base text-slate-700 whitespace-pre-wrap leading-relaxed pt-2 border-t border-slate-100">
|
||||
{notice.BODY}
|
||||
</div>
|
||||
)}
|
||||
{notice.URL && (
|
||||
<a href={notice.URL} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-bold text-emerald-700 hover:underline pt-2 border-t border-slate-100">
|
||||
연결 페이지 열기 →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 푸시 공지 목록 (admin) — 최근 작성한 공지
|
||||
import { NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
import { ensureNoticesTable } from "@/lib/notices";
|
||||
|
||||
export async function POST() {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
await ensureNoticesTable();
|
||||
const rows = await queryRows(
|
||||
`SELECT objid AS "OBJID", title AS "TITLE", body AS "BODY",
|
||||
image_url AS "IMAGE_URL", url AS "URL",
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE",
|
||||
regid AS "REGID", sent_count AS "SENT_COUNT"
|
||||
FROM momo_notices
|
||||
WHERE COALESCE(is_del,'N') != 'Y'
|
||||
ORDER BY regdate DESC
|
||||
LIMIT 100`
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 푸시 공지 수신자 후보 — 현재 푸시 구독한 사용자 목록.
|
||||
// 구독이 없는 사용자에게는 보내봐야 도착하지 않으므로 구독자만 보여준다.
|
||||
import { NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
import { ensurePushTable } from "@/lib/push";
|
||||
|
||||
export async function POST() {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
await ensurePushTable();
|
||||
const rows = await queryRows(
|
||||
`SELECT s.user_id AS "USER_ID",
|
||||
COALESCE(u.user_name,'') AS "USER_NAME",
|
||||
COALESCE(u.user_type,'') AS "USER_TYPE",
|
||||
COALESCE(u.user_type,'')='A' AS "IS_ADMIN",
|
||||
COALESCE(u.dept_name,'') AS "DEPT_NAME",
|
||||
MAX(s.user_agent) AS "USER_AGENT",
|
||||
TO_CHAR(MAX(s.last_seen),'YYYY-MM-DD HH24:MI') AS "LAST_SEEN",
|
||||
COUNT(*)::int AS "DEVICE_CNT"
|
||||
FROM momo_push_subscriptions s
|
||||
LEFT JOIN user_info u ON u.user_id = s.user_id
|
||||
GROUP BY s.user_id, u.user_name, u.user_type, u.dept_name
|
||||
ORDER BY u.user_name NULLS LAST, s.user_id`
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 푸시 공지 저장 (admin) — 신규/수정 모두 처리. 발송은 별도 send-push 라우트에서.
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute, queryOne } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
import { ensureNoticesTable } from "@/lib/notices";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
await ensureNoticesTable();
|
||||
const userId = g.user.objid || g.user.userId;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { objid, title, body: text, imageUrl, url } = body as {
|
||||
objid?: string; title?: string; body?: string; imageUrl?: string; url?: string;
|
||||
};
|
||||
if (!title || !title.trim()) {
|
||||
return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (objid) {
|
||||
const cur = await queryOne(`SELECT 1 FROM momo_notices WHERE objid = $1`, [objid]);
|
||||
if (!cur) return NextResponse.json({ success: false, message: "공지를 찾을 수 없습니다." }, { status: 404 });
|
||||
await execute(
|
||||
`UPDATE momo_notices SET title=$2, body=$3, image_url=$4, url=$5 WHERE objid=$1`,
|
||||
[objid, title.trim(), text ?? null, imageUrl || null, url || null]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: objid });
|
||||
}
|
||||
|
||||
const newId = createObjectId();
|
||||
await execute(
|
||||
`INSERT INTO momo_notices (objid, title, body, image_url, url, regdate, regid)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6)`,
|
||||
[newId, title.trim(), text ?? null, imageUrl || null, url || null, userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: newId });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 공지 푸시 발송 — 선택한 사용자(userIds) 에게 푸시. URL 은 자체 공지 페이지(/m/notices/[id]).
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
import { sendPush, ensurePushTable } from "@/lib/push";
|
||||
import { ensureNoticesTable } from "@/lib/notices";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
await ensurePushTable();
|
||||
await ensureNoticesTable();
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { noticeObjid, title, message, url, userIds, sendAll } = body as {
|
||||
noticeObjid?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
url?: string;
|
||||
userIds?: string[];
|
||||
sendAll?: boolean;
|
||||
};
|
||||
if (!title || !title.trim()) {
|
||||
return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 });
|
||||
}
|
||||
const targets = Array.isArray(userIds) ? userIds.filter(Boolean) : [];
|
||||
if (!sendAll && targets.length === 0) {
|
||||
return NextResponse.json({ success: false, message: "수신자를 선택하세요." }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetUrl = url && url.trim() !== "" ? url
|
||||
: noticeObjid ? `/m/notices/${noticeObjid}`
|
||||
: "/m/orders/new";
|
||||
|
||||
const res = await sendPush(
|
||||
{ title: title.trim(), body: (message ?? "").slice(0, 240), url: targetUrl, tag: noticeObjid ? `notice-${noticeObjid}` : undefined },
|
||||
sendAll ? undefined : targets
|
||||
);
|
||||
|
||||
// 발송 통계 누적 — 공지가 있으면 sent_count 업데이트
|
||||
if (noticeObjid) {
|
||||
await execute(
|
||||
`UPDATE momo_notices SET sent_count = COALESCE(sent_count,0) + $2 WHERE objid = $1`,
|
||||
[noticeObjid, res.sent]
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
console.log(`[notices/send-push] notice=${noticeObjid ?? "-"} title="${title}" targets=${sendAll ? "ALL" : targets.length} sent=${res.sent} failed=${res.failed}`);
|
||||
return NextResponse.json({ success: true, sent: res.sent, failed: res.failed });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 푸시 공지 단건 조회 — 로그인한 모든 사용자 가능 (푸시 탭하면 열리는 페이지가 호출).
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryOne } from "@/lib/db";
|
||||
import { requireMomoUser } from "@/lib/momo-guard";
|
||||
import { ensureNoticesTable } from "@/lib/notices";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const r = await requireMomoUser();
|
||||
if (r instanceof NextResponse) return r;
|
||||
await ensureNoticesTable();
|
||||
const { id } = await ctx.params;
|
||||
const row = await queryOne(
|
||||
`SELECT objid AS "OBJID", title AS "TITLE", body AS "BODY",
|
||||
image_url AS "IMAGE_URL", url AS "URL",
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE"
|
||||
FROM momo_notices
|
||||
WHERE objid = $1 AND COALESCE(is_del,'N') != 'Y'`,
|
||||
[id]
|
||||
);
|
||||
if (!row) return NextResponse.json({ success: false, message: "공지를 찾을 수 없습니다." }, { status: 404 });
|
||||
return NextResponse.json({ success: true, notice: row });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 푸시 공지 게시판 — 관리자가 작성한 공지(이미지+본문)를 푸시로 발송하고,
|
||||
// 사용자는 푸시를 탭하면 /m/notices/[id] 페이지에서 본문/이미지를 확인할 수 있다.
|
||||
import { pool } from "./db";
|
||||
|
||||
let ensured = false;
|
||||
export async function ensureNoticesTable() {
|
||||
if (ensured) return;
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS momo_notices (
|
||||
objid TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
image_url TEXT,
|
||||
url TEXT, -- 외부 링크가 있으면 사용 (없으면 자체 /m/notices/[id])
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
sent_count INTEGER DEFAULT 0,
|
||||
is_del CHAR(1) DEFAULT 'N'
|
||||
);
|
||||
`);
|
||||
ensured = true;
|
||||
} catch (err) {
|
||||
console.error("[notices/ensure]", err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user