feat:add Web Push notification system
This commit is contained in:
@@ -38,3 +38,8 @@ R2_BUCKET_NAME=""
|
|||||||
|
|
||||||
# Google Maps API for Places search
|
# Google Maps API for Places search
|
||||||
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
||||||
|
|
||||||
|
# Web Push VAPID Keys for notifications (generate with: node scripts/generate-vapid-keys.js)
|
||||||
|
VAPID_PUBLIC_KEY=""
|
||||||
|
VAPID_PRIVATE_KEY=""
|
||||||
|
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||||
|
|||||||
37
drizzle/0007_grey_dark_beast.sql
Normal file
37
drizzle/0007_grey_dark_beast.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE "notification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"data" jsonb,
|
||||||
|
"is_read" boolean DEFAULT false,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notification_preferences" (
|
||||||
|
"user_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"friend_requests" boolean DEFAULT true,
|
||||||
|
"friend_accepted" boolean DEFAULT true,
|
||||||
|
"find_liked" boolean DEFAULT true,
|
||||||
|
"find_commented" boolean DEFAULT true,
|
||||||
|
"push_enabled" boolean DEFAULT true,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notification_subscription" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"endpoint" text NOT NULL,
|
||||||
|
"p256dh_key" text NOT NULL,
|
||||||
|
"auth_key" text NOT NULL,
|
||||||
|
"user_agent" text,
|
||||||
|
"is_active" boolean DEFAULT true,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_subscription" ADD CONSTRAINT "notification_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
764
drizzle/meta/0007_snapshot.json
Normal file
764
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
{
|
||||||
|
"id": "1dbab94c-004e-4d34-b171-408bb1d36c91",
|
||||||
|
"prevId": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.find": {
|
||||||
|
"name": "find",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_name": {
|
||||||
|
"name": "location_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"name": "is_public",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_user_id_user_id_fk": {
|
||||||
|
"name": "find_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_comment": {
|
||||||
|
"name": "find_comment",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_comment_find_id_find_id_fk": {
|
||||||
|
"name": "find_comment_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_comment_user_id_user_id_fk": {
|
||||||
|
"name": "find_comment_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_like": {
|
||||||
|
"name": "find_like",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_like_find_id_find_id_fk": {
|
||||||
|
"name": "find_like_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_like_user_id_user_id_fk": {
|
||||||
|
"name": "find_like_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_media": {
|
||||||
|
"name": "find_media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_url": {
|
||||||
|
"name": "fallback_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_thumbnail_url": {
|
||||||
|
"name": "fallback_thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"order_index": {
|
||||||
|
"name": "order_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_media_find_id_find_id_fk": {
|
||||||
|
"name": "find_media_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_media",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_id": {
|
||||||
|
"name": "friend_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_user_id_user_id_fk": {
|
||||||
|
"name": "friendship_user_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_friend_id_user_id_fk": {
|
||||||
|
"name": "friendship_friend_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"friend_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.notification": {
|
||||||
|
"name": "notification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_read": {
|
||||||
|
"name": "is_read",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"notification_user_id_user_id_fk": {
|
||||||
|
"name": "notification_user_id_user_id_fk",
|
||||||
|
"tableFrom": "notification",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.notification_preferences": {
|
||||||
|
"name": "notification_preferences",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_requests": {
|
||||||
|
"name": "friend_requests",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"friend_accepted": {
|
||||||
|
"name": "friend_accepted",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"find_liked": {
|
||||||
|
"name": "find_liked",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"find_commented": {
|
||||||
|
"name": "find_commented",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"push_enabled": {
|
||||||
|
"name": "push_enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"notification_preferences_user_id_user_id_fk": {
|
||||||
|
"name": "notification_preferences_user_id_user_id_fk",
|
||||||
|
"tableFrom": "notification_preferences",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.notification_subscription": {
|
||||||
|
"name": "notification_subscription",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"name": "endpoint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"p256dh_key": {
|
||||||
|
"name": "p256dh_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"auth_key": {
|
||||||
|
"name": "auth_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"notification_subscription_user_id_user_id_fk": {
|
||||||
|
"name": "notification_subscription_user_id_user_id_fk",
|
||||||
|
"tableFrom": "notification_subscription",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"profile_picture_url": {
|
||||||
|
"name": "profile_picture_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
"when": 1762428302491,
|
"when": 1762428302491,
|
||||||
"tag": "0006_strange_firebird",
|
"tag": "0006_strange_firebird",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762522687342,
|
||||||
|
"tag": "0007_grey_dark_beast",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -59,9 +59,11 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@sveltejs/adapter-vercel": "^5.10.2",
|
"@sveltejs/adapter-vercel": "^5.10.2",
|
||||||
"arctic": "^3.7.0",
|
"arctic": "^3.7.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"svelte-maplibre": "^1.2.1"
|
"svelte-maplibre": "^1.2.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
20
scripts/generate-vapid-keys.js
Executable file
20
scripts/generate-vapid-keys.js
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate VAPID keys for Web Push notifications
|
||||||
|
* Run this script once to generate your VAPID keys and add them to your .env file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import webpush from 'web-push';
|
||||||
|
|
||||||
|
console.log('Generating VAPID keys for Web Push notifications...\n');
|
||||||
|
|
||||||
|
const vapidKeys = webpush.generateVAPIDKeys();
|
||||||
|
|
||||||
|
console.log('VAPID Keys Generated Successfully!');
|
||||||
|
console.log('Add these to your .env file:\n');
|
||||||
|
console.log(`VAPID_PUBLIC_KEY="${vapidKeys.publicKey}"`);
|
||||||
|
console.log(`VAPID_PRIVATE_KEY="${vapidKeys.privateKey}"`);
|
||||||
|
console.log('VAPID_SUBJECT="mailto:your-email@example.com"');
|
||||||
|
console.log('\nReplace "your-email@example.com" with your actual email address.');
|
||||||
|
console.log('\nIMPORTANT: Keep your private key secret and never commit it to version control!');
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core';
|
import { pgTable, integer, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
export const user = pgTable('user', {
|
export const user = pgTable('user', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -88,15 +88,62 @@ export const findComment = pgTable('find_comment', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type exports for the new tables
|
// Notification system tables
|
||||||
|
export const notification = pgTable('notification', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
type: text('type').notNull(), // 'friend_request', 'friend_accepted', 'find_liked', 'find_commented'
|
||||||
|
title: text('title').notNull(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
data: jsonb('data'), // Additional context data (findId, friendId, etc.)
|
||||||
|
isRead: boolean('is_read').default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notificationSubscription = pgTable('notification_subscription', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
endpoint: text('endpoint').notNull(),
|
||||||
|
p256dhKey: text('p256dh_key').notNull(),
|
||||||
|
authKey: text('auth_key').notNull(),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
isActive: boolean('is_active').default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notificationPreferences = pgTable('notification_preferences', {
|
||||||
|
userId: text('user_id')
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
friendRequests: boolean('friend_requests').default(true),
|
||||||
|
friendAccepted: boolean('friend_accepted').default(true),
|
||||||
|
findLiked: boolean('find_liked').default(true),
|
||||||
|
findCommented: boolean('find_commented').default(true),
|
||||||
|
pushEnabled: boolean('push_enabled').default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type exports for the tables
|
||||||
export type Find = typeof find.$inferSelect;
|
export type Find = typeof find.$inferSelect;
|
||||||
export type FindMedia = typeof findMedia.$inferSelect;
|
export type FindMedia = typeof findMedia.$inferSelect;
|
||||||
export type FindLike = typeof findLike.$inferSelect;
|
export type FindLike = typeof findLike.$inferSelect;
|
||||||
export type FindComment = typeof findComment.$inferSelect;
|
export type FindComment = typeof findComment.$inferSelect;
|
||||||
export type Friendship = typeof friendship.$inferSelect;
|
export type Friendship = typeof friendship.$inferSelect;
|
||||||
|
export type Notification = typeof notification.$inferSelect;
|
||||||
|
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
|
||||||
|
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
||||||
|
|
||||||
export type FindInsert = typeof find.$inferInsert;
|
export type FindInsert = typeof find.$inferInsert;
|
||||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||||
export type FindCommentInsert = typeof findComment.$inferInsert;
|
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||||
|
export type NotificationInsert = typeof notification.$inferInsert;
|
||||||
|
export type NotificationSubscriptionInsert = typeof notificationSubscription.$inferInsert;
|
||||||
|
export type NotificationPreferencesInsert = typeof notificationPreferences.$inferInsert;
|
||||||
|
|||||||
165
src/lib/server/notifications.ts
Normal file
165
src/lib/server/notifications.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { db } from './db';
|
||||||
|
import { notification, notificationPreferences } from './db/schema';
|
||||||
|
import type { NotificationInsert } from './db/schema';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export type NotificationType = 'friend_request' | 'friend_accepted' | 'find_liked' | 'find_commented';
|
||||||
|
|
||||||
|
export interface CreateNotificationData {
|
||||||
|
userId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetNotificationsOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationService {
|
||||||
|
/**
|
||||||
|
* Create a new notification record in the database
|
||||||
|
*/
|
||||||
|
async createNotification(data: CreateNotificationData): Promise<void> {
|
||||||
|
const notificationData: NotificationInsert = {
|
||||||
|
id: nanoid(),
|
||||||
|
userId: data.userId,
|
||||||
|
type: data.type,
|
||||||
|
title: data.title,
|
||||||
|
message: data.message,
|
||||||
|
data: data.data || null,
|
||||||
|
isRead: false
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(notification).values(notificationData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has notifications enabled for a specific type
|
||||||
|
*/
|
||||||
|
async shouldNotify(userId: string, type: NotificationType): Promise<boolean> {
|
||||||
|
const prefs = await db
|
||||||
|
.select()
|
||||||
|
.from(notificationPreferences)
|
||||||
|
.where(eq(notificationPreferences.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// If no preferences exist, default to true
|
||||||
|
if (prefs.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pref = prefs[0];
|
||||||
|
|
||||||
|
// Check if push is enabled and specific notification type is enabled
|
||||||
|
if (!pref.pushEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'friend_request':
|
||||||
|
return pref.friendRequests ?? true;
|
||||||
|
case 'friend_accepted':
|
||||||
|
return pref.friendAccepted ?? true;
|
||||||
|
case 'find_liked':
|
||||||
|
return pref.findLiked ?? true;
|
||||||
|
case 'find_commented':
|
||||||
|
return pref.findCommented ?? true;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user notifications with pagination and filtering
|
||||||
|
*/
|
||||||
|
async getUserNotifications(userId: string, options: GetNotificationsOptions = {}) {
|
||||||
|
const { limit = 20, offset = 0, unreadOnly = false } = options;
|
||||||
|
|
||||||
|
let query = db
|
||||||
|
.select()
|
||||||
|
.from(notification)
|
||||||
|
.where(eq(notification.userId, userId))
|
||||||
|
.orderBy(desc(notification.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
if (unreadOnly) {
|
||||||
|
query = db
|
||||||
|
.select()
|
||||||
|
.from(notification)
|
||||||
|
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)))
|
||||||
|
.orderBy(desc(notification.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notifications as read
|
||||||
|
*/
|
||||||
|
async markAsRead(notificationIds: string[]): Promise<void> {
|
||||||
|
for (const id of notificationIds) {
|
||||||
|
await db
|
||||||
|
.update(notification)
|
||||||
|
.set({ isRead: true })
|
||||||
|
.where(eq(notification.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a single notification as read
|
||||||
|
*/
|
||||||
|
async markOneAsRead(notificationId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(notification)
|
||||||
|
.set({ isRead: true })
|
||||||
|
.where(eq(notification.id, notificationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read for a user
|
||||||
|
*/
|
||||||
|
async markAllAsRead(userId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(notification)
|
||||||
|
.set({ isRead: true })
|
||||||
|
.where(eq(notification.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notification count for a user
|
||||||
|
*/
|
||||||
|
async getUnreadCount(userId: string): Promise<number> {
|
||||||
|
const notifications = await db
|
||||||
|
.select()
|
||||||
|
.from(notification)
|
||||||
|
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)));
|
||||||
|
|
||||||
|
return notifications.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a notification
|
||||||
|
*/
|
||||||
|
async deleteNotification(notificationId: string, userId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(notification)
|
||||||
|
.where(and(eq(notification.id, notificationId), eq(notification.userId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all notifications for a user
|
||||||
|
*/
|
||||||
|
async deleteAllNotifications(userId: string): Promise<void> {
|
||||||
|
await db.delete(notification).where(eq(notification.userId, userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationService = new NotificationService();
|
||||||
183
src/lib/server/push.ts
Normal file
183
src/lib/server/push.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import webpush from 'web-push';
|
||||||
|
import { db } from './db';
|
||||||
|
import { notificationSubscription } from './db/schema';
|
||||||
|
import type { NotificationSubscriptionInsert } from './db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import {
|
||||||
|
VAPID_PUBLIC_KEY,
|
||||||
|
VAPID_PRIVATE_KEY,
|
||||||
|
VAPID_SUBJECT
|
||||||
|
} from '$env/static/private';
|
||||||
|
|
||||||
|
// Initialize web-push with VAPID keys
|
||||||
|
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY || !VAPID_SUBJECT) {
|
||||||
|
throw new Error(
|
||||||
|
'VAPID keys are not configured. Please set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT in your environment variables.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
export interface PushSubscriptionData {
|
||||||
|
endpoint: string;
|
||||||
|
keys: {
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushNotificationPayload {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
icon?: string;
|
||||||
|
badge?: string;
|
||||||
|
tag?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PushService {
|
||||||
|
/**
|
||||||
|
* Save a push subscription for a user
|
||||||
|
*/
|
||||||
|
async saveSubscription(
|
||||||
|
userId: string,
|
||||||
|
subscription: PushSubscriptionData,
|
||||||
|
userAgent?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const subscriptionData: NotificationSubscriptionInsert = {
|
||||||
|
id: nanoid(),
|
||||||
|
userId,
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dhKey: subscription.keys.p256dh,
|
||||||
|
authKey: subscription.keys.auth,
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if subscription already exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(notificationSubscription)
|
||||||
|
.where(eq(notificationSubscription.endpoint, subscription.endpoint))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing subscription
|
||||||
|
await db
|
||||||
|
.update(notificationSubscription)
|
||||||
|
.set({
|
||||||
|
p256dhKey: subscription.keys.p256dh,
|
||||||
|
authKey: subscription.keys.auth,
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
isActive: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(notificationSubscription.endpoint, subscription.endpoint));
|
||||||
|
} else {
|
||||||
|
// Insert new subscription
|
||||||
|
await db.insert(notificationSubscription).values(subscriptionData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a push subscription
|
||||||
|
*/
|
||||||
|
async removeSubscription(userId: string, endpoint: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(notificationSubscription)
|
||||||
|
.where(
|
||||||
|
and(eq(notificationSubscription.userId, userId), eq(notificationSubscription.endpoint, endpoint))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active subscriptions for a user
|
||||||
|
*/
|
||||||
|
async getUserSubscriptions(userId: string) {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(notificationSubscription)
|
||||||
|
.where(
|
||||||
|
and(eq(notificationSubscription.userId, userId), eq(notificationSubscription.isActive, true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send push notification to a user's subscriptions
|
||||||
|
*/
|
||||||
|
async sendPushNotification(userId: string, payload: PushNotificationPayload): Promise<void> {
|
||||||
|
const subscriptions = await this.getUserSubscriptions(userId);
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
console.log(`No active subscriptions found for user ${userId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationPayload = JSON.stringify({
|
||||||
|
title: payload.title,
|
||||||
|
body: payload.message,
|
||||||
|
icon: payload.icon || '/logo.svg',
|
||||||
|
badge: payload.badge || '/logo.svg',
|
||||||
|
tag: payload.tag,
|
||||||
|
data: {
|
||||||
|
url: payload.url || '/',
|
||||||
|
...payload.data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendPromises = subscriptions.map(async (sub) => {
|
||||||
|
const pushSubscription: webpush.PushSubscription = {
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: sub.p256dhKey,
|
||||||
|
auth: sub.authKey
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(pushSubscription, notificationPayload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send push notification to ${sub.endpoint}:`, error);
|
||||||
|
|
||||||
|
// If the subscription is invalid (410 Gone or 404), deactivate it
|
||||||
|
if (error instanceof Error && 'statusCode' in error) {
|
||||||
|
const statusCode = (error as { statusCode: number }).statusCode;
|
||||||
|
if (statusCode === 410 || statusCode === 404) {
|
||||||
|
await db
|
||||||
|
.update(notificationSubscription)
|
||||||
|
.set({ isActive: false })
|
||||||
|
.where(eq(notificationSubscription.id, sub.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(sendPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send push notification to multiple users
|
||||||
|
*/
|
||||||
|
async sendPushNotificationToUsers(
|
||||||
|
userIds: string[],
|
||||||
|
payload: PushNotificationPayload
|
||||||
|
): Promise<void> {
|
||||||
|
const sendPromises = userIds.map((userId) => this.sendPushNotification(userId, payload));
|
||||||
|
await Promise.all(sendPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate a subscription
|
||||||
|
*/
|
||||||
|
async deactivateSubscription(endpoint: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(notificationSubscription)
|
||||||
|
.set({ isActive: false })
|
||||||
|
.where(eq(notificationSubscription.endpoint, endpoint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pushService = new PushService();
|
||||||
|
export { VAPID_PUBLIC_KEY };
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { findComment, user } from '$lib/server/db/schema';
|
import { findComment, user, find } from '$lib/server/db/schema';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
const session = locals.session;
|
const session = locals.session;
|
||||||
@@ -108,6 +110,54 @@ export const POST: RequestHandler = async ({ params, locals, request }) => {
|
|||||||
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send notification to find owner if not self-comment
|
||||||
|
const findData = await db.select().from(find).where(eq(find.id, findId)).limit(1);
|
||||||
|
|
||||||
|
if (findData.length > 0 && findData[0].userId !== session.userId) {
|
||||||
|
const findOwner = findData[0];
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(findOwner.userId, 'find_commented');
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
// Get commenter's username
|
||||||
|
const commenterUser = await db
|
||||||
|
.select({ username: user.username })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, session.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const commenterUsername = commenterUser[0]?.username || 'Someone';
|
||||||
|
const findTitle = findOwner.title || 'your find';
|
||||||
|
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: findOwner.userId,
|
||||||
|
type: 'find_commented',
|
||||||
|
title: 'New comment on your find',
|
||||||
|
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
commentId,
|
||||||
|
commenterId: session.userId,
|
||||||
|
commenterUsername,
|
||||||
|
findTitle,
|
||||||
|
commentContent: content.trim().substring(0, 100)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(findOwner.userId, {
|
||||||
|
title: 'New comment on your find',
|
||||||
|
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||||
|
url: `/?find=${findOwner.id}`,
|
||||||
|
tag: 'find_commented',
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
commentId,
|
||||||
|
commenterId: session.userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
data: commentWithUser[0]
|
data: commentWithUser[0]
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { findLike, find } from '$lib/server/db/schema';
|
import { findLike, find, user } from '$lib/server/db/schema';
|
||||||
import { eq, and, count } from 'drizzle-orm';
|
import { eq, and, count } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
function generateLikeId(): string {
|
function generateLikeId(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
@@ -59,6 +61,49 @@ export async function POST({
|
|||||||
|
|
||||||
const likeCount = likeCountResult[0]?.count ?? 0;
|
const likeCount = likeCountResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
// Send notification to find owner if not self-like
|
||||||
|
const findOwner = existingFind[0];
|
||||||
|
if (findOwner.userId !== locals.user.id) {
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(findOwner.userId, 'find_liked');
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
// Get liker's username
|
||||||
|
const likerUser = await db
|
||||||
|
.select({ username: user.username })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, locals.user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const likerUsername = likerUser[0]?.username || 'Someone';
|
||||||
|
const findTitle = findOwner.title || 'your find';
|
||||||
|
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: findOwner.userId,
|
||||||
|
type: 'find_liked',
|
||||||
|
title: 'Someone liked your find',
|
||||||
|
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
likerId: locals.user.id,
|
||||||
|
likerUsername,
|
||||||
|
findTitle
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(findOwner.userId, {
|
||||||
|
title: 'Someone liked your find',
|
||||||
|
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||||
|
url: `/?find=${findOwner.id}`,
|
||||||
|
tag: 'find_liked',
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
likerId: locals.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
likeId,
|
likeId,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { friendship, user } from '$lib/server/db/schema';
|
|||||||
import { eq, and, or } from 'drizzle-orm';
|
import { eq, and, or } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
import { getSignedR2Url } from '$lib/server/r2';
|
import { getSignedR2Url } from '$lib/server/r2';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
function generateFriendshipId(): string {
|
function generateFriendshipId(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
@@ -180,6 +182,34 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Send notification to friend
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(friendId, 'friend_request');
|
||||||
|
if (shouldNotify) {
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: friendId,
|
||||||
|
type: 'friend_request',
|
||||||
|
title: 'New friend request',
|
||||||
|
message: `${locals.user.username} sent you a friend request`,
|
||||||
|
data: {
|
||||||
|
friendshipId: newFriendship[0].id,
|
||||||
|
senderId: locals.user.id,
|
||||||
|
senderUsername: locals.user.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(friendId, {
|
||||||
|
title: 'New friend request',
|
||||||
|
message: `${locals.user.username} sent you a friend request`,
|
||||||
|
url: '/friends',
|
||||||
|
tag: 'friend_request',
|
||||||
|
data: {
|
||||||
|
friendshipId: newFriendship[0].id,
|
||||||
|
senderId: locals.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true, friendship: newFriendship[0] });
|
return json({ success: true, friendship: newFriendship[0] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error sending friend request:', err);
|
console.error('Error sending friend request:', err);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { friendship } from '$lib/server/db/schema';
|
import { friendship, user } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
@@ -72,6 +74,47 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
.where(eq(friendship.id, friendshipId))
|
.where(eq(friendship.id, friendshipId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Send notification if friend request was accepted
|
||||||
|
if (action === 'accept') {
|
||||||
|
const senderId = friendshipRecord.userId;
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(senderId, 'friend_accepted');
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
// Get accepter's username
|
||||||
|
const accepterUser = await db
|
||||||
|
.select({ username: user.username })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, locals.user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const accepterUsername = accepterUser[0]?.username || 'Someone';
|
||||||
|
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: senderId,
|
||||||
|
type: 'friend_accepted',
|
||||||
|
title: 'Friend request accepted',
|
||||||
|
message: `${accepterUsername} accepted your friend request`,
|
||||||
|
data: {
|
||||||
|
friendshipId: friendshipRecord.id,
|
||||||
|
accepterId: locals.user.id,
|
||||||
|
accepterUsername
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(senderId, {
|
||||||
|
title: 'Friend request accepted',
|
||||||
|
message: `${accepterUsername} accepted your friend request`,
|
||||||
|
url: '/friends',
|
||||||
|
tag: 'friend_accepted',
|
||||||
|
data: {
|
||||||
|
friendshipId: friendshipRecord.id,
|
||||||
|
accepterId: locals.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true, friendship: updatedFriendship[0] });
|
return json({ success: true, friendship: updatedFriendship[0] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating friendship:', err);
|
console.error('Error updating friendship:', err);
|
||||||
|
|||||||
77
src/routes/api/notifications/+server.ts
Normal file
77
src/routes/api/notifications/+server.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
|
||||||
|
// GET /api/notifications - Get user notifications
|
||||||
|
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||||
|
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||||
|
const unreadOnly = url.searchParams.get('unreadOnly') === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notifications = await notificationService.getUserNotifications(user.id, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
unreadOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ notifications });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notifications:', error);
|
||||||
|
return json({ error: 'Failed to fetch notifications' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// PATCH /api/notifications - Mark notifications as read
|
||||||
|
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { notificationIds, markAll } = await request.json();
|
||||||
|
|
||||||
|
if (markAll) {
|
||||||
|
await notificationService.markAllAsRead(user.id);
|
||||||
|
} else if (Array.isArray(notificationIds) && notificationIds.length > 0) {
|
||||||
|
await notificationService.markAsRead(notificationIds);
|
||||||
|
} else {
|
||||||
|
return json({ error: 'Invalid request: provide notificationIds or markAll' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking notifications as read:', error);
|
||||||
|
return json({ error: 'Failed to mark notifications as read' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/notifications - Delete notifications
|
||||||
|
export const DELETE: RequestHandler = async ({ locals, request }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { notificationId, deleteAll } = await request.json();
|
||||||
|
|
||||||
|
if (deleteAll) {
|
||||||
|
await notificationService.deleteAllNotifications(user.id);
|
||||||
|
} else if (notificationId) {
|
||||||
|
await notificationService.deleteNotification(notificationId, user.id);
|
||||||
|
} else {
|
||||||
|
return json({ error: 'Invalid request: provide notificationId or deleteAll' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting notifications:', error);
|
||||||
|
return json({ error: 'Failed to delete notifications' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
18
src/routes/api/notifications/count/+server.ts
Normal file
18
src/routes/api/notifications/count/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
|
||||||
|
// GET /api/notifications/count - Get unread notification count
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await notificationService.getUnreadCount(user.id);
|
||||||
|
return json({ count });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching unread count:', error);
|
||||||
|
return json({ error: 'Failed to fetch unread count' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
54
src/routes/api/notifications/subscribe/+server.ts
Normal file
54
src/routes/api/notifications/subscribe/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { pushService, VAPID_PUBLIC_KEY } from '$lib/server/push';
|
||||||
|
|
||||||
|
// GET /api/notifications/subscribe - Get VAPID public key
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
return json({ publicKey: VAPID_PUBLIC_KEY });
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/notifications/subscribe - Subscribe to push notifications
|
||||||
|
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { subscription } = await request.json();
|
||||||
|
const userAgent = request.headers.get('user-agent') || undefined;
|
||||||
|
|
||||||
|
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||||
|
return json({ error: 'Invalid subscription data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pushService.saveSubscription(user.id, subscription, userAgent);
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving subscription:', error);
|
||||||
|
return json({ error: 'Failed to save subscription' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/notifications/subscribe - Unsubscribe from push notifications
|
||||||
|
export const DELETE: RequestHandler = async ({ locals, request }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { endpoint } = await request.json();
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return json({ error: 'Endpoint is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pushService.removeSubscription(user.id, endpoint);
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing subscription:', error);
|
||||||
|
return json({ error: 'Failed to remove subscription' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -222,3 +222,52 @@ self.addEventListener('fetch', (event) => {
|
|||||||
|
|
||||||
event.respondWith(respond());
|
event.respondWith(respond());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle push notifications
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = event.data.json();
|
||||||
|
const title = data.title || 'Serengo';
|
||||||
|
const options = {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: data.icon || '/logo.svg',
|
||||||
|
badge: data.badge || '/logo.svg',
|
||||||
|
tag: data.tag || 'default',
|
||||||
|
data: data.data || {},
|
||||||
|
requireInteraction: false,
|
||||||
|
vibrate: [200, 100, 200]
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification clicks
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const urlToOpen = event.notification.data?.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
includeUncontrolled: true
|
||||||
|
})
|
||||||
|
.then((clientList) => {
|
||||||
|
// Check if there's already a window open
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url === urlToOpen && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, open a new window
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(urlToOpen);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user