mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
Implement server-side push notifications with scheduler and always-remind option
This commit is contained in:
@@ -28,6 +28,8 @@ FROM node:20-alpine AS runner
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV DATABASE_PATH=/app/data/quietthanks.db
|
ENV DATABASE_PATH=/app/data/quietthanks.db
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,25 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/quietthanks.db
|
- DATABASE_PATH=/app/data/quietthanks.db
|
||||||
|
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BIKukAq5-KPwJAMpksxD7UNL8XfF-oJOI0CLGGZQAY93igZgf1PYa9MVvS8GaBv-vv9ckcXPCEKdzWDCtOyQpKg
|
||||||
|
- VAPID_PRIVATE_KEY=IBkQ14BLKFCg2PmGOWheC7xfYHS5J49vXS8duHCeDBw
|
||||||
|
- VAPID_EMAIL=mailto:admin@quietthanks.local
|
||||||
|
- TZ=Australia/Perth
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
image: alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
quietthanks:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
- TZ=Australia/Perth
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command: >
|
||||||
|
-c "apk add --no-cache curl &&
|
||||||
|
while true; do
|
||||||
|
echo 'Checking for notifications...' &&
|
||||||
|
curl -s -X POST http://quietthanks:3000/api/notifications/send &&
|
||||||
|
echo '' &&
|
||||||
|
sleep 60;
|
||||||
|
done"
|
||||||
|
|||||||
11
drizzle/0004_sweet_gravity.sql
Normal file
11
drizzle/0004_sweet_gravity.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE `push_subscriptions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`endpoint` text NOT NULL,
|
||||||
|
`p256dh` text NOT NULL,
|
||||||
|
`auth` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `push_subscriptions_endpoint_unique` ON `push_subscriptions` (`endpoint`);
|
||||||
1
drizzle/0005_previous_junta.sql
Normal file
1
drizzle/0005_previous_junta.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` ADD `reminder_always` integer DEFAULT 0 NOT NULL;
|
||||||
433
drizzle/meta/0004_snapshot.json
Normal file
433
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "ad5a452a-05fe-4e4e-b2e7-b14058c0370c",
|
||||||
|
"prevId": "ef0ed770-bbc5-44b8-8842-fb51ffcde604",
|
||||||
|
"tables": {
|
||||||
|
"entries": {
|
||||||
|
"name": "entries",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"name": "text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mood": {
|
||||||
|
"name": "mood",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rough_day": {
|
||||||
|
"name": "rough_day",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"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": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"entries_user_id_users_id_fk": {
|
||||||
|
"name": "entries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "entries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"entry_tags": {
|
||||||
|
"name": "entry_tags",
|
||||||
|
"columns": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "entry_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag_id": {
|
||||||
|
"name": "tag_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"entry_tags_entry_id_entries_id_fk": {
|
||||||
|
"name": "entry_tags_entry_id_entries_id_fk",
|
||||||
|
"tableFrom": "entry_tags",
|
||||||
|
"tableTo": "entries",
|
||||||
|
"columnsFrom": [
|
||||||
|
"entry_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"entry_tags_tag_id_tags_id_fk": {
|
||||||
|
"name": "entry_tags_tag_id_tags_id_fk",
|
||||||
|
"tableFrom": "entry_tags",
|
||||||
|
"tableTo": "tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"entry_tags_entry_id_tag_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"entry_id",
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"name": "entry_tags_entry_id_tag_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"push_subscriptions": {
|
||||||
|
"name": "push_subscriptions",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"name": "endpoint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"p256dh": {
|
||||||
|
"name": "p256dh",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"name": "auth",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"push_subscriptions_endpoint_unique": {
|
||||||
|
"name": "push_subscriptions_endpoint_unique",
|
||||||
|
"columns": [
|
||||||
|
"endpoint"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"push_subscriptions_user_id_users_id_fk": {
|
||||||
|
"name": "push_subscriptions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "push_subscriptions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tags_user_id_users_id_fk": {
|
||||||
|
"name": "tags_user_id_users_id_fk",
|
||||||
|
"tableFrom": "tags",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_admin": {
|
||||||
|
"name": "is_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"reminder_enabled": {
|
||||||
|
"name": "reminder_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"reminder_time": {
|
||||||
|
"name": "reminder_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"llm_provider": {
|
||||||
|
"name": "llm_provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"llm_api_key": {
|
||||||
|
"name": "llm_api_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"llm_model": {
|
||||||
|
"name": "llm_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
441
drizzle/meta/0005_snapshot.json
Normal file
441
drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "783b3d85-d1bd-470a-943b-469f6177c228",
|
||||||
|
"prevId": "ad5a452a-05fe-4e4e-b2e7-b14058c0370c",
|
||||||
|
"tables": {
|
||||||
|
"entries": {
|
||||||
|
"name": "entries",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"name": "text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mood": {
|
||||||
|
"name": "mood",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rough_day": {
|
||||||
|
"name": "rough_day",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"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": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"entries_user_id_users_id_fk": {
|
||||||
|
"name": "entries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "entries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"entry_tags": {
|
||||||
|
"name": "entry_tags",
|
||||||
|
"columns": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "entry_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag_id": {
|
||||||
|
"name": "tag_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"entry_tags_entry_id_entries_id_fk": {
|
||||||
|
"name": "entry_tags_entry_id_entries_id_fk",
|
||||||
|
"tableFrom": "entry_tags",
|
||||||
|
"tableTo": "entries",
|
||||||
|
"columnsFrom": [
|
||||||
|
"entry_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"entry_tags_tag_id_tags_id_fk": {
|
||||||
|
"name": "entry_tags_tag_id_tags_id_fk",
|
||||||
|
"tableFrom": "entry_tags",
|
||||||
|
"tableTo": "tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"entry_tags_entry_id_tag_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"entry_id",
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"name": "entry_tags_entry_id_tag_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"push_subscriptions": {
|
||||||
|
"name": "push_subscriptions",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"name": "endpoint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"p256dh": {
|
||||||
|
"name": "p256dh",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"name": "auth",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"push_subscriptions_endpoint_unique": {
|
||||||
|
"name": "push_subscriptions_endpoint_unique",
|
||||||
|
"columns": [
|
||||||
|
"endpoint"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"push_subscriptions_user_id_users_id_fk": {
|
||||||
|
"name": "push_subscriptions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "push_subscriptions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tags_user_id_users_id_fk": {
|
||||||
|
"name": "tags_user_id_users_id_fk",
|
||||||
|
"tableFrom": "tags",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_admin": {
|
||||||
|
"name": "is_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"reminder_enabled": {
|
||||||
|
"name": "reminder_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"reminder_always": {
|
||||||
|
"name": "reminder_always",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"reminder_time": {
|
||||||
|
"name": "reminder_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"llm_provider": {
|
||||||
|
"name": "llm_provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"llm_api_key": {
|
||||||
|
"name": "llm_api_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"llm_model": {
|
||||||
|
"name": "llm_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,20 @@
|
|||||||
"when": 1769257297672,
|
"when": 1769257297672,
|
||||||
"tag": "0003_blue_korg",
|
"tag": "0003_blue_korg",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769308162051,
|
||||||
|
"tag": "0004_sweet_gravity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769310687405,
|
||||||
|
"tag": "0005_previous_junta",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1142
package-lock.json
generated
1142
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,9 @@
|
|||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"uuid": "^13.0.0"
|
"sqlite3": "^5.1.7",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.3",
|
"eslint-config-next": "16.1.3",
|
||||||
|
|||||||
79
src/app/api/notifications/send/route.ts
Normal file
79
src/app/api/notifications/send/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { sendPushNotification } from "@/lib/push";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
const currentMinute = now.getMinutes();
|
||||||
|
|
||||||
|
// Format: HH:MM
|
||||||
|
const timeString = `${currentHour.toString().padStart(2, "0")}:${currentMinute.toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
|
// Find users who have reminders enabled and time matches
|
||||||
|
const users = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(and(eq(schema.users.reminderEnabled, 1), eq(schema.users.reminderTime, timeString)));
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
// Check if user has already made an entry today, unless they want reminders always
|
||||||
|
if (user.reminderAlways !== 1) {
|
||||||
|
const today = now.toISOString().split("T")[0];
|
||||||
|
const entries = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(and(eq(schema.entries.userId, user.id), eq(schema.entries.date, today)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
continue; // Already journaled today
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscriptions
|
||||||
|
const subscriptions = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.pushSubscriptions)
|
||||||
|
.where(eq(schema.pushSubscriptions.userId, user.id));
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
try {
|
||||||
|
const success = await sendPushNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
p256dh: sub.p256dh,
|
||||||
|
auth: sub.auth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Time to reflect",
|
||||||
|
body: "Take a moment to record what you're grateful for today.",
|
||||||
|
url: "/new",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
sent++;
|
||||||
|
} else {
|
||||||
|
// Expired
|
||||||
|
await db.delete(schema.pushSubscriptions).where(eq(schema.pushSubscriptions.id, sub.id));
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Push error:", error);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ sent, failed });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Scheduler error:", error);
|
||||||
|
return NextResponse.json({ error: "Scheduler failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/notifications/subscribe/route.ts
Normal file
71
src/app/api/notifications/subscribe/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!VAPID_PUBLIC_KEY) {
|
||||||
|
return NextResponse.json({ error: "Push notifications not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ publicKey: VAPID_PUBLIC_KEY });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const user = await getSession();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { subscription } = await request.json();
|
||||||
|
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||||
|
return NextResponse.json({ error: "Invalid subscription" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.pushSubscriptions)
|
||||||
|
.where(eq(schema.pushSubscriptions.endpoint, subscription.endpoint))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await db.insert(schema.pushSubscriptions).values({
|
||||||
|
id: uuidv4(),
|
||||||
|
userId: user.id,
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: subscription.keys.p256dh,
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Subscription error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to subscribe" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const user = await getSession();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const endpoint = searchParams.get("endpoint");
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return NextResponse.json({ error: "Endpoint required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.pushSubscriptions)
|
||||||
|
.where(eq(schema.pushSubscriptions.endpoint, endpoint));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export async function GET() {
|
|||||||
.select({
|
.select({
|
||||||
reminderEnabled: schema.users.reminderEnabled,
|
reminderEnabled: schema.users.reminderEnabled,
|
||||||
reminderTime: schema.users.reminderTime,
|
reminderTime: schema.users.reminderTime,
|
||||||
|
reminderAlways: schema.users.reminderAlways,
|
||||||
llmProvider: schema.users.llmProvider,
|
llmProvider: schema.users.llmProvider,
|
||||||
llmApiKey: schema.users.llmApiKey,
|
llmApiKey: schema.users.llmApiKey,
|
||||||
llmModel: schema.users.llmModel,
|
llmModel: schema.users.llmModel,
|
||||||
@@ -30,6 +31,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
reminderEnabled: users[0].reminderEnabled === 1,
|
reminderEnabled: users[0].reminderEnabled === 1,
|
||||||
reminderTime: users[0].reminderTime || "20:00",
|
reminderTime: users[0].reminderTime || "20:00",
|
||||||
|
reminderAlways: users[0].reminderAlways === 1,
|
||||||
llmProvider: users[0].llmProvider || null,
|
llmProvider: users[0].llmProvider || null,
|
||||||
hasLlmKey: Boolean(users[0].llmApiKey),
|
hasLlmKey: Boolean(users[0].llmApiKey),
|
||||||
llmModel: users[0].llmModel || null,
|
llmModel: users[0].llmModel || null,
|
||||||
@@ -55,6 +57,10 @@ export async function PATCH(request: NextRequest) {
|
|||||||
updates.reminderEnabled = body.reminderEnabled ? 1 : 0;
|
updates.reminderEnabled = body.reminderEnabled ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof body.reminderAlways === "boolean") {
|
||||||
|
updates.reminderAlways = body.reminderAlways ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof body.reminderTime === "string") {
|
if (typeof body.reminderTime === "string") {
|
||||||
updates.reminderTime = body.reminderTime;
|
updates.reminderTime = body.reminderTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Link from "next/link";
|
|||||||
interface UserSettings {
|
interface UserSettings {
|
||||||
reminderEnabled: boolean;
|
reminderEnabled: boolean;
|
||||||
reminderTime: string;
|
reminderTime: string;
|
||||||
|
reminderAlways: boolean;
|
||||||
llmProvider: string | null;
|
llmProvider: string | null;
|
||||||
hasLlmKey: boolean;
|
hasLlmKey: boolean;
|
||||||
llmModel: string | null;
|
llmModel: string | null;
|
||||||
@@ -30,7 +31,7 @@ const PROVIDERS = [
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { isSupported: swSupported, isRegistered: swRegistered, showNotification } = useServiceWorker();
|
const { isSupported: swSupported, isRegistered: swRegistered, showNotification, subscribeToPush } = useServiceWorker();
|
||||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -78,6 +79,13 @@ export default function SettingsPage() {
|
|||||||
setIsPWA(isStandalone);
|
setIsPWA(isStandalone);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Ensure push subscription is active if permissions are granted
|
||||||
|
useEffect(() => {
|
||||||
|
if (notificationPermission === "granted" && swRegistered) {
|
||||||
|
subscribeToPush().catch(console.error);
|
||||||
|
}
|
||||||
|
}, [notificationPermission, swRegistered, subscribeToPush]);
|
||||||
|
|
||||||
const updateSetting = async (key: string, value: unknown) => {
|
const updateSetting = async (key: string, value: unknown) => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -102,6 +110,14 @@ export default function SettingsPage() {
|
|||||||
setNotificationPermission(permission);
|
setNotificationPermission(permission);
|
||||||
if (permission === "granted") {
|
if (permission === "granted") {
|
||||||
updateSetting("reminderEnabled", true);
|
updateSetting("reminderEnabled", true);
|
||||||
|
const subscribed = await subscribeToPush();
|
||||||
|
if (subscribed) {
|
||||||
|
showNotification(APP_NAME, {
|
||||||
|
body: "Notifications enabled! You will receive daily reminders.",
|
||||||
|
icon: "/icons/icon.svg",
|
||||||
|
tag: "welcome-notification",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -308,7 +324,8 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{settings.reminderEnabled && (
|
{settings.reminderEnabled && (
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-sm text-muted">Remind me at:</label>
|
<label className="text-sm text-muted">Remind me at:</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
@@ -317,6 +334,19 @@ export default function SettingsPage() {
|
|||||||
className="px-3 py-1 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
className="px-3 py-1 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="reminderAlways"
|
||||||
|
checked={settings.reminderAlways}
|
||||||
|
onChange={(e) => updateSetting("reminderAlways", e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<label htmlFor="reminderAlways" className="text-sm text-muted cursor-pointer">
|
||||||
|
Always remind me, even if I've already journaled today
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{settings.reminderEnabled && notificationPermission === "granted" && swRegistered && (
|
{settings.reminderEnabled && notificationPermission === "granted" && swRegistered && (
|
||||||
<div className="mt-3 flex items-center justify-between">
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
|||||||
75
src/components/ReminderScheduler.tsx
Normal file
75
src/components/ReminderScheduler.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useServiceWorker } from "./ServiceWorkerProvider";
|
||||||
|
import { useAuth } from "./AuthProvider";
|
||||||
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function ReminderScheduler() {
|
||||||
|
const { showNotification } = useServiceWorker();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const lastShownMinuteRef = useRef<number>(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
let settings: { reminderEnabled: boolean; reminderTime: string } | null = null;
|
||||||
|
|
||||||
|
async function fetchSettings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
settings = {
|
||||||
|
reminderEnabled: data.reminderEnabled,
|
||||||
|
reminderTime: data.reminderTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkReminder() {
|
||||||
|
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
||||||
|
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||||
|
const targetMinute = hours * 60 + minutes;
|
||||||
|
|
||||||
|
if (currentMinute === targetMinute && currentMinute !== lastShownMinuteRef.current) {
|
||||||
|
lastShownMinuteRef.current = currentMinute;
|
||||||
|
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
console.log("[Reminder] Showing notification at", now.toLocaleTimeString());
|
||||||
|
await showNotification(APP_NAME, {
|
||||||
|
body: "Take a moment to reflect on what you're grateful for today.",
|
||||||
|
icon: "/icons/icon-192.png",
|
||||||
|
tag: "daily-reminder",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
fetchSettings();
|
||||||
|
|
||||||
|
// Refresh settings periodically (in case user changes them)
|
||||||
|
const settingsInterval = setInterval(fetchSettings, 60000);
|
||||||
|
|
||||||
|
// Check reminder every 30 seconds for more reliable timing
|
||||||
|
const reminderInterval = setInterval(checkReminder, 30000);
|
||||||
|
|
||||||
|
// Also check immediately after settings load
|
||||||
|
const initialCheck = setTimeout(checkReminder, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(settingsInterval);
|
||||||
|
clearInterval(reminderInterval);
|
||||||
|
clearTimeout(initialCheck);
|
||||||
|
};
|
||||||
|
}, [user, showNotification]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ interface ServiceWorkerContextValue {
|
|||||||
isRegistered: boolean;
|
isRegistered: boolean;
|
||||||
registration: ServiceWorkerRegistration | null;
|
registration: ServiceWorkerRegistration | null;
|
||||||
showNotification: (title: string, options?: NotificationOptions) => Promise<void>;
|
showNotification: (title: string, options?: NotificationOptions) => Promise<void>;
|
||||||
|
subscribeToPush: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceWorkerContext = createContext<ServiceWorkerContextValue>({
|
const ServiceWorkerContext = createContext<ServiceWorkerContextValue>({
|
||||||
@@ -14,12 +15,25 @@ const ServiceWorkerContext = createContext<ServiceWorkerContextValue>({
|
|||||||
isRegistered: false,
|
isRegistered: false,
|
||||||
registration: null,
|
registration: null,
|
||||||
showNotification: async () => {},
|
showNotification: async () => {},
|
||||||
|
subscribeToPush: async () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useServiceWorker() {
|
export function useServiceWorker() {
|
||||||
return useContext(ServiceWorkerContext);
|
return useContext(ServiceWorkerContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper for VAPID key conversion
|
||||||
|
function urlBase64ToUint8Array(base64String: string) {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceWorkerProviderProps {
|
interface ServiceWorkerProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -60,6 +74,40 @@ export function ServiceWorkerProvider({ children }: ServiceWorkerProviderProps)
|
|||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const subscribeToPush = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Wait for service worker to be ready if registration isn't set yet
|
||||||
|
const reg = registration || await navigator.serviceWorker.ready;
|
||||||
|
if (!reg || !reg.pushManager) {
|
||||||
|
console.error("Push manager not available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get public key
|
||||||
|
const response = await fetch("/api/notifications/subscribe");
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const { publicKey } = await response.json();
|
||||||
|
|
||||||
|
const subscription = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to server
|
||||||
|
const saveResponse = await fetch("/api/notifications/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ subscription }),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Push subscription saved:", saveResponse.ok);
|
||||||
|
return saveResponse.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Push subscription failed:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [registration]);
|
||||||
|
|
||||||
const showNotification = useCallback(
|
const showNotification = useCallback(
|
||||||
async (title: string, options?: NotificationOptions) => {
|
async (title: string, options?: NotificationOptions) => {
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
@@ -91,6 +139,7 @@ export function ServiceWorkerProvider({ children }: ServiceWorkerProviderProps)
|
|||||||
isRegistered,
|
isRegistered,
|
||||||
registration,
|
registration,
|
||||||
showNotification,
|
showNotification,
|
||||||
|
subscribeToPush,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const users = sqliteTable("users", {
|
|||||||
name: text("name"),
|
name: text("name"),
|
||||||
isAdmin: integer("is_admin").notNull().default(0), // 1 = admin
|
isAdmin: integer("is_admin").notNull().default(0), // 1 = admin
|
||||||
reminderEnabled: integer("reminder_enabled").notNull().default(0),
|
reminderEnabled: integer("reminder_enabled").notNull().default(0),
|
||||||
|
reminderAlways: integer("reminder_always").notNull().default(0), // 1 = always send, 0 = only if no entry
|
||||||
reminderTime: text("reminder_time"), // HH:MM format
|
reminderTime: text("reminder_time"), // HH:MM format
|
||||||
llmProvider: text("llm_provider"), // anthropic, openai, openrouter
|
llmProvider: text("llm_provider"), // anthropic, openai, openrouter
|
||||||
llmApiKey: text("llm_api_key"), // API key for LLM
|
llmApiKey: text("llm_api_key"), // API key for LLM
|
||||||
@@ -58,6 +59,17 @@ export const entryTags = sqliteTable(
|
|||||||
(table) => [primaryKey({ columns: [table.entryId, table.tagId] })]
|
(table) => [primaryKey({ columns: [table.entryId, table.tagId] })]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const pushSubscriptions = sqliteTable("push_subscriptions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
endpoint: text("endpoint").notNull().unique(),
|
||||||
|
p256dh: text("p256dh").notNull(),
|
||||||
|
auth: text("auth").notNull(),
|
||||||
|
createdAt: integer("created_at").notNull(), // Unix ms
|
||||||
|
});
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type NewUser = typeof users.$inferInsert;
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
|||||||
39
src/lib/push.ts
Normal file
39
src/lib/push.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import webpush from "web-push";
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
|
||||||
|
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || "";
|
||||||
|
const VAPID_EMAIL = process.env.VAPID_EMAIL || "mailto:admin@quietthanks.local";
|
||||||
|
|
||||||
|
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
|
||||||
|
webpush.setVapidDetails(VAPID_EMAIL, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPushNotification(
|
||||||
|
subscription: { endpoint: string; p256dh: string; auth: string },
|
||||||
|
payload: { title: string; body: string; url?: string }
|
||||||
|
) {
|
||||||
|
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
|
||||||
|
console.warn("VAPID keys not configured");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: subscription.p256dh,
|
||||||
|
auth: subscription.auth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JSON.stringify(payload)
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||||
|
return false; // Expired
|
||||||
|
}
|
||||||
|
console.error("Push error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user