← back to Bubbesblock

server.js

367 lines

'use strict';
const express = require('express');
const fs = require('fs');
const path = require('path');
const cookieParser = require('cookie-parser');
require('dotenv').config({ path: path.join(__dirname, '.env') });

const app = express();
const PORT = process.env.PORT || 3201;
const DATA = path.join(__dirname, 'data', 'posts.json');
// require a real secret in production (DB present); allow a dev fallback only in JSON-only mode
const SECRET = process.env.SESSION_SECRET || (process.env.DATABASE_URL ? null : 'bubbesblock-dev-secret');
if (process.env.DATABASE_URL && !SECRET) { console.error('SESSION_SECRET is required in production'); process.exit(1); }
process.on('unhandledRejection', e => console.error('unhandledRejection:', e && e.message));

let pool = null;
if (process.env.DATABASE_URL) {
  const { Pool } = require('pg');
  pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 6 });
  pool.on('error', e => console.error('pg pool error:', e.message));
}
// optional home-history module (built by la-research-agent); tolerate absence
let homeHistory = null;
try { homeHistory = require('./lib/home-history.js'); } catch { /* not built yet */ }

app.use(express.json({ limit: '12kb' }));
app.use(cookieParser(SECRET));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'bubbesblock', db: !!pool }));

// ---------- helpers ----------
const q = (text, params) => pool.query(text, params);
function jsonAll() { return JSON.parse(fs.readFileSync(DATA, 'utf8')); }
// escape user-supplied text at write-time (client renders via innerHTML; seed content stays trusted)
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const uid = name => 'u-' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');

async function currentUser(req) {
  if (!pool) return null;
  const id = req.signedCookies && req.signedCookies.bb_uid;
  if (!id) return null;
  const r = await q('SELECT * FROM users WHERE id=$1', [id]);
  return r.rows[0] || null;
}
function setSession(res, userId) {
  res.cookie('bb_uid', userId, { signed: true, httpOnly: true, sameSite: 'lax', secure: !!process.env.DATABASE_URL, maxAge: 1000 * 60 * 60 * 24 * 90 });
}
function need(res, code, msg) { res.status(code).json({ error: msg }); return null; }

function shapePost(p, commentCount, extra = {}) {
  return {
    id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address,
    time: p.time_label, edited: p.edited, category: p.category, catColor: p.cat_color,
    body: p.body, photo: p.photo, reactions: p.reactions, reactedBy: p.reacted_by,
    shares: p.shares, moreComments: p.more_comments, commentCount, ...extra
  };
}
const shapeComment = (m, extra = {}) => ({ id: m.id, author: m.author, initials: m.initials, color: m.color, nb: m.nb, time: m.time_label, thanks: m.thanks, reply: m.is_reply, body: m.body, ...extra });
async function config(client) {
  const r = await client.query("SELECT k,v FROM site_config WHERE k IN ('me','neighborhood','service_area','categories_for_you')");
  const o = {}; r.rows.forEach(x => (o[x.k] = x.v)); return o;
}
async function reactionCount(client, type, id) {
  const r = await client.query("SELECT count(*) FROM reactions WHERE target_type=$1 AND target_id=$2", [type, String(id)]);
  return Number(r.rows[0].count);
}

// ===================== READ: feed / post / opportunities =====================
app.get('/api/posts', async (req, res) => {
  if (pool) {
    let client;
    try {
      client = await pool.connect();
      const me = await currentUser(req);
      const cfg = await config(client);
      const r = await client.query(
        `SELECT p.*, (SELECT count(*) FROM comments c WHERE c.post_id=p.id) AS live,
                (SELECT count(*) FROM reactions x WHERE x.target_type='post' AND x.target_id=p.id) AS thanks
           FROM posts p ORDER BY p.position`);
      let mine = new Set();
      if (me) { const yr = await client.query("SELECT target_id FROM reactions WHERE target_type='post' AND user_id=$1", [me.id]); mine = new Set(yr.rows.map(x => x.target_id)); }
      const posts = r.rows.map(p => shapePost(p, Number(p.live) + (p.more_comments || 0), { thanks: Number(p.thanks), youThanked: mine.has(p.id) }));
      posts.forEach(p => delete p.moreComments);
      return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, you: me, posts });
    } catch (e) { console.error('feed PG fail:', e.message); } finally { if (client) client.release(); }
  }
  try { const d = jsonAll(); res.json({ neighborhood: d.neighborhood, me: d.me, you: null,
    posts: d.posts.map(p => ({ id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address, time: p.time, edited: !!p.edited, category: p.category, catColor: p.catColor, body: p.body, photo: p.photo, reactions: p.reactions, reactedBy: p.reactedBy, shares: p.shares, thanks: 0, commentCount: (p.comments ? p.comments.length : 0) + (p.moreComments || 0) })) }); }
  catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/posts/:id', async (req, res) => {
  if (pool) {
    let client;
    try {
      client = await pool.connect();
      const me = await currentUser(req);
      const cfg = await config(client);
      const pr = await client.query('SELECT * FROM posts WHERE id=$1', [req.params.id]);
      if (!pr.rows.length) return res.status(404).json({ error: 'not found' });
      const cr = await client.query('SELECT * FROM comments WHERE post_id=$1 ORDER BY position, id', [req.params.id]);
      const thanks = await reactionCount(client, 'post', req.params.id);
      let youThanked = false;
      if (me) youThanked = (await client.query("SELECT 1 FROM reactions WHERE target_type='post' AND target_id=$1 AND user_id=$2", [req.params.id, me.id])).rows.length > 0;
      const post = shapePost(pr.rows[0], cr.rows.length, { thanks, youThanked });
      post.comments = cr.rows.map(shapeComment);
      return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, you: me, post });
    } catch (e) { console.error('post PG fail:', e.message); } finally { if (client) client.release(); }
  }
  try { const d = jsonAll(); const post = d.posts.find(p => p.id === req.params.id); if (!post) return res.status(404).json({ error: 'not found' }); res.json({ neighborhood: d.neighborhood, me: d.me, you: null, post }); }
  catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/posts/:id/comments', async (req, res) => {
  if (!pool) return res.json({ comments: [] });
  try { const r = await q('SELECT * FROM comments WHERE post_id=$1 ORDER BY position, id', [req.params.id]); res.json({ comments: r.rows.map(c => shapeComment(c)) }); }
  catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/opportunities', async (req, res) => {
  if (pool) {
    let client;
    try {
      client = await pool.connect();
      const me = await currentUser(req);
      const cfg = await config(client);
      const r = await client.query('SELECT * FROM opportunities ORDER BY position');
      let unlocked = new Set(), responded = new Set();
      if (me) {
        unlocked = new Set((await client.query('SELECT opportunity_id FROM opp_unlocks WHERE user_id=$1', [me.id])).rows.map(x => x.opportunity_id));
        responded = new Set((await client.query('SELECT DISTINCT opportunity_id FROM opp_responses WHERE user_id=$1', [me.id])).rows.map(x => x.opportunity_id));
      }
      const opportunities = r.rows.map(o => ({ id: o.id, neighbor: o.neighbor, initials: o.initials, color: o.color, category: o.category, forYou: o.for_you, area: o.area, address: o.address, time: o.time_label, responses: o.responses, locked: o.locked && !unlocked.has(o.id), youResponded: responded.has(o.id), body: o.body }));
      return res.json({ me: cfg.me, you: me, serviceArea: cfg.service_area, categoriesForYou: cfg.categories_for_you || [], opportunities });
    } catch (e) { console.error('opps PG fail:', e.message); } finally { if (client) client.release(); }
  }
  try { const d = jsonAll(); const od = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'opportunities.json'), 'utf8'));
    res.json({ me: d.me, you: null, serviceArea: od.service_area, categoriesForYou: od.categories_for_you || [], opportunities: (od.opportunities || []).map(o => ({ ...o, forYou: o.for_you })) }); }
  catch (e) { res.status(500).json({ error: String(e) }); }
});

// ===================== SESSION / AUTH / CLAIM =====================
app.get('/api/session', async (req, res) => { res.json({ you: await currentUser(req) }); });

// chrome bootstrap for auxiliary pages (me for avatar/rails + current session)
app.get('/api/me', async (req, res) => {
  let me = { name: 'BubbesBlock', initials: 'BB', color: 'a1', address: 'Beekman Ave' };
  if (pool) { try { const r = await q("SELECT v FROM site_config WHERE k='me'"); if (r.rows.length) me = r.rows[0].v; } catch {} }
  res.json({ me, you: await currentUser(req) });
});

app.post('/api/session/login', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const name = (req.body.name || '').trim();
  if (!name) return need(res, 400, 'name required');
  const id = uid(name);
  const initials = esc(name.split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase());
  await q(`INSERT INTO users(id,name,initials,color) VALUES($1,$2,$3,'a1') ON CONFLICT(id) DO NOTHING`, [id, esc(name), initials]);
  setSession(res, id);
  res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [id])).rows[0] });
});
app.post('/api/session/logout', (_req, res) => { res.clearCookie('bb_uid'); res.json({ ok: true }); });

app.post('/api/claim', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  const address = (req.body.address || '').trim(); if (!address) return need(res, 400, 'address required');
  await q('UPDATE users SET address=$1, verified=true WHERE id=$2', [esc(address), me.id]);
  res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [me.id])).rows[0] });
});

// ===================== WRITES: posts / comments / reactions =====================
app.post('/api/posts', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  if (!me.verified) return need(res, 403, 'claim your address to post');
  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
  const category = esc(req.body.category || '📣 Block Post');
  const catColor = ['green', 'plum', 'amber'].includes(req.body.catColor) ? req.body.catColor : 'plum';
  const id = 'p-' + Date.now().toString(36);
  try {
    const pos = (await q('SELECT coalesce(min(position),0)-1 AS n FROM posts')).rows[0].n; // new posts sort to top
    await q(`INSERT INTO posts(id,position,author,initials,color,address,time_label,edited,category,cat_color,body,photo,reactions,reacted_by,shares,more_comments,source)
             VALUES($1,$2,$3,$4,$5,$6,'just now',false,$7,$8,$9,$10,'[]'::jsonb,'',0,0,'user')`,
      [id, pos, me.name, me.initials, me.color, me.address, category, catColor,
       JSON.stringify(body.split(/\n\n+/).filter(Boolean).map(esc)), req.body.photo ? JSON.stringify(req.body.photo) : null]);
    res.json({ ok: true, id });
  } catch (e) { console.error('create post fail:', e.message); need(res, 500, 'could not post'); }
});

app.post('/api/posts/:id/comments', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  if (!me.verified) return need(res, 403, 'claim your address to reply');
  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
  const pos = (await q('SELECT coalesce(max(position),-1)+1 AS n FROM comments WHERE post_id=$1', [req.params.id])).rows[0].n;
  const r = await q(`INSERT INTO comments(post_id,position,author,initials,color,nb,time_label,thanks,is_reply,body)
                     VALUES($1,$2,$3,$4,$5,$6,'just now',0,$7,$8) RETURNING *`,
    [req.params.id, pos, me.name, me.initials, me.color, me.address || 'neighbor', !!req.body.reply, esc(body)]);
  res.json({ ok: true, comment: shapeComment(r.rows[0]) });
});

// toggle a reaction (Thank/like) on a post|comment|opportunity
app.post('/api/react', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  const { targetType, targetId } = req.body; const kind = req.body.kind || 'thank';
  if (!['post', 'comment', 'opportunity'].includes(targetType) || !targetId) return need(res, 400, 'bad target');
  const ex = await q('SELECT id FROM reactions WHERE target_type=$1 AND target_id=$2 AND user_id=$3 AND kind=$4', [targetType, String(targetId), me.id, kind]);
  let on;
  if (ex.rows.length) { await q('DELETE FROM reactions WHERE id=$1', [ex.rows[0].id]); on = false; }
  else { await q('INSERT INTO reactions(target_type,target_id,user_id,kind) VALUES($1,$2,$3,$4)', [targetType, String(targetId), me.id, kind]); on = true; }
  const count = Number((await q('SELECT count(*) FROM reactions WHERE target_type=$1 AND target_id=$2', [targetType, String(targetId)])).rows[0].count);
  res.json({ ok: true, on, count });
});

app.post('/api/posts/:id/share', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  await q('UPDATE posts SET shares=shares+1 WHERE id=$1', [req.params.id]);
  const r = await q('SELECT shares FROM posts WHERE id=$1', [req.params.id]);
  res.json({ ok: true, shares: r.rows.length ? r.rows[0].shares : 0, url: `/p/${req.params.id}` });
});

app.post('/api/posts/:id/bookmark', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  const ex = await q('SELECT 1 FROM bookmarks WHERE user_id=$1 AND post_id=$2', [me.id, req.params.id]);
  let on; if (ex.rows.length) { await q('DELETE FROM bookmarks WHERE user_id=$1 AND post_id=$2', [me.id, req.params.id]); on = false; }
  else { await q('INSERT INTO bookmarks(user_id,post_id) VALUES($1,$2)', [me.id, req.params.id]); on = true; }
  res.json({ ok: true, on });
});
app.get('/api/bookmarks', async (req, res) => {
  if (!pool) return res.json({ posts: [] });
  const me = await currentUser(req); if (!me) return res.json({ posts: [] });
  const r = await q(`SELECT p.* FROM bookmarks b JOIN posts p ON p.id=b.post_id WHERE b.user_id=$1 ORDER BY b.created_at DESC`, [me.id]);
  res.json({ posts: r.rows.map(p => shapePost(p, 0)) });
});

// ===================== OPPORTUNITIES: respond / unlock =====================
app.post('/api/opportunities/:id/respond', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  if (!me.verified) return need(res, 403, 'claim your address to respond');
  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
  const ins = await q('INSERT INTO opp_responses(opportunity_id,user_id,body) VALUES($1,$2,$3) ON CONFLICT (opportunity_id,user_id) DO NOTHING RETURNING id', [req.params.id, me.id, esc(body)]);
  if (ins.rows.length) await q('UPDATE opportunities SET responses=responses+1 WHERE id=$1', [req.params.id]);
  const r = await q('SELECT responses FROM opportunities WHERE id=$1', [req.params.id]);
  res.json({ ok: true, responses: r.rows.length ? r.rows[0].responses : 0 });
});
app.post('/api/opportunities/:id/unlock', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  await q('INSERT INTO opp_unlocks(opportunity_id,user_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [req.params.id, me.id]);
  res.json({ ok: true, unlocked: true });
});

// ===================== SEARCH =====================
app.get('/api/search', async (req, res) => {
  const term = (req.query.q || '').trim(); if (!term) return res.json({ posts: [], opportunities: [], neighbors: [] });
  if (!pool) return res.json({ posts: [], opportunities: [], neighbors: [] });
  const like = '%' + term + '%';
  const posts = (await q(`SELECT id,author,category,body FROM posts WHERE body::text ILIKE $1 OR author ILIKE $1 OR category ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
  const opps = (await q(`SELECT id,neighbor,category,body FROM opportunities WHERE body ILIKE $1 OR category ILIKE $1 OR neighbor ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
  const neighbors = (await q(`SELECT id,name,initials,color FROM users WHERE name ILIKE $1 LIMIT 8`, [like])).rows;
  res.json({ posts, opportunities: opps, neighbors });
});

// ===================== NOTIFICATIONS / INBOX =====================
app.get('/api/notifications', async (req, res) => {
  if (!pool) return res.json({ items: [], unread: 0 });
  const me = await currentUser(req); if (!me) return res.json({ items: [], unread: 0 });
  const r = await q('SELECT * FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT 30', [me.id]);
  const unread = r.rows.filter(n => !n.read).length;
  res.json({ items: r.rows, unread });
});
app.post('/api/notifications/read', async (req, res) => {
  if (!pool) return res.json({ ok: true });
  const me = await currentUser(req); if (!me) return res.json({ ok: true });
  await q('UPDATE notifications SET read=true WHERE user_id=$1', [me.id]);
  res.json({ ok: true });
});
app.get('/api/inbox', async (req, res) => {
  if (!pool) return res.json({ conversations: [] });
  const me = await currentUser(req); if (!me) return res.json({ conversations: [] });
  const r = await q(`SELECT c.*, (SELECT body FROM messages m WHERE m.conv_id=c.id ORDER BY id DESC LIMIT 1) AS last
                     FROM conversations c WHERE c.user_id=$1 ORDER BY c.id DESC`, [me.id]);
  res.json({ conversations: r.rows });
});
app.get('/api/inbox/:id', async (req, res) => {
  if (!pool) return res.json({ messages: [] });
  const me = await currentUser(req); if (!me) return res.json({ messages: [] });
  const r = await q('SELECT m.* FROM messages m JOIN conversations c ON c.id=m.conv_id WHERE m.conv_id=$1 AND c.user_id=$2 ORDER BY m.id', [req.params.id, me.id]);
  res.json({ messages: r.rows });
});
app.post('/api/inbox/:id/messages', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
  const own = await q('SELECT 1 FROM conversations WHERE id=$1 AND user_id=$2', [req.params.id, me.id]);
  if (!own.rows.length) return need(res, 403, 'not your conversation');
  await q('INSERT INTO messages(conv_id,from_me,body) VALUES($1,true,$2)', [req.params.id, esc(body)]);
  res.json({ ok: true });
});

// ===================== EVENTS / GROUPS =====================
app.get('/api/events', async (req, res) => {
  if (!pool) return res.json({ events: [] });
  const me = await currentUser(req);
  const r = await q('SELECT * FROM events ORDER BY position', []);
  let going = new Set();
  if (me) going = new Set((await q('SELECT event_id FROM event_rsvps WHERE user_id=$1', [me.id])).rows.map(x => x.event_id));
  res.json({ you: me, events: r.rows.map(e => ({ ...e, youGoing: going.has(e.id) })) });
});
app.post('/api/events/:id/rsvp', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  const ex = await q('SELECT 1 FROM event_rsvps WHERE event_id=$1 AND user_id=$2', [req.params.id, me.id]);
  let going; if (ex.rows.length) { await q('DELETE FROM event_rsvps WHERE event_id=$1 AND user_id=$2', [req.params.id, me.id]); await q('UPDATE events SET going=greatest(going-1,0) WHERE id=$1', [req.params.id]); going = false; }
  else { await q('INSERT INTO event_rsvps(event_id,user_id) VALUES($1,$2)', [req.params.id, me.id]); await q('UPDATE events SET going=going+1 WHERE id=$1', [req.params.id]); going = true; }
  const r = await q('SELECT going FROM events WHERE id=$1', [req.params.id]);
  res.json({ ok: true, going, count: r.rows[0].going });
});
app.get('/api/groups', async (req, res) => {
  if (!pool) return res.json({ groups: [] });
  const me = await currentUser(req);
  const r = await q('SELECT * FROM groups ORDER BY position', []);
  let joined = new Set();
  if (me) joined = new Set((await q('SELECT slug FROM group_members WHERE user_id=$1', [me.id])).rows.map(x => x.slug));
  res.json({ you: me, groups: r.rows.map(g => ({ ...g, youJoined: joined.has(g.slug) })) });
});
app.get('/api/groups/:slug', async (req, res) => {
  if (!pool) return res.status(404).json({ error: 'not found' });
  const r = await q('SELECT * FROM groups WHERE slug=$1', [req.params.slug]);
  if (!r.rows.length) return res.status(404).json({ error: 'not found' });
  res.json({ group: r.rows[0] });
});
app.post('/api/groups/:slug/join', async (req, res) => {
  if (!pool) return need(res, 503, 'db required');
  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
  const ex = await q('SELECT 1 FROM group_members WHERE slug=$1 AND user_id=$2', [req.params.slug, me.id]);
  let joined; if (ex.rows.length) { await q('DELETE FROM group_members WHERE slug=$1 AND user_id=$2', [req.params.slug, me.id]); await q('UPDATE groups SET members=greatest(members-1,0) WHERE slug=$1', [req.params.slug]); joined = false; }
  else { await q('INSERT INTO group_members(slug,user_id) VALUES($1,$2)', [req.params.slug, me.id]); await q('UPDATE groups SET members=members+1 WHERE slug=$1', [req.params.slug]); joined = true; }
  const r = await q('SELECT members FROM groups WHERE slug=$1', [req.params.slug]);
  res.json({ ok: true, joined, members: r.rows[0].members });
});

// ===================== HOME HISTORY (wholivedthere; module optional) =====================
app.get('/api/home-history', async (req, res) => {
  const address = (req.query.address || '').trim(); if (!address) return need(res, 400, 'address required');
  if (homeHistory && homeHistory.getHomeHistory) {
    try { return res.json(await homeHistory.getHomeHistory(address)); } catch (e) { console.error('home-history fail:', e.message); }
  }
  res.json({ address, source: 'unavailable', summary: 'Home history coming soon.', link: `https://wholivedthere.com/?address=${encodeURIComponent(address)}` });
});

// ===================== page routes =====================
app.get('/p/:id', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'post.html')));
app.get('/opportunities', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'opportunities.html')));
for (const r of ['events', 'groups', 'bookmarks', 'search', 'inbox', 'notifications']) {
  app.get('/' + r, (_req, res) => res.sendFile(path.join(__dirname, 'public', r + '.html')));
}
for (const r of ['about', 'guidelines', 'privacy', 'help']) {
  app.get('/' + r, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'content.html')));
}

app.listen(PORT, () => console.log(`BubbesBlock on http://localhost:${PORT} (db=${!!pool})`));