diff --git a/.env.example b/.env.example index cfbb5aa..e414b04 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,8 @@ R2_BUCKET_NAME="" # Google Maps API for Places search 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" diff --git a/drizzle/0007_grey_dark_beast.sql b/drizzle/0007_grey_dark_beast.sql new file mode 100644 index 0000000..c2a1df8 --- /dev/null +++ b/drizzle/0007_grey_dark_beast.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..ae63837 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 183feaf..f4867dc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1762428302491, "tag": "0006_strange_firebird", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1762522687342, + "tag": "0007_grey_dark_beast", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 7ea896c..98d74ff 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,11 @@ "@node-rs/argon2": "^2.0.2", "@sveltejs/adapter-vercel": "^5.10.2", "arctic": "^3.7.0", + "nanoid": "^5.1.6", "postgres": "^3.4.5", "sharp": "^0.34.4", - "svelte-maplibre": "^1.2.1" + "svelte-maplibre": "^1.2.1", + "web-push": "^3.6.7" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/scripts/generate-vapid-keys.js b/scripts/generate-vapid-keys.js new file mode 100755 index 0000000..36020b3 --- /dev/null +++ b/scripts/generate-vapid-keys.js @@ -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!'); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 67fa7c3..4115e97 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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', { id: text('id').primaryKey(), @@ -88,15 +88,62 @@ export const findComment = pgTable('find_comment', { 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 FindMedia = typeof findMedia.$inferSelect; export type FindLike = typeof findLike.$inferSelect; export type FindComment = typeof findComment.$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 FindMediaInsert = typeof findMedia.$inferInsert; export type FindLikeInsert = typeof findLike.$inferInsert; export type FindCommentInsert = typeof findComment.$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; diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts new file mode 100644 index 0000000..2037732 --- /dev/null +++ b/src/lib/server/notifications.ts @@ -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; +} + +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 { + 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 { + 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 { + 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 { + 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 { + await db + .update(notification) + .set({ isRead: true }) + .where(eq(notification.userId, userId)); + } + + /** + * Get unread notification count for a user + */ + async getUnreadCount(userId: string): Promise { + 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 { + 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 { + await db.delete(notification).where(eq(notification.userId, userId)); + } +} + +export const notificationService = new NotificationService(); diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts new file mode 100644 index 0000000..6ff1e60 --- /dev/null +++ b/src/lib/server/push.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + const sendPromises = userIds.map((userId) => this.sendPushNotification(userId, payload)); + await Promise.all(sendPromises); + } + + /** + * Deactivate a subscription + */ + async deactivateSubscription(endpoint: string): Promise { + await db + .update(notificationSubscription) + .set({ isActive: false }) + .where(eq(notificationSubscription.endpoint, endpoint)); + } +} + +export const pushService = new PushService(); +export { VAPID_PUBLIC_KEY }; diff --git a/src/routes/api/finds/[findId]/comments/+server.ts b/src/routes/api/finds/[findId]/comments/+server.ts index 781cb82..b940258 100644 --- a/src/routes/api/finds/[findId]/comments/+server.ts +++ b/src/routes/api/finds/[findId]/comments/+server.ts @@ -1,8 +1,10 @@ import { json } from '@sveltejs/kit'; 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 type { RequestHandler } from './$types'; +import { notificationService } from '$lib/server/notifications'; +import { pushService } from '$lib/server/push'; export const GET: RequestHandler = async ({ params, locals }) => { 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 }); } + // 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({ success: true, data: commentWithUser[0] diff --git a/src/routes/api/finds/[findId]/like/+server.ts b/src/routes/api/finds/[findId]/like/+server.ts index e557834..a95a0a1 100644 --- a/src/routes/api/finds/[findId]/like/+server.ts +++ b/src/routes/api/finds/[findId]/like/+server.ts @@ -1,8 +1,10 @@ import { json, error } from '@sveltejs/kit'; 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 { encodeBase64url } from '@oslojs/encoding'; +import { notificationService } from '$lib/server/notifications'; +import { pushService } from '$lib/server/push'; function generateLikeId(): string { const bytes = crypto.getRandomValues(new Uint8Array(15)); @@ -59,6 +61,49 @@ export async function POST({ 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({ success: true, likeId, diff --git a/src/routes/api/friends/+server.ts b/src/routes/api/friends/+server.ts index 4ed719d..79a6974 100644 --- a/src/routes/api/friends/+server.ts +++ b/src/routes/api/friends/+server.ts @@ -5,6 +5,8 @@ import { friendship, user } from '$lib/server/db/schema'; import { eq, and, or } from 'drizzle-orm'; import { encodeBase64url } from '@oslojs/encoding'; import { getSignedR2Url } from '$lib/server/r2'; +import { notificationService } from '$lib/server/notifications'; +import { pushService } from '$lib/server/push'; function generateFriendshipId(): string { const bytes = crypto.getRandomValues(new Uint8Array(15)); @@ -180,6 +182,34 @@ export const POST: RequestHandler = async ({ request, locals }) => { }) .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] }); } catch (err) { console.error('Error sending friend request:', err); diff --git a/src/routes/api/friends/[friendshipId]/+server.ts b/src/routes/api/friends/[friendshipId]/+server.ts index df1f6bb..6b19e88 100644 --- a/src/routes/api/friends/[friendshipId]/+server.ts +++ b/src/routes/api/friends/[friendshipId]/+server.ts @@ -1,8 +1,10 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; 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 { notificationService } from '$lib/server/notifications'; +import { pushService } from '$lib/server/push'; export const PUT: RequestHandler = async ({ params, request, locals }) => { if (!locals.user) { @@ -72,6 +74,47 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { .where(eq(friendship.id, friendshipId)) .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] }); } catch (err) { console.error('Error updating friendship:', err); diff --git a/src/routes/api/notifications/+server.ts b/src/routes/api/notifications/+server.ts new file mode 100644 index 0000000..987552f --- /dev/null +++ b/src/routes/api/notifications/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/notifications/count/+server.ts b/src/routes/api/notifications/count/+server.ts new file mode 100644 index 0000000..7c169d8 --- /dev/null +++ b/src/routes/api/notifications/count/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/notifications/subscribe/+server.ts b/src/routes/api/notifications/subscribe/+server.ts new file mode 100644 index 0000000..dca8e17 --- /dev/null +++ b/src/routes/api/notifications/subscribe/+server.ts @@ -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 }); + } +}; diff --git a/src/service-worker.ts b/src/service-worker.ts index 472542f..f67491b 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -222,3 +222,52 @@ self.addEventListener('fetch', (event) => { 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); + } + }) + ); +});