Compare commits
11 Commits
ecc2620a4c
...
892487eb4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892487eb4c | ||
|
|
392deb9cbe | ||
|
|
865bdd1328 | ||
|
|
1208e93953 | ||
|
|
16d92f171c | ||
|
|
19a9aa6862 | ||
|
|
9c8a96ba03 | ||
|
|
661bc6c346 | ||
|
|
32002151e0 | ||
|
|
d624ee6c3c | ||
|
|
e88ffceb22 |
11
.env.example
11
.env.example
@@ -14,3 +14,14 @@ AUTH_COOKIE_SECRET=
|
|||||||
# View Channel, Send Messages, and Attach Files in this channel.
|
# View Channel, Send Messages, and Attach Files in this channel.
|
||||||
DISCORD_BOT_TOKEN=
|
DISCORD_BOT_TOKEN=
|
||||||
DISCORD_SHARING_CHANNEL_ID=
|
DISCORD_SHARING_CHANNEL_ID=
|
||||||
|
# Optional comma-separated Discord user IDs permitted to post. Empty permits all accounts.
|
||||||
|
DISCORD_SHARING_ALLOWED_USER_IDS=
|
||||||
|
# Production snapshots, plus a snapshot before migrations. Paths resolve beside DATABASE_PATH.
|
||||||
|
BACKUP_ENABLED=true
|
||||||
|
BACKUP_DIR=backups
|
||||||
|
BACKUP_INTERVAL_HOURS=24
|
||||||
|
BACKUP_RETAIN=7
|
||||||
|
# Existing mounted off-machine backup directory (absolute path recommended).
|
||||||
|
BACKUP_REPLICA_DIR=
|
||||||
|
# Optional public HTTPS health URL for the independent monitor.
|
||||||
|
MONITOR_URL=
|
||||||
|
|||||||
76
README.md
76
README.md
@@ -3,6 +3,8 @@
|
|||||||
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
|
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.
|
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,
|
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
|
||||||
combined calendars, historical corrections, daily resets, and optional count carryover.
|
combined calendars, historical corrections, daily resets, and optional count carryover.
|
||||||
|
|
||||||
@@ -58,18 +60,26 @@ right. It stacks on narrow screens and shows an initial if the avatar is unavail
|
|||||||
|
|
||||||
Signed-in users can create manual, count, or task habits, choose a recurrence and
|
Signed-in users can create manual, count, or task habits, choose a recurrence and
|
||||||
color, log today’s progress, and inspect their real calendars. Completed-versus-due
|
color, log today’s progress, and inspect their real calendars. Completed-versus-due
|
||||||
counts exclude days off. Inline controls beside habit headings edit the name,
|
counts exclude days off. Controls beside habit headings open a modal to edit the name,
|
||||||
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
|
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
|
||||||
Each task can be renamed, rescheduled, or deleted in place; Add task also supports
|
Each task can be renamed, rescheduled, or deleted in a modal; Add task also supports
|
||||||
its own recurrence. Both habit and task schedules support daily, selected weekdays,
|
its own recurrence. Both habit and task schedules support daily, selected weekdays,
|
||||||
day intervals, and week intervals with an editable start date and weekday.
|
day intervals, and week intervals with an editable start date and weekday.
|
||||||
Delete asks for confirmation in place and preserves earlier history. Tasks
|
Archive asks for confirmation in a modal and preserves earlier history.
|
||||||
|
**Archived habits** opens the archive modal, with history inspection and restoration
|
||||||
|
starting today. Task deletion uses the same modal confirmation. Tasks
|
||||||
remain manageable on days off, while their checkboxes stay disabled.
|
remain manageable on days off, while their checkboxes stay disabled.
|
||||||
|
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
|
||||||
|
Dialogs keep the background inactive, contain keyboard focus, support Escape and
|
||||||
|
backdrop dismissal, and return focus to their opener. Long content scrolls beneath
|
||||||
|
a fixed header; closing is disabled during saves.
|
||||||
Mutations are saved through the authenticated API; failed
|
Mutations are saved through the authenticated API; failed
|
||||||
saves retain the recorded values and allow retry. Account and calendar failures
|
saves retain the recorded values and allow retry. Account and calendar failures
|
||||||
have retry states. The dashboard refreshes on window focus and every minute while
|
have retry states. The dashboard refreshes on window focus and every minute while
|
||||||
visible, using the server’s date and saved account timezone. Historical editing
|
visible, using the server’s date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts,
|
||||||
and combined-chart management are not exposed on this homepage.
|
checkboxes, or task occurrences using that date’s saved requirements. A date picker
|
||||||
|
also reaches history outside the displayed year. Future dates and days off are read-only.
|
||||||
|
Combined-chart management is not exposed on this homepage.
|
||||||
|
|
||||||
For browser QA without changing application data, run
|
For browser QA without changing application data, run
|
||||||
`bun scripts/dashboard-preview.ts` and visit
|
`bun scripts/dashboard-preview.ts` and visit
|
||||||
@@ -137,8 +147,11 @@ bun run db:migrate
|
|||||||
```
|
```
|
||||||
|
|
||||||
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
|
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
|
||||||
`bun start` apply pending migrations before starting the server. The initial
|
`bun start` apply pending migrations before starting the server. Development also
|
||||||
migration creates the users and sessions tables.
|
checks for pending migrations when the server entrypoint is hot-reloaded, so new
|
||||||
|
routes do not run against an older schema. After adding only migration files,
|
||||||
|
run `bun run db:migrate` or restart `bun dev`. The initial migration creates the
|
||||||
|
users and sessions tables.
|
||||||
|
|
||||||
Import `db` from `src/db/index.ts` in backend code to query the database.
|
Import `db` from `src/db/index.ts` in backend code to query the database.
|
||||||
`GET /api/health` checks the database connection before returning success.
|
`GET /api/health` checks the database connection before returning success.
|
||||||
@@ -215,8 +228,9 @@ production habit data separate from development data.
|
|||||||
server-side; Discord access/refresh tokens are never persisted or sent to React.
|
server-side; Discord access/refresh tokens are never persisted or sent to React.
|
||||||
- React detects the browser timezone during initial registration. The server
|
- React detects the browser timezone during initial registration. The server
|
||||||
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
||||||
update the Discord profile without changing the saved timezone. A timezone
|
update the Discord profile without changing the saved timezone. **Settings** lets users change their timezone, download a complete JSON export,
|
||||||
settings UI is deferred; `PATCH /api/me` updates the timezone. Habit timestamps
|
and permanently delete their account with typed confirmation. `PATCH /api/me`
|
||||||
|
updates the timezone. Habit timestamps
|
||||||
remain UTC and daily boundaries follow the saved timezone with preserved historical
|
remain UTC and daily boundaries follow the saved timezone with preserved historical
|
||||||
deadlines. See the API reference for travel and DST behavior.
|
deadlines. See the API reference for travel and DST behavior.
|
||||||
|
|
||||||
@@ -323,3 +337,47 @@ Habit tests cover every route, all PRD acceptance behaviors, ownership and Origi
|
|||||||
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
|
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
|
||||||
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
|
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
|
||||||
separate the database foundation, REST implementation, carryover, and verification.
|
separate the database foundation, REST implementation, carryover, and verification.
|
||||||
|
|
||||||
|
### Account data
|
||||||
|
|
||||||
|
`GET /api/account/export` downloads versioned JSON containing all owned profile,
|
||||||
|
habit, revision, dated progress, task, audit, calendar, chart, and delivery records.
|
||||||
|
It excludes session tokens and credentials. `DELETE /api/account` requires matching
|
||||||
|
Origin and `{ "confirmation": "DELETE" }`; it atomically removes owned records and
|
||||||
|
revokes every session. Discord posts and retained operator backups are not erased
|
||||||
|
by this action.
|
||||||
|
|
||||||
|
Discord sharing reserves persistent quotas before posting: one new delivery per
|
||||||
|
Discord user per 60 seconds, and ten per channel per 60 seconds. Rechecking an
|
||||||
|
existing delivery does not consume quota. Rejected attempts consume quota too;
|
||||||
|
429 responses include `Retry-After`. Optional `DISCORD_SHARING_ALLOWED_USER_IDS`
|
||||||
|
(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.
|
||||||
84
docs/OPERATIONS.md
Normal file
84
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Deployment operations
|
||||||
|
|
||||||
|
## Backups and recovery
|
||||||
|
|
||||||
|
Production creates a verified SQLite snapshot before migrations and automatically
|
||||||
|
when the latest snapshot is older than `BACKUP_INTERVAL_HOURS` (24 by default).
|
||||||
|
The server checks once a minute. Snapshot files are private (0600), created with
|
||||||
|
SQLite `VACUUM INTO`, and checked for integrity and foreign-key errors before being
|
||||||
|
published. Seven snapshots are retained by default; `BACKUP_RETAIN` accepts 2–365.
|
||||||
|
Pre-migration snapshots are mandatory; a failure prevents migration. Set
|
||||||
|
`BACKUP_ENABLED=false` only to disable the periodic runner if an external backup
|
||||||
|
system already handles it. Manual `bun run db:backup` always takes a snapshot.
|
||||||
|
|
||||||
|
`BACKUP_DIR` defaults to `backups` beside the database. Relative backup paths are
|
||||||
|
resolved relative to the database directory, consistently in CLI and production.
|
||||||
|
Use a durable volume for both the database and backups. Configure
|
||||||
|
`BACKUP_REPLICA_DIR` to an existing mounted directory on a separate machine/storage
|
||||||
|
service to keep an off-machine copy. The app does not provision or authenticate
|
||||||
|
that storage. If the directory is unavailable, backup reports failure and does
|
||||||
|
not prune old snapshots. Local-only backups do not protect against machine loss.
|
||||||
|
Snapshots run synchronously; for larger databases use the backup CLI from an
|
||||||
|
external scheduler and disable the in-process periodic runner.
|
||||||
|
|
||||||
|
To rehearse a restore without touching the live database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run db:backup
|
||||||
|
bun run db:restore /absolute/path/to/backup.sqlite /absolute/path/to/recovered.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore requires a new destination, verifies integrity, and revokes all sessions
|
||||||
|
in the restored copy. It never overwrites a database or its WAL/SHM sidecars.
|
||||||
|
Test the recovered copy with the matching release and an isolated local port.
|
||||||
|
For an actual recovery, stop the service, set `DATABASE_PATH` to the recovered
|
||||||
|
file, then start the service and sign in again. Keep the original file for rollback.
|
||||||
|
Do not copy only the live `.sqlite` file: recent commits may be in its WAL.
|
||||||
|
|
||||||
|
Backups include account data and session hashes. Protect backup storage and expire
|
||||||
|
copies according to your published retention period. Restoring an older backup
|
||||||
|
can restore deleted accounts; reapply deletions performed since the snapshot
|
||||||
|
before reopening access. Discord images already posted are separate from app data.
|
||||||
|
|
||||||
|
Verification: `bun test src/ops/backups.test.ts` exercises WAL progress, restored
|
||||||
|
content, revoked sessions, replicas, retention, missing storage, and refusal to
|
||||||
|
overwrite an existing destination.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Every API response includes a server-generated `X-Request-ID`. Unexpected errors
|
||||||
|
return that reference to the browser and write a JSON `request_failed` event to
|
||||||
|
stderr with the request ID, method, route template, error type, and stack locations.
|
||||||
|
Error messages, SQL values, request bodies, cookies, and query strings are omitted
|
||||||
|
so credentials and habit content do not enter logs. Production source maps in
|
||||||
|
`dist/` help map stack locations back to the source. Backup outcomes also emit
|
||||||
|
`backup_completed` and `backup_failed` events.
|
||||||
|
|
||||||
|
`GET /api/health` checks SQLite and returns 503 if enabled automatic backups are
|
||||||
|
failing or have never succeeded. It returns no account data, paths, or credentials.
|
||||||
|
Run the health monitor independently of the application process:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run monitor --once
|
||||||
|
bun run monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
`MONITOR_URL` defaults to `http://127.0.0.1:$PORT/api/health`. For an outside-in
|
||||||
|
check, run the monitor on another machine and set it to the public HTTPS endpoint.
|
||||||
|
The one-shot command exits nonzero on failure. Continuous mode checks every minute,
|
||||||
|
times out after ten seconds, reports `health_failed` after three consecutive
|
||||||
|
failures, and emits one `health_recovered` event on recovery. It stays quiet while
|
||||||
|
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.
|
||||||
5
drizzle/0006_sharing_limits.sql
Normal file
5
drizzle/0006_sharing_limits.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE `sharing_limits` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`started_at` integer NOT NULL,
|
||||||
|
`count` integer NOT NULL
|
||||||
|
);
|
||||||
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
|
||||||
|
);
|
||||||
788
drizzle/meta/0006_snapshot.json
Normal file
788
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b5822e36-a1a9-42dd-9439-9490297bc097",
|
||||||
|
"prevId": "8241efb4-d92c-4b72-ac08-a5b5c1cd4a18",
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,27 @@
|
|||||||
"when": 1788536554543,
|
"when": 1788536554543,
|
||||||
"tag": "0005_bored_firelord",
|
"tag": "0005_bored_firelord",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"test:smoke": "bun run build && bun scripts/api-smoke.ts"
|
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
|
||||||
|
"db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
|
||||||
|
"db:restore": "bun scripts/restore.ts",
|
||||||
|
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nivo/calendar": "^0.99.0",
|
"@nivo/calendar": "^0.99.0",
|
||||||
|
|||||||
5
scripts/backup.ts
Normal file
5
scripts/backup.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { databasePath } from "../src/db/config";
|
||||||
|
import { backupConfig, createBackup } from "../src/ops/backups";
|
||||||
|
const db = new Database(databasePath, { readonly: true, strict: true });
|
||||||
|
try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// Isolated browser QA server. Never opens or changes the user's database.
|
// 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 { fixture } from "../src/habits/test-fixture";
|
||||||
import { createApi } from "../src/api";
|
import { createApi } from "../src/api";
|
||||||
import index from "../src/index.html";
|
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 previewCookie = "minabot_dashboard_preview_session";
|
||||||
const sharingPreview = process.env.SHARE_PREVIEW === "1";
|
const sharingPreview = process.env.SHARE_PREVIEW === "1";
|
||||||
let sharedImage: Blob | null = null;
|
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) {
|
if (sharingPreview) {
|
||||||
f.setTime("2026-08-01T12:00:00Z");
|
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);
|
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,
|
undefined,
|
||||||
() => Date.parse("2026-09-04T12:00:00Z"),
|
() => Date.parse("2026-09-04T12:00:00Z"),
|
||||||
sharingPreview ? async (_url, init) => {
|
sharingPreview ? previewDiscord : undefined,
|
||||||
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 ? { token: "preview-bot-token", channelId: "223456789012345678" } : 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({
|
const server = Bun.serve({
|
||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
port: 3107,
|
port: 3107,
|
||||||
routes: {
|
routes: {
|
||||||
|
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
||||||
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
||||||
"/__preview/login": () =>
|
"/__preview/login": () =>
|
||||||
new Response(null, {
|
new Response(null, {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { backupConfig, createBackup } from "../src/ops/backups";
|
||||||
|
import { databasePath } from "../src/db/config";
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { db, sqlite } from "../src/db";
|
import { db, sqlite } from "../src/db";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (process.env.NODE_ENV === "production" && sqlite.query("SELECT name FROM sqlite_master WHERE name = 'habits'").get()) {
|
||||||
|
// Fail before migrations if the safety snapshot cannot be created.
|
||||||
|
createBackup(sqlite, backupConfig(databasePath));
|
||||||
|
}
|
||||||
migrate(db, { migrationsFolder: "./drizzle" });
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
console.log("Database migrations applied.");
|
console.log("Database migrations applied.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
21
scripts/monitor.ts
Normal file
21
scripts/monitor.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { probeHealth, healthObserver } from "../src/ops/monitor";
|
||||||
|
const target = new URL(process.env.MONITOR_URL || `http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`);
|
||||||
|
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) throw new Error("MONITOR_URL must be an HTTP(S) URL without credentials");
|
||||||
|
const emit = (event: Record<string, unknown>) => console.log(JSON.stringify(event));
|
||||||
|
if (process.argv.includes("--once")) {
|
||||||
|
const result = await probeHealth(target.href);
|
||||||
|
emit({ event: "health_probe", ...result });
|
||||||
|
process.exitCode = result.healthy ? 0 : 1;
|
||||||
|
} else {
|
||||||
|
const observe = healthObserver(emit);
|
||||||
|
let running = false;
|
||||||
|
const check = async () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
try { observe(await probeHealth(target.href)); } finally { running = false; }
|
||||||
|
};
|
||||||
|
await check();
|
||||||
|
const timer = setInterval(() => void check(), 60000);
|
||||||
|
const stop = () => { clearInterval(timer); process.exit(0); };
|
||||||
|
process.on("SIGTERM", stop); process.on("SIGINT", stop);
|
||||||
|
}
|
||||||
5
scripts/restore.ts
Normal file
5
scripts/restore.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
import { restoreBackup } from "../src/ops/backups";
|
||||||
|
const [source, destination] = process.argv.slice(2);
|
||||||
|
if (!source || !destination || process.argv.length !== 4) throw new Error("Usage: bun scripts/restore.ts BACKUP NEW_DATABASE_PATH");
|
||||||
|
console.log(`Restored and verified: ${restoreBackup(resolve(source), resolve(destination))}. All sessions revoked.`);
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { backupConfig } from "../src/ops/backups";
|
||||||
import { databasePath } from "../src/db/config";
|
import { databasePath } from "../src/db/config";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// Bun's built HTML assets resolve from dist; keep the database path absolute.
|
// Bun's built HTML assets resolve from dist; keep the database path absolute.
|
||||||
process.env.DATABASE_PATH = databasePath;
|
process.env.DATABASE_PATH = databasePath;
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
|
const backups = backupConfig(databasePath);
|
||||||
|
process.env.BACKUP_DIR = backups.directory;
|
||||||
|
if (backups.replica) process.env.BACKUP_REPLICA_DIR = backups.replica;
|
||||||
process.chdir(fileURLToPath(new URL("../dist", import.meta.url)));
|
process.chdir(fileURLToPath(new URL("../dist", import.meta.url)));
|
||||||
const entrypoint = new URL("../dist/index.js", import.meta.url).href;
|
const entrypoint = new URL("../dist/index.js", import.meta.url).href;
|
||||||
await import(entrypoint);
|
await import(entrypoint);
|
||||||
|
|||||||
120
src/App.test.tsx
120
src/App.test.tsx
@@ -1,3 +1,5 @@
|
|||||||
|
import { ReminderSettings } from "./components/ReminderSettings";
|
||||||
|
import { Modal } from "./components/design-system/Modal";
|
||||||
import {
|
import {
|
||||||
afterAll,
|
afterAll,
|
||||||
afterEach,
|
afterEach,
|
||||||
@@ -9,7 +11,7 @@ import {
|
|||||||
test,
|
test,
|
||||||
} from "bun:test";
|
} from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { act } from "react";
|
import { act, StrictMode, useState } from "react";
|
||||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||||
import type { Root } from "react-dom/client";
|
import type { Root } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
@@ -116,6 +118,90 @@ const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonE
|
|||||||
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
|
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
|
||||||
|
|
||||||
describe("homepage", () => {
|
describe("homepage", () => {
|
||||||
|
test("development Strict Mode does not restore background focus while a modal is open", async () => {
|
||||||
|
await act(async () => root.render(<StrictMode><Modal id="strict-modal" title="Editor" onClose={() => {}} initialFocus="input"><input aria-label="Draft" /></Modal></StrictMode>));
|
||||||
|
await new Promise(resolve => dom.requestAnimationFrame(() => dom.requestAnimationFrame(resolve)));
|
||||||
|
expect(document.activeElement).toBe(container.querySelector("input"));
|
||||||
|
expect(container.querySelector<HTMLDialogElement>("dialog")?.open).toBe(true);
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
test("settings open as a modal and Escape restores the opener and scrolling", async () => {
|
||||||
|
connectAccount();
|
||||||
|
await render("/");
|
||||||
|
const opener = buttonNamed("Settings");
|
||||||
|
opener.focus();
|
||||||
|
await act(async () => opener.click());
|
||||||
|
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
|
||||||
|
expect(dialog.open).toBe(true);
|
||||||
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
await act(async () => dialog.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
||||||
|
await new Promise(resolve => dom.requestAnimationFrame(resolve));
|
||||||
|
expect(container.querySelector("dialog")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(opener);
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modal dismissal waits for saves and nested dialogs retain the scroll lock", async () => {
|
||||||
|
function Harness() {
|
||||||
|
const [nested, setNested] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(true);
|
||||||
|
return <Modal id="outer" title="Outer" onClose={() => {}}>
|
||||||
|
{nested && <Modal id="inner" title="Inner" busy={busy} onClose={() => setNested(false)}>
|
||||||
|
<button onClick={() => setBusy(false)}>Finish save</button>
|
||||||
|
</Modal>}
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
|
await act(async () => root.render(<Harness />));
|
||||||
|
const inner = container.querySelector<HTMLDialogElement>("#inner")!;
|
||||||
|
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
||||||
|
expect(inner.open).toBe(true);
|
||||||
|
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
|
||||||
|
await act(async () => buttonNamed("Finish save").click());
|
||||||
|
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
||||||
|
expect(container.querySelector("#inner")).toBeNull();
|
||||||
|
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
await act(async () => root.render(null));
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archived habits expose history and restore to the dashboard", async () => {
|
||||||
|
const f = connectAccount();
|
||||||
|
f.setTime("2026-09-03T12:00:00Z");
|
||||||
|
const h = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||||
|
await f.json(`/habits/${h.id}/days/2026-09-03/progress`, "PUT", { done: true });
|
||||||
|
f.setTime("2026-09-04T12:00:00Z");
|
||||||
|
await f.request(`/habits/${h.id}`, "DELETE");
|
||||||
|
await render("/");
|
||||||
|
await act(async () => buttonNamed("Archived habits").click());
|
||||||
|
expect(buttonNamed("Restore Reading")).toBeDefined();
|
||||||
|
await act(async () => buttonNamed("View history").click());
|
||||||
|
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||||
|
await act(async () => buttonNamed("Restore Reading").click());
|
||||||
|
expect(container.textContent).toContain("No archived habits.");
|
||||||
|
expect((await f.json("/today")).habits[0].name).toBe("Reading");
|
||||||
|
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
|
||||||
|
const f = connectAccount();
|
||||||
|
f.setTime("2026-09-03T12:00:00Z");
|
||||||
|
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||||
|
f.setTime("2026-09-04T12:00:00Z");
|
||||||
|
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
|
||||||
|
await render("/");
|
||||||
|
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
|
||||||
|
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
|
||||||
|
await act(async () => buttonNamed("Edit selected check-in").click());
|
||||||
|
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
|
||||||
|
expect(past).toBeDefined();
|
||||||
|
await act(async () => past.click());
|
||||||
|
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
|
||||||
|
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
|
||||||
|
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
|
||||||
|
});
|
||||||
|
|
||||||
test("signed-out visitors can try progress without calling private habit APIs", async () => {
|
test("signed-out visitors can try progress without calling private habit APIs", async () => {
|
||||||
await render("/");
|
await render("/");
|
||||||
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
||||||
@@ -223,12 +309,12 @@ describe("homepage", () => {
|
|||||||
expect(saved.target).toBe(10);
|
expect(saved.target).toBe(10);
|
||||||
expect(container.querySelector("form")).toBeNull();
|
expect(container.querySelector("form")).toBeNull();
|
||||||
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
||||||
await act(async () => buttonNamed("Delete habit Daily water").click());
|
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||||
await act(async () => buttonNamed("Cancel").click());
|
await act(async () => buttonNamed("Cancel").click());
|
||||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||||
await act(async () => buttonNamed("Delete habit Daily water").click());
|
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||||
await act(async () => buttonNamed("Delete habit").click());
|
await act(async () => buttonNamed("Archive habit").click());
|
||||||
expect((await f.json("/habits")).habits).toHaveLength(0);
|
expect((await f.json("/habits")).habits).toHaveLength(0);
|
||||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||||
@@ -381,15 +467,15 @@ describe("homepage", () => {
|
|||||||
const f = connectAccount();
|
const f = connectAccount();
|
||||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => buttonNamed("Delete habit Reading").click());
|
await act(async () => buttonNamed("Archive habit Reading").click());
|
||||||
fetchMock.mockImplementationOnce((async (input, init) => {
|
fetchMock.mockImplementationOnce((async (input, init) => {
|
||||||
return f.request(String(input).replace("/api", ""), init?.method);
|
return f.request(String(input).replace("/api", ""), init?.method);
|
||||||
}) as typeof fetch);
|
}) as typeof fetch);
|
||||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||||
await act(async () => buttonNamed("Delete habit").click());
|
await act(async () => buttonNamed("Archive habit").click());
|
||||||
expect(container.querySelector("form")).toBeNull();
|
expect(container.querySelector("form")).toBeNull();
|
||||||
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
||||||
expect(buttonNamed("Delete habit Reading").disabled).toBe(true);
|
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
|
||||||
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
||||||
await act(async () => buttonNamed("Try again").click());
|
await act(async () => buttonNamed("Try again").click());
|
||||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||||
@@ -627,3 +713,23 @@ describe("design system tabs", () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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" });
|
||||||
|
});
|
||||||
|
|||||||
33
src/account/routes.test.ts
Normal file
33
src/account/routes.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
import { fixture } from '../habits/test-fixture';
|
||||||
|
|
||||||
|
test('export is complete, owned, and excludes sessions; deletion removes only the authenticated account', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const a = await f.json('/habits', 'POST', { name: 'Private tasks', method: 'tasks', tasks: [{ name: 'Stretch' }] }, 201);
|
||||||
|
const b = await f.json('/habits', 'POST', { name: 'Bob only', method: 'manual' }, 201, 'b');
|
||||||
|
const day = await f.json(`/habits/${a.id}/days/2026-09-04`);
|
||||||
|
await f.json(`/habits/${a.id}/days/2026-09-04/tasks/${day.tasks[0].taskId}`, 'PUT', { done: true });
|
||||||
|
await f.json('/charts', 'POST', { name: 'Mine', habitIds: [a.id] }, 201);
|
||||||
|
const response = await f.request('/account/export');
|
||||||
|
expect(response.headers.get('content-disposition')).toContain('attachment');
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.habits.map((h: any) => h.id)).toEqual([a.id]);
|
||||||
|
expect(data.tasks[0].done).toBe(true);
|
||||||
|
expect(data.progressEvents.length).toBe(1);
|
||||||
|
expect(data.revisions.length).toBeGreaterThan(0);
|
||||||
|
expect(data.charts.length).toBe(1);
|
||||||
|
expect(JSON.stringify(data)).not.toContain('tokenHash');
|
||||||
|
expect(JSON.stringify(data)).not.toContain('Bob only');
|
||||||
|
expect((await f.request('/account', 'DELETE', { confirmation: 'DELETE' }, 'a', { Origin: 'https://wrong.test' })).status).toBe(403);
|
||||||
|
expect((await f.request('/account', 'DELETE', {})).status).toBe(422);
|
||||||
|
const removed = await f.request('/account', 'DELETE', { confirmation: 'DELETE' });
|
||||||
|
expect(removed.status).toBe(204);
|
||||||
|
expect(removed.headers.get('set-cookie')).toContain('Max-Age=0');
|
||||||
|
expect((await f.request('/me')).status).toBe(401);
|
||||||
|
expect((await f.request('/account/export')).status).toBe(401);
|
||||||
|
expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits.map((h: any) => h.id)).toEqual([b.id]);
|
||||||
|
expect(f.sqlite.query('PRAGMA foreign_key_check').all()).toEqual([]);
|
||||||
|
expect(f.sqlite.query('SELECT * FROM progress_events').all()).toEqual([]);
|
||||||
|
} finally { f.close(); }
|
||||||
|
});
|
||||||
55
src/account/routes.ts
Normal file
55
src/account/routes.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||||
|
import * as schema from "../db/schema";
|
||||||
|
import { HabitService } from "../habits/service";
|
||||||
|
|
||||||
|
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
|
||||||
|
const app = new Hono<AuthEnv>();
|
||||||
|
app.use("*", auth.requireAuth);
|
||||||
|
app.get("/export", c => {
|
||||||
|
const user = c.get("user");
|
||||||
|
const service = new HabitService(db, user, now()); service.sync();
|
||||||
|
const data = db.transaction(tx => {
|
||||||
|
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
|
||||||
|
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||||
|
return {
|
||||||
|
formatVersion: 1, exportedAt: new Date(now()).toISOString(),
|
||||||
|
user: tx.select().from(schema.users).where(eq(schema.users.id, user.id)).get(),
|
||||||
|
habits: tx.select().from(schema.habits).where(eq(schema.habits.userId, user.id)).all(),
|
||||||
|
revisions: tx.select().from(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).all(),
|
||||||
|
days: tx.select().from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).all(),
|
||||||
|
tasks: tx.select().from(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).all(),
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
c.header("Content-Disposition", 'attachment; filename="minabot-export.json"');
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
|
||||||
|
const userId = c.get("user").id;
|
||||||
|
db.transaction(tx => {
|
||||||
|
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
|
||||||
|
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||||
|
tx.delete(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).run();
|
||||||
|
tx.delete(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).run();
|
||||||
|
tx.delete(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).run();
|
||||||
|
tx.delete(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).run();
|
||||||
|
tx.delete(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).run();
|
||||||
|
tx.delete(schema.combinedCharts).where(eq(schema.combinedCharts.userId, userId)).run();
|
||||||
|
tx.delete(schema.habits).where(eq(schema.habits.userId, userId)).run();
|
||||||
|
tx.delete(schema.users).where(eq(schema.users.id, userId)).run();
|
||||||
|
});
|
||||||
|
auth.clearSession(c);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
20
src/api.ts
20
src/api.ts
@@ -1,3 +1,6 @@
|
|||||||
|
import { createReminderRoutes } from "./reminders/routes";
|
||||||
|
import { errorDetails } from "./ops/monitor";
|
||||||
|
import { createAccountRoutes } from "./account/routes";
|
||||||
import { createHabitRoutes } from "./habits/routes";
|
import { createHabitRoutes } from "./habits/routes";
|
||||||
import { ApiError } from "./habits/service";
|
import { ApiError } from "./habits/service";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@@ -8,14 +11,23 @@ import type { FetchDiscord } from "./auth/discord";
|
|||||||
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
||||||
import type { DiscordSharingConfig } from "./sharing/config";
|
import type { DiscordSharingConfig } from "./sharing/config";
|
||||||
|
|
||||||
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }) {
|
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void } = {}) {
|
||||||
const app = new Hono<AuthEnv>();
|
const app = new Hono<AuthEnv>();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
const auth = createAuth(db, config, request, now);
|
const auth = createAuth(db, config, request, now);
|
||||||
app.get("/api/health", c => {
|
app.get("/api/health", c => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
db.get(sql`SELECT 1`);
|
db.get(sql`SELECT 1`);
|
||||||
|
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
|
||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
app.route("/api/auth", auth.routes);
|
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.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||||
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
|
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
|
||||||
@@ -23,7 +35,11 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
|||||||
app.onError((_error, c) => {
|
app.onError((_error, c) => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status);
|
if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status);
|
||||||
return c.json({ error: "Internal server error" }, 500);
|
const requestId = c.get("requestId");
|
||||||
|
const event = { event: "request_failed", timestamp: new Date().toISOString(), requestId, method: c.req.method,
|
||||||
|
route: c.req.routePath, ...errorDetails(_error) };
|
||||||
|
(operations.log ?? (event => console.error(JSON.stringify(event))))(event);
|
||||||
|
return c.json({ error: "Internal server error", requestId }, 500);
|
||||||
});
|
});
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { fetchDiscordProfile, type FetchDiscord } from "./discord";
|
|||||||
const { users, sessions } = schema;
|
const { users, sessions } = schema;
|
||||||
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||||
const STATE_SECONDS = 10 * 60;
|
const STATE_SECONDS = 10 * 60;
|
||||||
export type AuthEnv = { Variables: { user: PublicUser } };
|
export type AuthEnv = { Variables: { user: PublicUser; requestId: string } };
|
||||||
export type AppDatabase = BunSQLiteDatabase<typeof schema>;
|
export type AppDatabase = BunSQLiteDatabase<typeof schema>;
|
||||||
export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||||
const randomToken = () => randomBytes(32).toString("base64url");
|
const randomToken = () => randomBytes(32).toString("base64url");
|
||||||
@@ -142,5 +142,5 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
|
|||||||
deleteCookie(c, sessionName, cookieOptions);
|
deleteCookie(c, sessionName, cookieOptions);
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
});
|
});
|
||||||
return { routes, requireAuth, requireSameOrigin };
|
return { routes, requireAuth, requireSameOrigin, clearSession: (c: Parameters<typeof requireAuth>[0]) => deleteCookie(c, sessionName, cookieOptions) };
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/components/AccountSettings.tsx
Normal file
51
src/components/AccountSettings.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { ReminderSettings } from "./ReminderSettings";
|
||||||
|
import { useId, useRef, useState } from "react";
|
||||||
|
import type { PublicUser } from "../shared/user";
|
||||||
|
import { habitRequest } from "../lib/dashboard";
|
||||||
|
import { Button, ButtonLink } from "./design-system/primitives";
|
||||||
|
import { Download, Trash2 } from "lucide-react";
|
||||||
|
import { FormMessage, SettingRow, WorkspacePanel } from "./design-system/WorkspacePanel";
|
||||||
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
|
export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser; onChanged: () => void; onClose: () => void }) {
|
||||||
|
const [timezone, setTimezone] = useState(user.timezone);
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [reminderBusy, setReminderBusy] = useState(false);
|
||||||
|
const saving = useRef(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const listId = useId();
|
||||||
|
async function run(action: () => Promise<unknown>) {
|
||||||
|
if (saving.current || reminderBusy) return;
|
||||||
|
saving.current = true; setBusy(true); setError("");
|
||||||
|
try { await action(); onChanged(); }
|
||||||
|
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
||||||
|
finally { saving.current = false; setBusy(false); }
|
||||||
|
}
|
||||||
|
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
|
||||||
|
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
|
||||||
|
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
|
||||||
|
<Field label="Timezone">
|
||||||
|
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
|
||||||
|
</Field>
|
||||||
|
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
|
||||||
|
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || reminderBusy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div>
|
||||||
|
</form>
|
||||||
|
</SettingRow>
|
||||||
|
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
|
||||||
|
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
|
||||||
|
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
|
||||||
|
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :
|
||||||
|
<form className="ds-form-stack ds-confirmation" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
|
||||||
|
<p className="type-ui-heading">This cannot be undone.</p>
|
||||||
|
<p className="ds-supporting-copy">Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.</p>
|
||||||
|
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy || reminderBusy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
|
||||||
|
<div className="ds-action-row"><Button type="submit" disabled={busy || reminderBusy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy || reminderBusy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
|
||||||
|
</form>}
|
||||||
|
</SettingRow>
|
||||||
|
<FormMessage error>{error}</FormMessage>
|
||||||
|
</WorkspacePanel>;
|
||||||
|
}
|
||||||
58
src/components/ArchivedHabits.tsx
Normal file
58
src/components/ArchivedHabits.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
|
import { Button } from "./design-system/primitives";
|
||||||
|
import { Archive, ChevronDown, RotateCcw } from "lucide-react";
|
||||||
|
import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel";
|
||||||
|
import { HabitHistory } from "./HabitHistory";
|
||||||
|
|
||||||
|
export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: {
|
||||||
|
date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const [habits, setHabits] = useState<TodayHabit[]>([]);
|
||||||
|
const [selected, setSelected] = useState<string>();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const saving = useRef(false);
|
||||||
|
const expired = useRef(onExpired); expired.current = onExpired;
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController(); setLoading(true); setError("");
|
||||||
|
habitRequest<{ habits: TodayHabit[] }>(`/days/${date}`, { signal: controller.signal })
|
||||||
|
.then(data => { if (!controller.signal.aborted) setHabits(data.habits.filter(habit => habit.requirements?.archived)); })
|
||||||
|
.catch(error => { if (!controller.signal.aborted) { setError(error.message); if (error.message.includes("session has expired")) expired.current(); } })
|
||||||
|
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [date, revision, attempt]);
|
||||||
|
async function restore(habit: TodayHabit) {
|
||||||
|
if (saving.current) return;
|
||||||
|
saving.current = true; setBusy(true); setError(""); setNotice("");
|
||||||
|
try {
|
||||||
|
await habitRequest(`/habits/${habit.habitId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ archived: false }) });
|
||||||
|
setHabits(current => current.filter(item => item.habitId !== habit.habitId));
|
||||||
|
setNotice(`${habit.name} restored. Check-ins resume today.`); onChanged();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Could not restore. Try again.";
|
||||||
|
setError(message); if (message.includes("session has expired")) expired.current();
|
||||||
|
} finally { saving.current = false; setBusy(false); }
|
||||||
|
}
|
||||||
|
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever you’re ready." onClose={onClose} busy={busy} closeLabel="Close archive">
|
||||||
|
<FormMessage error>{error}</FormMessage>
|
||||||
|
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
||||||
|
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
|
||||||
|
<FormMessage>{notice}</FormMessage>
|
||||||
|
<div className="ds-record-list">
|
||||||
|
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
|
||||||
|
<header className="ds-record-heading">
|
||||||
|
<div className="ds-record-copy"><h3 id={`archive-title-${habit.habitId}`}>{habit.name ?? "Habit"}</h3><p>{scheduleLabel(habit.requirements?.schedule)} · Archived</p></div>
|
||||||
|
<div className="ds-action-row">
|
||||||
|
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId} aria-controls={selected === habit.habitId ? `archive-history-${habit.habitId}` : undefined}>{selected === habit.habitId ? "Hide history" : "View history"}<ChevronDown className="ds-disclosure-icon" size={16} aria-hidden="true" /></Button>
|
||||||
|
<Button variant="secondary" disabled={busy || loading} aria-label={`Restore ${habit.name}`} onClick={() => void restore(habit)}><RotateCcw size={16} aria-hidden="true" />Restore</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||||
|
</article>)}
|
||||||
|
</div>
|
||||||
|
</WorkspacePanel>;
|
||||||
|
}
|
||||||
61
src/components/CheckInEditor.tsx
Normal file
61
src/components/CheckInEditor.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { FormMessage } from "./design-system/WorkspacePanel";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashboard";
|
||||||
|
import { Button, Checkbox } from "./design-system/primitives";
|
||||||
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
|
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired, onBusyChange }: {
|
||||||
|
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void; onBusyChange?: (busy: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [day, setDay] = useState<TodayHabit | null>(null);
|
||||||
|
const [count, setCount] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const saving = useRef(false);
|
||||||
|
const expired = useRef(onExpired); expired.current = onExpired;
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setDay(null); setError("");
|
||||||
|
habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}`, { signal: controller.signal })
|
||||||
|
.then(value => { if (!controller.signal.aborted) { setDay(value); setCount(String(value.value)); } })
|
||||||
|
.catch(error => { if (!controller.signal.aborted) {
|
||||||
|
if (error.message.includes("session has expired")) expired.current?.();
|
||||||
|
setError(error.message);
|
||||||
|
} });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [habitId, date, attempt]);
|
||||||
|
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
|
||||||
|
if (saving.current || disabled) return;
|
||||||
|
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
|
||||||
|
try {
|
||||||
|
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setDay(result); setCount(String(result.value)); setNotice(`Saved check-in for ${date}.`); onSaved();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Could not save. Try again.";
|
||||||
|
setError(message); if (message.includes("session has expired")) expired.current?.();
|
||||||
|
} finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
|
||||||
|
}
|
||||||
|
return <div className="ds-form-stack ds-check-in-controls" aria-label={`Check-in for ${date}`}>
|
||||||
|
<header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>
|
||||||
|
<FormMessage error>{error}</FormMessage>
|
||||||
|
{!day ? error ? <Button onClick={() => setAttempt(value => value + 1)}>Retry check-in</Button> : <p role="status">Loading check-in…</p>
|
||||||
|
: day.future ? <p>Future check-ins cannot be edited.</p>
|
||||||
|
: !day.due ? <p>Nothing was scheduled for this date.</p>
|
||||||
|
: <>
|
||||||
|
<p className="ds-supporting-copy">{day.timezone} · Using this day’s saved target and schedule.</p>
|
||||||
|
{day.method === "count" ? <form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void save({ count: Number(count) }); }}>
|
||||||
|
<Field label={`Count (${day.unit}, target ${day.target})`}>
|
||||||
|
{id => <input id={id} type="number" min={0} max={1000000000} step={1} required value={count} disabled={busy || disabled} onChange={event => setCount(event.target.value)} />}
|
||||||
|
</Field>
|
||||||
|
{day.carriedFrom && <p className="type-small">Includes carryover from {day.carriedFrom}. Saving sets this day's total.</p>}
|
||||||
|
<div className="ds-action-row"><Button type="submit" disabled={busy || disabled}>{busy ? "Saving…" : "Save check-in"}</Button></div>
|
||||||
|
</form> : day.method === "manual" ? <Checkbox label="Completed on this date" checked={day.complete} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked })} />
|
||||||
|
: day.tasks.map(task => <Checkbox key={task.taskId} label={task.name} checked={task.done} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked }, task.taskId)} />)}
|
||||||
|
</>}
|
||||||
|
<FormMessage>{notice}</FormMessage>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
import { Modal } from "./design-system/Modal";
|
||||||
import { Button, SectionHeading } from "./design-system/primitives";
|
import { useRef, useState, type FormEvent } from "react";
|
||||||
|
import { Button } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||||
@@ -30,12 +31,6 @@ export function HabitForm({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
|
||||||
form.current?.querySelector<HTMLInputElement>("input")?.focus();
|
|
||||||
return () => previous?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving.current) return;
|
if (saving.current) return;
|
||||||
@@ -80,23 +75,7 @@ export function HabitForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
|
||||||
className="ds-section ds-split-section"
|
|
||||||
aria-labelledby="new-habit-title"
|
|
||||||
id="new-habit"
|
|
||||||
>
|
|
||||||
<SectionHeading
|
|
||||||
number="A NEW HABIT"
|
|
||||||
id="new-habit-title"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Make it <em>yours.</em>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Choose what counts as complete. Your schedule follows your account’s
|
|
||||||
timezone.
|
|
||||||
</SectionHeading>
|
|
||||||
<form ref={form} className="ds-edit-content" onSubmit={submit}>
|
<form ref={form} className="ds-edit-content" onSubmit={submit}>
|
||||||
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
|
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
|
||||||
<Field label="Habit name">
|
<Field label="Habit name">
|
||||||
@@ -223,6 +202,6 @@ export function HabitForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Modal } from "./design-system/Modal";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { CalendarResponse } from "../shared/calendar";
|
import type { CalendarResponse } from "../shared/calendar";
|
||||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
@@ -7,6 +9,8 @@ import { Button } from "./design-system/primitives";
|
|||||||
import { HabitChart } from "./design-system/HabitChart";
|
import { HabitChart } from "./design-system/HabitChart";
|
||||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||||
import { shade } from "../habits/calendar";
|
import { shade } from "../habits/calendar";
|
||||||
|
import { CheckInEditor } from "./CheckInEditor";
|
||||||
|
import { Field } from "./design-system/Field";
|
||||||
import { ItemActions } from "./design-system/ItemActions";
|
import { ItemActions } from "./design-system/ItemActions";
|
||||||
|
|
||||||
export function HabitHistory({
|
export function HabitHistory({
|
||||||
@@ -20,6 +24,8 @@ export function HabitHistory({
|
|||||||
tasks,
|
tasks,
|
||||||
editor,
|
editor,
|
||||||
onExpired,
|
onExpired,
|
||||||
|
onHistorySaved,
|
||||||
|
calendarOnly = false,
|
||||||
}: {
|
}: {
|
||||||
habit: TodayHabit;
|
habit: TodayHabit;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -31,7 +37,12 @@ export function HabitHistory({
|
|||||||
tasks?: ReactNode;
|
tasks?: ReactNode;
|
||||||
editor?: ReactNode;
|
editor?: ReactNode;
|
||||||
onExpired?: () => void;
|
onExpired?: () => void;
|
||||||
|
onHistorySaved?: () => void;
|
||||||
|
calendarOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>();
|
||||||
|
const [editingDay, setEditingDay] = useState(false);
|
||||||
|
const [savingDay, setSavingDay] = useState(false);
|
||||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
@@ -106,7 +117,10 @@ export function HabitHistory({
|
|||||||
Loading your habit history…
|
Loading your habit history…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<CalendarHeatmap
|
<CalendarHeatmap
|
||||||
|
onSelectDate={setSelectedDate}
|
||||||
|
selectedDate={selectedDate}
|
||||||
compact
|
compact
|
||||||
historyLabel="Your recorded progress"
|
historyLabel="Your recorded progress"
|
||||||
label={`${habit.name} progress calendar`}
|
label={`${habit.name} progress calendar`}
|
||||||
@@ -114,6 +128,7 @@ export function HabitHistory({
|
|||||||
color={calendar.settings.mainColor}
|
color={calendar.settings.mainColor}
|
||||||
emptyColor={calendar.settings.emptyColor}
|
emptyColor={calendar.settings.emptyColor}
|
||||||
days={days}
|
days={days}
|
||||||
|
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
|
||||||
legend={
|
legend={
|
||||||
<CalendarLegend
|
<CalendarLegend
|
||||||
label={`${habit.name} progress calendar`}
|
label={`${habit.name} progress calendar`}
|
||||||
@@ -123,7 +138,15 @@ export function HabitHistory({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{editingDay && <Modal id={`check-in-${habit.habitId}`} eyebrow="YOUR HISTORY" title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
|
||||||
|
<div className="ds-history-editor">
|
||||||
|
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
|
||||||
|
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
||||||
|
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
||||||
|
</div></Modal>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
if (calendarOnly) return chart;
|
||||||
return (
|
return (
|
||||||
<HabitChart
|
<HabitChart
|
||||||
id={`history-${habit.habitId}`}
|
id={`history-${habit.habitId}`}
|
||||||
@@ -171,11 +194,11 @@ export function HabitHistory({
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
aria-label={`Delete ${habit.name}`}
|
aria-label={`Archive ${habit.name}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
>
|
>
|
||||||
Delete
|
Archive
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { Modal } from "./design-system/Modal";
|
||||||
|
import { useId, useRef, useState, type ReactNode } from "react";
|
||||||
import { Button } from "./design-system/primitives";
|
import { Button } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
export type ItemMode = "edit" | "delete";
|
export type ItemMode = "edit" | "delete";
|
||||||
|
|
||||||
/** In-place editing and confirmation, shared by habit headings and task rows. */
|
/** Shared editing and confirmation dialogs for habit headings and task rows. */
|
||||||
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
|
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
|
||||||
name: string;
|
name: string;
|
||||||
kind: "habit" | "task";
|
kind: "habit" | "task";
|
||||||
@@ -22,17 +23,12 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
|||||||
const [draft, setDraft] = useState(name);
|
const [draft, setDraft] = useState(name);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
useEffect(() => {
|
const id = useId();
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
|
||||||
form.current?.querySelector<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
|
|
||||||
return () => { if (previous?.isConnected) previous.focus(); };
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : "Delete"} ${kind} ${name}`}
|
<Modal id={`item-${id}`} eyebrow={kind === "habit" ? "YOUR HABIT" : "YOUR TASK"} title={formLabel ?? `${action} ${kind}`} description={name || undefined} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
|
||||||
onKeyDown={(event) => {
|
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
|
||||||
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
|
|
||||||
}}
|
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving.current || disabled) return;
|
if (saving.current || disabled) return;
|
||||||
@@ -60,8 +56,8 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
) : (
|
) : (
|
||||||
<p className="ds-inline-delete-copy">
|
<p className="ds-inline-delete-copy">
|
||||||
Delete <strong>{name}</strong>? {kind === "habit"
|
{kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {kind === "habit"
|
||||||
? "This removes the habit from your dashboard. Earlier history is kept."
|
? "This removes the habit from your dashboard starting today. Earlier history is kept. You can restore it from Archived habits."
|
||||||
: "This removes the task from today and future check-ins. Earlier history is kept."}
|
: "This removes the task from today and future check-ins. Earlier history is kept."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -69,10 +65,11 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
|||||||
<div className="ds-inline-form-actions">
|
<div className="ds-inline-form-actions">
|
||||||
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
||||||
<Button type="submit" disabled={busy || disabled}>
|
<Button type="submit" disabled={busy || disabled}>
|
||||||
{busy ? "Saving…" : mode === "delete" ? `Delete ${kind}` : submitLabel ?? "Save changes"}
|
{busy ? "Saving…" : mode === "delete" ? `${kind === "habit" ? "Archive" : "Delete"} ${kind}` : submitLabel ?? "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/components/ReminderSettings.tsx
Normal file
55
src/components/ReminderSettings.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { FormMessage, SettingRow } from "./design-system/WorkspacePanel";
|
||||||
|
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, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) {
|
||||||
|
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 <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
|
||||||
|
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders…</p> : <form className="ds-form-stack" onSubmit={async event => {
|
||||||
|
event.preventDefault(); if (saving.current || disabled) 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); onBusyChange?.(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); onBusyChange?.(false); }
|
||||||
|
}}>
|
||||||
|
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
|
||||||
|
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}>
|
||||||
|
<Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." 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-clock-fields">
|
||||||
|
{([['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="ds-supporting-copy">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>
|
||||||
|
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
|
||||||
|
</fieldset>
|
||||||
|
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}
|
||||||
|
</form>}
|
||||||
|
<FormMessage error>{error}</FormMessage>
|
||||||
|
<FormMessage>{notice}</FormMessage>
|
||||||
|
</SettingRow>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { Modal } from "./design-system/Modal";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Download, Send, X } from "lucide-react";
|
import { Download, Send } from "lucide-react";
|
||||||
import { Button, Checkbox } from "./design-system/primitives";
|
import { Button, Checkbox } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
import { habitRequest, type TodayResponse } from "../lib/dashboard";
|
import { habitRequest, type TodayResponse } from "../lib/dashboard";
|
||||||
@@ -25,12 +26,10 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
const [sendError, setSendError] = useState("");
|
const [sendError, setSendError] = useState("");
|
||||||
const lock = useRef(false);
|
const lock = useRef(false);
|
||||||
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
|
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
|
||||||
const heading = useRef<HTMLHeadingElement>(null);
|
|
||||||
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
|
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
|
||||||
const ready = card?.key === key ? card : null;
|
const ready = card?.key === key ? card : null;
|
||||||
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
|
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
|
||||||
const busy = sending;
|
const busy = sending;
|
||||||
useEffect(() => { heading.current?.focus(); }, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setConnectionError("");
|
setConnectionError("");
|
||||||
@@ -70,11 +69,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
finally { lock.current = false; setSending(false); }
|
finally { lock.current = false; setSending(false); }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
|
<Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
|
||||||
<header className="ds-share-heading">
|
|
||||||
<div><p className="ds-eyebrow">A LITTLE PROGRESS, WORTH SHARING</p><h2 id="share-progress-title" ref={heading} tabIndex={-1} className="type-section">Your progress, in a picture.</h2></div>
|
|
||||||
<Button variant="text" disabled={busy} onClick={onClose} aria-label="Close sharing"><X size={20} aria-hidden="true" /></Button>
|
|
||||||
</header>
|
|
||||||
<div className="ds-share-layout">
|
<div className="ds-share-layout">
|
||||||
<div className="ds-share-controls">
|
<div className="ds-share-controls">
|
||||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
<fieldset className="ds-share-fieldset" disabled={busy}>
|
||||||
@@ -103,6 +98,6 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
|
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function CalendarHeatmap({
|
|||||||
onSelectDate,
|
onSelectDate,
|
||||||
historyLabel,
|
historyLabel,
|
||||||
legend,
|
legend,
|
||||||
|
selectedDayAction,
|
||||||
}: {
|
}: {
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -35,6 +36,7 @@ export function CalendarHeatmap({
|
|||||||
onSelectDate?: (date: string) => void;
|
onSelectDate?: (date: string) => void;
|
||||||
historyLabel?: string;
|
historyLabel?: string;
|
||||||
legend?: ReactNode;
|
legend?: ReactNode;
|
||||||
|
selectedDayAction?: (date: string) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [months, setMonths] = useState<MonthView | "custom">(12);
|
const [months, setMonths] = useState<MonthView | "custom">(12);
|
||||||
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
||||||
@@ -281,8 +283,8 @@ export function CalendarHeatmap({
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
|
<div id={inspectorId} className="ds-date-inspector">
|
||||||
<div className="ds-date-inspector-heading">
|
<div className="ds-date-inspector-heading" aria-live="polite">
|
||||||
<CalendarDays size={22} aria-hidden="true" />
|
<CalendarDays size={22} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<span className="ds-eyebrow">SELECTED DAY</span>
|
<span className="ds-eyebrow">SELECTED DAY</span>
|
||||||
@@ -297,6 +299,7 @@ export function CalendarHeatmap({
|
|||||||
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
|
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedDayAction && <div className="ds-action-row ds-date-inspector-actions">{selectedDayAction(selected.date)}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Archive, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "./primitives";
|
import { Button } from "./primitives";
|
||||||
|
|
||||||
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
|
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
|
||||||
@@ -13,8 +13,8 @@ export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
|
|||||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
|
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
|
||||||
<Pencil size={16} aria-hidden="true" />
|
<Pencil size={16} aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}>
|
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`${kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`} title={`${kind === "habit" ? "Archive" : "Delete"} ${kind}`}>
|
||||||
<Trash2 size={16} aria-hidden="true" />
|
{kind === "habit" ? <Archive size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
80
src/components/design-system/Modal.tsx
Normal file
80
src/components/design-system/Modal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Button } from "./primitives";
|
||||||
|
|
||||||
|
let openModals = 0;
|
||||||
|
let previousOverflow = "";
|
||||||
|
|
||||||
|
/** Native top-layer dialog supplies focus containment and an inert background. */
|
||||||
|
export function Modal({ id, eyebrow, title, description, children, onClose, closeLabel = "Close dialog", busy = false, size = "wide", initialFocus, returnFocus }: {
|
||||||
|
id: string; eyebrow?: string; title: ReactNode; description?: ReactNode; children: ReactNode;
|
||||||
|
onClose?: () => void; closeLabel?: string; busy?: boolean; size?: "wide" | "compact"; initialFocus?: string; returnFocus?: () => HTMLElement | null;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const heading = useRef<HTMLHeadingElement>(null);
|
||||||
|
const pointerOnBackdrop = useRef(false);
|
||||||
|
const mounted = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
const element = dialog.current!;
|
||||||
|
const opener = returnFocus?.() ?? document.activeElement as HTMLElement | null;
|
||||||
|
if (!openModals++) {
|
||||||
|
previousOverflow = document.documentElement.style.overflow;
|
||||||
|
document.documentElement.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
element.showModal();
|
||||||
|
const target = initialFocus ? element.querySelector<HTMLElement>(initialFocus) : heading.current;
|
||||||
|
target?.focus({ preventScroll: true });
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
element.close();
|
||||||
|
if (!--openModals) document.documentElement.style.overflow = previousOverflow;
|
||||||
|
// A successful save may keep its opener disabled until the dashboard
|
||||||
|
// refresh finishes. Restore focus when it becomes available again.
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
|
||||||
|
// Ignore React Strict Mode's setup/cleanup rehearsal.
|
||||||
|
if (mounted.current || !opener?.isConnected) return;
|
||||||
|
if (!opener.matches(":disabled")) { opener.focus({ preventScroll: true }); return; }
|
||||||
|
const observer = new window.MutationObserver(() => {
|
||||||
|
if (opener.matches(":disabled")) return;
|
||||||
|
observer.disconnect();
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
if (opener.isConnected && document.activeElement === document.body) opener.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
observer.observe(opener, { attributes: true, attributeFilter: ["disabled"] });
|
||||||
|
const timeout = window.setTimeout(() => observer.disconnect(), 5000);
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => { if (!busy) onClose?.(); };
|
||||||
|
return <dialog ref={dialog} id={id} className={`ds-modal ds-modal--${size}`} aria-labelledby={`${id}-title`}
|
||||||
|
onCancel={event => { event.preventDefault(); event.stopPropagation(); dismiss(); }}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
event.stopPropagation();
|
||||||
|
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
|
||||||
|
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1);
|
||||||
|
if (!first || !last) { event.preventDefault(); heading.current?.focus(); return; }
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (event.shiftKey && (active === first || !controls.includes(active as HTMLElement))) {
|
||||||
|
event.preventDefault(); last.focus();
|
||||||
|
} else if (!event.shiftKey && active === last) {
|
||||||
|
event.preventDefault(); first.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={event => { pointerOnBackdrop.current = event.target === event.currentTarget; }}
|
||||||
|
onClick={event => {
|
||||||
|
if (!pointerOnBackdrop.current || event.target !== event.currentTarget) return;
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dismiss();
|
||||||
|
}}>
|
||||||
|
<header className="ds-modal-heading">
|
||||||
|
<div>{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}<h2 id={`${id}-title`} ref={heading} tabIndex={-1}>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
|
||||||
|
<Button variant="text" className="ds-panel-close" disabled={busy || !onClose} onClick={dismiss} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>
|
||||||
|
</header>
|
||||||
|
<div className="ds-modal-body">{children}</div>
|
||||||
|
</dialog>;
|
||||||
|
}
|
||||||
22
src/components/design-system/WorkspacePanel.tsx
Normal file
22
src/components/design-system/WorkspacePanel.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useId, type ReactNode } from "react";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
|
/** Secondary workspace flows share one modal presentation. */
|
||||||
|
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: {
|
||||||
|
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
|
||||||
|
}) {
|
||||||
|
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent label, explanation and control columns for preference forms. */
|
||||||
|
export function SettingRow({ title, description, children }: { title: string; description: ReactNode; children: ReactNode }) {
|
||||||
|
const id = useId();
|
||||||
|
return <section className="ds-setting-row" aria-labelledby={id}>
|
||||||
|
<header className="ds-setting-heading"><h3 id={id}>{title}</h3><div>{description}</div></header>
|
||||||
|
<div className="ds-setting-controls">{children}</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormMessage({ children, error = false }: { children: ReactNode; error?: boolean }) {
|
||||||
|
return <p className={`ds-form-message${error ? " ds-form-message--error" : ""}`} role={error ? "alert" : "status"}>{children}</p>;
|
||||||
|
}
|
||||||
53
src/db/development-start.test.ts
Normal file
53
src/db/development-start.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
test("development entrypoint upgrades an existing database before serving requests", async () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), "minabot-dev-upgrade-"));
|
||||||
|
const path = join(directory, "app.sqlite");
|
||||||
|
const sqlite = new Database(path);
|
||||||
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
||||||
|
try {
|
||||||
|
const migrations = join(directory, "migrations");
|
||||||
|
mkdirSync(join(migrations, "meta"), { recursive: true });
|
||||||
|
const journal = JSON.parse(readFileSync("drizzle/meta/_journal.json", "utf8"));
|
||||||
|
journal.entries = journal.entries.slice(0, 6);
|
||||||
|
writeFileSync(join(migrations, "meta/_journal.json"), JSON.stringify(journal));
|
||||||
|
for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(migrations, `${entry.tag}.sql`));
|
||||||
|
migrate(drizzle(sqlite), { migrationsFolder: migrations });
|
||||||
|
sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('existing', 'existing', 'Existing user', 0, 0)");
|
||||||
|
expect(sqlite.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()).toBeNull();
|
||||||
|
|
||||||
|
// Launch directly, as Bun does when re-evaluating the entrypoint during HMR;
|
||||||
|
// deliberately skip package.json's separate migration command.
|
||||||
|
server = Bun.spawn([process.execPath, "src/index.ts"], {
|
||||||
|
env: { ...process.env, NODE_ENV: "development", DATABASE_PATH: path, PORT: "0", APP_ORIGIN: "http://127.0.0.1", DISCORD_BOT_TOKEN: "", DISCORD_ALLOWED_USER_IDS: "" },
|
||||||
|
stdout: "pipe", stderr: "pipe",
|
||||||
|
});
|
||||||
|
const reader = (server.stdout as ReadableStream<Uint8Array>).getReader();
|
||||||
|
let output = "";
|
||||||
|
while (!output.includes("Server running at")) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) throw new Error("Development server exited before it was ready");
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
reader.releaseLock();
|
||||||
|
const origin = output.match(/Server running at (http:\/\/[^\s]+)/)?.[1];
|
||||||
|
expect(origin).toBeDefined();
|
||||||
|
expect((await fetch(new URL("/api/health", origin))).status).toBe(200);
|
||||||
|
expect(sqlite.query("SELECT COUNT(*) AS count FROM __drizzle_migrations").get()).toEqual({ count: JSON.parse(readFileSync("drizzle/meta/_journal.json", "utf8")).entries.length });
|
||||||
|
expect(sqlite.query("SELECT enabled FROM reminder_settings").all()).toEqual([]);
|
||||||
|
expect(sqlite.query("SELECT retry_at FROM reminder_deliveries").all()).toEqual([]);
|
||||||
|
expect(sqlite.query("SELECT blocked_until FROM reminder_worker_state").all()).toEqual([]);
|
||||||
|
expect(sqlite.query("SELECT username FROM users WHERE id = 'existing'").get()).toEqual({ username: "Existing user" });
|
||||||
|
} finally {
|
||||||
|
server?.kill();
|
||||||
|
if (server) await server.exited;
|
||||||
|
sqlite.close();
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
@@ -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", {
|
export const users = sqliteTable("users", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -60,3 +60,27 @@ export const combinedCharts = sqliteTable('combined_charts', {
|
|||||||
habitIds: text('habit_ids', { mode: 'json' }).$type<string[]>().notNull(), settings: text('settings', { mode: 'json' }).$type<import('../habits/contracts').CalendarSettings>().notNull(),
|
habitIds: text('habit_ids', { mode: 'json' }).$type<string[]>().notNull(), settings: text('settings', { mode: 'json' }).$type<import('../habits/contracts').CalendarSettings>().notNull(),
|
||||||
createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(),
|
createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(),
|
||||||
}, t => [index('combined_charts_user_idx').on(t.userId)]);
|
}, t => [index('combined_charts_user_idx').on(t.userId)]);
|
||||||
|
|
||||||
|
export const sharingLimits = sqliteTable('sharing_limits', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
|
|||||||
test('failed revision writes roll back the habit atomically and do not expose database errors', async () => {
|
test('failed revision writes roll back the habit atomically and do not expose database errors', async () => {
|
||||||
f.sqlite.exec("CREATE TRIGGER reject_revision BEFORE INSERT ON habit_revisions BEGIN SELECT RAISE(ABORT, 'private database detail'); END");
|
f.sqlite.exec("CREATE TRIGGER reject_revision BEFORE INSERT ON habit_revisions BEGIN SELECT RAISE(ABORT, 'private database detail'); END");
|
||||||
const response = await f.request('/habits', 'POST', { name: 'Rollback', method: 'manual' });
|
const response = await f.request('/habits', 'POST', { name: 'Rollback', method: 'manual' });
|
||||||
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error' });
|
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error', requestId: response.headers.get('x-request-id') });
|
||||||
expect((await f.json('/habits')).habits).toHaveLength(0);
|
expect((await f.json('/habits')).habits).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ test('creation saves color atomically and never puts presentation into requireme
|
|||||||
f.sqlite.exec("CREATE TRIGGER reject_color BEFORE INSERT ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
|
f.sqlite.exec("CREATE TRIGGER reject_color BEFORE INSERT ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
|
||||||
const response = await f.request('/habits', 'POST', { name: 'Rollback color', method: 'manual', color: '#426582' });
|
const response = await f.request('/habits', 'POST', { name: 'Rollback color', method: 'manual', color: '#426582' });
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(await response.json()).toEqual({ error: 'Internal server error' });
|
expect(await response.json()).toEqual({ error: 'Internal server error', requestId: response.headers.get('x-request-id') });
|
||||||
expect((await f.json('/habits')).habits).toHaveLength(3);
|
expect((await f.json('/habits')).habits).toHaveLength(3);
|
||||||
});
|
});
|
||||||
test('edits save color with requirements atomically, preserving history and other settings', async () => {
|
test('edits save color with requirements atomically, preserving history and other settings', async () => {
|
||||||
|
|||||||
20
src/index.ts
20
src/index.ts
@@ -1,10 +1,26 @@
|
|||||||
import { db } from "./db";
|
import { startReminders } from "./reminders/worker";
|
||||||
|
import { backupConfig, startBackups } from "./ops/backups";
|
||||||
|
import { databasePath } from "./db/config";
|
||||||
|
import { db, sqlite } from "./db";
|
||||||
import { createApi } from "./api";
|
import { createApi } from "./api";
|
||||||
import { readAuthConfig } from "./auth/config";
|
import { readAuthConfig } from "./auth/config";
|
||||||
import { readDiscordSharingConfig } from "./sharing/config";
|
import { readDiscordSharingConfig } from "./sharing/config";
|
||||||
import index from "./index.html";
|
import index from "./index.html";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
|
||||||
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig());
|
// Hot reloads skip the package script's migration step. Apply pending changes
|
||||||
|
// before the reloaded development server accepts requests.
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
const server = Bun.serve({
|
||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function habitRequest<T>(
|
|||||||
throw new Error("Your session has expired. Sign in again to continue.");
|
throw new Error("Your session has expired. Sign in again to continue.");
|
||||||
const body = await response.json().catch(() => null);
|
const body = await response.json().catch(() => null);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
body?.error || "Could not reach your workspace. Please try again.",
|
(body?.error || "Could not reach your workspace. Please try again.") + (response.status >= 500 && body?.requestId ? ` Reference: ${body.requestId}` : ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return response.status === 204
|
return response.status === 204
|
||||||
|
|||||||
73
src/ops/backups.test.ts
Normal file
73
src/ops/backups.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { fixture } from "../habits/test-fixture";
|
||||||
|
import { backupConfig, createBackup, latestBackupTime, restoreBackup, startBackups } from "./backups";
|
||||||
|
|
||||||
|
test("WAL snapshots restore committed progress, revoke sessions, replicate, and retain only owned snapshots", async () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-"));
|
||||||
|
const f = fixture(join(directory, "live.sqlite"));
|
||||||
|
try {
|
||||||
|
f.sqlite.exec("PRAGMA journal_mode=WAL");
|
||||||
|
const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_RETAIN: "2", BACKUP_REPLICA_DIR: "replica" });
|
||||||
|
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);
|
||||||
|
const destination = join(directory, "restored.sqlite");
|
||||||
|
restoreBackup(first, destination);
|
||||||
|
const restored = new Database(destination, { readonly: true });
|
||||||
|
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");
|
||||||
|
writeFileSync(join(config.directory, "keep.txt"), "unrelated");
|
||||||
|
createBackup(f.sqlite, config); createBackup(f.sqlite, config);
|
||||||
|
expect(readdirSync(config.directory).filter(name => name.endsWith(".sqlite"))).toHaveLength(2);
|
||||||
|
expect(readdirSync(config.replica!)).toHaveLength(2);
|
||||||
|
expect(readdirSync(config.directory)).toContain("keep.txt");
|
||||||
|
const runner = startBackups(f.sqlite, config);
|
||||||
|
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
|
||||||
|
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid backups and missing replica mounts fail without overwriting data", () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-failure-"));
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const bad = join(directory, "bad.sqlite"); writeFileSync(bad, "not a database");
|
||||||
|
expect(() => restoreBackup(bad, join(directory, "target.sqlite"))).toThrow();
|
||||||
|
const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_REPLICA_DIR: "missing" });
|
||||||
|
expect(() => createBackup(f.sqlite, config)).toThrow();
|
||||||
|
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 }); }
|
||||||
|
});
|
||||||
99
src/ops/backups.ts
Normal file
99
src/ops/backups.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
export type BackupConfig = { directory: string; replica?: string; retain: number; intervalMs: number; enabled: boolean };
|
||||||
|
const filenamePattern = /^minabot-\d{4}-\d{2}-\d{2}T[\d-]+Z-[a-f0-9-]+\.sqlite$/;
|
||||||
|
export function backupConfig(database: string, env = process.env): BackupConfig {
|
||||||
|
const retain = Number(env.BACKUP_RETAIN ?? 7);
|
||||||
|
const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
|
||||||
|
if (!Number.isInteger(retain) || retain < 2 || retain > 365 || !Number.isFinite(hours) || hours < 1 || hours > 168)
|
||||||
|
throw new Error("BACKUP_RETAIN must be 2–365 and BACKUP_INTERVAL_HOURS must be 1–168");
|
||||||
|
const base = dirname(database);
|
||||||
|
const directory = resolve(base, env.BACKUP_DIR ?? "backups");
|
||||||
|
const replica = env.BACKUP_REPLICA_DIR ? resolve(base, env.BACKUP_REPLICA_DIR) : undefined;
|
||||||
|
if (replica === directory) throw new Error("The backup replica must use a separate directory");
|
||||||
|
return { directory, replica, retain, intervalMs: hours * 3600000, enabled: env.BACKUP_ENABLED !== "false" };
|
||||||
|
}
|
||||||
|
function durable(path: string) {
|
||||||
|
chmodSync(path, 0o600);
|
||||||
|
const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); }
|
||||||
|
}
|
||||||
|
export function verifyBackup(path: string) {
|
||||||
|
const db = new Database(path, { readonly: true, strict: true });
|
||||||
|
try {
|
||||||
|
const integrity = db.query("PRAGMA integrity_check").all() as { integrity_check: string }[];
|
||||||
|
if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok" || db.query("PRAGMA foreign_key_check").all().length)
|
||||||
|
throw new Error("Backup integrity check failed");
|
||||||
|
db.query("SELECT id FROM users LIMIT 1").all();
|
||||||
|
db.query("SELECT id FROM habits LIMIT 1").all();
|
||||||
|
db.query("SELECT hash FROM __drizzle_migrations LIMIT 1").all();
|
||||||
|
} finally { db.close(); }
|
||||||
|
}
|
||||||
|
function snapshots(directory: string) {
|
||||||
|
if (!existsSync(directory)) return [];
|
||||||
|
return readdirSync(directory).filter(name => filenamePattern.test(name)).sort().reverse();
|
||||||
|
}
|
||||||
|
export function latestBackupTime(config: BackupConfig) {
|
||||||
|
const latest = snapshots(config.directory)[0];
|
||||||
|
if (!latest || (config.replica && !existsSync(join(config.replica, latest)))) return 0;
|
||||||
|
return statSync(join(config.directory, latest)).mtimeMs;
|
||||||
|
}
|
||||||
|
export function createBackup(database: Database, config: BackupConfig) {
|
||||||
|
const name = `minabot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID()}.sqlite`;
|
||||||
|
mkdirSync(config.directory, { recursive: true, mode: 0o700 });
|
||||||
|
const path = join(config.directory, name);
|
||||||
|
const staging = `${path}.partial`;
|
||||||
|
mkdirSync(staging, { mode: 0o700 });
|
||||||
|
const temporary = join(staging, "snapshot.sqlite");
|
||||||
|
try {
|
||||||
|
// VACUUM INTO includes committed WAL transactions in one consistent snapshot.
|
||||||
|
// SQLite creates the output inside a private staging directory.
|
||||||
|
database.query("VACUUM INTO ?").run(temporary);
|
||||||
|
verifyBackup(temporary); durable(temporary); renameSync(temporary, path);
|
||||||
|
if (config.replica) {
|
||||||
|
// Require the configured mount to exist; never silently create an absent mount.
|
||||||
|
if (!statSync(config.replica).isDirectory()) throw new Error("Backup replica directory unavailable");
|
||||||
|
const replica = join(config.replica, name);
|
||||||
|
copyFileSync(path, `${replica}.partial`, constants.COPYFILE_EXCL);
|
||||||
|
durable(`${replica}.partial`); verifyBackup(`${replica}.partial`); renameSync(`${replica}.partial`, replica);
|
||||||
|
}
|
||||||
|
for (const directory of [config.directory, ...(config.replica ? [config.replica] : [])])
|
||||||
|
for (const old of snapshots(directory).slice(config.retain)) unlinkSync(join(directory, old));
|
||||||
|
return path;
|
||||||
|
} finally { rmSync(staging, { recursive: true, force: true }); }
|
||||||
|
}
|
||||||
|
export function restoreBackup(source: string, destination: string) {
|
||||||
|
if (existsSync(destination) || existsSync(`${destination}-wal`) || existsSync(`${destination}-shm`))
|
||||||
|
throw new Error("Restore destination must be a new path with no SQLite sidecars");
|
||||||
|
verifyBackup(source);
|
||||||
|
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
||||||
|
const temporary = `${destination}.${crypto.randomUUID()}.partial`;
|
||||||
|
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;");
|
||||||
|
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);
|
||||||
|
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
export function startBackups(database: Database, config: BackupConfig) {
|
||||||
|
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
|
||||||
|
if (!config.enabled) return { state, stop() {} };
|
||||||
|
const tick = () => {
|
||||||
|
try {
|
||||||
|
const latest = latestBackupTime(config);
|
||||||
|
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; }
|
||||||
|
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false;
|
||||||
|
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() }));
|
||||||
|
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const timer = setInterval(tick, 60000); timer.unref();
|
||||||
|
return { state, stop: () => clearInterval(timer) };
|
||||||
|
}
|
||||||
39
src/ops/monitor.test.ts
Normal file
39
src/ops/monitor.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { fixture } from "../habits/test-fixture";
|
||||||
|
import { createApi } from "../api";
|
||||||
|
import { healthObserver, probeHealth } from "./monitor";
|
||||||
|
|
||||||
|
test("unexpected database errors are correlated without logging secrets", async () => {
|
||||||
|
const f = fixture(); const events: Record<string, unknown>[] = [];
|
||||||
|
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, undefined, { log: event => events.push(event) });
|
||||||
|
f.close();
|
||||||
|
const response = await app.request(`${f.origin}/api/health?token=private-secret`, { headers: { Cookie: "private-cookie", "X-Request-ID": "untrusted" } });
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.requestId).toBe(response.headers.get("x-request-id"));
|
||||||
|
expect(body.requestId).not.toBe("untrusted");
|
||||||
|
expect(events[0]?.requestId).toBe(body.requestId);
|
||||||
|
expect(events[0]?.type).toBeDefined();
|
||||||
|
expect(JSON.stringify(events)).not.toContain("private");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed backups degrade health, and probes validate status and body", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, undefined, { healthy: () => false });
|
||||||
|
const response = await app.request(`${f.origin}/api/health`);
|
||||||
|
expect(response.status).toBe(503); expect(await response.json()).toEqual({ status: "degraded" });
|
||||||
|
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => Response.json({ status: "ok" }) });
|
||||||
|
try { expect((await probeHealth(String(server.url))).healthy).toBe(true); } finally { await server.stop(true); }
|
||||||
|
expect((await probeHealth(String(server.url))).healthy).toBe(false);
|
||||||
|
} finally { f.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("monitor reports sustained failure and recovery once per transition", () => {
|
||||||
|
const events: Record<string, unknown>[] = [];
|
||||||
|
const observe = healthObserver(event => events.push(event));
|
||||||
|
observe({ healthy: true, reason: "ok" });
|
||||||
|
for (let i = 0; i < 5; i++) observe({ healthy: false, reason: "HTTP 503" });
|
||||||
|
observe({ healthy: true, reason: "ok" }); observe({ healthy: true, reason: "ok" });
|
||||||
|
expect(events.map(event => event.event)).toEqual(["monitor_started", "health_failed", "health_recovered"]);
|
||||||
|
});
|
||||||
34
src/ops/monitor.ts
Normal file
34
src/ops/monitor.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export type HealthResult = { healthy: boolean; reason: string };
|
||||||
|
export async function probeHealth(url: string, request: typeof fetch = fetch): Promise<HealthResult> {
|
||||||
|
try {
|
||||||
|
const response = await request(url, { signal: AbortSignal.timeout(10000), redirect: "error", cache: "no-store" });
|
||||||
|
if (!response.ok) return { healthy: false, reason: `HTTP ${response.status}` };
|
||||||
|
const body = await response.json() as { status?: string };
|
||||||
|
return body.status === "ok" ? { healthy: true, reason: "ok" } : { healthy: false, reason: "Invalid health response" };
|
||||||
|
} catch { return { healthy: false, reason: "Health check unavailable" }; }
|
||||||
|
}
|
||||||
|
export function healthObserver(emit: (event: Record<string, unknown>) => void, threshold = 3) {
|
||||||
|
let failures = 0;
|
||||||
|
let incident = false;
|
||||||
|
let initialized = false;
|
||||||
|
return (result: HealthResult) => {
|
||||||
|
failures = result.healthy ? 0 : failures + 1;
|
||||||
|
const failed = failures >= threshold;
|
||||||
|
const event = !initialized && result.healthy ? "monitor_started" : failed && !incident ? "health_failed" : incident && result.healthy ? "health_recovered" : null;
|
||||||
|
if (event) emit({ event, timestamp: new Date().toISOString(), reason: result.reason, failures });
|
||||||
|
if (failed) incident = true;
|
||||||
|
if (result.healthy) incident = false;
|
||||||
|
initialized = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep secrets, SQL values, request bodies, OAuth queries and cookies out of logs. */
|
||||||
|
export function errorDetails(error: Error) {
|
||||||
|
return {
|
||||||
|
type: ["Error", "TypeError", "RangeError", "SyntaxError", "SQLiteError"].includes(error.name) ? error.name : "Error",
|
||||||
|
frames: (error.stack ?? "").split("\n").slice(1).flatMap(line => {
|
||||||
|
const location = line.match(/([A-Za-z0-9_.-]+\.(?:js|ts|tsx):\d+:\d+)/);
|
||||||
|
return location ? [location[1]] : [];
|
||||||
|
}).slice(0, 8),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { Archive, Settings2 } from "lucide-react";
|
||||||
|
import { ArchivedHabits } from "../components/ArchivedHabits";
|
||||||
|
import { AccountSettings } from "../components/AccountSettings";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "../components/AuthProvider";
|
import { useAuth } from "../components/AuthProvider";
|
||||||
import { DiscordSignInButton } from "../components/DiscordSignInButton";
|
import { DiscordSignInButton } from "../components/DiscordSignInButton";
|
||||||
@@ -165,8 +168,11 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [settings, setSettings] = useState(false);
|
||||||
|
const [archive, setArchive] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [needsRefresh, setNeedsRefresh] = useState(false);
|
const [needsRefresh, setNeedsRefresh] = useState(false);
|
||||||
|
const focusAddAfterRefresh = useRef(false);
|
||||||
const saving = useRef(false);
|
const saving = useRef(false);
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
const expired = useRef(onExpired);
|
const expired = useRef(onExpired);
|
||||||
@@ -202,7 +208,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
if (!controller.signal.aborted) reportError(error);
|
if (!controller.signal.aborted) reportError(error);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
if (focusAddAfterRefresh.current) {
|
||||||
|
focusAddAfterRefresh.current = false;
|
||||||
|
window.requestAnimationFrame(() => document.getElementById("add-habit")?.focus({ preventScroll: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [attempt, reportError]);
|
}, [attempt, reportError]);
|
||||||
@@ -303,7 +315,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
}
|
}
|
||||||
if (mounted.current) {
|
if (mounted.current) {
|
||||||
setNotice(
|
setNotice(
|
||||||
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : "deleted"}.`
|
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.`
|
||||||
);
|
);
|
||||||
if (!patch)
|
if (!patch)
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
@@ -353,9 +365,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-actions">
|
<div className="ds-action-row ds-primary-actions">
|
||||||
<Button
|
<Button
|
||||||
id="add-habit"
|
id="add-habit"
|
||||||
|
aria-haspopup="dialog"
|
||||||
disabled={adding || busy || loading || needsRefresh}
|
disabled={adding || busy || loading || needsRefresh}
|
||||||
onClick={() => setAdding(true)}
|
onClick={() => setAdding(true)}
|
||||||
aria-expanded={adding}
|
aria-expanded={adding}
|
||||||
@@ -365,14 +378,20 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
</Button>
|
</Button>
|
||||||
{today.habits.length > 0 && (
|
{today.habits.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Button id="open-sharing" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
|
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
|
||||||
<ButtonLink href="#habits-title">View your habits ↓</ButtonLink>
|
<ButtonLink href="#habits-title">View your habits ↓</ButtonLink>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ds-action-row ds-utility-row">
|
||||||
|
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
|
||||||
|
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</WelcomePanel>
|
</WelcomePanel>
|
||||||
|
{archive && today && <ArchivedHabits onClose={() => setArchive(false)} date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />}
|
||||||
|
{settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />}
|
||||||
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
|
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="ds-form-feedback" role="alert">
|
<div className="ds-form-feedback" role="alert">
|
||||||
@@ -399,6 +418,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
onCancel={() => setAdding(false)}
|
onCancel={() => setAdding(false)}
|
||||||
onExpired={() => expired.current()}
|
onExpired={() => expired.current()}
|
||||||
onCreated={(name) => {
|
onCreated={(name) => {
|
||||||
|
focusAddAfterRefresh.current = true;
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
setNotice(`${name} created.`);
|
setNotice(`${name} created.`);
|
||||||
setAttempt((value) => value + 1);
|
setAttempt((value) => value + 1);
|
||||||
@@ -433,6 +453,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
date={today.date}
|
date={today.date}
|
||||||
revision={revision}
|
revision={revision}
|
||||||
disabled={busy || loading || needsRefresh}
|
disabled={busy || loading || needsRefresh}
|
||||||
|
onHistorySaved={() => setAttempt(value => value + 1)}
|
||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
onManage={manage}
|
onManage={manage}
|
||||||
onExpired={() => expired.current()}
|
onExpired={() => expired.current()}
|
||||||
@@ -454,6 +475,7 @@ function SavedHabit({
|
|||||||
disabled,
|
disabled,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onManage,
|
onManage,
|
||||||
|
onHistorySaved,
|
||||||
onExpired,
|
onExpired,
|
||||||
}: {
|
}: {
|
||||||
habit: TodayHabit;
|
habit: TodayHabit;
|
||||||
@@ -466,6 +488,7 @@ function SavedHabit({
|
|||||||
taskId?: string
|
taskId?: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onExpired: () => void;
|
onExpired: () => void;
|
||||||
|
onHistorySaved: () => void;
|
||||||
onManage: (
|
onManage: (
|
||||||
habit: TodayHabit,
|
habit: TodayHabit,
|
||||||
patch: Record<string, unknown> | null,
|
patch: Record<string, unknown> | null,
|
||||||
@@ -483,6 +506,7 @@ function SavedHabit({
|
|||||||
revision={revision}
|
revision={revision}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onExpired={onExpired}
|
onExpired={onExpired}
|
||||||
|
onHistorySaved={onHistorySaved}
|
||||||
onEdit={(color) => setEditing({ mode: "edit", color })}
|
onEdit={(color) => setEditing({ mode: "edit", color })}
|
||||||
onDelete={() => setEditing({ mode: "delete", color: "#196127" })}
|
onDelete={() => setEditing({ mode: "delete", color: "#196127" })}
|
||||||
editor={
|
editor={
|
||||||
@@ -521,7 +545,7 @@ function SavedHabit({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{addingTask ? (
|
{addingTask && (
|
||||||
<InlineTaskEditor
|
<InlineTaskEditor
|
||||||
name=""
|
name=""
|
||||||
schedule={{ type: "daily" }}
|
schedule={{ type: "daily" }}
|
||||||
@@ -534,20 +558,21 @@ function SavedHabit({
|
|||||||
onSave={(patch) => onManage(habit, patch, undefined, true)}
|
onSave={(patch) => onManage(habit, patch, undefined, true)}
|
||||||
onDelete={async () => {}}
|
onDelete={async () => {}}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<div className="ds-task-add-action">
|
<div className="ds-task-add-action">
|
||||||
{!habit.requirements.tasks.length && (
|
{!habit.requirements.tasks.length && (
|
||||||
<p className="ds-footnote">Add a task to start checking in.</p>
|
<p className="ds-footnote">Add a task to start checking in.</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={addingTask}
|
||||||
disabled={disabled || habit.requirements.tasks.length >= 100}
|
disabled={disabled || habit.requirements.tasks.length >= 100}
|
||||||
onClick={() => setAddingTask(true)}
|
onClick={() => setAddingTask(true)}
|
||||||
>
|
>
|
||||||
Add task +
|
Add task +
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
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) };
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
export type DiscordSharingConfig = { token: string; channelId: string };
|
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] };
|
||||||
|
|
||||||
/** Read only from the server entrypoint; never include credentials in public config. */
|
/** Read only from the server entrypoint; never include credentials in public config. */
|
||||||
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
||||||
|
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
|
||||||
|
if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs");
|
||||||
return {
|
return {
|
||||||
|
allowedUserIds,
|
||||||
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
||||||
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789
|
|||||||
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
|
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
|
||||||
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
|
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
|
||||||
const f = fixture(); fixtures.push(f);
|
const f = fixture(); fixtures.push(f);
|
||||||
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), request, config);
|
let timestamp = Date.parse("2026-09-04T12:00:00Z");
|
||||||
|
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => timestamp, request, config);
|
||||||
const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
|
const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
|
||||||
return { f, call, connect: () => call("/discord") };
|
return { f, call, advance: (seconds: number) => { timestamp += seconds * 1000; }, connect: () => call("/discord") };
|
||||||
}
|
}
|
||||||
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
|
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
|
||||||
function upload(id = crypto.randomUUID()) {
|
function upload(id = crypto.randomUUID()) {
|
||||||
@@ -87,12 +88,14 @@ test("delivery sends exactly the PNG with mentions disabled and deduplicates ret
|
|||||||
});
|
});
|
||||||
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
|
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
|
||||||
let posts = 0;
|
let posts = 0;
|
||||||
const { call, connect } = setup(async (_, init) => {
|
const { call, connect, advance } = setup(async (_, init) => {
|
||||||
if (init?.method !== "POST") return Response.json(channel);
|
if (init?.method !== "POST") return Response.json(channel);
|
||||||
posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
|
posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
|
||||||
});
|
});
|
||||||
await connect(); const id = crypto.randomUUID();
|
await connect(); const id = crypto.randomUUID();
|
||||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
||||||
|
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
||||||
|
advance(60);
|
||||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
|
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
|
||||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
|
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
|
||||||
});
|
});
|
||||||
@@ -103,3 +106,32 @@ test("invalid files and absent bot configuration cannot send", async () => {
|
|||||||
await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
|
await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
|
||||||
expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
|
expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("posting quotas block new deliveries, allow duplicate checks, and expire", async () => {
|
||||||
|
let posts = 0;
|
||||||
|
const { f, call, advance } = setup(async (_, init) => {
|
||||||
|
if (init?.method === "POST") { posts++; return Response.json({ id: "423456789012345678" }); }
|
||||||
|
return Response.json(channel);
|
||||||
|
});
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
|
||||||
|
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
|
||||||
|
const limited = await call("/discord/send", "POST", upload());
|
||||||
|
expect(limited.status).toBe(429); expect(limited.headers.get("retry-after")).toBe("60");
|
||||||
|
expect((await call("/discord/send", "POST", upload(), "b")).status).toBe(200);
|
||||||
|
expect(posts).toBe(2);
|
||||||
|
f.sqlite.query("UPDATE sharing_limits SET count = 10 WHERE key = ?").run(`channel:${channel.id}`);
|
||||||
|
advance(30);
|
||||||
|
expect((await call("/discord/send", "POST", upload())).status).toBe(429);
|
||||||
|
advance(30);
|
||||||
|
expect((await call("/discord/send", "POST", upload())).status).toBe(200);
|
||||||
|
expect(posts).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an allowlist blocks unauthorized posting without preventing exports", async () => {
|
||||||
|
const { call } = setup(undefined, { ...botConfig, allowedUserIds: ["bob"] });
|
||||||
|
expect((await call("/discord/send", "POST", upload())).status).toBe(403);
|
||||||
|
expect((await (await call("/discord")).json()).connected).toBe(false);
|
||||||
|
expect((await (await call("/discord", "GET", undefined, "b")).json()).connected).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, lt } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||||
import { discordDeliveries } from "../db/schema";
|
import { discordDeliveries, sharingLimits } from "../db/schema";
|
||||||
import { HabitService, ApiError } from "../habits/service";
|
import { HabitService, ApiError } from "../habits/service";
|
||||||
import { shade } from "../habits/calendar";
|
import { shade } from "../habits/calendar";
|
||||||
import { shareInput, type ShareData } from "./contracts";
|
import { shareInput, type ShareData } from "./contracts";
|
||||||
@@ -93,11 +93,14 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
|||||||
});
|
});
|
||||||
app.get("/discord", async c => {
|
app.get("/discord", async c => {
|
||||||
if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
|
if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
|
||||||
|
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId))
|
||||||
|
return c.json({ connected: false, message: "Discord sharing is limited to approved community members. PNG download remains available." });
|
||||||
const destination = await channel();
|
const destination = await channel();
|
||||||
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||||
});
|
});
|
||||||
app.post("/discord/send", async c => {
|
app.post("/discord/send", async c => {
|
||||||
const userId = c.get("user").id;
|
const userId = c.get("user").id;
|
||||||
|
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
|
||||||
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||||
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
||||||
const id = z.string().uuid().safeParse(form.get("deliveryId"));
|
const id = z.string().uuid().safeParse(form.get("deliveryId"));
|
||||||
@@ -112,7 +115,38 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
|||||||
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||||
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||||
}
|
}
|
||||||
db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run();
|
const timestamp = now();
|
||||||
|
// Reserve both quotas and the delivery before any asynchronous outbound work.
|
||||||
|
// SQLite makes limits survive restarts and coordinate concurrent workers.
|
||||||
|
const retryAfter = db.transaction(tx => {
|
||||||
|
const raced = tx.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
|
||||||
|
if (raced) {
|
||||||
|
if (raced.userId !== userId || raced.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
|
||||||
|
const quotas = [
|
||||||
|
{ key: `channel:${config.channelId}`, maximum: 10 },
|
||||||
|
{ key: `channel:${config.channelId}:user:${c.get("user").discordId}`, maximum: 1 },
|
||||||
|
].map(quota => ({ ...quota, row: tx.select().from(sharingLimits).where(eq(sharingLimits.key, quota.key)).get() }));
|
||||||
|
const blocked = quotas.filter(({ row, maximum }) => row && timestamp < row.startedAt + 60000 && row.count >= maximum);
|
||||||
|
if (blocked.length) return Math.max(...blocked.map(({ row }) => Math.ceil((row!.startedAt + 60000 - timestamp) / 1000)));
|
||||||
|
for (const { key, row } of quotas) {
|
||||||
|
const current = row && timestamp < row.startedAt + 60000;
|
||||||
|
tx.insert(sharingLimits).values({ key, startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 })
|
||||||
|
.onConflictDoUpdate({ target: sharingLimits.key, set: { startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 } }).run();
|
||||||
|
}
|
||||||
|
tx.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: timestamp }).run();
|
||||||
|
return 0;
|
||||||
|
}, { behavior: "immediate" });
|
||||||
|
if (retryAfter === -1) {
|
||||||
|
const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!;
|
||||||
|
return c.json({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
|
||||||
|
}
|
||||||
|
if (retryAfter > 0) {
|
||||||
|
c.header("Retry-After", String(retryAfter));
|
||||||
|
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
|
||||||
|
}
|
||||||
const attachment = new FormData();
|
const attachment = new FormData();
|
||||||
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
|
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
|
||||||
attachment.set("files[0]", image, "minabot-progress.png");
|
attachment.set("files[0]", image, "minabot-progress.png");
|
||||||
|
|||||||
@@ -993,6 +993,12 @@
|
|||||||
@apply type-small;
|
@apply type-small;
|
||||||
}
|
}
|
||||||
.ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
.ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.ds-date-inspector-actions { flex-shrink: 0; margin-left: auto; }
|
||||||
|
.ds-date-inspector:has(.ds-date-inspector-actions) { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ds-date-inspector:has(.ds-date-inspector-actions) { align-items: stretch; flex-direction: column; gap: 18px; }
|
||||||
|
.ds-date-inspector-actions { margin-left: 36px; }
|
||||||
|
}
|
||||||
.ds-date-inspector-heading > svg { flex-shrink: 0; color: var(--ds-secondary); }
|
.ds-date-inspector-heading > svg { flex-shrink: 0; color: var(--ds-secondary); }
|
||||||
.ds-date-inspector-heading time { display: block; margin-top: 3px; @apply type-ui-heading; }
|
.ds-date-inspector-heading time { display: block; margin-top: 3px; @apply type-ui-heading; }
|
||||||
.ds-date-inspector-status { display: block; margin-top: 4px; color: var(--ds-secondary); overflow-wrap: anywhere; }
|
.ds-date-inspector-status { display: block; margin-top: 4px; color: var(--ds-secondary); overflow-wrap: anywhere; }
|
||||||
@@ -1405,10 +1411,6 @@
|
|||||||
.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; }
|
.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; }
|
||||||
.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; }
|
.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; }
|
||||||
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; }
|
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; }
|
||||||
.ds-share-panel { margin-top: 32px; padding: 32px; border: 1px solid var(--ds-rule); }
|
|
||||||
.ds-share-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 32px; }
|
|
||||||
.ds-share-heading h2 { margin-top: 8px; }
|
|
||||||
.ds-share-heading > .ds-button { flex: 0 0 auto; padding: 12px; }
|
|
||||||
.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; }
|
.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; }
|
||||||
.ds-share-controls { display: grid; gap: 24px; min-width: 0; }
|
.ds-share-controls { display: grid; gap: 24px; min-width: 0; }
|
||||||
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
|
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
|
||||||
@@ -1426,7 +1428,6 @@
|
|||||||
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
||||||
.ds-share-panel { padding: 20px; }
|
|
||||||
.ds-share-preview { padding: 12px; }
|
.ds-share-preview { padding: 12px; }
|
||||||
}
|
}
|
||||||
.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
||||||
@@ -1435,3 +1436,89 @@
|
|||||||
.ds-editable-task-row { gap: 4px; }
|
.ds-editable-task-row { gap: 4px; }
|
||||||
.ds-inline-item-form { padding: 16px; }
|
.ds-inline-item-form { padding: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Secondary workspace flows share the same flat surfaces and editorial hierarchy. */
|
||||||
|
.ds-action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.ds-action-row > .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; max-width: 100%; overflow-wrap: anywhere; }
|
||||||
|
.ds-action-row > .ds-button svg { flex: 0 0 auto; }
|
||||||
|
.ds-primary-actions { margin-top: 24px; }
|
||||||
|
.ds-utility-row { border-top: 1px solid var(--ds-rule); padding-top: 12px; margin-top: 20px; gap: 4px; }
|
||||||
|
.ds-utility-row .ds-button { color: var(--ds-secondary); @apply type-small; }
|
||||||
|
.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); }
|
||||||
|
.ds-panel-description { margin-top: 12px !important; color: var(--ds-secondary); max-width: 52ch; }
|
||||||
|
.ds-panel-close { flex: 0 0 44px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ds-rule); }
|
||||||
|
.ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 48px; padding: 28px 0; border-top: 1px solid var(--ds-rule); }
|
||||||
|
.ds-setting-row:last-of-type { padding-bottom: 0; }
|
||||||
|
.ds-setting-heading h3 { @apply type-ui-heading; }
|
||||||
|
.ds-setting-heading > div { display: grid; gap: 12px; margin-top: 10px; max-width: 32ch; color: var(--ds-secondary); @apply type-small; }
|
||||||
|
.ds-setting-controls { display: grid; gap: 16px; min-width: 0; align-content: start; }
|
||||||
|
.ds-form-stack { display: grid; gap: 20px; min-width: 0; align-content: start; }
|
||||||
|
.ds-form-stack .ds-field { margin-bottom: 0; }
|
||||||
|
.ds-supporting-copy, .ds-meta-label { color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; }
|
||||||
|
.ds-meta-label { font-variant-numeric: tabular-nums; }
|
||||||
|
.ds-clock-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.ds-clock-fields input { font-variant-numeric: tabular-nums; }
|
||||||
|
.ds-preference-toggle { padding: 16px; background: var(--ds-surface); border: 1px solid var(--ds-rule); }
|
||||||
|
.ds-preference-toggle .ds-checkbox-description { margin-top: 4px; }
|
||||||
|
.ds-delivery-note { display: grid; gap: 6px; padding: 14px 16px; border-left: 2px solid var(--ds-control-border); background: var(--ds-surface); @apply type-small; }
|
||||||
|
.ds-confirmation { padding: 24px; background: var(--ds-surface); border-left: 2px solid var(--ds-ink); }
|
||||||
|
.ds-form-message { @apply type-small; color: var(--ds-secondary); overflow-wrap: anywhere; }
|
||||||
|
.ds-form-message:empty { display: none; }
|
||||||
|
.ds-form-message:not(:empty) { padding: 12px 0; }
|
||||||
|
.ds-form-message--error:not(:empty) { padding: 12px 16px; border-left: 2px solid var(--ds-ink); background: var(--ds-surface); color: var(--ds-ink); }
|
||||||
|
.ds-record { border-top: 1px solid var(--ds-rule); min-width: 0; }
|
||||||
|
.ds-record-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 0; }
|
||||||
|
.ds-record-copy { min-width: 0; }
|
||||||
|
.ds-record-copy h3 { @apply type-title; overflow-wrap: anywhere; }
|
||||||
|
.ds-record-copy p { margin-top: 6px; color: var(--ds-secondary); @apply type-small; }
|
||||||
|
.ds-record-heading > .ds-action-row { flex-shrink: 0; }
|
||||||
|
.ds-disclosure-icon { transition: transform 160ms ease; }
|
||||||
|
[aria-expanded="true"] > .ds-disclosure-icon { transform: rotate(180deg); }
|
||||||
|
.ds-record-detail { padding: 8px 0 24px; }
|
||||||
|
.ds-empty-panel { display: flex; align-items: flex-start; gap: 18px; padding: 28px; background: var(--ds-surface); }
|
||||||
|
.ds-empty-panel > svg { flex-shrink: 0; color: var(--ds-secondary); }
|
||||||
|
.ds-empty-panel .ds-supporting-copy { margin-top: 8px; max-width: 48ch; }
|
||||||
|
.ds-history-actions { justify-content: flex-end; margin-top: 12px; }
|
||||||
|
.ds-history-editor { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 32px; padding: 24px; margin-top: 12px; border: 1px solid var(--ds-rule); background: var(--ds-surface); }
|
||||||
|
.ds-check-in-heading { display: grid; gap: 6px; }
|
||||||
|
.ds-check-in-controls { padding-left: 28px; border-left: 1px solid var(--ds-rule); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ds-setting-row { gap: 28px; grid-template-columns: minmax(160px, 0.8fr) minmax(0, 1.6fr); }
|
||||||
|
.ds-clock-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.ds-clock-fields > .ds-field:first-child { grid-column: 1 / -1; }
|
||||||
|
.ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ds-panel-description { @apply type-small; }
|
||||||
|
.ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; }
|
||||||
|
.ds-setting-heading > div { max-width: none; }
|
||||||
|
.ds-confirmation, .ds-empty-panel { padding: 18px; }
|
||||||
|
.ds-history-editor { grid-template-columns: minmax(0, 1fr); padding: 20px; gap: 24px; }
|
||||||
|
.ds-check-in-controls { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--ds-rule); }
|
||||||
|
.ds-history-actions { justify-content: flex-start; }
|
||||||
|
.ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } }
|
||||||
|
|
||||||
|
/* Shared top-layer modals keep temporary flows out of the dashboard layout. */
|
||||||
|
.ds-modal { width: min(1040px, calc(100vw - 64px)); max-width: none; max-height: calc(100dvh - 64px); margin: auto; padding: 0; border: 1px solid var(--ds-rule); background: var(--ds-paper); color: var(--ds-ink); overflow: hidden; box-shadow: 0 24px 80px #00000026; }
|
||||||
|
.ds-modal[open] { display: flex; flex-direction: column; }
|
||||||
|
.ds-modal--compact { width: min(660px, calc(100vw - 64px)); }
|
||||||
|
.ds-modal::backdrop { background: #11111166; backdrop-filter: blur(3px); }
|
||||||
|
.ds-modal-heading { display: flex; flex: 0 0 auto; justify-content: space-between; align-items: flex-start; gap: 24px; padding: 28px 32px 24px; border-bottom: 1px solid var(--ds-rule); }
|
||||||
|
.ds-modal-heading > div { min-width: 0; }
|
||||||
|
.ds-modal-heading h2 { margin-top: 8px; @apply type-section; overflow-wrap: anywhere; }
|
||||||
|
.ds-modal-heading h2:focus { outline: none; }
|
||||||
|
.ds-modal-heading .ds-panel-description { @apply type-small; }
|
||||||
|
.ds-modal-body { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 28px 32px 32px; scrollbar-gutter: stable; }
|
||||||
|
.ds-modal-body > .ds-setting-row:first-child { border-top: 0; padding-top: 0; }
|
||||||
|
.ds-modal-body > .ds-record-list > .ds-record:first-child { border-top: 0; }
|
||||||
|
.ds-modal-body > .ds-record-list > .ds-record:first-child > .ds-record-heading { padding-top: 0; }
|
||||||
|
.ds-modal-form { min-width: 0; }
|
||||||
|
.ds-modal-body > .ds-history-editor { border: 0; background: transparent; padding: 0; margin: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ds-modal, .ds-modal--compact { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
|
||||||
|
.ds-modal-heading { padding: 20px; gap: 16px; }
|
||||||
|
.ds-modal-heading h2 { @apply type-title; }
|
||||||
|
.ds-modal-body { padding: 20px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user