Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s
Build and Push Docker Image / build-and-push (push) Failing after 21s
Reading plans now track a cross-plan activity log for streaks, surface a combined "today's reading" view on both Home and the Reading Plans page, and let custom plans be edited after creation (including narrowing a book to a specific chapter range). The reader auto-marks a plan's day read after a dwell period, active plans that fall behind their pace get a "catch me up" banner, and a new Settings section wires up daily reading reminders via ntfy.sh, checked once a minute by a small server-side scheduler. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
600b3d3ab9
commit
1dd6aef6a5
10 files changed
+711
-141
No files matched your search
+42
-1
@@ -63,6 +63,17 @@ export function initDb() {
|
||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate ntfy.sh daily reading reminders.
|
||||
if (!userCols.some((c) => c.name === 'ntfy_url')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN ntfy_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
|
||||
ALTER TABLE users ADD COLUMN reminder_time TEXT;
|
||||
ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN last_reminder_date TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Older databases predate shareable read-only links.
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||
@@ -125,6 +136,7 @@ function parseUserRow(row) {
|
||||
return {
|
||||
...row,
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
reminderEnabled: !!row.reminderEnabled,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
@@ -132,7 +144,9 @@ function parseUserRow(row) {
|
||||
const USER_SELECT = `
|
||||
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||
podcast_name AS podcastName
|
||||
podcast_name AS podcastName,
|
||||
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate
|
||||
FROM users
|
||||
`;
|
||||
|
||||
@@ -163,6 +177,33 @@ export function setPodcastName(userId, podcastName) {
|
||||
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily reading reminders (sent via ntfy.sh — see server/reminders.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
|
||||
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }) {
|
||||
db.prepare(`
|
||||
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?
|
||||
WHERE id = ?
|
||||
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, userId);
|
||||
}
|
||||
|
||||
/** Every account with reminders turned on and a topic configured — checked once a minute. */
|
||||
export function getUsersWithRemindersEnabled() {
|
||||
return db.prepare(`
|
||||
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||
last_reminder_date AS lastReminderDate
|
||||
FROM users
|
||||
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
|
||||
`).all();
|
||||
}
|
||||
|
||||
/** Marks that a user's reminder was sent today, so it isn't sent twice. */
|
||||
export function setLastReminderDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||
|
||||
+58
-7
@@ -14,6 +14,7 @@ import {
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage,
|
||||
getReadingProgress, setReadingProgress,
|
||||
setReminderSettings,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
import {
|
||||
@@ -21,6 +22,17 @@ import {
|
||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
} from './auth.js';
|
||||
import { startReminderScheduler } from './reminders.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
function userPayload(user) {
|
||||
return {
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||
reminderTime: user.reminderTime ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
@@ -108,7 +120,10 @@ app.post('/api/auth/register', async (req, res) => {
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) });
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/register error:', err);
|
||||
@@ -137,7 +152,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/login error:', err);
|
||||
@@ -176,7 +191,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/verify error:', err);
|
||||
@@ -194,10 +209,7 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
|
||||
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
|
||||
});
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -214,6 +226,44 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
|
||||
try {
|
||||
const reminderEnabled = !!req.body?.reminderEnabled;
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||||
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||||
|
||||
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
if (reminderEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||||
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||||
}
|
||||
}
|
||||
|
||||
setReminderSettings(req.session.userId, {
|
||||
ntfyUrl: ntfyUrl || null,
|
||||
ntfyTopic: ntfyTopic || null,
|
||||
reminderTime: reminderTime || null,
|
||||
reminderEnabled,
|
||||
});
|
||||
res.json({
|
||||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -575,6 +625,7 @@ if (isProd) {
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
initDb();
|
||||
startReminderScheduler();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||||
if (isProd) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
|
||||
// a free pub/sub push service: POST a message to a topic URL, anyone
|
||||
// subscribed to that topic (via the ntfy app or a browser) gets notified.
|
||||
// No API keys or push-subscription plumbing needed.
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
|
||||
|
||||
function todayDateString(d = new Date()) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function currentHHMM(d = new Date()) {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function sendNtfyReminder(user) {
|
||||
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Title: 'Reading Plan Reminder', Tags: 'open_book' },
|
||||
body: "Don't forget today's reading!",
|
||||
});
|
||||
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
|
||||
}
|
||||
|
||||
async function checkReminders() {
|
||||
const now = new Date();
|
||||
const hhmm = currentHHMM(now);
|
||||
const today = todayDateString(now);
|
||||
const due = getUsersWithRemindersEnabled().filter(
|
||||
(u) => u.reminderTime === hhmm && u.lastReminderDate !== today,
|
||||
);
|
||||
|
||||
for (const user of due) {
|
||||
try {
|
||||
const progress = getReadingProgress(user.id);
|
||||
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
|
||||
if (!hasActivePlan) continue; // nothing to nudge them about
|
||||
await sendNtfyReminder(user);
|
||||
setLastReminderDate(user.id, today);
|
||||
} catch (err) {
|
||||
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks once a minute whether any user's reminder time has arrived. */
|
||||
export function startReminderScheduler() {
|
||||
setInterval(() => {
|
||||
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
||||
}, 60 * 1000);
|
||||
}
|
||||
Reference in new issue
Block a user