・プッシュ通知の修正
・メニューをスマホに最適化 ・アラート送信済みの条件が再度発動しないように修正
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Bell, Globe2, Link, LogOut, ShieldCheck, UserRound } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Bell, Globe2, Link, LogOut, Menu, ShieldCheck, UserRound, X } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ view: 'sites', label: 'サイト一覧', icon: Globe2 },
|
||||
@@ -8,14 +9,44 @@ const navItems = [
|
||||
];
|
||||
|
||||
export function Sidebar({ activeView, user, onNavigate, onLogout }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const MenuIcon = menuOpen ? X : Menu;
|
||||
|
||||
function handleNavigate(view) {
|
||||
onNavigate(view);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
setMenuOpen(false);
|
||||
onLogout();
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar" aria-label="メインメニュー">
|
||||
<aside className={`sidebar ${menuOpen ? 'menu-open' : ''}`} aria-label="メインメニュー">
|
||||
<div className="sidebar-brand">
|
||||
<ShieldCheck aria-hidden="true" size={24} />
|
||||
<span>CertRemind</span>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="sidebar-mobile-user">
|
||||
<UserRound aria-hidden="true" size={18} />
|
||||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="sidebar-menu-button"
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="sidebar-menu"
|
||||
aria-label={menuOpen ? 'メニューを閉じる' : 'メニューを開く'}
|
||||
title={menuOpen ? 'メニューを閉じる' : 'メニューを開く'}
|
||||
>
|
||||
<MenuIcon aria-hidden="true" size={22} />
|
||||
</button>
|
||||
|
||||
<nav className="sidebar-nav" id="sidebar-menu">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = activeView === item.view;
|
||||
@@ -23,7 +54,7 @@ export function Sidebar({ activeView, user, onNavigate, onLogout }) {
|
||||
<button
|
||||
className={`sidebar-link ${active ? 'active' : ''}`}
|
||||
key={item.view}
|
||||
onClick={() => onNavigate(item.view)}
|
||||
onClick={() => handleNavigate(item.view)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
title={item.label}
|
||||
>
|
||||
@@ -39,7 +70,7 @@ export function Sidebar({ activeView, user, onNavigate, onLogout }) {
|
||||
<UserRound aria-hidden="true" size={18} />
|
||||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
<button className="sidebar-link" onClick={onLogout} title="ログアウト">
|
||||
<button className="sidebar-link" onClick={handleLogout} title="ログアウト">
|
||||
<LogOut aria-hidden="true" size={20} />
|
||||
<span>ログアウト</span>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowLeft, BellOff, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { request } from '../api/client.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
||||
import { Field } from '../components/Field.jsx';
|
||||
@@ -29,15 +29,32 @@ function urlBase64ToUint8Array(base64String) {
|
||||
}
|
||||
|
||||
function permissionText(permission) {
|
||||
if (permission === 'unsupported') return '非対応';
|
||||
if (permission === 'granted') return '許可済み';
|
||||
if (permission === 'denied') return '拒否されています';
|
||||
return '未設定';
|
||||
}
|
||||
|
||||
function pushStatusText(status) {
|
||||
if (status === 'checking') return '確認中';
|
||||
if (status === 'registered') return 'このブラウザは登録済みです';
|
||||
if (status === 'unregistered') return 'このブラウザは未登録です';
|
||||
if (status === 'no-subscription') return 'このブラウザは未登録です';
|
||||
if (status === 'permission-denied') return 'ブラウザ通知が拒否されています';
|
||||
if (status === 'unconfigured') return 'VAPID public key が未設定です';
|
||||
if (status === 'unsupported') return 'このブラウザは非対応です';
|
||||
if (status === 'error') return '状態を確認できませんでした';
|
||||
return '未確認';
|
||||
}
|
||||
|
||||
async function getPushRegistration() {
|
||||
return navigator.serviceWorker.register('/push-sw.js');
|
||||
}
|
||||
|
||||
export function NotificationMethodsView({ onBack }) {
|
||||
const [webhooks, setWebhooks] = useState([]);
|
||||
const [pushSubscriptions, setPushSubscriptions] = useState([]);
|
||||
const [vapidPublicKey, setVapidPublicKey] = useState('');
|
||||
const [currentPushStatus, setCurrentPushStatus] = useState('unchecked');
|
||||
const [form, setForm] = useState({ alias: '', url: '' });
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [permission, setPermission] = useState(
|
||||
@@ -46,16 +63,58 @@ export function NotificationMethodsView({ onBack }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
async function loadMethods() {
|
||||
const refreshCurrentPushStatus = useCallback(
|
||||
async (publicKey = vapidPublicKey) => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
setCurrentPushStatus('unsupported');
|
||||
return;
|
||||
}
|
||||
if (!publicKey) {
|
||||
setCurrentPushStatus('unconfigured');
|
||||
return;
|
||||
}
|
||||
if (typeof Notification === 'undefined') {
|
||||
setCurrentPushStatus('unsupported');
|
||||
return;
|
||||
}
|
||||
setPermission(Notification.permission);
|
||||
if (Notification.permission === 'denied') {
|
||||
setCurrentPushStatus('permission-denied');
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentPushStatus('checking');
|
||||
try {
|
||||
const registration = await getPushRegistration();
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
setCurrentPushStatus('no-subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await request('/api/notification-methods/push-subscription-status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
setCurrentPushStatus(data.registered ? 'registered' : 'unregistered');
|
||||
} catch (err) {
|
||||
setCurrentPushStatus('error');
|
||||
showToast({ type: 'error', message: err.message });
|
||||
}
|
||||
},
|
||||
[showToast, vapidPublicKey],
|
||||
);
|
||||
|
||||
const loadMethods = useCallback(async () => {
|
||||
const data = await request('/api/notification-methods');
|
||||
setWebhooks(data.webhooks);
|
||||
setPushSubscriptions(data.pushSubscriptions);
|
||||
setVapidPublicKey(data.vapidPublicKey);
|
||||
}
|
||||
await refreshCurrentPushStatus(data.vapidPublicKey);
|
||||
}, [refreshCurrentPushStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMethods().catch((err) => showToast({ type: 'error', message: err.message }));
|
||||
}, [showToast]);
|
||||
}, [loadMethods, showToast]);
|
||||
|
||||
async function submitWebhook(event) {
|
||||
event.preventDefault();
|
||||
@@ -110,6 +169,9 @@ export function NotificationMethodsView({ onBack }) {
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('VAPID public key が設定されていません');
|
||||
}
|
||||
if (typeof Notification === 'undefined') {
|
||||
throw new Error('このブラウザはプッシュ通知に対応していません');
|
||||
}
|
||||
|
||||
const nextPermission = await Notification.requestPermission();
|
||||
setPermission(nextPermission);
|
||||
@@ -117,18 +179,49 @@ export function NotificationMethodsView({ onBack }) {
|
||||
throw new Error('ブラウザ通知が許可されませんでした');
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.register('/push-sw.js');
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
const registration = await getPushRegistration();
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
const subscription =
|
||||
existingSubscription ??
|
||||
(await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
}));
|
||||
|
||||
await request('/api/notification-methods/push-subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
showToast({ type: 'success', message: 'プッシュ通知を登録しました' });
|
||||
await loadMethods();
|
||||
await refreshCurrentPushStatus();
|
||||
} catch (err) {
|
||||
showToast({ type: 'error', message: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribePush() {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
throw new Error('このブラウザはプッシュ通知に対応していません');
|
||||
}
|
||||
|
||||
const registration = await getPushRegistration();
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
await refreshCurrentPushStatus();
|
||||
throw new Error('解除対象のプッシュ通知登録が見つかりません');
|
||||
}
|
||||
|
||||
await request('/api/notification-methods/push-subscriptions', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
showToast({ type: 'success', message: 'プッシュ通知を解除しました' });
|
||||
await refreshCurrentPushStatus();
|
||||
} catch (err) {
|
||||
showToast({ type: 'error', message: err.message });
|
||||
} finally {
|
||||
@@ -239,35 +332,45 @@ export function NotificationMethodsView({ onBack }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="method-list">
|
||||
<article className="method-row">
|
||||
<div>
|
||||
<strong>現在のブラウザ</strong>
|
||||
<span>{pushStatusText(currentPushStatus)}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="push-actions">
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={subscribePush}
|
||||
disabled={busy || !vapidPublicKey}
|
||||
>
|
||||
<BellRing aria-hidden="true" size={18} />
|
||||
このブラウザを登録
|
||||
</button>
|
||||
{currentPushStatus === 'registered' ? (
|
||||
<ConfirmDialog
|
||||
title="プッシュ通知を解除"
|
||||
description="現在のブラウザをプッシュ通知の登録から解除します。"
|
||||
confirmLabel="解除"
|
||||
onConfirm={unsubscribePush}
|
||||
disabled={busy}
|
||||
trigger={
|
||||
<button className="secondary danger-text" type="button" disabled={busy}>
|
||||
<BellOff aria-hidden="true" size={18} />
|
||||
このブラウザを解除
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={subscribePush}
|
||||
disabled={busy || !vapidPublicKey}
|
||||
>
|
||||
<BellRing aria-hidden="true" size={18} />
|
||||
このブラウザを登録
|
||||
</button>
|
||||
)}
|
||||
{!vapidPublicKey ? (
|
||||
<p className="muted">VAPID public key を設定すると登録できます。</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="method-list">
|
||||
{pushSubscriptions.length === 0 ? (
|
||||
<div className="empty">登録済みのブラウザはありません。</div>
|
||||
) : (
|
||||
pushSubscriptions.map((subscription) => (
|
||||
<article className="method-row" key={subscription.notificationMethodId}>
|
||||
<div>
|
||||
<strong>Browser Push</strong>
|
||||
<span>{subscription.endpoint}</span>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
+76
-22
@@ -300,7 +300,8 @@ select:focus {
|
||||
|
||||
.sidebar-brand,
|
||||
.sidebar-link,
|
||||
.sidebar-user {
|
||||
.sidebar-user,
|
||||
.sidebar-mobile-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@@ -321,6 +322,11 @@ select:focus {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-mobile-user,
|
||||
.sidebar-menu-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid #d9e1de;
|
||||
padding-top: 12px;
|
||||
@@ -344,7 +350,8 @@ select:focus {
|
||||
color: #276761;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
.sidebar-user,
|
||||
.sidebar-mobile-user {
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
@@ -354,6 +361,7 @@ select:focus {
|
||||
}
|
||||
|
||||
.sidebar-user span,
|
||||
.sidebar-mobile-user span,
|
||||
.sidebar-link span,
|
||||
.sidebar-brand span {
|
||||
overflow: hidden;
|
||||
@@ -909,31 +917,77 @@ select:focus {
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-frame {
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 12px 8px;
|
||||
gap: 12px;
|
||||
z-index: 30;
|
||||
height: auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, auto) auto;
|
||||
grid-template-rows: auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d9e1de;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.sidebar-brand,
|
||||
.sidebar-link,
|
||||
.sidebar-user {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
.sidebar-brand {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand span,
|
||||
.sidebar-link span,
|
||||
.sidebar-user span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
.sidebar-mobile-user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-menu-button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #c9d4d0;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #40504b;
|
||||
}
|
||||
|
||||
.sidebar-menu-button:hover {
|
||||
border-color: #276761;
|
||||
color: #276761;
|
||||
}
|
||||
|
||||
.sidebar-nav,
|
||||
.sidebar-footer {
|
||||
grid-column: 1 / -1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.menu-open .sidebar-nav,
|
||||
.sidebar.menu-open .sidebar-footer {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
border-top: 1px solid #d9e1de;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer .sidebar-user {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
justify-content: flex-start;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
@@ -942,9 +996,9 @@ select:focus {
|
||||
}
|
||||
|
||||
.toast-viewport {
|
||||
top: 172px;
|
||||
top: 76px;
|
||||
right: 12px;
|
||||
width: min(360px, calc(100vw - 64px - 24px));
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.site-form {
|
||||
|
||||
@@ -3,6 +3,22 @@ import { getCertificateExpiry } from './certificate.js';
|
||||
import { deliverNotifications } from './notifications.js';
|
||||
|
||||
const DEFAULT_CONCURRENCY = 4;
|
||||
const MISSED_NOTIFICATION_GRACE_HOURS = 1;
|
||||
|
||||
function toTimestamp(value) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
}
|
||||
|
||||
function isSameCertificateExpiry(left, right) {
|
||||
const leftTimestamp = toTimestamp(left);
|
||||
const rightTimestamp = toTimestamp(right);
|
||||
return leftTimestamp !== null && rightTimestamp !== null && leftTimestamp === rightTimestamp;
|
||||
}
|
||||
|
||||
function hoursUntilExpiry(certificate) {
|
||||
return (certificate.expiresAt.getTime() - Date.now()) / 3_600_000;
|
||||
}
|
||||
|
||||
function alertChannels(condition) {
|
||||
const channels = ['app'];
|
||||
@@ -39,7 +55,11 @@ async function loadMonitoringTargets(client) {
|
||||
'site_alert_condition_id', c.site_alert_condition_id,
|
||||
'threshold_hours', c.threshold_hours,
|
||||
'webhook_method_ids', c.webhook_method_ids,
|
||||
'push_enabled', c.push_enabled
|
||||
'push_enabled', c.push_enabled,
|
||||
'last_notified_certificate_expires_at',
|
||||
c.last_notified_certificate_expires_at,
|
||||
'last_notified_at', c.last_notified_at,
|
||||
'last_notification_skipped_at', c.last_notification_skipped_at
|
||||
)
|
||||
ORDER BY c.threshold_hours ASC
|
||||
) FILTER (WHERE c.site_alert_condition_id IS NOT NULL),
|
||||
@@ -124,10 +144,40 @@ async function recordCertificateFailure(client, site, error) {
|
||||
);
|
||||
}
|
||||
|
||||
async function markConditionNotified(client, condition, certificate) {
|
||||
await client.query(
|
||||
`UPDATE site_alert_conditions
|
||||
SET last_notified_certificate_expires_at = $2,
|
||||
last_notified_at = now(),
|
||||
last_notification_skipped_at = NULL
|
||||
WHERE site_alert_condition_id = $1`,
|
||||
[condition.site_alert_condition_id, certificate.expiresAt],
|
||||
);
|
||||
}
|
||||
|
||||
async function markConditionSkipped(client, condition, certificate) {
|
||||
await client.query(
|
||||
`UPDATE site_alert_conditions
|
||||
SET last_notified_certificate_expires_at = $2,
|
||||
last_notification_skipped_at = now()
|
||||
WHERE site_alert_condition_id = $1`,
|
||||
[condition.site_alert_condition_id, certificate.expiresAt],
|
||||
);
|
||||
}
|
||||
|
||||
async function processMatchingCondition(client, site, condition, certificate) {
|
||||
if (certificate.hoursUntilExpiry > condition.threshold_hours) {
|
||||
return { alerted: false };
|
||||
}
|
||||
if (
|
||||
isSameCertificateExpiry(condition.last_notified_certificate_expires_at, certificate.expiresAt)
|
||||
) {
|
||||
return { alerted: false, alreadyNotified: true };
|
||||
}
|
||||
if (condition.threshold_hours - hoursUntilExpiry(certificate) >= MISSED_NOTIFICATION_GRACE_HOURS) {
|
||||
await markConditionSkipped(client, condition, certificate);
|
||||
return { alerted: false, skipped: true };
|
||||
}
|
||||
|
||||
const message = buildExpiryMessage(site, condition, certificate);
|
||||
const webhooks = await loadWebhooks(client, site.user_id, condition.webhook_method_ids);
|
||||
@@ -149,6 +199,7 @@ async function processMatchingCondition(client, site, condition, certificate) {
|
||||
deliveryResult,
|
||||
dedupeKey,
|
||||
});
|
||||
await markConditionNotified(client, condition, certificate);
|
||||
|
||||
return { alerted: Boolean(alert), dedupeKey };
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ const pushSubscriptionSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const pushSubscriptionStatusSchema = z.object({
|
||||
endpoint: z.string().trim().url().max(2048),
|
||||
});
|
||||
|
||||
function serializeWebhook(row) {
|
||||
return {
|
||||
notificationMethodId: row.notification_method_id,
|
||||
@@ -31,40 +35,25 @@ function serializeWebhook(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function serializePushSubscription(row) {
|
||||
return {
|
||||
notificationMethodId: row.notification_method_id,
|
||||
endpoint: row.push_endpoint,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
router.use('*', requireAuth);
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await query(
|
||||
`SELECT notification_method_id,
|
||||
notification_type,
|
||||
alias,
|
||||
url,
|
||||
push_endpoint,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM notification_methods
|
||||
WHERE user_id = $1
|
||||
AND notification_type = 'webhook'
|
||||
ORDER BY created_at DESC`,
|
||||
[user.user_id],
|
||||
);
|
||||
|
||||
return c.json({
|
||||
webhooks: result.rows
|
||||
.filter((row) => row.notification_type === 'webhook')
|
||||
.map(serializeWebhook),
|
||||
pushSubscriptions: result.rows
|
||||
.filter((row) => row.notification_type === 'push')
|
||||
.map(serializePushSubscription),
|
||||
webhooks: result.rows.map(serializeWebhook),
|
||||
vapidPublicKey: env.vapidPublicKey,
|
||||
});
|
||||
});
|
||||
@@ -150,6 +139,30 @@ router.delete('/webhooks/:methodId', async (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/push-subscription-status', async (c) => {
|
||||
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
throw badRequest('購読状態を確認できません', body.error.flatten());
|
||||
}
|
||||
|
||||
const endpoint = new URL(body.data.endpoint);
|
||||
if (endpoint.protocol !== 'https:') {
|
||||
throw badRequest('Push endpoint は HTTPS である必要があります');
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT notification_method_id
|
||||
FROM notification_methods
|
||||
WHERE user_id = $1
|
||||
AND notification_type = 'push'
|
||||
AND push_endpoint = $2
|
||||
LIMIT 1`,
|
||||
[c.get('user').user_id, body.data.endpoint],
|
||||
);
|
||||
|
||||
return c.json({ registered: Boolean(result.rows[0]) });
|
||||
});
|
||||
|
||||
router.post('/push-subscriptions', async (c) => {
|
||||
const body = pushSubscriptionSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
@@ -178,7 +191,43 @@ router.post('/push-subscriptions', async (c) => {
|
||||
[user.user_id, body.data.endpoint, body.data.keys.p256dh, body.data.keys.auth],
|
||||
);
|
||||
|
||||
return c.json({ pushSubscription: serializePushSubscription(result.rows[0]) }, 201);
|
||||
return c.json(
|
||||
{
|
||||
pushSubscription: {
|
||||
notificationMethodId: result.rows[0].notification_method_id,
|
||||
createdAt: result.rows[0].created_at,
|
||||
updatedAt: result.rows[0].updated_at,
|
||||
},
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
router.delete('/push-subscriptions', async (c) => {
|
||||
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
throw badRequest('購読情報を確認してください', body.error.flatten());
|
||||
}
|
||||
|
||||
const endpoint = new URL(body.data.endpoint);
|
||||
if (endpoint.protocol !== 'https:') {
|
||||
throw badRequest('Push endpoint は HTTPS である必要があります');
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM notification_methods
|
||||
WHERE user_id = $1
|
||||
AND notification_type = 'push'
|
||||
AND push_endpoint = $2
|
||||
RETURNING notification_method_id`,
|
||||
[c.get('user').user_id, body.data.endpoint],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
throw notFound('Push 購読情報が見つかりません');
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user