diff --git a/.env.example b/.env.example index 766fc7d..398d62d 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ BACKUP_RETAIN=7 BACKUP_REPLICA_DIR= # Optional public HTTPS health URL for the independent monitor. MONITOR_URL= +# Discord Developer Portal > General Information > Public Key. +# Interactions endpoint: https://YOUR_PRODUCTION_HOST/api/discord/interactions +DISCORD_PUBLIC_KEY= diff --git a/drizzle/0012_discord_commands.sql b/drizzle/0012_discord_commands.sql new file mode 100644 index 0000000..9505867 --- /dev/null +++ b/drizzle/0012_discord_commands.sql @@ -0,0 +1,20 @@ +CREATE TABLE `discord_controls` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `action` text NOT NULL, + `payload` text NOT NULL, + `expires_at` integer NOT NULL, + `used` integer DEFAULT false NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `discord_controls_expiry_idx` ON `discord_controls` (`expires_at`);--> statement-breakpoint +CREATE TABLE `discord_receipts` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `response` text NOT NULL, + `expires_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `discord_receipts_expiry_idx` ON `discord_receipts` (`expires_at`); \ No newline at end of file diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..c85acee --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1217 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "39a7a2c2-c411-4e0a-ba41-9e4abdc8fbcf", + "prevId": "31d42afd-5a60-4cbe-8188-d9aff933e13e", + "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_controls": { + "name": "discord_controls", + "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 + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "discord_controls_expiry_idx": { + "name": "discord_controls_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "discord_controls_user_id_users_id_fk": { + "name": "discord_controls_user_id_users_id_fk", + "tableFrom": "discord_controls", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": {} + }, + "discord_receipts": { + "name": "discord_receipts", + "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 + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "discord_receipts_expiry_idx": { + "name": "discord_receipts_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "discord_receipts_user_id_users_id_fk": { + "name": "discord_receipts_user_id_users_id_fk", + "tableFrom": "discord_receipts", + "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": {} + }, + "live_discord_cards": { + "name": "live_discord_cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dirty": { + "name": "dirty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "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": {}, + "foreignKeys": { + "live_discord_cards_user_id_users_id_fk": { + "name": "live_discord_cards_user_id_users_id_fk", + "tableFrom": "live_discord_cards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'*'" + }, + "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_slot_idx": { + "name": "reminder_deliveries_user_date_slot_idx", + "columns": [ + "user_id", + "date", + "slot" + ], + "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'" + }, + "reminders": { + "name": "reminders", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e89ed83..4dde595 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1788845281801, "tag": "0011_multiple_reminders", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1788847978168, + "tag": "0012_discord_commands", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index aa6a06e..a45c815 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,3 +1,4 @@ +import { createDiscordRoutes, type CommandConfig } from "./discord/routes"; import { createLiveDiscord } from "./sharing/live-discord"; import { createReminderRoutes } from "./reminders/routes"; import { errorDetails } from "./ops/monitor"; @@ -12,7 +13,7 @@ import type { FetchDiscord } from "./auth/discord"; import { createSharingRoutes, type DiscordFetch } from "./sharing/routes"; import type { DiscordSharingConfig } from "./sharing/config"; -export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record) => void } = {}) { +export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record) => void; discord?: CommandConfig } = {}) { const app = new Hono(); app.use("*", async (c, next) => { const requestId = crypto.randomUUID(); @@ -32,6 +33,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503); return c.json({ status: "ok" }); }); + app.route("/api/discord/interactions", createDiscordRoutes(db, operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }, now ?? Date.now, liveSharing.changed)); app.route("/api/sharing/live", liveSharing.routes); app.route("/api/auth", auth.routes); app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now)); diff --git a/src/auth/index.ts b/src/auth/index.ts index 0477dda..b50e523 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -26,7 +26,7 @@ export function normalizeTimezone(value: unknown): string { } } -function publicUser(user: typeof users.$inferSelect): PublicUser { +export function publicUser(user: typeof users.$inferSelect): PublicUser { return { id: user.id, discordId: user.discordId, diff --git a/src/db/schema.ts b/src/db/schema.ts index 5c90b1b..7e2cd37 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -101,3 +101,15 @@ export const liveDiscordCards = sqliteTable('live_discord_cards', { retryAt: integer('retry_at'), updatedAt: integer('updated_at').notNull(), }); + +// Short-lived UI state and replay receipts; no Discord interaction tokens are persisted. +export const discordControls = sqliteTable('discord_controls', { + id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + action: text('action').notNull(), payload: text('payload', { mode: 'json' }).$type().notNull(), + expiresAt: integer('expires_at').notNull(), used: integer('used', { mode: 'boolean' }).notNull().default(false), +}, t => [index('discord_controls_expiry_idx').on(t.expiresAt)]); +export const discordReceipts = sqliteTable('discord_receipts', { + id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + response: text('response', { mode: 'json' }).$type().notNull(), + expiresAt: integer('expires_at').notNull(), +}, t => [index('discord_receipts_expiry_idx').on(t.expiresAt)]); diff --git a/src/discord/README.md b/src/discord/README.md new file mode 100644 index 0000000..a59823b --- /dev/null +++ b/src/discord/README.md @@ -0,0 +1,21 @@ +# Discord interface + +Signed HTTP interactions enter `/api/discord/interactions`. The raw request body +is verified with the application's Ed25519 public key and a five-minute timestamp +window. `DISCORD_PUBLIC_KEY` enables the endpoint; `DISCORD_CLIENT_ID` identifies +the application. Commands are global with contexts `[0, 1]` (server and bot DM). + +`HabitService` remains the shared domain implementation. Verified Discord user IDs +resolve the same account as website OAuth; new accounts start in UTC. Server +responses are ephemeral. Buttons/forms carry opaque persisted owner-bound state, +expire after 15 minutes, and mutation controls are consumed after successful use. +Interaction IDs retain replay receipts for 24 hours. Neither table stores tokens. + +`daily.ts` implements the initial `/today` workflow. `types.ts`, `ui.ts`, and +`state.ts` provide the shared feature building blocks. Feature tasks extend command +registration and route dispatch while keeping their behavior in separate modules. + +Tests use real Ed25519 signatures and disposable migrated SQLite databases. The +foundation tests cover server/DM privacy, replay after restart, stale progress, +cross-account controls, and matching website state. Command registration and live +Discord validation will be completed during integration. diff --git a/src/discord/commands.ts b/src/discord/commands.ts new file mode 100644 index 0000000..e198f31 --- /dev/null +++ b/src/discord/commands.ts @@ -0,0 +1,4 @@ +// Global commands are required for bot DMs; guild-only registrations cannot run there. +export const commands = [ + { name: 'today', description: 'View and complete today’s habits' }, +].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); diff --git a/src/discord/daily.ts b/src/discord/daily.ts new file mode 100644 index 0000000..0ee1a0b --- /dev/null +++ b/src/discord/daily.ts @@ -0,0 +1,50 @@ +import { ApiError } from '../habits/service'; +import { progressInput } from '../habits/contracts'; +import type { Context, ControlData, Reply } from './types'; +import { fields } from './types'; +import { button, message, modal, pages, row, select, text } from './ui'; +export function today(c: Context, data: ControlData = {}): Reply { + if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.'); + const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due); + const page = data.page ?? 0, visible = all.slice(page * 10, page * 10 + 10); + return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`, + [...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', data, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder'); +} +export function detail(c: Context, data: ControlData): Reply { + const date = data.date ?? c.service.today; + if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.'); + const d = c.service.day(data.habitId!, date); + const state: ControlData = { ...data, date, expected: JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])]) }; + const controls = []; + if (d.due && !d.future) { + if (d.method === 'manual') controls.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, 1))); + if (d.method === 'count') controls.push(row(button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1), button(c, 'Enter amount', 'daily.amount', state))); + if (d.method === 'tasks') { + const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10); + controls.push(select(c, 'Choose a task to change its completion', 'daily.task', state, visible.map(t => ({ id: t.taskId, name: `${t.done ? '✓ ' : '○ '}${t.name}` })))); + controls.push(...pages(c, 'daily.detail', data, d.tasks.length)); + } + } + controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'))); + return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls); +} +export function dailyAction(c: Context, action: string, data: ControlData): Reply | undefined { + if (action === 'today') return today(c, data); + if (action === 'daily.select') return detail(c, { ...data, habitId: c.interaction.data?.values?.[0] }); + if (action === 'daily.detail') return detail(c, data); + if (action === 'daily.amount') return modal(c, 'Record amount', 'daily.save', data, [{ id: 'amount', label: 'Total for this date', value: String(c.service.day(data.habitId!, data.date!).value), max: 10 }]); + if (action === 'daily.save' || action === 'daily.task') { + if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.'); + const d = c.service.day(data.habitId!, data.date!); + if (data.expected !== JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])])) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.'); + if (action === 'daily.task') { + const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]); + if (!task) throw new ApiError(404, 'Task not found.'); + c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done); + } else { + const amount = fields(c.interaction).amount; + c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN })); + } + c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data); + } +} diff --git a/src/discord/routes.test.ts b/src/discord/routes.test.ts new file mode 100644 index 0000000..5a9f56d --- /dev/null +++ b/src/discord/routes.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { discordFixture, control, submit } from './test-fixture'; +import { commands } from './commands'; +let f: ReturnType; +afterEach(() => f?.close()); +describe('signed Discord interactions', () => { + test('registers commands globally for servers and bot DMs', () => { + for (const c of commands) { expect(c.contexts).toEqual([0, 1]); expect(c.integration_types).toEqual([0]); } + }); + test('rejects forged and old signatures and answers Discord PING', async () => { + f = discordFixture(); + expect((await f.requestDiscord(f.payload({ type: 1 }), { 'X-Signature-Ed25519': '0'.repeat(128) })).status).toBe(401); + expect((await f.requestDiscord(f.payload({ type: 1 }), { 'X-Signature-Timestamp': '1000000000' })).status).toBe(401); + expect(await f.run({ type: 1 })).toEqual({ type: 1 }); + expect((await f.requestDiscord(f.payload({ application_id: 'wrong' }))).status).toBe(400); + }); + for (const guild of [false, true]) test(`records progress from ${guild ? 'server' : 'DM'} and shares website state`, async () => { + f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Reading', method: 'count', target: 10, unit: 'pages' }, 201); + const today = await f.run({ data: { name: 'today' } }, guild); + expect(today.data.flags).toBe(guild ? 64 : undefined); + const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Choose a habit'), values: [h.id] } }, guild); + const click = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(detail, '+1') } }, guild); + expect((await (await f.requestDiscord(click)).json()).type).toBe(7); + f.restart(); await f.requestDiscord(click); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1); + expect(f.changed).toEqual(['alice']); + const stale = await f.run({ type: 3, data: { custom_id: control(detail, 'Enter amount') } }, guild); + const failed = await f.run(submit(stale.data.custom_id, { amount: '7' }), guild); + expect(failed.data.content).toContain('Progress changed'); + }); + test('owner-bound buttons and habit IDs prevent cross-account access', async () => { + f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Private', method: 'manual' }, 201); + const a = await f.run({ data: { name: 'today' } }); + const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Choose a habit'), values: [h.id] } }, true, f.bob); + expect(blocked.data.content).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64); + const b = await f.run({ data: { name: 'today' } }, false, f.bob); + expect(b.data.content).not.toContain('Private'); + }); + test('old daily buttons cannot correct history accidentally', async () => { + f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201); + const t = await f.run({ data: { name: 'today' } }); + const d = await f.run({ type: 3, data: { custom_id: control(t, 'Choose a habit'), values: [h.id] } }); + f.setDiscordTime('2026-09-05T12:00:00Z'); + expect((await f.run({ type: 3, data: { custom_id: control(d, 'Mark complete') } })).data.content).toContain('expired'); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0); + }); +}); diff --git a/src/discord/routes.ts b/src/discord/routes.ts new file mode 100644 index 0000000..89b077c --- /dev/null +++ b/src/discord/routes.ts @@ -0,0 +1,62 @@ +import { createPublicKey, verify } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { ZodError } from 'zod'; +import { publicUser, type AppDatabase } from '../auth'; +import { users, discordReceipts } from '../db/schema'; +import { ApiError, HabitService } from '../habits/service'; +import { ControlStore } from './state'; +import { interactionSchema, type Context, type Reply } from './types'; +import { dailyAction, today } from './daily'; +import { message } from './ui'; + +export type CommandConfig = { publicKey: string; applicationId: string; origin: string }; +export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) { + const app = new Hono(); + const key = /^[a-f\d]{64}$/i.test(config.publicKey) ? createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(config.publicKey, 'hex')]), format: 'der', type: 'spki' }) : null; + app.use('*', bodyLimit({ maxSize: 64 * 1024 })); + app.post('/', async c => { + c.header('Cache-Control', 'no-store'); + if (!key) return c.json({ error: 'Discord commands are not configured.' }, 503); + const signature = c.req.header('X-Signature-Ed25519') ?? '', timestamp = c.req.header('X-Signature-Timestamp') ?? ''; + const body = await c.req.text(); + if (!/^\d{10,12}$/.test(timestamp) || Math.abs(now() - Number(timestamp) * 1000) > 300000 || !/^[a-f\d]{128}$/i.test(signature) || !verify(null, Buffer.from(timestamp + body), key, Buffer.from(signature, 'hex'))) + return c.json({ error: 'Invalid request signature.' }, 401); + let value: unknown; + try { value = JSON.parse(body); } catch { return c.json({ error: "Malformed JSON." }, 400); } + const parsed = interactionSchema.safeParse(value); + if (!parsed.success || parsed.data.application_id !== config.applicationId) return c.json({ error: 'Invalid interaction.' }, 400); + const i = parsed.data; + if (i.type === 1) return c.json({ type: 1 }); + const profile = i.guild_id ? i.member?.user : i.user; + if (!profile || ![2, 3, 4, 5].includes(i.type)) return c.json({ error: 'Unsupported interaction.' }, 400); + const reply = db.transaction((): Reply => { + const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); + const userId = existing?.id ?? crypto.randomUUID(); + if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run(); + const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get(); + if (receipt && receipt.expiresAt > now()) return receipt.response; + const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!); + const controls = new ControlStore(db, userId, now()); controls.prune(); + const service = new HabitService(db, user, now()); service.sync(); + const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, changed: () => changed(userId) }; + let result: Reply; + try { + if (i.type === 4) result = { type: 8, data: { choices: [] } }; + else if (i.type === 2 && i.data?.name === 'today') result = today(ctx); + else if ([3, 5].includes(i.type)) { + const state = controls.read(i.data?.custom_id ?? ''); + result = dailyAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /today to get started.', [], false); + } else result = message(ctx, 'Run /today to get started.', [], false); + } catch (error) { + if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; + result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false); + } + db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run(); + return result; + }); + return c.json(reply); + }); + return app; +} diff --git a/src/discord/state.ts b/src/discord/state.ts new file mode 100644 index 0000000..35b9895 --- /dev/null +++ b/src/discord/state.ts @@ -0,0 +1,29 @@ +import { randomBytes } from 'node:crypto'; +import { eq, lt } from 'drizzle-orm'; +import type { AppDatabase } from '../auth'; +import { discordControls, discordReceipts } from '../db/schema'; +import { ApiError } from '../habits/service'; +import type { ControlData } from './types'; + +export class ControlStore { + constructor(readonly db: AppDatabase, readonly userId: string, readonly now: number) {} + create(action: string, payload: ControlData = {}, lifetime = 15 * 60000) { + const id = randomBytes(18).toString('base64url'); + this.db.insert(discordControls).values({ id, userId: this.userId, action, payload, expiresAt: this.now + lifetime }).run(); + return `mina:${id}`; + } + read(customId: string) { + const row = this.db.select().from(discordControls).where(eq(discordControls.id, customId.replace(/^mina:/, ''))).get(); + if (!customId.startsWith('mina:') || !row || row.userId !== this.userId || row.expiresAt <= this.now || row.used) + throw new ApiError(409, 'This control expired or belongs to another user. Run the command again.'); + return row; + } + consume(customId: string) { + const row = this.read(customId); + this.db.update(discordControls).set({ used: true }).where(eq(discordControls.id, row.id)).run(); + } + prune() { + this.db.delete(discordControls).where(lt(discordControls.expiresAt, this.now)).run(); + this.db.delete(discordReceipts).where(lt(discordReceipts.expiresAt, this.now)).run(); + } +} diff --git a/src/discord/test-fixture.ts b/src/discord/test-fixture.ts new file mode 100644 index 0000000..252ab5d --- /dev/null +++ b/src/discord/test-fixture.ts @@ -0,0 +1,35 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { fixture } from '../habits/test-fixture'; +import { users } from '../db/schema'; +import { createDiscordRoutes } from './routes'; +import type { Interaction } from './types'; +export function discordFixture() { + const f = fixture(); + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex'); + let time = Date.parse('2026-09-04T12:00:00Z'), sequence = 10000000000000000n; + const alice = '111111111111111111', bob = '222222222222222222', applicationId = '333333333333333333'; + f.db.update(users).set({ discordId: alice }).where(eq(users.id, 'alice')).run(); + f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run(); + const changed: string[] = []; + let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id)); + function payload(data: Partial, guild = false, user = alice): Interaction { + const profile = { id: user, username: user === alice ? 'alice' : 'bob' }; + return { id: String(++sequence), application_id: applicationId, type: 2, ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data }; + } + async function request(i: Interaction, headers: Record = {}) { + const body = JSON.stringify(i), timestamp = String(time / 1000); + return app.request('http://localhost/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature-Timestamp': timestamp, 'X-Signature-Ed25519': sign(null, Buffer.from(timestamp + body), privateKey).toString('hex'), ...headers }, body }); + } + async function run(data: Partial, guild = false, user = alice): Promise { return (await request(payload(data, guild, user))).json(); } + return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } }; +} +export function control(reply: any, label: string): string { + const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label); + if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`); + return item.custom_id; +} +export function submit(custom_id: string, values: Record) { + return { type: 5, message: { id: '123' }, data: { custom_id, components: Object.entries(values).map(([custom_id, value]) => ({ type: 1, components: [{ type: 4, custom_id, value }] })) } }; +} diff --git a/src/discord/types.ts b/src/discord/types.ts new file mode 100644 index 0000000..ccf381f --- /dev/null +++ b/src/discord/types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import type { AppDatabase } from '../auth'; +import type { HabitService } from '../habits/service'; +import type { ControlStore } from './state'; + +const option: z.ZodType