fix: Pulse UPSERT COALESCE — prevent partial POST from wiping other metrics

ON CONFLICT DO UPDATE now uses COALESCE($3, pulse_log.mood) etc.
so sending only {mood:3} no longer sets energy/focus to NULL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-03-20 23:01:18 +09:00
parent db5b3a3961
commit e7ccd829f6
1 changed files with 13 additions and 52 deletions

View File

@ -147,7 +147,6 @@ async function fetchMeta(url) {
const title = og('og:title') || doc.querySelector('title')?.text || url; const title = og('og:title') || doc.querySelector('title')?.text || url;
const desc = og('og:description') || meta('description') || ''; const desc = og('og:description') || meta('description') || '';
const img = og('og:image') || ''; const img = og('og:image') || '';
let host; // Declare host here for broader scope
// Google Favicon API優先→ favicon.icoフォールバック // Google Favicon API優先→ favicon.icoフォールバック
const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`; const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`;
@ -266,7 +265,7 @@ ${smartExtract(fullText || '', 5000)}
console.error('[Gemini] Raw response:', result.response.text()); console.error('[Gemini] Raw response:', result.response.text());
} }
return { return {
summary: `⚠️ AI分析失敗: ${String(e)}`, summary: 'AI分析に失敗しました。しばらく後にお試しください。',
topics: ['その他'], topics: ['その他'],
readingTime: 3 readingTime: 3
}; };
@ -441,50 +440,7 @@ async function initDB() {
`ALTER TABLE site_config ADD COLUMN IF NOT EXISTS user_id VARCHAR(50) NOT NULL DEFAULT 'maita'`, `ALTER TABLE site_config ADD COLUMN IF NOT EXISTS user_id VARCHAR(50) NOT NULL DEFAULT 'maita'`,
`ALTER TABLE site_config DROP CONSTRAINT IF EXISTS site_config_pkey`, `ALTER TABLE site_config DROP CONSTRAINT IF EXISTS site_config_pkey`,
`ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`, `ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`,
// together テーブルをクリーンリセット(古いスキーマを完全に作り直す) // together スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理
`DROP TABLE IF EXISTS together_comments, together_reactions, together_shares, together_members, together_groups CASCADE`,
`CREATE TABLE together_groups (
id SERIAL PRIMARY KEY,
invite_code VARCHAR(8) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE together_members (
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (group_id, username)
)`,
`CREATE TABLE together_shares (
id SERIAL PRIMARY KEY,
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
shared_by VARCHAR(50) NOT NULL,
url TEXT,
title TEXT,
message TEXT NOT NULL DEFAULT '',
og_image TEXT,
tags TEXT[] DEFAULT '{}',
full_content TEXT,
summary TEXT,
archive_status VARCHAR(10) NOT NULL DEFAULT 'pending',
shared_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_shares_group ON together_shares(group_id, shared_at DESC)`,
`CREATE TABLE together_reactions (
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'like',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (share_id, username, type)
)`,
`CREATE TABLE together_comments (
id SERIAL PRIMARY KEY,
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at)`,
]; ];
for (const sql of migrations) { for (const sql of migrations) {
await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message)); await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message));
@ -728,7 +684,7 @@ function buildRouter() {
// GET /api/history - 履歴取得 // GET /api/history - 履歴取得
r.get('/history', authMiddleware, async (req, res) => { r.get('/history', authMiddleware, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit || '50'), 100); const limit = Math.min(parseInt(req.query.limit || '50') || 50, 100);
try { try {
const result = await pool.query(` const result = await pool.query(`
@ -755,7 +711,7 @@ function buildRouter() {
// ?user=maita でユーザー指定可能(将来の独立サイト対応) // ?user=maita でユーザー指定可能(将来の独立サイト対応)
r.get('/journal/posts/public', async (req, res) => { r.get('/journal/posts/public', async (req, res) => {
try { try {
const limit = Math.min(parseInt(req.query.limit || '50'), 100); const limit = Math.min(parseInt(req.query.limit || '50') || 50, 100);
const userId = req.query.user || null; const userId = req.query.user || null;
const { rows } = userId const { rows } = userId
? await pool.query( ? await pool.query(
@ -1006,7 +962,7 @@ ${excerpt}
// GET /habit/heatmap — 過去 N 日分のチェック数(ヒートマップ用) // GET /habit/heatmap — 過去 N 日分のチェック数(ヒートマップ用)
r.get('/habit/heatmap', authMiddleware, async (req, res) => { r.get('/habit/heatmap', authMiddleware, async (req, res) => {
const days = Math.min(parseInt(req.query.days || '90'), 365); const days = Math.min(parseInt(req.query.days || '90') || 90, 365);
try { try {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT log_date::text AS date, COUNT(*) AS count SELECT log_date::text AS date, COUNT(*) AS count
@ -1039,7 +995,11 @@ ${excerpt}
INSERT INTO pulse_log (user_id, log_date, mood, energy, focus, note, updated_at) INSERT INTO pulse_log (user_id, log_date, mood, energy, focus, note, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW()) VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (user_id, log_date) DO UPDATE ON CONFLICT (user_id, log_date) DO UPDATE
SET mood=$3, energy=$4, focus=$5, note=$6, updated_at=NOW() SET mood=COALESCE($3, pulse_log.mood),
energy=COALESCE($4, pulse_log.energy),
focus=COALESCE($5, pulse_log.focus),
note=COALESCE(NULLIF($6,''), pulse_log.note),
updated_at=NOW()
RETURNING mood, energy, focus, note RETURNING mood, energy, focus, note
`, [req.userId, req.params.date, mood || null, energy || null, focus || null, note]); `, [req.userId, req.params.date, mood || null, energy || null, focus || null, note]);
res.json({ entry: rows[0] }); res.json({ entry: rows[0] });
@ -1048,7 +1008,7 @@ ${excerpt}
// GET /pulse/log — 範囲取得デフォルト直近30日 // GET /pulse/log — 範囲取得デフォルト直近30日
r.get('/pulse/log', authMiddleware, async (req, res) => { r.get('/pulse/log', authMiddleware, async (req, res) => {
const days = Math.min(parseInt(req.query.days || '30'), 365); const days = Math.min(parseInt(req.query.days || '30') || 30, 365);
try { try {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT log_date::text AS date, mood, energy, focus, note SELECT log_date::text AS date, mood, energy, focus, note
@ -1063,7 +1023,7 @@ ${excerpt}
// ── Lens API ────────────────────────────── // ── Lens API ──────────────────────────────
// GET /lens/history — スキャン履歴取得(直近 limit 件) // GET /lens/history — スキャン履歴取得(直近 limit 件)
r.get('/lens/history', authMiddleware, async (req, res) => { r.get('/lens/history', authMiddleware, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit || '20'), 100); const limit = Math.min(parseInt(req.query.limit || '20') || 20, 100);
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
'SELECT id, filename, exif_data, thumbnail, scanned_at FROM lens_history WHERE user_id=$1 ORDER BY scanned_at DESC LIMIT $2', 'SELECT id, filename, exif_data, thumbnail, scanned_at FROM lens_history WHERE user_id=$1 ORDER BY scanned_at DESC LIMIT $2',
@ -1409,6 +1369,7 @@ ${excerpt}
// GET /together/groups/:groupId — グループ情報 // GET /together/groups/:groupId — グループ情報
r.get('/together/groups/:groupId', async (req, res) => { r.get('/together/groups/:groupId', async (req, res) => {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.groupId)) return res.status(400).json({ error: 'invalid groupId' });
try { try {
const result = await pool.query('SELECT * FROM together_groups WHERE id=$1', [req.params.groupId]); const result = await pool.query('SELECT * FROM together_groups WHERE id=$1', [req.params.groupId]);
if (result.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' }); if (result.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });