feat: add opt-in daily Discord reminders with quiet hours
This commit is contained in:
29
README.md
29
README.md
@@ -3,6 +3,8 @@
|
||||
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
|
||||
client includes a homepage for signed-out visitors and a personal habit dashboard.
|
||||
|
||||
See [deployment operations](docs/OPERATIONS.md) for backup, recovery, and monitoring.
|
||||
|
||||
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
|
||||
combined calendars, historical corrections, daily resets, and optional count carryover.
|
||||
|
||||
@@ -345,3 +347,30 @@ existing delivery does not consume quota. Rejected attempts consume quota too;
|
||||
(comma-separated Discord IDs) restricts posting to community members you approve.
|
||||
An empty list permits all signed-in accounts. Restricted users can still preview
|
||||
and download PNGs. Quota rows expire and are pruned after 24 hours.
|
||||
|
||||
### Daily reminders
|
||||
|
||||
Open **Settings → Daily reminders** to opt into one private Discord DM when habits
|
||||
remain unfinished. Choose a local time and quiet hours; times follow the account
|
||||
timezone. Quiet-hour start is inclusive, end is exclusive, and equal times disable
|
||||
quiet hours. If the scheduled time falls in quiet hours, delivery waits until they
|
||||
end that same local day. A reminder missed for the whole day is not carried forward.
|
||||
|
||||
The production server checks once a minute. `DISCORD_BOT_TOKEN` enables delivery;
|
||||
a sharing channel is not required for reminders. Users must be reachable by the
|
||||
bot (normally a shared Discord server and enabled DMs). A message contains only
|
||||
the unfinished-habit count and app link. Settings show the most recent delivery
|
||||
result, including blocked DMs or unconfirmed delivery. Uncheck the option and save
|
||||
to opt out. No reminders are enabled by default.
|
||||
|
||||
`GET /api/reminders` returns settings and last delivery status. `PUT /api/reminders`
|
||||
requires authentication, matching Origin, and `{enabled, time, quietStart, quietEnd}`
|
||||
with `HH:MM` times. Per-user/date reservations persist across restarts and concurrent
|
||||
workers. Discord 429 retry deadlines are persisted; ambiguous message sends are
|
||||
not retried that day. Delivery records expire after 90 days. Restore disables
|
||||
reminders in the recovered database until users opt in again.
|
||||
|
||||
The disposable `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` mode also runs a
|
||||
reminder worker against a local transport stub. Set a reminder to `14:00` (its fixed
|
||||
Europe/Belgrade time), leave a habit unfinished, and inspect delivery status in
|
||||
settings or `/__preview/reminder`. No real Discord messages are sent in this mode.
|
||||
|
||||
22
docs/LAUNCH-VERIFICATION.md
Normal file
22
docs/LAUNCH-VERIFICATION.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Launch feature verification — September 4, 2026
|
||||
|
||||
| Feature | Implementation and verification |
|
||||
| --- | --- |
|
||||
| Historical check-ins | Calendar selection and date picker open dated count, checkbox, and task controls. Integration coverage changes a habit's method and corrects yesterday without changing today. Browser verified a saved historical count and refreshed calendar. |
|
||||
| Account settings | Timezone changes refresh the dashboard; JSON exports include owned records without session credentials. Typed-confirmation deletion removes dependent records and revokes sessions. API tests cover ownership, Origin checks, confirmation, foreign keys, and another account remaining intact. Browser verified timezone persistence. |
|
||||
| Discord sharing controls | SQLite reservations enforce one new post per user per minute and ten per channel per minute; duplicate checks remain safe. Tests cover cooldowns, channel quotas, allowlists, and rejected/uncertain delivery behavior. |
|
||||
| Backup and recovery | Production snapshots, pre-migration snapshot, retention, optional replica directory, and fresh-path recovery. Tests restore committed WAL progress, revoke sessions, disable recovered reminders, verify replicas and retention, reject unavailable storage and existing destinations, and execute both CLIs. |
|
||||
| Monitoring | Request IDs and sanitized structured error events; database/backup-aware health; independent monitor with sustained-failure and recovery transitions. Tests exercise a closed database and live HTTP probes; one-shot CLI passed against the disposable server. |
|
||||
| Archive and restore | Dashboard archive action, history view, and restore. Integration and browser checks confirm a restored habit returns to today's dashboard while earlier completion remains intact. |
|
||||
| Daily reminders | Opt-in settings, timezone/quiet hours, unfinished-only private messages, persistent daily reservations, rate-limit deferral, and delivery status. Tests cover concurrent workers, DST fall-back, opt-out during channel creation, uncertain sends, ownership, export, and deletion. Browser saved the selected time, delivered one message through the local stub, showed Delivered, and saved opt-out. |
|
||||
|
||||
Validation commands: `bun run typecheck`, `bun test`, `bun run test:smoke`, and
|
||||
`git diff --check`. Browser checks used the in-memory preview on port 3107 and
|
||||
verified desktop and narrow layouts (480px minimum layout viewport available in
|
||||
the in-app browser), with no page-level horizontal overflow in settings.
|
||||
|
||||
Live Discord messages, production account deletion, and deployment were not
|
||||
performed. Set the production origin, bot credentials/permissions, any sharing
|
||||
allowlist, durable backup volume/replica, and independent monitor/log collection
|
||||
when deploying. Off-machine storage and an external alert destination must be
|
||||
provided by the deployment environment; their provisioning is outside the app.
|
||||
@@ -71,3 +71,14 @@ failures, and emits one `health_recovered` event on recovery. It stays quiet whi
|
||||
state is unchanged. Run it under a process supervisor and route these JSON events
|
||||
to your deployment platform's alerting/log collection. No external alert provider
|
||||
or off-machine monitor is provisioned automatically by this repository.
|
||||
|
||||
## Reminder worker
|
||||
|
||||
The production process checks opt-in reminders every minute, with a lock preventing
|
||||
overlapping runs within the process and transactional delivery reservations across
|
||||
processes. A crash after reservation may leave a reminder unconfirmed; the worker
|
||||
chooses not to resend it that day. Known rate-limit rejections defer until Discord's
|
||||
retry deadline. Quiet hours and completion are checked again immediately before
|
||||
posting. Recovery disables saved reminder opt-ins to avoid replaying notifications
|
||||
from an old backup. Use the disposable preview and stubbed transport tests for QA;
|
||||
real Discord delivery depends on the bot token, shared server, and user DM permissions.
|
||||
|
||||
20
drizzle/0007_reminders.sql
Normal file
20
drizzle/0007_reminders.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE `reminder_deliveries` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`date` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`retry_at` integer,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `reminder_deliveries_user_date_idx` ON `reminder_deliveries` (`user_id`,`date`);--> statement-breakpoint
|
||||
CREATE TABLE `reminder_settings` (
|
||||
`user_id` text PRIMARY KEY NOT NULL,
|
||||
`enabled` integer DEFAULT false NOT NULL,
|
||||
`time` text DEFAULT '20:00' NOT NULL,
|
||||
`quiet_start` text DEFAULT '22:00' NOT NULL,
|
||||
`quiet_end` text DEFAULT '08:00' NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
4
drizzle/0008_reminder_cooldown.sql
Normal file
4
drizzle/0008_reminder_cooldown.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE `reminder_worker_state` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`blocked_until` integer NOT NULL
|
||||
);
|
||||
933
drizzle/meta/0007_snapshot.json
Normal file
933
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,933 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "1839b194-bee2-4f37-bf75-1235d2965a48",
|
||||
"prevId": "b5822e36-a1a9-42dd-9439-9490297bc097",
|
||||
"tables": {
|
||||
"combined_charts": {
|
||||
"name": "combined_charts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"habit_ids": {
|
||||
"name": "habit_ids",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"combined_charts_user_idx": {
|
||||
"name": "combined_charts_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"combined_charts_user_id_users_id_fk": {
|
||||
"name": "combined_charts_user_id_users_id_fk",
|
||||
"tableFrom": "combined_charts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"discord_deliveries": {
|
||||
"name": "discord_deliveries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_hash": {
|
||||
"name": "image_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message_url": {
|
||||
"name": "message_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"discord_deliveries_user_id_users_id_fk": {
|
||||
"name": "discord_deliveries_user_id_users_id_fk",
|
||||
"tableFrom": "discord_deliveries",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_calendar_settings": {
|
||||
"name": "habit_calendar_settings",
|
||||
"columns": {
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_calendar_settings",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_days": {
|
||||
"name": "habit_days",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revision_id": {
|
||||
"name": "revision_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ends_at": {
|
||||
"name": "ends_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"count_set": {
|
||||
"name": "count_set",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"done": {
|
||||
"name": "done",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habit_days_lookup_idx": {
|
||||
"name": "habit_days_lookup_idx",
|
||||
"columns": [
|
||||
"habit_id",
|
||||
"date",
|
||||
"revision_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habit_days_habit_id_habits_id_fk": {
|
||||
"name": "habit_days_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_days",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||
"tableFrom": "habit_days",
|
||||
"tableTo": "habit_revisions",
|
||||
"columnsFrom": [
|
||||
"revision_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_revisions": {
|
||||
"name": "habit_revisions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"effective_date": {
|
||||
"name": "effective_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habit_revisions_date_idx": {
|
||||
"name": "habit_revisions_date_idx",
|
||||
"columns": [
|
||||
"habit_id",
|
||||
"effective_date",
|
||||
"id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habit_revisions_habit_id_habits_id_fk": {
|
||||
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_revisions",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habits": {
|
||||
"name": "habits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_date": {
|
||||
"name": "created_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"materialized_through": {
|
||||
"name": "materialized_through",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habits_user_idx": {
|
||||
"name": "habits_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habits_user_id_users_id_fk": {
|
||||
"name": "habits_user_id_users_id_fk",
|
||||
"tableFrom": "habits",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"progress_events": {
|
||||
"name": "progress_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"day_id": {
|
||||
"name": "day_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurrence_id": {
|
||||
"name": "occurrence_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"before": {
|
||||
"name": "before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"after": {
|
||||
"name": "after",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"progress_events_day_idx": {
|
||||
"name": "progress_events_day_idx",
|
||||
"columns": [
|
||||
"day_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"progress_events_day_id_habit_days_id_fk": {
|
||||
"name": "progress_events_day_id_habit_days_id_fk",
|
||||
"tableFrom": "progress_events",
|
||||
"tableTo": "habit_days",
|
||||
"columnsFrom": [
|
||||
"day_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reminder_deliveries": {
|
||||
"name": "reminder_deliveries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retry_at": {
|
||||
"name": "retry_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"reminder_deliveries_user_date_idx": {
|
||||
"name": "reminder_deliveries_user_date_idx",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"reminder_deliveries_user_id_users_id_fk": {
|
||||
"name": "reminder_deliveries_user_id_users_id_fk",
|
||||
"tableFrom": "reminder_deliveries",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reminder_settings": {
|
||||
"name": "reminder_settings",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"time": {
|
||||
"name": "time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'20:00'"
|
||||
},
|
||||
"quiet_start": {
|
||||
"name": "quiet_start",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'22:00'"
|
||||
},
|
||||
"quiet_end": {
|
||||
"name": "quiet_end",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'08:00'"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reminder_settings_user_id_users_id_fk": {
|
||||
"name": "reminder_settings_user_id_users_id_fk",
|
||||
"tableFrom": "reminder_settings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sessions_user_id_idx": {
|
||||
"name": "sessions_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"sessions_expires_at_idx": {
|
||||
"name": "sessions_expires_at_idx",
|
||||
"columns": [
|
||||
"expires_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sharing_limits": {
|
||||
"name": "sharing_limits",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_occurrences": {
|
||||
"name": "task_occurrences",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"day_id": {
|
||||
"name": "day_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"done": {
|
||||
"name": "done",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"expired_at": {
|
||||
"name": "expired_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"closed_at": {
|
||||
"name": "closed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"task_occurrences_day_idx": {
|
||||
"name": "task_occurrences_day_idx",
|
||||
"columns": [
|
||||
"day_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"task_occurrences_day_id_habit_days_id_fk": {
|
||||
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||
"tableFrom": "task_occurrences",
|
||||
"tableTo": "habit_days",
|
||||
"columnsFrom": [
|
||||
"day_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"discord_id": {
|
||||
"name": "discord_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"global_name": {
|
||||
"name": "global_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'UTC'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_discord_id_unique": {
|
||||
"name": "users_discord_id_unique",
|
||||
"columns": [
|
||||
"discord_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
957
drizzle/meta/0008_snapshot.json
Normal file
957
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,957 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "1861c134-87ad-4e7e-a113-0f845763483a",
|
||||
"prevId": "1839b194-bee2-4f37-bf75-1235d2965a48",
|
||||
"tables": {
|
||||
"combined_charts": {
|
||||
"name": "combined_charts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"habit_ids": {
|
||||
"name": "habit_ids",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"combined_charts_user_idx": {
|
||||
"name": "combined_charts_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"combined_charts_user_id_users_id_fk": {
|
||||
"name": "combined_charts_user_id_users_id_fk",
|
||||
"tableFrom": "combined_charts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"discord_deliveries": {
|
||||
"name": "discord_deliveries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_hash": {
|
||||
"name": "image_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message_url": {
|
||||
"name": "message_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"discord_deliveries_user_id_users_id_fk": {
|
||||
"name": "discord_deliveries_user_id_users_id_fk",
|
||||
"tableFrom": "discord_deliveries",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_calendar_settings": {
|
||||
"name": "habit_calendar_settings",
|
||||
"columns": {
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_calendar_settings",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_days": {
|
||||
"name": "habit_days",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revision_id": {
|
||||
"name": "revision_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ends_at": {
|
||||
"name": "ends_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"count_set": {
|
||||
"name": "count_set",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"done": {
|
||||
"name": "done",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habit_days_lookup_idx": {
|
||||
"name": "habit_days_lookup_idx",
|
||||
"columns": [
|
||||
"habit_id",
|
||||
"date",
|
||||
"revision_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habit_days_habit_id_habits_id_fk": {
|
||||
"name": "habit_days_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_days",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||
"tableFrom": "habit_days",
|
||||
"tableTo": "habit_revisions",
|
||||
"columnsFrom": [
|
||||
"revision_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_revisions": {
|
||||
"name": "habit_revisions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"effective_date": {
|
||||
"name": "effective_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habit_revisions_date_idx": {
|
||||
"name": "habit_revisions_date_idx",
|
||||
"columns": [
|
||||
"habit_id",
|
||||
"effective_date",
|
||||
"id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habit_revisions_habit_id_habits_id_fk": {
|
||||
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_revisions",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habits": {
|
||||
"name": "habits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_date": {
|
||||
"name": "created_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"materialized_through": {
|
||||
"name": "materialized_through",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habits_user_idx": {
|
||||
"name": "habits_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habits_user_id_users_id_fk": {
|
||||
"name": "habits_user_id_users_id_fk",
|
||||
"tableFrom": "habits",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"progress_events": {
|
||||
"name": "progress_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"day_id": {
|
||||
"name": "day_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurrence_id": {
|
||||
"name": "occurrence_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"before": {
|
||||
"name": "before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"after": {
|
||||
"name": "after",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"progress_events_day_idx": {
|
||||
"name": "progress_events_day_idx",
|
||||
"columns": [
|
||||
"day_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"progress_events_day_id_habit_days_id_fk": {
|
||||
"name": "progress_events_day_id_habit_days_id_fk",
|
||||
"tableFrom": "progress_events",
|
||||
"tableTo": "habit_days",
|
||||
"columnsFrom": [
|
||||
"day_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reminder_deliveries": {
|
||||
"name": "reminder_deliveries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retry_at": {
|
||||
"name": "retry_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"reminder_deliveries_user_date_idx": {
|
||||
"name": "reminder_deliveries_user_date_idx",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"reminder_deliveries_user_id_users_id_fk": {
|
||||
"name": "reminder_deliveries_user_id_users_id_fk",
|
||||
"tableFrom": "reminder_deliveries",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reminder_settings": {
|
||||
"name": "reminder_settings",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"time": {
|
||||
"name": "time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'20:00'"
|
||||
},
|
||||
"quiet_start": {
|
||||
"name": "quiet_start",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'22:00'"
|
||||
},
|
||||
"quiet_end": {
|
||||
"name": "quiet_end",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'08:00'"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reminder_settings_user_id_users_id_fk": {
|
||||
"name": "reminder_settings_user_id_users_id_fk",
|
||||
"tableFrom": "reminder_settings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reminder_worker_state": {
|
||||
"name": "reminder_worker_state",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"blocked_until": {
|
||||
"name": "blocked_until",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sessions_user_id_idx": {
|
||||
"name": "sessions_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"sessions_expires_at_idx": {
|
||||
"name": "sessions_expires_at_idx",
|
||||
"columns": [
|
||||
"expires_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sharing_limits": {
|
||||
"name": "sharing_limits",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_occurrences": {
|
||||
"name": "task_occurrences",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"day_id": {
|
||||
"name": "day_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"done": {
|
||||
"name": "done",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"expired_at": {
|
||||
"name": "expired_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"closed_at": {
|
||||
"name": "closed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"task_occurrences_day_idx": {
|
||||
"name": "task_occurrences_day_idx",
|
||||
"columns": [
|
||||
"day_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"task_occurrences_day_id_habit_days_id_fk": {
|
||||
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||
"tableFrom": "task_occurrences",
|
||||
"tableTo": "habit_days",
|
||||
"columnsFrom": [
|
||||
"day_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"discord_id": {
|
||||
"name": "discord_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"global_name": {
|
||||
"name": "global_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'UTC'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_discord_id_unique": {
|
||||
"name": "users_discord_id_unique",
|
||||
"columns": [
|
||||
"discord_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,20 @@
|
||||
"when": 1788539042148,
|
||||
"tag": "0006_sharing_limits",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1788539729696,
|
||||
"tag": "0007_reminders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1788539875559,
|
||||
"tag": "0008_reminder_cooldown",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// Isolated browser QA server. Never opens or changes the user's database.
|
||||
import { sendDueReminders } from "../src/reminders/worker";
|
||||
import type { DiscordFetch } from "../src/sharing/routes";
|
||||
import { fixture } from "../src/habits/test-fixture";
|
||||
import { createApi } from "../src/api";
|
||||
import index from "../src/index.html";
|
||||
@@ -8,6 +10,16 @@ const origin = "http://127.0.0.1:3107";
|
||||
const previewCookie = "minabot_dashboard_preview_session";
|
||||
const sharingPreview = process.env.SHARE_PREVIEW === "1";
|
||||
let sharedImage: Blob | null = null;
|
||||
let reminderMessage: string | null = null;
|
||||
const previewDiscord: DiscordFetch = async (url, init) => {
|
||||
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "523456789012345678" });
|
||||
if (init?.method === "POST") {
|
||||
if (init.body instanceof FormData) sharedImage = init.body.get("files[0]") as Blob;
|
||||
else reminderMessage = JSON.parse(String(init.body)).content;
|
||||
return Response.json({ id: "423456789012345678" });
|
||||
}
|
||||
return Response.json({ type: 0, id: "223456789012345678", guild_id: "323456789012345678", name: "preview-only" });
|
||||
};
|
||||
if (sharingPreview) {
|
||||
f.setTime("2026-08-01T12:00:00Z");
|
||||
const reading = await f.json("/habits", "POST", { name: "A few pages, every day", method: "count", target: 10, unit: "pages", color: "#90647e" }, 201);
|
||||
@@ -29,19 +41,20 @@ const app = createApi(
|
||||
},
|
||||
undefined,
|
||||
() => Date.parse("2026-09-04T12:00:00Z"),
|
||||
sharingPreview ? async (_url, init) => {
|
||||
if (init?.method === "POST") {
|
||||
sharedImage = (init.body as FormData).get("files[0]") as Blob;
|
||||
return Response.json({ id: "423456789012345678" });
|
||||
}
|
||||
return Response.json({ type: 0, id: "223456789012345678", guild_id: "323456789012345678", name: "preview-only" });
|
||||
} : undefined,
|
||||
sharingPreview ? previewDiscord : undefined,
|
||||
sharingPreview ? { token: "preview-bot-token", channelId: "223456789012345678" } : undefined,
|
||||
);
|
||||
let reminderRunning = false;
|
||||
if (sharingPreview) setInterval(async () => {
|
||||
if (reminderRunning) return; reminderRunning = true;
|
||||
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
|
||||
finally { reminderRunning = false; }
|
||||
}, 1000).unref();
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 3107,
|
||||
routes: {
|
||||
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
||||
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
||||
"/__preview/login": () =>
|
||||
new Response(null, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReminderSettings } from "./components/ReminderSettings";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
@@ -662,3 +663,23 @@ describe("design system tabs", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("reminder form submits native time values and explicit opt-out", async () => {
|
||||
const saved: unknown[] = [];
|
||||
fetchMock.mockImplementation((async (_input, init) => {
|
||||
if (init?.method === "PUT") { saved.push(JSON.parse(String(init.body))); return Response.json({}); }
|
||||
return Response.json({ enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00", available: true, lastDelivery: null });
|
||||
}) as typeof fetch);
|
||||
await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />));
|
||||
const time = container.querySelector<HTMLInputElement>('input[name="time"]')!;
|
||||
await act(async () => {
|
||||
time.value = "14:30";
|
||||
container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
|
||||
});
|
||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect(saved[0]).toEqual({ enabled: true, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
|
||||
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
|
||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect(saved[1]).toEqual({ enabled: false, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
progressEvents: tx.select().from(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).all(),
|
||||
calendarSettings: tx.select().from(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).all(),
|
||||
charts: tx.select().from(schema.combinedCharts).where(eq(schema.combinedCharts.userId, user.id)).all(),
|
||||
reminderSettings: tx.select().from(schema.reminderSettings).where(eq(schema.reminderSettings.userId, user.id)).all(),
|
||||
reminderDeliveries: tx.select().from(schema.reminderDeliveries).where(eq(schema.reminderDeliveries.userId, user.id)).all(),
|
||||
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createReminderRoutes } from "./reminders/routes";
|
||||
import { errorDetails } from "./ops/monitor";
|
||||
import { createAccountRoutes } from "./account/routes";
|
||||
import { createHabitRoutes } from "./habits/routes";
|
||||
@@ -25,6 +26,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
app.route("/api/auth", auth.routes);
|
||||
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
||||
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
|
||||
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReminderSettings } from "./ReminderSettings";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
@@ -29,6 +30,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
||||
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
|
||||
<Button type="submit" disabled={busy}>Save timezone</Button>
|
||||
</form>
|
||||
<ReminderSettings timezone={user.timezone} />
|
||||
<div className="ds-actions">
|
||||
<ButtonLink href="/api/account/export" download="minabot-export.json">Export my data</ButtonLink>
|
||||
<Button variant="text" disabled={busy} onClick={() => setDeleting(!deleting)} aria-expanded={deleting}>Delete account</Button>
|
||||
|
||||
56
src/components/ReminderSettings.tsx
Normal file
56
src/components/ReminderSettings.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
import { defaultReminder, type ReminderResponse } from "../reminders/contracts";
|
||||
import { Button, Checkbox } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
|
||||
const deliveryLabels: Record<string, string> = {
|
||||
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.",
|
||||
failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed",
|
||||
};
|
||||
export function ReminderSettings({ timezone }: { timezone: string }) {
|
||||
const [settings, setSettings] = useState<ReminderResponse>();
|
||||
const [draft, setDraft] = useState(defaultReminder);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const saving = useRef(false);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); setError("");
|
||||
habitRequest<ReminderResponse>("/reminders", { signal: controller.signal })
|
||||
.then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time, quietStart: data.quietStart, quietEnd: data.quietEnd }); } })
|
||||
.catch(error => { if (!controller.signal.aborted) setError(error.message); });
|
||||
return () => controller.abort();
|
||||
}, [attempt]);
|
||||
return <div className="ds-inline-item-form" aria-label="Daily reminders">
|
||||
<h3 className="type-ui-heading">Daily reminders</h3>
|
||||
<p>One private Discord message when you still have habits left today. Times follow {timezone}. No habit names are sent.</p>
|
||||
{!settings ? <Button onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <form onSubmit={async event => {
|
||||
event.preventDefault(); if (saving.current) return;
|
||||
const form = event.currentTarget;
|
||||
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
|
||||
saving.current = true; setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
|
||||
setDraft(submitted);
|
||||
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
|
||||
finally { saving.current = false; setBusy(false); }
|
||||
}}>
|
||||
{!settings.available && <p>Discord reminders are not configured on this server.</p>}
|
||||
<fieldset className="ds-inline-item-fields" disabled={busy}>
|
||||
<Checkbox label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
|
||||
<div className="ds-form-grid">
|
||||
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
|
||||
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
|
||||
</div>
|
||||
<p className="type-small">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
|
||||
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save reminders"}</Button>
|
||||
</fieldset>
|
||||
{settings.lastDelivery && <p className="type-small">{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</p>}
|
||||
</form>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<p role="status">{notice}</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -66,3 +66,21 @@ export const sharingLimits = sqliteTable('sharing_limits', {
|
||||
startedAt: integer('started_at').notNull(),
|
||||
count: integer('count').notNull(),
|
||||
});
|
||||
|
||||
export const reminderSettings = sqliteTable('reminder_settings', {
|
||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
time: text('time').notNull().default('20:00'),
|
||||
quietStart: text('quiet_start').notNull().default('22:00'),
|
||||
quietEnd: text('quiet_end').notNull().default('08:00'),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
export const reminderDeliveries = sqliteTable('reminder_deliveries', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
date: text('date').notNull(), status: text('status').notNull(),
|
||||
retryAt: integer('retry_at'), updatedAt: integer('updated_at').notNull(),
|
||||
}, t => [uniqueIndex('reminder_deliveries_user_date_idx').on(t.userId, t.date)]);
|
||||
export const reminderWorkerState = sqliteTable('reminder_worker_state', {
|
||||
id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { startReminders } from "./reminders/worker";
|
||||
import { backupConfig, startBackups } from "./ops/backups";
|
||||
import { databasePath } from "./db/config";
|
||||
import { db, sqlite } from "./db";
|
||||
@@ -9,6 +10,9 @@ import index from "./index.html";
|
||||
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
|
||||
|
||||
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin }) : undefined;
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
|
||||
|
||||
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
|
||||
|
||||
const server = Bun.serve({
|
||||
|
||||
@@ -15,6 +15,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
|
||||
mkdirSync(config.replica!);
|
||||
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "cups" }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { count: 6 });
|
||||
f.sqlite.exec("INSERT INTO reminder_settings (user_id, enabled, time, quiet_start, quiet_end, updated_at) VALUES ('alice', 1, '20:00', '22:00', '08:00', 0)");
|
||||
const first = createBackup(f.sqlite, config);
|
||||
expect(statSync(first).mode & 0o777).toBe(0o600);
|
||||
expect(latestBackupTime(config)).toBeGreaterThan(0);
|
||||
@@ -24,6 +25,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
|
||||
try {
|
||||
expect(restored.query("SELECT count FROM habit_days").get()).toEqual({ count: 6 });
|
||||
expect(restored.query("SELECT * FROM sessions").all()).toEqual([]);
|
||||
expect(restored.query("SELECT enabled FROM reminder_settings").get()).toEqual({ enabled: 0 });
|
||||
expect(restored.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
|
||||
} finally { restored.close(); }
|
||||
expect(() => restoreBackup(first, destination)).toThrow("new path");
|
||||
@@ -48,3 +50,24 @@ test("invalid backups and missing replica mounts fail without overwriting data",
|
||||
expect(latestBackupTime(config)).toBe(0);
|
||||
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("backup and restore CLIs work against a live database without overwriting it", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-cli-"));
|
||||
const path = join(directory, "live.sqlite");
|
||||
const f = fixture(path);
|
||||
try {
|
||||
f.sqlite.exec("PRAGMA journal_mode=WAL");
|
||||
await f.json('/habits', 'POST', { name: 'CLI recovery', method: 'manual' }, 201);
|
||||
const backup = Bun.spawnSync([process.execPath, 'scripts/backup.ts'], {
|
||||
env: { ...process.env, DATABASE_PATH: path, BACKUP_DIR: 'backups', BACKUP_REPLICA_DIR: '', BACKUP_RETAIN: '7', BACKUP_INTERVAL_HOURS: '24' },
|
||||
});
|
||||
expect(backup.exitCode).toBe(0);
|
||||
const snapshot = backup.stdout.toString().trim();
|
||||
const restored = join(directory, 'recovered.sqlite');
|
||||
const result = Bun.spawnSync([process.execPath, 'scripts/restore.ts', snapshot, restored]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
const check = new Database(restored, { readonly: true });
|
||||
try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
|
||||
expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
|
||||
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -72,7 +72,10 @@ export function restoreBackup(source: string, destination: string) {
|
||||
try {
|
||||
copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600);
|
||||
const restored = new Database(temporary);
|
||||
try { restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;"); } finally { restored.close(); }
|
||||
try {
|
||||
restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;");
|
||||
if (restored.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()) restored.exec("UPDATE reminder_settings SET enabled=0");
|
||||
} finally { restored.close(); }
|
||||
verifyBackup(temporary); durable(temporary);
|
||||
// Link is exclusive: a concurrently-created destination is never overwritten.
|
||||
linkSync(temporary, destination);
|
||||
|
||||
10
src/reminders/contracts.ts
Normal file
10
src/reminders/contracts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
const clock = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM time");
|
||||
export const reminderInput = z.object({ enabled: z.boolean(), time: clock, quietStart: clock, quietEnd: clock }).strict();
|
||||
export const defaultReminder = { enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00" };
|
||||
export type ReminderPreferences = z.infer<typeof reminderInput>;
|
||||
export type ReminderResponse = ReminderPreferences & { available: boolean; lastDelivery: { date: string; status: string } | null };
|
||||
export function inQuietHours(time: string, start: string, end: string) {
|
||||
if (start === end) return false;
|
||||
return start < end ? time >= start && time < end : time >= start || time < end;
|
||||
}
|
||||
28
src/reminders/routes.ts
Normal file
28
src/reminders/routes.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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, reminderInput } from "./contracts";
|
||||
|
||||
export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, available: boolean, now: () => number) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
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, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available,
|
||||
lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null });
|
||||
});
|
||||
app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 2048 }), async c => {
|
||||
const parsed = reminderInput.safeParse(await c.req.json().catch(() => null));
|
||||
if (!parsed.success) return c.json({ error: "Choose valid reminder and quiet-hour times." }, 422);
|
||||
if (parsed.data.enabled && !available) return c.json({ error: "Discord reminders are not configured on this server." }, 422);
|
||||
const row = { ...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);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
100
src/reminders/worker.test.ts
Normal file
100
src/reminders/worker.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { fixture } from "../habits/test-fixture";
|
||||
import { reminderDeliveries, reminderSettings } from "../db/schema";
|
||||
import { sendDueReminders } from "./worker";
|
||||
import { createApi } from "../api";
|
||||
import { defaultReminder, inQuietHours } from "./contracts";
|
||||
import type { DiscordFetch } from "../sharing/routes";
|
||||
const config = { token: "test-only-bot", origin: "https://minabot.example" };
|
||||
async function setup() {
|
||||
const f = fixture();
|
||||
const habit = await f.json("/habits", "POST", { name: "Private habit name", method: "manual" }, 201);
|
||||
f.db.insert(reminderSettings).values({ userId: "alice", ...defaultReminder, enabled: true, updatedAt: 0 }).run();
|
||||
return { f, habit };
|
||||
}
|
||||
function transport(sent: string[]): DiscordFetch {
|
||||
return async (url, init) => {
|
||||
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
|
||||
sent.push(String(init?.body)); return Response.json({ id: "323456789012345678" });
|
||||
};
|
||||
}
|
||||
|
||||
test("reminders follow local time, send only unfinished habits, and deduplicate concurrent workers", async () => {
|
||||
const { f, habit } = await setup(); const sent: string[] = [];
|
||||
try {
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T17:59:00Z")); expect(sent).toHaveLength(0);
|
||||
const now = () => Date.parse("2026-09-04T18:00:00Z");
|
||||
await Promise.all([sendDueReminders(f.db, config, transport(sent), now), sendDueReminders(f.db, config, transport(sent), now)]);
|
||||
expect(sent).toHaveLength(1); expect(sent[0]).not.toContain("Private habit name");
|
||||
expect(JSON.parse(sent[0]!).allowed_mentions.parse).toEqual([]);
|
||||
await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1);
|
||||
f.setTime("2026-09-05T18:00:00Z");
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-05/progress`, "PUT", { done: true });
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(1);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("quiet hours defer until their end and DST overlap sends once", async () => {
|
||||
const { f } = await setup(); const sent: string[] = [];
|
||||
try {
|
||||
expect(inQuietHours("23:00", "22:00", "08:00")).toBe(true);
|
||||
expect(inQuietHours("07:59", "22:00", "08:00")).toBe(true);
|
||||
expect(inQuietHours("08:00", "22:00", "08:00")).toBe(false);
|
||||
f.db.update(reminderSettings).set({ quietStart: "19:00", quietEnd: "21:00" }).run();
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z")); expect(sent).toHaveLength(0);
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T19:00:00Z")); expect(sent).toHaveLength(1);
|
||||
f.db.update(reminderSettings).set({ time: "02:30", quietStart: "00:00", quietEnd: "00:00" }).run();
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T00:30:00Z"));
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T01:30:00Z")); expect(sent).toHaveLength(2);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("opt-out during channel creation cancels the message", async () => {
|
||||
const { f } = await setup(); let posts = 0;
|
||||
try {
|
||||
await sendDueReminders(f.db, config, async url => {
|
||||
if (!url.endsWith("/users/@me/channels")) posts++;
|
||||
f.db.update(reminderSettings).set({ enabled: false }).run();
|
||||
return Response.json({ id: "223456789012345678" });
|
||||
}, () => Date.parse("2026-09-04T18:00:00Z"));
|
||||
expect(posts).toBe(0);
|
||||
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("skipped");
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("rate limits persist their retry deadline; ambiguous sends are not retried", async () => {
|
||||
const { f } = await setup(); let attempts = 0;
|
||||
let timestamp = Date.parse("2026-09-04T18:00:00Z");
|
||||
try {
|
||||
const request: DiscordFetch = async url => {
|
||||
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
|
||||
attempts++;
|
||||
if (attempts === 1) return Response.json({ retry_after: 600 }, { status: 429 });
|
||||
throw new Error("Ambiguous connection failure");
|
||||
};
|
||||
await sendDueReminders(f.db, config, request, () => timestamp);
|
||||
timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(1);
|
||||
timestamp += 540000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
|
||||
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("uncertain");
|
||||
timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("reminder preferences require authentication, origin, valid clocks, and configured delivery", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
expect((await f.request("/reminders", "PUT", { ...defaultReminder, enabled: true })).status).toBe(422);
|
||||
expect((await f.request("/reminders", "PUT", defaultReminder, "a", { Origin: "https://evil.test" })).status).toBe(403);
|
||||
expect((await f.request("/reminders", "GET", undefined, "x")).status).toBe(401);
|
||||
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, { token: "test", channelId: "" });
|
||||
const call = (body: unknown) => app.request(`${f.origin}/api/reminders`, { method: "PUT", headers: { Cookie: `minabot_session=${"a".repeat(43)}`, Origin: f.origin, "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
expect((await call({ ...defaultReminder, time: "25:00" })).status).toBe(422);
|
||||
expect((await call({ ...defaultReminder, enabled: true })).status).toBe(200);
|
||||
expect(f.db.select().from(reminderSettings).where(eq(reminderSettings.userId, "alice")).get()?.enabled).toBe(true);
|
||||
expect((await f.json("/reminders", "GET", undefined, 200, "b")).enabled).toBe(false);
|
||||
const exported = await f.json("/account/export"); expect(exported.reminderSettings[0].enabled).toBe(true);
|
||||
await f.request("/account", "DELETE", { confirmation: "DELETE" });
|
||||
expect(f.db.select().from(reminderSettings).all()).toEqual([]);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
86
src/reminders/worker.ts
Normal file
86
src/reminders/worker.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../auth";
|
||||
import { reminderDeliveries, reminderSettings, reminderWorkerState, users } from "../db/schema";
|
||||
import { HabitService } from "../habits/service";
|
||||
import { localDate } from "../habits/calendar";
|
||||
import { inQuietHours } from "./contracts";
|
||||
import type { DiscordFetch } from "../sharing/routes";
|
||||
|
||||
export type ReminderConfig = { token: string; origin: string };
|
||||
function localClock(timestamp: number, timezone: string) {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(timestamp);
|
||||
return `${parts.find(part => part.type === "hour")!.value}:${parts.find(part => part.type === "minute")!.value}`;
|
||||
}
|
||||
export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, request: DiscordFetch = fetch, now: () => number = Date.now) {
|
||||
if (!config.token) return;
|
||||
const cooldown = db.select().from(reminderWorkerState).where(eq(reminderWorkerState.id, "discord")).get();
|
||||
if (cooldown && cooldown.blockedUntil > now()) return;
|
||||
const headers = { Authorization: `Bot ${config.token}`, "Content-Type": "application/json" };
|
||||
const rows = db.select({ prefs: reminderSettings, user: users }).from(reminderSettings).innerJoin(users, eq(users.id, reminderSettings.userId)).where(eq(reminderSettings.enabled, true)).all();
|
||||
// Retain recent delivery history and deduplication records without unbounded growth.
|
||||
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
|
||||
for (const { user } of rows) {
|
||||
function due() {
|
||||
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
|
||||
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
|
||||
if (!currentUser || !prefs?.enabled) return null;
|
||||
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
|
||||
if (clock < prefs.time || inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
|
||||
const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp);
|
||||
service.sync();
|
||||
// Avoid sending an artificial "today" after a backwards timezone change.
|
||||
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
|
||||
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
|
||||
return remaining ? { date: service.today, remaining } : null;
|
||||
}
|
||||
const initial = due(); if (!initial) continue;
|
||||
const id = db.transaction(tx => {
|
||||
const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).get();
|
||||
if (previous && (previous.status !== "deferred" || (previous.retryAt ?? 0) > now())) return null;
|
||||
const id = previous?.id ?? crypto.randomUUID();
|
||||
if (previous) tx.update(reminderDeliveries).set({ status: "pending", retryAt: null, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
|
||||
else tx.insert(reminderDeliveries).values({ id, userId: user.id, date: initial.date, status: "pending", updatedAt: now() }).run();
|
||||
return id;
|
||||
}, { behavior: "immediate" });
|
||||
if (!id) continue;
|
||||
const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
|
||||
const defer = async (response?: Response) => {
|
||||
const body = response ? await response.json().catch(() => null) as { retry_after?: number } | null : null;
|
||||
const seconds = Math.min(86400, Math.max(60, Number(body?.retry_after) || 60));
|
||||
update("deferred", now() + seconds * 1000);
|
||||
if (response?.status === 429) db.insert(reminderWorkerState).values({ id: "discord", blockedUntil: now() + seconds * 1000 })
|
||||
.onConflictDoUpdate({ target: reminderWorkerState.id, set: { blockedUntil: now() + seconds * 1000 } }).run();
|
||||
};
|
||||
let channel: Response;
|
||||
try { channel = await request("https://discord.com/api/v10/users/@me/channels", {
|
||||
method: "POST", headers, body: JSON.stringify({ recipient_id: user.discordId }), redirect: "error", signal: AbortSignal.timeout(10000),
|
||||
}); } catch { await defer(); continue; }
|
||||
if (channel.status === 429) { await defer(channel); return; }
|
||||
if (channel.status >= 500) { await defer(channel); continue; }
|
||||
const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null;
|
||||
if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
|
||||
// A user may opt out or finish a habit while Discord opens the DM channel.
|
||||
const latest = due();
|
||||
if (!latest || latest.date !== initial.date) { update("skipped"); continue; }
|
||||
let response: Response;
|
||||
try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
|
||||
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000),
|
||||
body: JSON.stringify({ content: `A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
|
||||
allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(id).digest("hex").slice(0, 24), enforce_nonce: true }),
|
||||
}); } catch { update("uncertain"); continue; }
|
||||
if (response.status === 429) { await defer(response); return; }
|
||||
update(response.ok ? "sent" : response.status >= 500 ? "uncertain" : "failed");
|
||||
}
|
||||
}
|
||||
export function startReminders(db: AppDatabase, config: ReminderConfig) {
|
||||
let running = false;
|
||||
const tick = async () => {
|
||||
if (running) return; running = true;
|
||||
try { await sendDueReminders(db, config); }
|
||||
catch { console.error(JSON.stringify({ event: "reminder_worker_failed", timestamp: new Date().toISOString() })); }
|
||||
finally { running = false; }
|
||||
};
|
||||
const timer = setInterval(() => void tick(), 60000); timer.unref();
|
||||
return { stop: () => clearInterval(timer) };
|
||||
}
|
||||
Reference in New Issue
Block a user