From 5dbec04b04ecd89f43714e1fceb3cd3bd3b6c434 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 08:23:27 +0200 Subject: [PATCH] feat: add Discord reminder and timezone settings --- src/api.ts | 2 +- src/discord/README.md | 26 +++++++ src/discord/commands.ts | 2 + src/discord/preferences.test.ts | 134 ++++++++++++++++++++++++++++++++ src/discord/preferences.ts | 113 +++++++++++++++++++++++++++ src/discord/routes.ts | 8 +- src/discord/test-fixture.ts | 6 +- src/discord/types.ts | 4 +- src/habits/service.ts | 2 +- src/reminders/contracts.ts | 3 +- src/reminders/routes.ts | 21 +---- src/reminders/service.ts | 31 ++++++++ 12 files changed, 324 insertions(+), 28 deletions(-) create mode 100644 src/discord/preferences.test.ts create mode 100644 src/discord/preferences.ts create mode 100644 src/reminders/service.ts diff --git a/src/api.ts b/src/api.ts index a45c815..629031e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -33,7 +33,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503); return c.json({ status: "ok" }); }); - app.route("/api/discord/interactions", createDiscordRoutes(db, operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }, now ?? Date.now, liveSharing.changed)); + app.route("/api/discord/interactions", createDiscordRoutes(db, { ...(operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed)); app.route("/api/sharing/live", liveSharing.routes); app.route("/api/auth", auth.routes); app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now)); diff --git a/src/discord/README.md b/src/discord/README.md index 195b7d6..c8bc389 100644 --- a/src/discord/README.md +++ b/src/discord/README.md @@ -33,3 +33,29 @@ local midnight (including DST). Delivery claims, nonce, opt-out, and retry rules are unchanged. The dashboard preview explicitly disables buttons because it has no signed interaction endpoint configured. Global command registration and the Discord interaction endpoint must be configured separately before production use. +## Preferences + +`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus +paginate ten named schedules at a time; entries with matching name and schedule +are edited together as multiple comma-separated 24-hour times. Forms accept +weekday names (or `every day`) or one ISO calendar date. Delivery is toggled for +all schedules. Removing the last schedule disables delivery and stores an empty +list; the shared contract permits empty lists only while disabled. Invalid forms +provide a prefilled retry button. UTC accounts receive a timezone setup hint. + +`ReminderService` is shared with the website routes. Every save advances its +`updatedAt` monotonically; Discord controls compare the saved preference snapshot +and the user's timezone/update version before editing. `HabitService.setTimezone` +also advances the user timestamp monotonically, including repeated writes in the +same millisecond. This rejects old Discord forms after edits from either interface, +including change-and-revert sequences. Website PUT retains its existing request +contract. Delivery receipts do not invalidate preference controls. + +Integration: retain the `preferences` command dispatch and `preferencesAction` +component dispatch in `routes.ts`, the two definitions in `commands.ts`, and the +preference fields in `ControlData`. `CommandConfig.remindersAvailable` supplies +availability; `createApi` derives it from the existing sharing bot token, matching +the website and reminder worker. Direct route fixtures default it to false. +No new environment variables, migrations, outbound requests, or remote command +registration are needed by this module. Global command registration remains an +integration step. Successful mutations invoke the supplied live-card dirty hook. diff --git a/src/discord/commands.ts b/src/discord/commands.ts index d4c8c24..b03ed3f 100644 --- a/src/discord/commands.ts +++ b/src/discord/commands.ts @@ -3,6 +3,8 @@ export const commands = [ { name: 'help', description: 'Show commands and how to use Minabot' }, { name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' }, { name: 'habits', description: 'Create, edit, archive and restore your habits' }, + { name: 'reminders', description: 'Manage named reminders, schedules, and quiet hours' }, + { name: 'settings', description: 'Set your timezone and view delivery settings' }, { name: 'today', description: 'View and complete today’s habits' }, { name: 'history', description: 'View and correct habit progress for a calendar date' }, ].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); diff --git a/src/discord/preferences.test.ts b/src/discord/preferences.test.ts new file mode 100644 index 0000000..b07ea26 --- /dev/null +++ b/src/discord/preferences.test.ts @@ -0,0 +1,134 @@ +import { afterEach, expect, test } from 'bun:test'; +import { eq } from 'drizzle-orm'; +import { reminderDeliveries, users } from '../db/schema'; +import { discordFixture, control, submit } from './test-fixture'; +import { commands } from './commands'; +let f: ReturnType; +afterEach(() => f?.close()); +const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } }); +const draft = { name: 'Take a breath', times: '09:00, 18:30', days: 'Mon, Wed, Fri', date: '' }; +async function choose(list: any, guild = false, index = '0') { + return f.run({ ...click(list, 'Choose a reminder'), data: { custom_id: control(list, 'Choose a reminder'), values: [index] } }, guild); +} +test('settings and reminders are global commands in both contexts', () => { + for (const name of ['settings', 'reminders']) expect(commands.find(c => c.name === name)?.contexts).toEqual([0, 1]); +}); +for (const guild of [false, true]) test(`full named reminder management shares website state in ${guild ? 'server' : 'DM'}`, async () => { + f = discordFixture(true); + await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' }); + let list = await f.run({ data: { name: 'reminders' } }, guild); + expect(list.data.flags).toBe(guild ? 64 : undefined); + const form = await f.run(click(list, 'Add reminder'), guild); + const interaction = f.payload(submit(form.data.custom_id, draft), guild); + list = await (await f.requestDiscord(interaction)).json(); + f.restart(); await f.requestDiscord(interaction); + let web = await f.json('/reminders'); + expect(web.reminders).toEqual(['09:00', '18:30'].map(time => ({ type: 'weekly', name: draft.name, time, days: [1, 3, 5] }))); + expect(f.changed).toEqual(['alice']); + list = await f.run(click(list, 'Enable delivery'), guild); + expect((await f.json('/reminders')).enabled).toBe(true); + let detail = await choose(list, guild); + const edit = await f.run(click(detail, 'Edit reminder'), guild); + list = await f.run(submit(edit.data.custom_id, { name: 'Birthday', times: '12:00, 14:00', days: '', date: '2026-09-30' }), guild); + web = await f.json('/reminders'); + expect(web.reminders).toEqual(['12:00', '14:00'].map(time => ({ type: 'date', name: 'Birthday', time, date: '2026-09-30' }))); + const quiet = await f.run(click(list, 'Quiet hours'), guild); + list = await f.run(submit(quiet.data.custom_id, { start: '23:00', end: '07:00' }), guild); + expect((await f.json('/reminders')).quietStart).toBe('23:00'); + detail = await choose(list, guild); + const confirmation = await f.run(click(detail, 'Remove reminder'), guild); + list = await f.run(click(confirmation, 'Confirm removal'), guild); + expect((await f.json('/reminders')).reminders).toEqual([]); + expect((await f.json('/reminders')).enabled).toBe(false); + expect(list.data.content).toContain('No reminders'); + const settings = await f.run({ data: { name: 'settings' } }, guild); + const tz = await f.run(click(settings, 'Change timezone'), guild); + await f.run(submit(tz.data.custom_id, { timezone: 'America/New_York' }), guild); + expect((await f.json('/me')).timezone).toBe('America/New_York'); + expect((await f.json('/today')).timezone).toBe('America/New_York'); + expect(f.changed.length).toBe(6); +}); + +test('bad input can reopen a prefilled form without consuming the valid control', async () => { + f = discordFixture(); + const list = await f.run({ data: { name: 'reminders' } }); + let form = await f.run(click(list, 'Add reminder')); + for (const invalid of [{ ...draft, times: '25:00' }, { ...draft, days: 'Funday' }, { ...draft, days: '', date: '2026-02-30' }, { ...draft, date: '2026-09-30' }]) { + const failed = await f.run(submit(form.data.custom_id, invalid)); + expect(failed.type).toBe(4); + form = await f.run(click(failed, 'Try again')); + expect(form.type).toBe(9); + expect(form.data.components[0].components[0].value).toBe(draft.name); + } + const saved = await f.run(submit(form.data.custom_id, draft)); + expect(saved.data.content).toContain(draft.name); + const enabled = await f.run(click(saved, 'Enable delivery')); + expect(enabled.data.content).toContain('not configured'); + expect((await f.json('/reminders')).enabled).toBe(false); + const settings = await f.run({ data: { name: 'settings' } }); + const timezone = await f.run(click(settings, 'Change timezone')); + const invalid = await f.run(submit(timezone.data.custom_id, { timezone: 'Invalid/Zone' })); + expect(invalid.data.content).toContain('Use a timezone'); + const retry = await f.run(click(invalid, 'Try again')); + await f.run(submit(retry.data.custom_id, { timezone: 'UTC' })); + expect((await f.json('/me')).timezone).toBe('UTC'); +}); + +test('website edits and timezone changes invalidate old controls even within one millisecond', async () => { + f = discordFixture(); + const list = await f.run({ data: { name: 'reminders' } }); + const form = await f.run(click(list, 'Add reminder')); + const before = await f.json('/reminders'); + const prefs = { enabled: false, reminders: before.reminders, quietStart: '22:00', quietEnd: '08:00' }; + await f.json('/reminders', 'PUT', prefs); + const blocked = await f.run(submit(form.data.custom_id, draft)); + expect(blocked.data.content).toContain('Settings changed'); + const newer = await f.run({ data: { name: 'reminders' } }); + const newerForm = await f.run(click(newer, 'Add reminder')); + await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' }); + await f.json('/reminders', 'PUT', prefs); + expect((await f.run(submit(newerForm.data.custom_id, draft))).data.content).toContain('Settings changed'); + const settings = await f.run({ data: { name: 'settings' } }); + const tz = await f.run(click(settings, 'Change timezone')); + await f.json('/me', 'PATCH', { timezone: 'UTC' }); + await f.json('/me', 'PATCH', { timezone: 'Europe/Belgrade' }); + expect((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))).data.content).toContain('Settings changed'); + expect((await f.json('/me')).timezone).toBe('Europe/Belgrade'); +}); + +test('old forms cannot cross accounts; new Discord accounts discover UTC setup', async () => { + f = discordFixture(); + const list = await f.run({ data: { name: 'reminders' } }); + const form = await f.run(click(list, 'Add reminder')); + const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob); + expect(blocked.data.flags).toBe(64); + expect(blocked.data.content).toContain('belongs to another user'); + expect((await f.json('/reminders', 'GET', undefined, 200, 'b')).reminders).toHaveLength(1); + const id = '555555555555555555'; + const settings = await f.run({ data: { name: 'settings' } }, false, id); + expect(settings.data.content).toContain('New accounts start in UTC'); + expect(f.db.select().from(users).where(eq(users.discordId, id)).get()?.timezone).toBe('UTC'); +}); + +test('paginated list, 20-slot limit, delivery status and quiet-hour validation', async () => { + f = discordFixture(true); + const reminders = Array.from({ length: 20 }, (_, i) => ({ type: 'weekly', name: `Reminder ${i + 1}`, time: '12:00', days: [1] })); + await f.json('/reminders', 'PUT', { enabled: false, reminders, quietStart: '22:00', quietEnd: '08:00' }); + f.db.insert(reminderDeliveries).values({ id: 'delivery', userId: 'alice', date: '2026-09-04', status: 'pending', updatedAt: 1 }).run(); + const list = await f.run({ data: { name: 'reminders' } }); + expect(list.data.content).toContain('Delivery unconfirmed'); + const next = await f.run(click(list, 'Next')); + expect(next.data.content).toContain('Reminder 20'); + const form = await f.run(click(next, 'Add reminder')); + const fail = await f.run(submit(form.data.custom_id, draft)); + expect(fail.data.content).toContain('up to 20'); + expect((await f.json('/reminders')).reminders).toHaveLength(20); + const quiet = await f.run(click(list, 'Quiet hours')); + const bad = await f.run(submit(quiet.data.custom_id, { start: '25:00', end: '00:00' })); + const retry = await f.run(click(bad, 'Try again')); + const done = await f.run(submit(retry.data.custom_id, { start: '00:00', end: '00:00' })); + expect(done.data.content).toContain('(off)'); + const expired = await f.run({ data: { name: 'settings' } }); + f.setDiscordTime('2026-09-04T12:16:00Z'); + expect((await f.run(click(expired, 'Change timezone'))).data.content).toContain('expired'); +}); diff --git a/src/discord/preferences.ts b/src/discord/preferences.ts new file mode 100644 index 0000000..cb9ae04 --- /dev/null +++ b/src/discord/preferences.ts @@ -0,0 +1,113 @@ +import { eq } from 'drizzle-orm'; +import { users } from '../db/schema'; +import { timezoneInput } from '../habits/contracts'; +import { ApiError } from '../habits/service'; +import { ReminderService } from '../reminders/service'; +import type { ReminderEntry } from '../reminders/contracts'; +import type { Context, ControlData, Reply } from './types'; +import { fields } from './types'; +import { button, message, modal, pages, row, select, text } from './ui'; + +const deliveryLabels: Record = { + sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.', + failed: 'Could not deliver. Check your DM permissions and that you share a server with the bot.', skipped: 'Skipped because check-ins or settings changed', +}; +const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const service = (c: Context) => new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now); +const user = (c: Context) => c.db.select().from(users).where(eq(users.id, c.service.user.id)).get()!; +const timezoneVersion = (c: Context) => { const u = user(c); return JSON.stringify([u.timezone, u.updatedAt]); }; +const snapshot = (c: Context, data: ControlData = {}): ControlData => ({ ...data, preferenceVersion: service(c).version(), timezoneVersion: timezoneVersion(c) }); +function current(c: Context, data: ControlData, timezoneOnly = false) { + if (data.timezoneVersion !== timezoneVersion(c) || (!timezoneOnly && data.preferenceVersion !== service(c).version())) + throw new ApiError(409, 'Settings changed since this control was opened. Run /settings or /reminders to refresh.'); +} +function groups(c: Context) { + const result: { name: string; entry: ReminderEntry; indexes: number[]; times: string[] }[] = []; + service(c).read().reminders.forEach((entry, index) => { + const key = (e: ReminderEntry) => JSON.stringify([e.name ?? '', e.type, e.type === 'date' ? e.date : [...e.days].sort()]); + let group = result.find(g => key(g.entry) === key(entry)); + if (!group) { group = { name: entry.name || 'Unnamed reminder', entry, indexes: [], times: [] }; result.push(group); } + group.indexes.push(index); group.times.push(entry.time); + }); + return result; +} +function group(c: Context, data: ControlData) { + const found = groups(c)[data.index ?? -1]; + if (!found) throw new ApiError(404, 'Reminder not found. Run /reminders to refresh.'); + return found; +} +const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.length === 7 ? 'Every day' : e.days.map(d => weekdays[d]).join(', '); +export function preferences(c: Context, command: string, data: ControlData = {}): Reply { + const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data); + const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`; + if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]); + const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10); + return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [ + ...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []), + ...pages(c, 'prefs.list', { page }, all.length), + row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)), + row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page })), + ]); +} +function edit(c: Context, data: ControlData): Reply { + const g = data.index === undefined ? undefined : group(c, data), draft = data.draft; + return modal(c, g ? 'Edit reminder' : 'Add reminder', 'prefs.save', data, [ + { id: 'name', label: 'Reminder name', value: draft?.name ?? g?.entry.name ?? '', max: 80 }, + { id: 'times', label: 'Times (24-hour, separated by commas)', value: draft?.times ?? g?.times.join(', ') ?? '', placeholder: '09:00, 18:30', max: 160 }, + { id: 'days', label: 'Weekdays (leave blank for a specific date)', value: draft?.days ?? (g?.entry.type === 'weekly' ? g.entry.days.map(d => weekdays[d]).join(', ') : ''), placeholder: 'Mon, Wed, Fri or every day', required: false, max: 100 }, + { id: 'date', label: 'Specific date (leave blank for weekdays)', value: draft?.date ?? (g?.entry.type === 'date' ? g.entry.date : ''), placeholder: '2026-09-30', required: false, max: 10 }, + ]); +} +function parseReminder(input: Record): ReminderEntry[] { + const name = input.name?.trim(), times = input.times?.split(',').map(t => t.trim()) ?? [], daysText = input.days?.trim() ?? '', date = input.date?.trim() ?? ''; + if (!name || name.length > 80) throw new ApiError(422, 'Enter a reminder name (up to 80 characters).'); + if (!times.length || times.some(t => !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(t)) || new Set(times).size !== times.length) + throw new ApiError(422, 'Use distinct 24-hour times separated by commas, for example 09:00, 18:30.'); + if (!!daysText === !!date) throw new ApiError(422, 'Enter either weekdays or a specific date, leaving the other field blank.'); + if (date) return times.map(time => ({ type: 'date', name, time, date })); + const days = /^(every day|daily)$/i.test(daysText) ? [0, 1, 2, 3, 4, 5, 6] : daysText.toLowerCase().split(/[\s,]+/).map(day => ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].findIndex(full => day === full || day === full.slice(0, 3))); + if (days.some(d => d < 0) || new Set(days).size !== days.length) throw new ApiError(422, 'Choose distinct weekdays such as Mon, Wed, Fri, or enter every day.'); + return times.map(time => ({ type: 'weekly', name, time, days })); +} +function save(c: Context, data: ControlData, changes: Record) { + const prefs = service(c).read(); + service(c).save({ enabled: prefs.enabled, reminders: prefs.reminders, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, ...changes }, data.preferenceVersion); + c.controls.consume(c.interaction.data!.custom_id!); c.changed(); + return preferences(c, 'reminders'); +} +export function preferencesAction(c: Context, action: string, data: ControlData): Reply | undefined { + if (!action.startsWith('prefs.')) return; + if (action === 'prefs.list') return preferences(c, 'reminders', data); + if (action === 'prefs.settings') return preferences(c, 'settings'); + current(c, data, action === 'prefs.timezone' || action === 'prefs.timezoneSave'); + if (action === 'prefs.timezone') return modal(c, 'Change timezone', 'prefs.timezoneSave', data, [{ id: 'timezone', label: 'Timezone (region/city)', value: data.draft?.timezone ?? user(c).timezone, placeholder: 'Europe/Belgrade, America/New_York, UTC', max: 100 }]); + if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); } + if (action === 'prefs.edit') return edit(c, data); + if (action === 'prefs.select') { + const selected = c.interaction.data?.values?.[0] ?? ''; + if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.'); + const state = { ...data, index: Number(selected) }, g = group(c, state); + return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]); + } + if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]); + try { + if (action === 'prefs.timezoneSave') { + const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() }); + if (!parsed.success) throw new ApiError(422, 'Use a timezone such as Europe/Belgrade, America/New_York, or UTC.'); + c.service.setTimezone(parsed.data.timezone); c.controls.consume(c.interaction.data!.custom_id!); c.changed(); + return preferences(c, 'settings'); + } + if (action === 'prefs.quietSave') { const input = fields(c.interaction); return save(c, data, { quietStart: input.start?.trim(), quietEnd: input.end?.trim() }); } + if (action === 'prefs.toggle') return save(c, data, { enabled: data.done }); + if (action === 'prefs.save' || action === 'prefs.removeSave') { + const removed = data.index === undefined ? [] : group(c, data).indexes; + const remaining = service(c).read().reminders.filter((_, i) => !removed.includes(i)); + const reminders = [...remaining, ...(action === 'prefs.save' ? parseReminder(fields(c.interaction)) : [])]; + return save(c, data, { reminders, ...(!reminders.length ? { enabled: false } : {}) }); + } + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 422) throw error; + const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action]; + return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false); + } +} diff --git a/src/discord/routes.ts b/src/discord/routes.ts index 2808440..9a81e4c 100644 --- a/src/discord/routes.ts +++ b/src/discord/routes.ts @@ -11,10 +11,11 @@ import { interactionSchema, type Context, type Reply } from './types'; import { dailyAction, today } from './daily'; import { manageHabits, manageTasks, managementAction } from './management'; import { history, historyAction } from './history'; +import { preferences, preferencesAction } from './preferences'; import { message } from './ui'; import { help } from './help'; -export type CommandConfig = { publicKey: string; applicationId: string; origin: string }; +export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean }; export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) { const app = new Hono(); const key = /^[a-f\d]{64}$/i.test(config.publicKey) ? createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(config.publicKey, 'hex')]), format: 'der', type: 'spki' }) : null; @@ -45,17 +46,18 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!); const controls = new ControlStore(db, userId, now()); controls.prune(); const service = new HabitService(db, user, now()); service.sync(); - const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, changed: () => changed(userId) }; + const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId) }; let result: Reply; try { if (i.type === 4) result = { type: 8, data: { choices: [] } }; else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx); else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(ctx); else if (i.type === 2 && i.data?.name === 'today') result = today(ctx); + else if (i.type === 2 && ['settings', 'reminders'].includes(i.data?.name ?? '')) result = preferences(ctx, i.data!.name!); else if (i.type === 2 && i.data?.name === 'history') result = history(ctx); else if ([3, 5].includes(i.type)) { const state = controls.read(i.data?.custom_id ?? ''); - result = historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); + result = preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); } else result = message(ctx, 'Run /help to see available commands.', [], false); } catch (error) { if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; diff --git a/src/discord/test-fixture.ts b/src/discord/test-fixture.ts index 252ab5d..d316edf 100644 --- a/src/discord/test-fixture.ts +++ b/src/discord/test-fixture.ts @@ -4,7 +4,7 @@ import { fixture } from '../habits/test-fixture'; import { users } from '../db/schema'; import { createDiscordRoutes } from './routes'; import type { Interaction } from './types'; -export function discordFixture() { +export function discordFixture(remindersAvailable = false) { const f = fixture(); const { publicKey, privateKey } = generateKeyPairSync('ed25519'); const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex'); @@ -13,7 +13,7 @@ export function discordFixture() { f.db.update(users).set({ discordId: alice }).where(eq(users.id, 'alice')).run(); f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run(); const changed: string[] = []; - let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id)); + let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id)); function payload(data: Partial, guild = false, user = alice): Interaction { const profile = { id: user, username: user === alice ? 'alice' : 'bob' }; return { id: String(++sequence), application_id: applicationId, type: 2, ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data }; @@ -23,7 +23,7 @@ export function discordFixture() { return app.request('http://localhost/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature-Timestamp': timestamp, 'X-Signature-Ed25519': sign(null, Buffer.from(timestamp + body), privateKey).toString('hex'), ...headers }, body }); } async function run(data: Partial, guild = false, user = alice): Promise { return (await request(payload(data, guild, user))).json(); } - return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } }; + return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } }; } export function control(reply: any, label: string): string { const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label); diff --git a/src/discord/types.ts b/src/discord/types.ts index ccf381f..663f3a4 100644 --- a/src/discord/types.ts +++ b/src/discord/types.ts @@ -13,8 +13,8 @@ export type Interaction = z.infer; export type DiscordComponent = { type: number; [key: string]: unknown }; export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] }; export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } }; -export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string }; -export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; changed: () => void }; +export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record }; +export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; remindersAvailable?: boolean; changed: () => void }; export function fields(i: Interaction) { const result: Record = {}; const walk = (parts: ComponentInput[]) => parts.forEach(c => { if (c.custom_id && c.value !== undefined) result[c.custom_id] = c.value; if (c.components) walk(c.components); }); diff --git a/src/habits/service.ts b/src/habits/service.ts index bc57187..ad7735d 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -325,7 +325,7 @@ export class HabitService { data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) }; } setTimezone(timezone: string) { - this.db.update(users).set({ timezone, updatedAt: this.now }).where(eq(users.id, this.user.id)).run(); + this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run(); return { ...this.user, timezone }; } } diff --git a/src/reminders/contracts.ts b/src/reminders/contracts.ts index c6606f6..1cc5b84 100644 --- a/src/reminders/contracts.ts +++ b/src/reminders/contracts.ts @@ -6,7 +6,8 @@ export const reminderEntry = z.discriminatedUnion("type", [ z.object({ type: z.literal("date"), name: z.string().trim().max(80).optional(), time: clock, date: z.iso.date() }).strict(), ]); export type ReminderEntry = z.infer; -export const reminderInput = z.object({ enabled: z.boolean(), time: clock.optional(), reminders: z.array(reminderEntry).min(1).max(20).optional(), quietStart: clock, quietEnd: clock }).strict() +export const reminderInput = z.object({ enabled: z.boolean(), time: clock.optional(), reminders: z.array(reminderEntry).max(20).optional(), quietStart: clock, quietEnd: clock }).strict() + .refine(value => !value.enabled || value.reminders === undefined || value.reminders.length > 0, "Add a reminder before enabling delivery") .refine(value => value.reminders !== undefined || value.time !== undefined, "Add a reminder time"); export function entries(prefs: { reminders?: ReminderEntry[] | null; time?: string }): ReminderEntry[] { return prefs.reminders ?? [{ type: "weekly", time: prefs.time ?? "20:00", days: allWeekdays }]; diff --git a/src/reminders/routes.ts b/src/reminders/routes.ts index 843fbce..be8b8e7 100644 --- a/src/reminders/routes.ts +++ b/src/reminders/routes.ts @@ -1,28 +1,15 @@ import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; -import { desc, eq } from "drizzle-orm"; import type { AppDatabase, AuthEnv, createAuth } from "../auth"; -import { reminderDeliveries, reminderSettings } from "../db/schema"; -import { defaultReminder, entries, reminderInput } from "./contracts"; +import { ReminderService } from "./service"; export function createReminderRoutes(db: AppDatabase, auth: ReturnType, available: boolean, now: () => number) { const app = new Hono(); app.use("*", auth.requireAuth); - app.get("/", c => { - const userId = c.get("user").id; - const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, userId)).get() ?? defaultReminder; - const last = db.select().from(reminderDeliveries).where(eq(reminderDeliveries.userId, userId)).orderBy(desc(reminderDeliveries.updatedAt)).get(); - return c.json({ enabled: prefs.enabled, time: prefs.time, reminders: entries(prefs), quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available, - lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null }); - }); + app.get("/", c => c.json(new ReminderService(db, c.get("user").id, available, now()).read())); app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 8192 }), async c => { - const parsed = reminderInput.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) return c.json({ error: "Choose valid reminder times, days or dates, and quiet hours (up to 20 reminders)." }, 422); - if (parsed.data.enabled && !available) return c.json({ error: "Discord reminders are not configured on this server." }, 422); - const row = { ...parsed.data, reminders: entries(parsed.data), updatedAt: now() }; - db.insert(reminderSettings).values({ userId: c.get("user").id, ...row }) - .onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run(); - return c.json(parsed.data); + const input = await c.req.json().catch(() => null); + return c.json(new ReminderService(db, c.get("user").id, available, now()).save(input)); }); return app; } diff --git a/src/reminders/service.ts b/src/reminders/service.ts new file mode 100644 index 0000000..ed3a1d0 --- /dev/null +++ b/src/reminders/service.ts @@ -0,0 +1,31 @@ +import { desc, eq } from 'drizzle-orm'; +import type { AppDatabase } from '../auth'; +import { reminderDeliveries, reminderSettings } from '../db/schema'; +import { ApiError } from '../habits/service'; +import { defaultReminder, entries, reminderInput } from './contracts'; + +/** Shared website/Discord persistence. The monotonic revision also catches A → B → A edits. */ +export class ReminderService { + constructor(readonly db: AppDatabase, readonly userId: string, readonly available: boolean, readonly now: number) {} + read() { + const prefs = this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get() ?? defaultReminder; + const last = this.db.select().from(reminderDeliveries).where(eq(reminderDeliveries.userId, this.userId)).orderBy(desc(reminderDeliveries.updatedAt)).get(); + return { enabled: prefs.enabled, time: prefs.time, reminders: entries(prefs), quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available: this.available, + lastDelivery: last ? { date: last.date, status: last.status === 'pending' ? 'uncertain' : last.status } : null }; + } + version() { + return JSON.stringify(this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get() ?? null); + } + save(input: unknown, expected?: string) { + return this.db.transaction(() => { + if (expected !== undefined && expected !== this.version()) throw new ApiError(409, 'Reminders changed since this form was opened. Run /reminders to refresh.'); + const parsed = reminderInput.safeParse(input); + if (!parsed.success) throw new ApiError(422, 'Choose valid reminder times, days or dates, and quiet hours (up to 20 reminders).'); + if (parsed.data.enabled && !this.available) throw new ApiError(422, 'Discord reminders are not configured on this server. You can save schedules while disabled.'); + const previous = this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get(); + const row = { ...parsed.data, reminders: entries(parsed.data), updatedAt: Math.max(this.now, (previous?.updatedAt ?? 0) + 1) }; + this.db.insert(reminderSettings).values({ userId: this.userId, ...row }).onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run(); + return parsed.data; + }); + } +}