From 18c7785b130be6ad60765fe3cf67f1fb41ab6b2e Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 Nov 2025 21:16:21 +0200 Subject: [PATCH] Feat notifications (#594) * Add notifications for deferred and due tasks * Cleanup * fixup! Cleanup * Add notifications settings * ADd dismissed for notifications * Beautify project cards * fixup! Beautify project cards * Fix an issue with icon badge * Cleanup scripts * fixup! Cleanup scripts --- backend/app.js | 1 + .../20251124000002-create-notifications.js | 90 ++++ ...0251125000001-add-notification-features.js | 94 +++++ backend/models/index.js | 6 + backend/models/notification.js | 315 ++++++++++++++ backend/models/user.js | 11 + backend/routes/notifications.js | 159 ++++++++ backend/routes/projects.js | 4 + backend/routes/users.js | 5 + backend/scripts/fix-inbox-uids.js | 97 ----- backend/scripts/reset-and-seed.js | 107 +++++ backend/scripts/run-notification-services.js | 56 +++ .../scripts/seed-notification-test-data.js | 234 +++++++++++ backend/scripts/test-query.js | 56 --- backend/seeders/dev-seeder.js | 44 ++ backend/seeders/massive-tasks.js | 38 +- backend/services/deferredTaskService.js | 184 +++++++++ backend/services/dueProjectService.js | 178 ++++++++ backend/services/dueTaskService.js | 176 ++++++++ backend/services/taskScheduler.js | 48 +++ .../notification-preferences.test.js | 199 +++++++++ .../notification-soft-delete.test.js | 230 +++++++++++ .../utils/notificationPreferences.test.js | 183 +++++++++ backend/utils/migration-utils.js | 22 + backend/utils/notificationPreferences.js | 62 +++ frontend/Layout.tsx | 12 +- frontend/components/Calendar.tsx | 236 +++-------- .../components/Calendar/CalendarDayView.tsx | 6 +- .../components/Calendar/CalendarMonthView.tsx | 8 +- .../components/Calendar/CalendarWeekView.tsx | 6 +- frontend/components/Navbar.tsx | 3 + .../Notifications/NotificationsDropdown.tsx | 384 ++++++++++++++++++ .../components/Profile/ProfileSettings.tsx | 23 ++ .../Profile/tabs/NotificationsTab.tsx | 270 ++++++++++++ frontend/components/Profile/types.ts | 9 + frontend/components/Project/ProjectItem.tsx | 28 +- package-lock.json | 124 +++++- package.json | 3 + 38 files changed, 3350 insertions(+), 361 deletions(-) create mode 100644 backend/migrations/20251124000002-create-notifications.js create mode 100644 backend/migrations/20251125000001-add-notification-features.js create mode 100644 backend/models/notification.js create mode 100644 backend/routes/notifications.js delete mode 100755 backend/scripts/fix-inbox-uids.js create mode 100755 backend/scripts/reset-and-seed.js create mode 100644 backend/scripts/run-notification-services.js create mode 100644 backend/scripts/seed-notification-test-data.js delete mode 100644 backend/scripts/test-query.js create mode 100644 backend/services/deferredTaskService.js create mode 100644 backend/services/dueProjectService.js create mode 100644 backend/services/dueTaskService.js create mode 100644 backend/tests/integration/notification-preferences.test.js create mode 100644 backend/tests/integration/notification-soft-delete.test.js create mode 100644 backend/tests/unit/utils/notificationPreferences.test.js create mode 100644 backend/utils/notificationPreferences.js create mode 100644 frontend/components/Notifications/NotificationsDropdown.tsx create mode 100644 frontend/components/Profile/tabs/NotificationsTab.tsx diff --git a/backend/app.js b/backend/app.js index 64b02e4..e28e070 100644 --- a/backend/app.js +++ b/backend/app.js @@ -184,6 +184,7 @@ const registerApiRoutes = (basePath) => { app.use(basePath, require('./routes/task-events')); app.use(`${basePath}/search`, require('./routes/search')); app.use(`${basePath}/views`, require('./routes/views')); + app.use(`${basePath}/notifications`, require('./routes/notifications')); }; // Register routes at both /api and /api/v1 (if versioned) to maintain backwards compatibility diff --git a/backend/migrations/20251124000002-create-notifications.js b/backend/migrations/20251124000002-create-notifications.js new file mode 100644 index 0000000..ed41c7f --- /dev/null +++ b/backend/migrations/20251124000002-create-notifications.js @@ -0,0 +1,90 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'notifications', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: Sequelize.STRING, + unique: true, + allowNull: false, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + type: { + type: Sequelize.STRING, + allowNull: false, + comment: 'Type of notification (task_assigned, reminder, etc.)', + }, + level: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'info', + comment: 'Notification level: info, warning, error, success', + }, + title: { + type: Sequelize.STRING, + allowNull: false, + }, + message: { + type: Sequelize.TEXT, + allowNull: true, + }, + data: { + type: Sequelize.JSON, + allowNull: true, + comment: 'Additional structured data for the notification', + }, + sources: { + type: Sequelize.JSON, + allowNull: false, + defaultValue: '[]', + comment: 'Array of source platforms: telegram, mobile, browser', + }, + read_at: { + type: Sequelize.DATE, + allowNull: true, + }, + sent_at: { + type: Sequelize.DATE, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + // Add indexes for efficient querying + await safeAddIndex(queryInterface, 'notifications', ['user_id']); + await safeAddIndex(queryInterface, 'notifications', ['read_at']); + await safeAddIndex(queryInterface, 'notifications', ['created_at']); + await safeAddIndex(queryInterface, 'notifications', [ + 'user_id', + 'read_at', + ]); + }, + + async down(queryInterface) { + await queryInterface.dropTable('notifications'); + }, +}; diff --git a/backend/migrations/20251125000001-add-notification-features.js b/backend/migrations/20251125000001-add-notification-features.js new file mode 100644 index 0000000..02ab45d --- /dev/null +++ b/backend/migrations/20251125000001-add-notification-features.js @@ -0,0 +1,94 @@ +'use strict'; + +const { + safeAddColumns, + safeAddIndex, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + // Add notification_preferences to users table + await safeAddColumns(queryInterface, 'users', [ + { + name: 'notification_preferences', + definition: { + type: Sequelize.JSON, + allowNull: true, + defaultValue: { + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { + inApp: true, + email: false, + push: false, + }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { + inApp: true, + email: false, + push: false, + }, + deferUntil: { inApp: true, email: false, push: false }, + }, + comment: + 'User notification channel preferences for different notification types', + }, + }, + ]); + + // Add dismissed_at to notifications table + await safeAddColumns(queryInterface, 'notifications', [ + { + name: 'dismissed_at', + definition: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + }, + ]); + + // Add indexes for better query performance + await safeAddIndex(queryInterface, 'notifications', ['dismissed_at'], { + name: 'notifications_dismissed_at_idx', + }); + + await safeAddIndex( + queryInterface, + 'notifications', + ['user_id', 'dismissed_at'], + { + name: 'notifications_user_dismissed_idx', + } + ); + }, + + async down(queryInterface, Sequelize) { + // Remove indexes first + try { + await queryInterface.removeIndex( + 'notifications', + 'notifications_user_dismissed_idx' + ); + } catch (error) { + console.log('Index notifications_user_dismissed_idx not found'); + } + + try { + await queryInterface.removeIndex( + 'notifications', + 'notifications_dismissed_at_idx' + ); + } catch (error) { + console.log('Index notifications_dismissed_at_idx not found'); + } + + // Remove columns + await safeRemoveColumn(queryInterface, 'notifications', 'dismissed_at'); + await safeRemoveColumn( + queryInterface, + 'users', + 'notification_preferences' + ); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index 87ebdd1..e7de6f4 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -35,6 +35,7 @@ const Permission = require('./permission')(sequelize); const View = require('./view')(sequelize); const ApiToken = require('./api_token')(sequelize); const Setting = require('./setting')(sequelize); +const Notification = require('./notification')(sequelize); // Define associations User.hasMany(Area, { foreignKey: 'user_id' }); @@ -144,6 +145,10 @@ View.belongsTo(User, { foreignKey: 'user_id' }); User.hasMany(ApiToken, { foreignKey: 'user_id', as: 'apiTokens' }); ApiToken.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); +// Notification associations +User.hasMany(Notification, { foreignKey: 'user_id', as: 'Notifications' }); +Notification.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); + module.exports = { sequelize, User, @@ -160,4 +165,5 @@ module.exports = { View, ApiToken, Setting, + Notification, }; diff --git a/backend/models/notification.js b/backend/models/notification.js new file mode 100644 index 0000000..df576e0 --- /dev/null +++ b/backend/models/notification.js @@ -0,0 +1,315 @@ +const { DataTypes } = require('sequelize'); +const { v4: uuid } = require('uuid'); + +module.exports = (sequelize) => { + const Notification = sequelize.define( + 'Notification', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: DataTypes.STRING, + unique: true, + allowNull: false, + defaultValue: () => uuid(), + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + type: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isIn: [ + [ + 'task_assigned', + 'task_completed', + 'task_due_soon', + 'task_overdue', + 'comment_added', + 'mention', + 'reminder', + 'system', + 'project_due_soon', + 'project_overdue', + ], + ], + }, + }, + level: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'info', + validate: { + isIn: [['info', 'warning', 'error', 'success']], + }, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + message: { + type: DataTypes.TEXT, + allowNull: true, + }, + data: { + type: DataTypes.JSON, + allowNull: true, + }, + sources: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: [], + validate: { + isValidSources(value) { + if (!Array.isArray(value)) { + throw new Error('Sources must be an array'); + } + const validSources = ['telegram', 'mobile', 'email']; + const invalidSources = value.filter( + (s) => !validSources.includes(s) + ); + if (invalidSources.length > 0) { + throw new Error( + `Invalid sources: ${invalidSources.join(', ')}` + ); + } + }, + }, + }, + read_at: { + type: DataTypes.DATE, + allowNull: true, + }, + sent_at: { + type: DataTypes.DATE, + allowNull: true, + }, + dismissed_at: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + tableName: 'notifications', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + fields: ['user_id'], + }, + { + fields: ['read_at'], + }, + { + fields: ['created_at'], + }, + { + fields: ['user_id', 'read_at'], + }, + { + fields: ['dismissed_at'], + }, + { + fields: ['user_id', 'dismissed_at'], + }, + ], + } + ); + + // Define associations + Notification.associate = function (models) { + // Notification belongs to User + Notification.belongsTo(models.User, { + foreignKey: 'user_id', + as: 'User', + }); + }; + + /** + * Create a notification and send it via configured sources + */ + Notification.createNotification = async function ({ + userId, + type, + title, + message, + data = null, + sources = [], + sentAt = null, + level = 'info', + }) { + const notification = await Notification.create({ + user_id: userId, + type, + title, + message, + data, + sources, + level, + sent_at: sentAt || new Date(), + }); + + if (sources.includes('email')) { + await sendEmailNotification(userId, title, message, Notification); + } + + return notification; + }; + + /** + * Send email notification + */ + async function sendEmailNotification( + userId, + title, + message, + NotificationModel + ) { + try { + const { + sendEmail, + isEmailEnabled, + } = require('../services/emailService'); + + if (!isEmailEnabled() || !message) { + return; + } + + const UserModel = NotificationModel.sequelize.models.User; + const user = await UserModel.findByPk(userId, { + attributes: ['email', 'name'], + }); + + if (user?.email) { + await sendEmail({ + to: user.email, + subject: title, + text: message, + }); + } + } catch (error) { + console.error('Failed to send email notification:', error); + } + } + + /** + * Mark a notification as read + */ + Notification.prototype.markAsRead = async function () { + if (!this.read_at) { + this.read_at = new Date(); + await this.save(); + } + return this; + }; + + /** + * Mark a notification as unread + */ + Notification.prototype.markAsUnread = async function () { + this.read_at = null; + await this.save(); + return this; + }; + + /** + * Check if notification is read + */ + Notification.prototype.isRead = function () { + return this.read_at !== null; + }; + + /** + * Dismiss (soft delete) a notification + */ + Notification.prototype.dismiss = async function () { + if (!this.dismissed_at) { + this.dismissed_at = new Date(); + await this.save(); + } + return this; + }; + + /** + * Check if notification is dismissed + */ + Notification.prototype.isDismissed = function () { + return this.dismissed_at !== null; + }; + + /** + * Get notifications for a user with pagination + */ + Notification.getUserNotifications = async function (userId, options = {}) { + const { + limit = 10, + offset = 0, + includeRead = true, + type = null, + } = options; + + const where = { + user_id: userId, + dismissed_at: null, // Exclude dismissed notifications + }; + if (!includeRead) { + where.read_at = null; + } + if (type) { + where.type = type; + } + + const result = await Notification.findAndCountAll({ + where, + order: [['created_at', 'DESC']], + limit, + offset, + }); + + return { + notifications: result.rows, + total: result.count, + }; + }; + + /** + * Get count of unread notifications for a user + */ + Notification.getUnreadCount = async function (userId) { + return await Notification.count({ + where: { + user_id: userId, + read_at: null, + dismissed_at: null, // Exclude dismissed notifications + }, + }); + }; + + /** + * Mark all notifications as read for a user + */ + Notification.markAllAsRead = async function (userId) { + return await Notification.update( + { read_at: new Date() }, + { + where: { + user_id: userId, + read_at: null, + dismissed_at: null, // Only mark non-dismissed notifications as read + }, + } + ); + }; + + return Notification; +}; diff --git a/backend/models/user.js b/backend/models/user.js index ae3751d..40ba6a6 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -176,6 +176,17 @@ module.exports = (sequelize) => { }, }, }, + notification_preferences: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: { + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }, + }, email_verified: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/backend/routes/notifications.js b/backend/routes/notifications.js new file mode 100644 index 0000000..958620f --- /dev/null +++ b/backend/routes/notifications.js @@ -0,0 +1,159 @@ +const express = require('express'); +const { Notification } = require('../models'); +const { logError } = require('../services/logService'); +const router = express.Router(); +const { getAuthenticatedUserId } = require('../utils/request-utils'); + +// Middleware to require authentication +router.use((req, res, next) => { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + req.authUserId = userId; + next(); +}); + +// GET /notifications - Get user's notifications +router.get('/', async (req, res) => { + try { + const { + limit = 10, + offset = 0, + includeRead = 'true', + type, + } = req.query; + + const { notifications, total } = + await Notification.getUserNotifications(req.authUserId, { + limit: parseInt(limit), + offset: parseInt(offset), + includeRead: includeRead === 'true', + type: type || null, + }); + + res.json({ + notifications, + total, + }); + } catch (error) { + logError('Error fetching notifications:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}); + +// GET /notifications/unread-count - Get count of unread notifications +router.get('/unread-count', async (req, res) => { + try { + const count = await Notification.getUnreadCount(req.authUserId); + res.json({ count }); + } catch (error) { + logError('Error fetching unread count:', error); + res.status(500).json({ error: 'Failed to fetch unread count' }); + } +}); + +// POST /notifications/:id/read - Mark notification as read +router.post('/:id/read', async (req, res) => { + try { + const notification = await Notification.findOne({ + where: { + id: req.params.id, + user_id: req.authUserId, + }, + }); + + if (!notification) { + return res.status(404).json({ error: 'Notification not found' }); + } + + await notification.markAsRead(); + + res.json({ + notification, + message: 'Notification marked as read', + }); + } catch (error) { + logError('Error marking notification as read:', error); + res.status(500).json({ error: 'Failed to mark notification as read' }); + } +}); + +// POST /notifications/:id/unread - Mark notification as unread +router.post('/:id/unread', async (req, res) => { + try { + const notification = await Notification.findOne({ + where: { + id: req.params.id, + user_id: req.authUserId, + }, + }); + + if (!notification) { + return res.status(404).json({ error: 'Notification not found' }); + } + + await notification.markAsUnread(); + + res.json({ + notification, + message: 'Notification marked as unread', + }); + } catch (error) { + logError('Error marking notification as unread:', error); + res.status(500).json({ + error: 'Failed to mark notification as unread', + }); + } +}); + +// POST /notifications/mark-all-read - Mark all notifications as read +router.post('/mark-all-read', async (req, res) => { + try { + const [count] = await Notification.markAllAsRead(req.authUserId); + + res.json({ + count, + message: `Marked ${count} notifications as read`, + }); + } catch (error) { + logError('Error marking all notifications as read:', error); + res.status(500).json({ + error: 'Failed to mark all notifications as read', + }); + } +}); + +// DELETE /notifications/:id - Soft delete (dismiss) a notification +router.delete('/:id', async (req, res) => { + try { + console.log( + `Attempting to dismiss notification ${req.params.id} for user ${req.authUserId}` + ); + + const notification = await Notification.findOne({ + where: { + id: req.params.id, + user_id: req.authUserId, + dismissed_at: null, // Only allow dismissing non-dismissed notifications + }, + }); + + if (!notification) { + console.log( + `Notification ${req.params.id} not found or already dismissed for user ${req.authUserId}` + ); + return res.status(404).json({ error: 'Notification not found' }); + } + + await notification.dismiss(); + console.log(`Successfully dismissed notification ${req.params.id}`); + + res.json({ message: 'Notification dismissed successfully' }); + } catch (error) { + logError('Error dismissing notification:', error); + res.status(500).json({ error: 'Failed to dismiss notification' }); + } +}); + +module.exports = router; diff --git a/backend/routes/projects.js b/backend/routes/projects.js index 66eeb26..f5f7f57 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -221,6 +221,10 @@ router.get('/projects', async (req, res) => { model: Task, required: false, attributes: ['id', 'status'], + where: { + parent_task_id: null, + recurring_parent_id: null, + }, }, { model: Area, diff --git a/backend/routes/users.js b/backend/routes/users.js index c8bfa40..8c50509 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -129,6 +129,7 @@ router.get('/profile', async (req, res) => { 'sidebar_settings', 'productivity_assistant_enabled', 'next_task_suggestion_enabled', + 'notification_preferences', ], }); @@ -186,6 +187,7 @@ router.patch('/profile', async (req, res) => { next_task_suggestion_enabled, pomodoro_enabled, ui_settings, + notification_preferences, currentPassword, newPassword, } = req.body; @@ -223,6 +225,8 @@ router.patch('/profile', async (req, res) => { if (pomodoro_enabled !== undefined) allowedUpdates.pomodoro_enabled = pomodoro_enabled; if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings; + if (notification_preferences !== undefined) + allowedUpdates.notification_preferences = notification_preferences; // Validate first_day_of_week if provided if (first_day_of_week !== undefined) { @@ -287,6 +291,7 @@ router.patch('/profile', async (req, res) => { 'productivity_assistant_enabled', 'next_task_suggestion_enabled', 'pomodoro_enabled', + 'notification_preferences', ], }); diff --git a/backend/scripts/fix-inbox-uids.js b/backend/scripts/fix-inbox-uids.js deleted file mode 100755 index 771c8d8..0000000 --- a/backend/scripts/fix-inbox-uids.js +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env node -/** - * Simple script to populate missing UIDs for inbox items using sqlite3 directly - * Usage: node backend/scripts/fix-inbox-uids.js [database_path] - */ - -const sqlite3 = require('sqlite3').verbose(); -const { uid } = require('../utils/uid'); - -const dbPath = process.argv[2] || 'backend/db/development.sqlite3'; - -console.log(`\nConnecting to database: ${dbPath}\n`); - -const db = new sqlite3.Database(dbPath, (err) => { - if (err) { - console.error('Error opening database:', err); - process.exit(1); - } -}); - -function getItemsWithoutUID() { - return new Promise((resolve, reject) => { - db.all( - 'SELECT id, content FROM inbox_items WHERE uid IS NULL OR uid = ""', - [], - (err, rows) => { - if (err) reject(err); - else resolve(rows); - } - ); - }); -} - -function updateItemUID(id, newUid) { - return new Promise((resolve, reject) => { - db.run( - 'UPDATE inbox_items SET uid = ? WHERE id = ?', - [newUid, id], - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); -} - -async function fixInboxItemUIDs() { - try { - console.log('Checking for inbox items without UIDs...\n'); - - const items = await getItemsWithoutUID(); - - console.log(`Found ${items.length} inbox item(s) without UIDs\n`); - - if (items.length === 0) { - console.log('✓ All inbox items have UIDs!'); - db.close(); - return; - } - - console.log('Items to fix:'); - items.forEach((item) => { - const preview = item.content.substring(0, 50); - console.log( - ` - ID: ${item.id}, Content: ${preview}${item.content.length > 50 ? '...' : ''}` - ); - }); - - console.log('\nGenerating and assigning UIDs...\n'); - - for (const item of items) { - const newUid = uid(); - await updateItemUID(item.id, newUid); - console.log(`✓ Fixed item ${item.id}: assigned UID ${newUid}`); - } - - console.log(`\n✓ Successfully fixed ${items.length} inbox item(s)!\n`); - - // Verify - const remainingItems = await getItemsWithoutUID(); - if (remainingItems.length === 0) { - console.log('✓ Verification passed: All items now have UIDs\n'); - } else { - console.log( - `⚠ Warning: ${remainingItems.length} item(s) still without UIDs\n` - ); - } - } catch (error) { - console.error('Error fixing inbox item UIDs:', error); - process.exit(1); - } finally { - db.close(); - } -} - -// Run the fix -fixInboxItemUIDs(); diff --git a/backend/scripts/reset-and-seed.js b/backend/scripts/reset-and-seed.js new file mode 100755 index 0000000..7ca8816 --- /dev/null +++ b/backend/scripts/reset-and-seed.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { getConfig } = require('../config/config'); +const { sequelize } = require('../models'); + +/** + * Reset database and seed with comprehensive test data + * Run with: NODE_ENV=development node backend/scripts/reset-and-seed.js + */ + +const config = getConfig(); + +console.log('🔄 Starting database reset and seed...\n'); +console.log(`📁 Database: ${config.dbFile}`); +console.log(`🌍 Environment: ${config.environment}\n`); + +if (config.environment === 'production') { + console.error( + '❌ ERROR: Cannot run this script in production environment!' + ); + process.exit(1); +} + +async function main() { + try { + // Step 1: Delete existing database file + console.log('1️⃣ Removing existing database...'); + if (fs.existsSync(config.dbFile)) { + fs.unlinkSync(config.dbFile); + console.log(' ✅ Database removed\n'); + } else { + console.log(' ℹ️ No existing database found\n'); + } + + // Step 2: Reset database using sequelize.sync + console.log('2️⃣ Creating fresh database...'); + await sequelize.sync({ force: true }); + console.log(' ✅ Database created\n'); + + // Step 3: Seed basic development data + console.log('3️⃣ Seeding basic development data...'); + const { seedDatabase } = require('../seeders/dev-seeder'); + await seedDatabase(); + console.log(' ✅ Basic data seeded\n'); + + // Step 4: Seed notification test data + console.log('4️⃣ Seeding notification test data...'); + const { + seedNotificationTestData, + } = require('./seed-notification-test-data'); + + // Override process.exit to prevent the seeder from exiting + const originalExit = process.exit; + process.exit = () => {}; // No-op + + await seedNotificationTestData(); + + // Restore original process.exit + process.exit = originalExit; + + console.log(' ✅ Notification test data seeded\n'); + + // Step 5: Generate notifications + console.log('5️⃣ Generating notifications...'); + + const { checkDueTasks } = require('../services/dueTaskService'); + const { + checkDeferredTasks, + } = require('../services/deferredTaskService'); + const { checkDueProjects } = require('../services/dueProjectService'); + + const dueTasksResult = await checkDueTasks(); + const deferredTasksResult = await checkDeferredTasks(); + const dueProjectsResult = await checkDueProjects(); + + const total = + dueTasksResult.notificationsCreated + + deferredTasksResult.notificationsCreated + + dueProjectsResult.notificationsCreated; + + console.log(` ✅ Generated ${total} notifications\n`); + + // Final summary + console.log('✅ Database reset and seed completed successfully!\n'); + console.log('📊 Summary:'); + console.log(' • Database: Fresh and ready'); + console.log(' • Users: Test users created'); + console.log(' • Tasks: Sample tasks with various due dates'); + console.log(' • Projects: Sample projects with various due dates'); + console.log(` • Notifications: ${total} notifications generated`); + console.log('\n🚀 You can now start the application with:'); + console.log(' npm start\n'); + + await sequelize.close(); + process.exit(0); + } catch (error) { + console.error('❌ Error:', error.message); + console.error(error); + await sequelize.close(); + process.exit(1); + } +} + +// Run the main function +main(); diff --git a/backend/scripts/run-notification-services.js b/backend/scripts/run-notification-services.js new file mode 100644 index 0000000..687cf73 --- /dev/null +++ b/backend/scripts/run-notification-services.js @@ -0,0 +1,56 @@ +const { checkDueTasks } = require('../services/dueTaskService'); +const { checkDeferredTasks } = require('../services/deferredTaskService'); +const { checkDueProjects } = require('../services/dueProjectService'); + +/** + * Run all notification services + * Run with: NODE_ENV=development node backend/scripts/run-notification-services.js + */ + +async function runAllNotificationServices() { + console.log('🔔 Running all notification services...\n'); + + try { + // Run due tasks service + console.log('📋 Checking due tasks...'); + const dueTasksResult = await checkDueTasks(); + console.log(' Result:', JSON.stringify(dueTasksResult, null, 2)); + + // Run deferred tasks service + console.log('\n⏰ Checking deferred tasks...'); + const deferredTasksResult = await checkDeferredTasks(); + console.log(' Result:', JSON.stringify(deferredTasksResult, null, 2)); + + // Run due projects service + console.log('\n📁 Checking due projects...'); + const dueProjectsResult = await checkDueProjects(); + console.log(' Result:', JSON.stringify(dueProjectsResult, null, 2)); + + console.log('\n✅ All notification services completed!'); + console.log('\n📊 Summary:'); + console.log( + ` • Due tasks: ${dueTasksResult.notificationsCreated} notifications created` + ); + console.log( + ` • Deferred tasks: ${deferredTasksResult.notificationsCreated} notifications created` + ); + console.log( + ` • Due projects: ${dueProjectsResult.notificationsCreated} notifications created` + ); + console.log( + ` • Total: ${dueTasksResult.notificationsCreated + deferredTasksResult.notificationsCreated + dueProjectsResult.notificationsCreated} notifications created\n` + ); + + process.exit(0); + } catch (error) { + console.error('\n❌ Error running notification services:', error); + process.exit(1); + } +} + +// Run the services +if (require.main === module) { + runAllNotificationServices(); +} + +module.exports = { runAllNotificationServices }; diff --git a/backend/scripts/seed-notification-test-data.js b/backend/scripts/seed-notification-test-data.js new file mode 100644 index 0000000..5bec6cb --- /dev/null +++ b/backend/scripts/seed-notification-test-data.js @@ -0,0 +1,234 @@ +const { User, Task, Project } = require('../models'); + +/** + * Seed script to create test tasks and projects for notification testing + * Run with: NODE_ENV=development node backend/scripts/seed-notification-test-data.js + */ + +async function seedNotificationTestData() { + try { + console.log('🌱 Starting to seed notification test data...'); + + // Get the first user (or create one if none exists) + let user = await User.findOne(); + + if (!user) { + console.log('📝 No users found, creating test user...'); + const bcrypt = require('bcrypt'); + const passwordHash = await bcrypt.hash('password123', 10); + + user = await User.create({ + email: 'test@tududi.com', + password_digest: passwordHash, + name: 'Test', + surname: 'User', + appearance: 'light', + language: 'en', + timezone: 'UTC', + }); + console.log(`✅ Created test user: ${user.email}`); + } else { + console.log( + `👤 Using existing user: ${user.email} (ID: ${user.id})` + ); + } + + const now = new Date(); + + // Helper to create date offsets + const hoursAgo = (hours) => + new Date(now.getTime() - hours * 60 * 60 * 1000); + const hoursFromNow = (hours) => + new Date(now.getTime() + hours * 60 * 60 * 1000); + const daysAgo = (days) => + new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + const daysFromNow = (days) => + new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + + console.log('\n📋 Creating test tasks...'); + + const tasks = [ + // Overdue tasks + { + name: '🚨 Very overdue task', + user_id: user.id, + status: 0, + due_date: daysAgo(5), + description: 'This task is 5 days overdue', + }, + { + name: '⚠️ Overdue yesterday', + user_id: user.id, + status: 0, + due_date: daysAgo(1), + description: 'This task was due yesterday', + }, + { + name: '🔴 Overdue today', + user_id: user.id, + status: 0, + due_date: hoursAgo(6), + description: 'This task was due 6 hours ago', + }, + + // Due soon tasks + { + name: '🟡 Due in 2 hours', + user_id: user.id, + status: 0, + due_date: hoursFromNow(2), + description: 'This task is due soon', + }, + { + name: '🟢 Due in 12 hours', + user_id: user.id, + status: 0, + due_date: hoursFromNow(12), + description: 'This task is due within 24 hours', + }, + { + name: '📅 Due tomorrow', + user_id: user.id, + status: 0, + due_date: daysFromNow(1), + description: 'This task is due tomorrow', + }, + + // Deferred tasks + { + name: '⏰ Defer until now (should be active)', + user_id: user.id, + status: 0, + defer_until: hoursAgo(1), + description: 'This task was deferred but is now available', + }, + { + name: '⏳ Defer until in 2 hours', + user_id: user.id, + status: 0, + defer_until: hoursFromNow(2), + description: 'This task will be available in 2 hours', + }, + { + name: '📆 Defer until tomorrow', + user_id: user.id, + status: 0, + defer_until: daysFromNow(1), + description: 'This task will be available tomorrow', + }, + + // Tasks with no due date (should not trigger notifications) + { + name: '✨ No due date', + user_id: user.id, + status: 0, + description: 'This task has no due date', + }, + + // Completed task (should not trigger notifications) + { + name: '✅ Completed overdue task', + user_id: user.id, + status: 2, + due_date: daysAgo(3), + description: 'This task is completed so no notification', + completed_at: new Date(), + }, + ]; + + for (const taskData of tasks) { + const task = await Task.create(taskData); + console.log(` ✓ Created: ${task.name}`); + } + + console.log('\n📁 Creating test projects...'); + + const projects = [ + // Overdue projects + { + name: '🚨 Very overdue project', + user_id: user.id, + state: 'active', + due_date_at: daysAgo(7), + description: 'This project is 7 days overdue', + }, + { + name: '⚠️ Project overdue yesterday', + user_id: user.id, + state: 'active', + due_date_at: daysAgo(1), + description: 'This project was due yesterday', + }, + + // Due soon projects + { + name: '🟡 Project due in 6 hours', + user_id: user.id, + state: 'active', + due_date_at: hoursFromNow(6), + description: 'This project is due soon', + }, + { + name: '📅 Project due tomorrow', + user_id: user.id, + state: 'active', + due_date_at: daysFromNow(1), + description: 'This project is due within 24 hours', + }, + + // Projects with no due date + { + name: '✨ Project with no due date', + user_id: user.id, + state: 'active', + description: 'This project has no due date', + }, + + // Completed project (should not trigger notifications) + { + name: '✅ Completed overdue project', + user_id: user.id, + state: 'completed', + due_date_at: daysAgo(5), + description: 'This project is completed so no notification', + }, + ]; + + for (const projectData of projects) { + const project = await Project.create(projectData); + console.log(` ✓ Created: ${project.name}`); + } + + console.log('\n✅ Seeding complete!'); + console.log('\n📊 Summary:'); + console.log(` • Created ${tasks.length} tasks`); + console.log(` • Created ${projects.length} projects`); + console.log(` • For user: ${user.email}\n`); + + console.log( + '🔔 To generate notifications, run the notification services:' + ); + console.log( + ' • Due tasks: NODE_ENV=development node -e "require(\'./services/dueTaskService\').checkDueTasks().then(console.log)"' + ); + console.log( + ' • Deferred tasks: NODE_ENV=development node -e "require(\'./services/deferredTaskService\').checkDeferredTasks().then(console.log)"' + ); + console.log( + ' • Due projects: NODE_ENV=development node -e "require(\'./services/dueProjectService\').checkDueProjects().then(console.log)"' + ); + console.log(''); + + process.exit(0); + } catch (error) { + console.error('❌ Error seeding data:', error); + process.exit(1); + } +} + +// Run the seeder +if (require.main === module) { + seedNotificationTestData(); +} + +module.exports = { seedNotificationTestData }; diff --git a/backend/scripts/test-query.js b/backend/scripts/test-query.js deleted file mode 100644 index 0b1cc51..0000000 --- a/backend/scripts/test-query.js +++ /dev/null @@ -1,56 +0,0 @@ -const { Task, sequelize } = require('../models'); -const { Op } = require('sequelize'); - -async function testQuery() { - const whereClause = { - parent_task_id: null, - status: { - [Op.notIn]: [ - Task.STATUS.DONE, - Task.STATUS.ARCHIVED, - 'done', - 'archived', - ], - }, - }; - - whereClause[Op.or] = [ - { - [Op.and]: [ - { - [Op.or]: [ - { recurrence_type: 'none' }, - { recurrence_type: null }, - ], - }, - { recurring_parent_id: null }, - ], - }, - { - [Op.and]: [{ recurring_parent_id: { [Op.ne]: null } }], - }, - ]; - - // Log the SQL that will be generated - const query = Task.findAll({ - where: whereClause, - attributes: ['id', 'name', 'recurrence_type', 'recurring_parent_id'], - logging: console.log, - }); - - console.log('\nThis query should:'); - console.log( - '✓ Include: Regular tasks (recurrence_type = null/none, recurring_parent_id = null)' - ); - console.log('✓ Include: Recurring instances (recurring_parent_id != null)'); - console.log( - '✗ Exclude: Recurring parent templates (recurrence_type = daily/weekly/etc, recurring_parent_id = null)' - ); - - await sequelize.close(); -} - -testQuery().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/backend/seeders/dev-seeder.js b/backend/seeders/dev-seeder.js index 926fd44..43352a0 100644 --- a/backend/seeders/dev-seeder.js +++ b/backend/seeders/dev-seeder.js @@ -348,6 +348,50 @@ async function seedDatabase() { tasks.push(todayTask); } + // Create subtasks for some tasks + console.log('📋 Creating subtasks for parent tasks...'); + const { faker } = require('@faker-js/faker'); + + // Select 15-20 random tasks to have subtasks + const parentTaskIndices = []; + while (parentTaskIndices.length < 15) { + const randomIndex = Math.floor(Math.random() * tasks.length); + if ( + !parentTaskIndices.includes(randomIndex) && + tasks[randomIndex].status !== 2 + ) { + // Don't add subtasks to completed tasks + parentTaskIndices.push(randomIndex); + } + } + + for (const parentIndex of parentTaskIndices) { + const parentTask = tasks[parentIndex]; + const numSubtasks = Math.floor(Math.random() * 4) + 2; // 2-5 subtasks + + for (let i = 0; i < numSubtasks; i++) { + const subtask = await Task.create({ + name: faker.lorem.sentence({ min: 3, max: 6 }), + description: + Math.random() < 0.5 ? faker.lorem.paragraph() : null, + priority: Math.floor(Math.random() * 3), + status: Math.floor(Math.random() * 3), // 0, 1, or 2 + user_id: testUser.id, + parent_task_id: parentTask.id, + order: i, + note: + Math.random() < 0.3 + ? `${faker.lorem.sentence()}\n\n- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}` + : null, + }); + tasks.push(subtask); + } + } + + console.log( + ` ✅ Created subtasks for ${parentTaskIndices.length} parent tasks` + ); + // Create intelligent task-tag associations console.log('🔗 Creating intelligent task-tag associations...'); diff --git a/backend/seeders/massive-tasks.js b/backend/seeders/massive-tasks.js index 1ab481d..7ade40b 100644 --- a/backend/seeders/massive-tasks.js +++ b/backend/seeders/massive-tasks.js @@ -1,3 +1,5 @@ +const { faker } = require('@faker-js/faker'); + // Helper function to create massive task data with AI feature triggers function createMassiveTaskData(projects, getRandomDate, getPastDate) { // Helper to get random items from array @@ -15,6 +17,26 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) { return statuses[Math.floor(Math.random() * statuses.length)]; }; + // Helper to generate task description (70% of tasks get a description) + const maybeDescription = () => { + if (Math.random() < 0.7) { + return faker.lorem.paragraph({ min: 1, max: 3 }); + } + return null; + }; + + // Helper to generate task notes (40% of tasks get notes) + const maybeNote = () => { + if (Math.random() < 0.4) { + const bulletPoints = Array.from( + { length: faker.number.int({ min: 2, max: 5 }) }, + () => `- ${faker.lorem.sentence()}` + ).join('\n'); + return `${faker.lorem.sentence()}\n\n${bulletPoints}`; + } + return null; + }; + // Productivity and work tasks const workTasks = [ 'Review quarterly performance metrics', @@ -341,24 +363,34 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) { }, { name: 'Create wireframes for homepage', + description: + 'Design low-fidelity wireframes for the new homepage layout. Focus on user flow, CTA placement, and mobile-first approach.', project_id: projects[0].id, priority: 2, status: 1, + note: 'Need to review with stakeholders\n\n- Include hero section\n- Add testimonials section\n- Feature products prominently\n- Ensure accessibility standards', }, { name: 'Design new color palette', + description: + 'Research and create a modern color palette that aligns with brand identity. Should work well for both light and dark modes.', project_id: projects[0].id, priority: 1, status: 0, }, { name: 'Write content for About page', + description: + 'Draft engaging copy for the About page that tells our story and highlights company values.', project_id: projects[0].id, priority: 1, status: 0, + note: 'Content guidelines:\n\n- Keep it under 500 words\n- Include team photos\n- Highlight mission and values\n- Add company timeline', }, { name: 'Set up staging environment', + description: + 'Configure staging server with proper environment variables, SSL certificates, and deployment pipeline.', project_id: projects[0].id, priority: 2, status: 0, @@ -1282,12 +1314,10 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) { const task = { name: taskName, + description: maybeDescription(), priority: getRandomPriority(), status: isCompleted ? 2 : getRandomStatus(), - note: - Math.random() < 0.1 - ? 'Added some notes during planning phase' - : null, + note: maybeNote(), }; if (hasProject) { diff --git a/backend/services/deferredTaskService.js b/backend/services/deferredTaskService.js new file mode 100644 index 0000000..44f2c8f --- /dev/null +++ b/backend/services/deferredTaskService.js @@ -0,0 +1,184 @@ +const { Task, Notification, User } = require('../models'); +const { Op } = require('sequelize'); +const { logError } = require('./logService'); +const { + shouldSendInAppNotification, +} = require('../utils/notificationPreferences'); + +/** + * Service to check for deferred tasks that are now active + * and create notifications for users + */ + +/** + * Check for tasks that have a defer_until date that has passed + * and create notifications for the task owners + */ +async function checkDeferredTasks() { + try { + const now = new Date(); + const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000); + + const deferredTasks = await Task.findAll({ + where: { + defer_until: { + [Op.not]: null, + [Op.lte]: fiveMinutesFromNow, + }, + status: { + [Op.ne]: 2, + }, + }, + include: [ + { + model: User, + attributes: [ + 'id', + 'email', + 'name', + 'notification_preferences', + ], + }, + ], + }); + + if (deferredTasks.length === 0) { + return { + success: true, + tasksProcessed: 0, + notificationsCreated: 0, + }; + } + + let notificationsCreated = 0; + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + for (const task of deferredTasks) { + try { + // Check if user wants defer until notifications + if (!shouldSendInAppNotification(task.User, 'deferUntil')) { + continue; + } + + // Check for existing notifications (including dismissed ones) + // If a notification was dismissed, don't create it again + const recentNotifications = await Notification.findAll({ + where: { + user_id: task.user_id, + type: 'task_due_soon', + created_at: { + [Op.gte]: oneDayAgo, + }, + }, + }); + + const existingNotification = recentNotifications.find( + (notif) => + notif.data?.taskUid === task.uid && + notif.data?.reason === 'defer_until_reached' + ); + + if (existingNotification) { + // Skip if notification exists, even if it was dismissed + // This prevents re-notifying users about tasks they've already dismissed + continue; + } + + await Notification.createNotification({ + userId: task.user_id, + type: 'task_due_soon', + title: 'Task is now active', + message: `Your task "${task.name}" is now available to work on`, + sources: [], + data: { + taskUid: task.uid, + taskName: task.name, + deferUntil: task.defer_until, + reason: 'defer_until_reached', + }, + sentAt: new Date(), + }); + + notificationsCreated++; + } catch (error) { + logError( + `Error creating notification for task ${task.id}:`, + error + ); + } + } + + return { + success: true, + tasksProcessed: deferredTasks.length, + notificationsCreated, + }; + } catch (error) { + logError('Error checking deferred tasks:', error); + throw error; + } +} + +/** + * Get statistics about deferred tasks + */ +async function getDeferredTaskStats() { + try { + const now = new Date(); + + const [totalDeferred, activeNow, activeSoon] = await Promise.all([ + // Total tasks with defer_until set + Task.count({ + where: { + defer_until: { + [Op.not]: null, + }, + status: { + [Op.ne]: 2, // Not completed + }, + }, + }), + + // Tasks that should be active now + Task.count({ + where: { + defer_until: { + [Op.not]: null, + [Op.lte]: now, + }, + status: { + [Op.ne]: 2, // Not completed + }, + }, + }), + + // Tasks that will be active in the next hour + Task.count({ + where: { + defer_until: { + [Op.not]: null, + [Op.gt]: now, + [Op.lte]: new Date(now.getTime() + 60 * 60 * 1000), + }, + status: { + [Op.ne]: 2, // Not completed + }, + }, + }), + ]); + + return { + totalDeferred, + activeNow, + activeSoon, + }; + } catch (error) { + logError('Error getting deferred task stats:', error); + throw error; + } +} + +module.exports = { + checkDeferredTasks, + getDeferredTaskStats, +}; diff --git a/backend/services/dueProjectService.js b/backend/services/dueProjectService.js new file mode 100644 index 0000000..1f897bb --- /dev/null +++ b/backend/services/dueProjectService.js @@ -0,0 +1,178 @@ +const { Project, Notification, User } = require('../models'); +const { Op } = require('sequelize'); +const { logError } = require('./logService'); +const { + shouldSendInAppNotification, +} = require('../utils/notificationPreferences'); + +/** + * Service to check for due and overdue projects + * and create notifications for users + */ + +/** + * Check for projects that are due soon or overdue + * and create notifications for the project owners + */ +async function checkDueProjects() { + try { + const now = new Date(); + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + + const dueProjects = await Project.findAll({ + where: { + due_date_at: { + [Op.not]: null, + [Op.lte]: tomorrow, + }, + state: { + [Op.notIn]: ['completed'], + }, + }, + include: [ + { + model: User, + attributes: [ + 'id', + 'email', + 'name', + 'notification_preferences', + ], + }, + ], + }); + + if (dueProjects.length === 0) { + return { + success: true, + projectsProcessed: 0, + notificationsCreated: 0, + }; + } + + let notificationsCreated = 0; + + for (const project of dueProjects) { + try { + const dueDate = new Date(project.due_date_at); + const isOverdue = dueDate < now; + const notificationType = isOverdue + ? 'project_overdue' + : 'project_due_soon'; + const level = isOverdue ? 'error' : 'warning'; + + // Check if user wants this notification + if ( + !shouldSendInAppNotification(project.User, notificationType) + ) { + continue; + } + + // Check for existing notifications (including dismissed ones) + // If a notification was dismissed, don't create it again + const recentNotifications = await Notification.findAll({ + where: { + user_id: project.user_id, + type: { + [Op.in]: ['project_due_soon', 'project_overdue'], + }, + created_at: { + [Op.gte]: twoDaysAgo, + }, + }, + }); + + const existingNotification = recentNotifications.find( + (notif) => + notif.data?.projectUid === project.uid && + notif.type === notificationType + ); + + if (existingNotification) { + // Skip if notification exists, even if it was dismissed + // This prevents re-notifying users about tasks they've already dismissed + continue; + } + + const { title, message } = generateNotificationContent( + project.name, + dueDate, + now, + isOverdue + ); + + await Notification.createNotification({ + userId: project.user_id, + type: notificationType, + title, + message, + level, + sources: [], + data: { + projectUid: project.uid, + projectName: project.name, + dueDate: project.due_date_at, + isOverdue, + }, + sentAt: new Date(), + }); + + notificationsCreated++; + } catch (error) { + logError( + `Error creating notification for project ${project.id}:`, + error + ); + } + } + + return { + success: true, + projectsProcessed: dueProjects.length, + notificationsCreated, + }; + } catch (error) { + logError('Error checking due projects:', error); + throw error; + } +} + +/** + * Generate notification title and message based on project due date + */ +function generateNotificationContent(projectName, dueDate, now, isOverdue) { + if (isOverdue) { + const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24)); + const title = 'Project is overdue'; + let message; + + if (daysOverdue === 0) { + message = `Your project "${projectName}" was due today`; + } else if (daysOverdue === 1) { + message = `Your project "${projectName}" was due yesterday`; + } else { + message = `Your project "${projectName}" was due ${daysOverdue} days ago`; + } + + return { title, message }; + } else { + const hoursUntilDue = Math.floor((dueDate - now) / (1000 * 60 * 60)); + const title = 'Project due soon'; + let message; + + if (hoursUntilDue < 1) { + message = `Your project "${projectName}" is due in less than 1 hour`; + } else if (hoursUntilDue < 24) { + message = `Your project "${projectName}" is due in ${hoursUntilDue} hours`; + } else { + message = `Your project "${projectName}" is due tomorrow`; + } + + return { title, message }; + } +} + +module.exports = { + checkDueProjects, +}; diff --git a/backend/services/dueTaskService.js b/backend/services/dueTaskService.js new file mode 100644 index 0000000..10e5101 --- /dev/null +++ b/backend/services/dueTaskService.js @@ -0,0 +1,176 @@ +const { Task, Notification, User } = require('../models'); +const { Op } = require('sequelize'); +const { logError } = require('./logService'); +const { + shouldSendInAppNotification, +} = require('../utils/notificationPreferences'); + +/** + * Service to check for due and overdue tasks + * and create notifications for users + */ + +/** + * Check for tasks that are due soon or overdue + * and create notifications for the task owners + */ +async function checkDueTasks() { + try { + const now = new Date(); + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + + const dueTasks = await Task.findAll({ + where: { + due_date: { + [Op.not]: null, + [Op.lte]: tomorrow, + }, + status: { + [Op.ne]: 2, + }, + }, + include: [ + { + model: User, + attributes: [ + 'id', + 'email', + 'name', + 'notification_preferences', + ], + }, + ], + }); + + if (dueTasks.length === 0) { + return { + success: true, + tasksProcessed: 0, + notificationsCreated: 0, + }; + } + + let notificationsCreated = 0; + + for (const task of dueTasks) { + try { + const dueDate = new Date(task.due_date); + const isOverdue = dueDate < now; + const notificationType = isOverdue + ? 'task_overdue' + : 'task_due_soon'; + const level = isOverdue ? 'error' : 'warning'; + + // Check if user wants this notification + if (!shouldSendInAppNotification(task.User, notificationType)) { + continue; + } + + // Check for existing notifications (including dismissed ones) + // If a notification was dismissed, don't create it again + const recentNotifications = await Notification.findAll({ + where: { + user_id: task.user_id, + type: { + [Op.in]: ['task_due_soon', 'task_overdue'], + }, + created_at: { + [Op.gte]: twoDaysAgo, + }, + }, + }); + + const existingNotification = recentNotifications.find( + (notif) => + notif.data?.taskUid === task.uid && + notif.type === notificationType + ); + + if (existingNotification) { + // Skip if notification exists, even if it was dismissed + // This prevents re-notifying users about tasks they've already dismissed + continue; + } + + const { title, message } = generateNotificationContent( + task.name, + dueDate, + now, + isOverdue + ); + + await Notification.createNotification({ + userId: task.user_id, + type: notificationType, + title, + message, + level, + sources: [], + data: { + taskUid: task.uid, + taskName: task.name, + dueDate: task.due_date, + isOverdue, + }, + sentAt: new Date(), + }); + + notificationsCreated++; + } catch (error) { + logError( + `Error creating notification for task ${task.id}:`, + error + ); + } + } + + return { + success: true, + tasksProcessed: dueTasks.length, + notificationsCreated, + }; + } catch (error) { + logError('Error checking due tasks:', error); + throw error; + } +} + +/** + * Generate notification title and message based on task due date + */ +function generateNotificationContent(taskName, dueDate, now, isOverdue) { + if (isOverdue) { + const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24)); + const title = 'Task is overdue'; + let message; + + if (daysOverdue === 0) { + message = `Your task "${taskName}" was due today`; + } else if (daysOverdue === 1) { + message = `Your task "${taskName}" was due yesterday`; + } else { + message = `Your task "${taskName}" was due ${daysOverdue} days ago`; + } + + return { title, message }; + } else { + const hoursUntilDue = Math.floor((dueDate - now) / (1000 * 60 * 60)); + const title = 'Task due soon'; + let message; + + if (hoursUntilDue < 1) { + message = `Your task "${taskName}" is due in less than 1 hour`; + } else if (hoursUntilDue < 24) { + message = `Your task "${taskName}" is due in ${hoursUntilDue} hours`; + } else { + message = `Your task "${taskName}" is due tomorrow`; + } + + return { title, message }; + } +} + +module.exports = { + checkDueTasks, +}; diff --git a/backend/services/taskScheduler.js b/backend/services/taskScheduler.js index 6967d03..4dac17a 100644 --- a/backend/services/taskScheduler.js +++ b/backend/services/taskScheduler.js @@ -36,6 +36,9 @@ const getCronExpression = (frequency) => { '12h': '0 */12 * * *', recurring_tasks: '0 6 * * *', // Daily at 6 AM for recurring task generation cleanup_tokens: '0 2 * * *', // Daily at 2 AM for cleaning up expired tokens + deferred_tasks: '*/5 * * * *', // Every 5 minutes to check deferred tasks + due_tasks: '*/15 * * * *', // Every 15 minutes to check due/overdue tasks + due_projects: '*/15 * * * *', // Every 15 minutes to check due/overdue projects }; return expressions[frequency]; }; @@ -46,6 +49,12 @@ const createJobHandler = (frequency) => async () => { await processRecurringTasks(); } else if (frequency === 'cleanup_tokens') { await cleanupExpiredTokens(); + } else if (frequency === 'deferred_tasks') { + await processDeferredTasks(); + } else if (frequency === 'due_tasks') { + await processDueTasks(); + } else if (frequency === 'due_projects') { + await processDueProjects(); } else { await processSummariesForFrequency(frequency); } @@ -64,6 +73,9 @@ const createJobEntries = () => { '12h', 'recurring_tasks', 'cleanup_tokens', + 'deferred_tasks', + 'due_tasks', + 'due_projects', ]; return frequencies.map((frequency) => { @@ -151,6 +163,39 @@ const cleanupExpiredTokens = async () => { } }; +// Function to process deferred tasks (contains side effects) +const processDeferredTasks = async () => { + try { + const { checkDeferredTasks } = require('./deferredTaskService'); + const result = await checkDeferredTasks(); + return result; + } catch (error) { + throw error; + } +}; + +// Function to process due tasks (contains side effects) +const processDueTasks = async () => { + try { + const { checkDueTasks } = require('./dueTaskService'); + const result = await checkDueTasks(); + return result; + } catch (error) { + throw error; + } +}; + +// Function to process due projects (contains side effects) +const processDueProjects = async () => { + try { + const { checkDueProjects } = require('./dueProjectService'); + const result = await checkDueProjects(); + return result; + } catch (error) { + throw error; + } +}; + // Function to initialize scheduler (contains side effects) const initialize = async () => { if (schedulerState.isInitialized) { @@ -214,6 +259,9 @@ module.exports = { processSummariesForFrequency, processRecurringTasks, cleanupExpiredTokens, + processDeferredTasks, + processDueTasks, + processDueProjects, // For testing _createSchedulerState: createSchedulerState, _shouldDisableScheduler: shouldDisableScheduler, diff --git a/backend/tests/integration/notification-preferences.test.js b/backend/tests/integration/notification-preferences.test.js new file mode 100644 index 0000000..b1fdd0a --- /dev/null +++ b/backend/tests/integration/notification-preferences.test.js @@ -0,0 +1,199 @@ +const request = require('supertest'); +const app = require('../../app'); +const { User } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); + +describe('Notification Preferences', () => { + let user, agent; + + beforeEach(async () => { + user = await createTestUser({ + email: `test_${Date.now()}@example.com`, + }); + + // Create authenticated agent + agent = request.agent(app); + await agent.post('/api/login').send({ + email: user.email, + password: 'password123', + }); + }); + + describe('GET /api/profile - notification_preferences', () => { + it('should include notification_preferences in profile response', async () => { + const response = await agent.get('/api/profile'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('notification_preferences'); + }); + + it('should return default notification_preferences for new users', async () => { + const response = await agent.get('/api/profile'); + + expect(response.status).toBe(200); + expect(response.body.notification_preferences).toEqual({ + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }); + }); + + it('should return saved notification_preferences', async () => { + const preferences = { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: false, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }; + + await User.update( + { notification_preferences: preferences }, + { where: { id: user.id } } + ); + + const response = await agent.get('/api/profile'); + + expect(response.status).toBe(200); + expect(response.body.notification_preferences).toEqual(preferences); + }); + }); + + describe('PATCH /api/profile - notification_preferences', () => { + it('should update notification preferences', async () => { + const preferences = { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: false, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }; + + const response = await agent + .patch('/api/profile') + .send({ notification_preferences: preferences }); + + expect(response.status).toBe(200); + expect(response.body.notification_preferences).toEqual(preferences); + + // Verify it was saved to database + const updatedUser = await User.findByPk(user.id); + expect(updatedUser.notification_preferences).toEqual(preferences); + }); + + it('should allow partial notification preference updates', async () => { + // Set initial preferences + const initialPreferences = { + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }; + + await agent + .patch('/api/profile') + .send({ notification_preferences: initialPreferences }); + + // Update only some types + const updatedPreferences = { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: false, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }; + + const response = await agent + .patch('/api/profile') + .send({ notification_preferences: updatedPreferences }); + + expect(response.status).toBe(200); + expect(response.body.notification_preferences).toEqual( + updatedPreferences + ); + }); + + it('should allow setting preferences to null', async () => { + // First set some preferences + await agent.patch('/api/profile').send({ + notification_preferences: { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: false, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }, + }); + + // Then set to null + const response = await agent + .patch('/api/profile') + .send({ notification_preferences: null }); + + expect(response.status).toBe(200); + expect(response.body.notification_preferences).toBeNull(); + }); + + it('should not affect other profile fields', async () => { + const preferences = { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: false, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }; + + const response = await agent + .patch('/api/profile') + .send({ notification_preferences: preferences }); + + expect(response.status).toBe(200); + expect(response.body.email).toBe(user.email); + expect(response.body.appearance).toBe(user.appearance); + expect(response.body.language).toBe(user.language); + }); + + it('should require authentication', async () => { + const preferences = { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: false, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }; + + const response = await request(app) + .patch('/api/profile') + .send({ notification_preferences: preferences }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Authentication required'); + }); + + it('should work with other profile updates in same request', async () => { + const updateData = { + appearance: 'dark', + language: 'es', + notification_preferences: { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: false, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }, + }; + + const response = await agent.patch('/api/profile').send(updateData); + + expect(response.status).toBe(200); + expect(response.body.appearance).toBe('dark'); + expect(response.body.language).toBe('es'); + expect(response.body.notification_preferences).toEqual( + updateData.notification_preferences + ); + }); + }); +}); diff --git a/backend/tests/integration/notification-soft-delete.test.js b/backend/tests/integration/notification-soft-delete.test.js new file mode 100644 index 0000000..0c1ef97 --- /dev/null +++ b/backend/tests/integration/notification-soft-delete.test.js @@ -0,0 +1,230 @@ +const request = require('supertest'); +const app = require('../../app'); +const { User, Notification, Task } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); + +describe('Notification Soft Delete', () => { + let user, agent, task; + + beforeEach(async () => { + user = await createTestUser({ + email: `test_${Date.now()}@example.com`, + }); + + // Create authenticated agent + agent = request.agent(app); + await agent.post('/api/login').send({ + email: user.email, + password: 'password123', + }); + + // Create a test task directly in database + task = await Task.create({ + name: 'Test Task', + user_id: user.id, + status: 0, + }); + }); + + describe('DELETE /api/notifications/:id - Soft Delete', () => { + it('should soft delete (dismiss) a notification', async () => { + // Create a notification + const notification = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Test Notification', + message: 'This is a test', + sources: [], + data: { taskUid: task.uid }, + }); + + // Delete (dismiss) the notification + const deleteResponse = await agent.delete( + `/api/notifications/${notification.id}` + ); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.message).toBe( + 'Notification dismissed successfully' + ); + + // Verify the notification still exists in database but is dismissed + const dismissedNotification = await Notification.findByPk( + notification.id + ); + expect(dismissedNotification).not.toBeNull(); + expect(dismissedNotification.dismissed_at).not.toBeNull(); + expect(dismissedNotification.isDismissed()).toBe(true); + }); + + it('should not allow dismissing an already dismissed notification', async () => { + // Create and dismiss a notification + const notification = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Test Notification', + message: 'This is a test', + sources: [], + data: { taskUid: task.uid }, + }); + + await notification.dismiss(); + + // Try to dismiss again + const deleteResponse = await agent.delete( + `/api/notifications/${notification.id}` + ); + + expect(deleteResponse.status).toBe(404); + expect(deleteResponse.body.error).toBe('Notification not found'); + }); + + it('should hide dismissed notifications from GET /api/notifications', async () => { + // Create two notifications + const notification1 = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Notification 1', + message: 'This is test 1', + sources: [], + }); + + const notification2 = await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Notification 2', + message: 'This is test 2', + sources: [], + }); + + // Dismiss the first notification + await agent.delete(`/api/notifications/${notification1.id}`); + + // Get notifications + const getResponse = await agent.get('/api/notifications'); + + expect(getResponse.status).toBe(200); + expect(getResponse.body.total).toBe(1); + expect(getResponse.body.notifications.length).toBe(1); + expect(getResponse.body.notifications[0].id).toBe(notification2.id); + }); + + it('should exclude dismissed notifications from unread count', async () => { + // Create two unread notifications + const notification1 = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Notification 1', + message: 'This is test 1', + sources: [], + }); + + await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Notification 2', + message: 'This is test 2', + sources: [], + }); + + // Check unread count (should be 2) + let countResponse = await agent.get( + '/api/notifications/unread-count' + ); + expect(countResponse.body.count).toBe(2); + + // Dismiss one notification + await agent.delete(`/api/notifications/${notification1.id}`); + + // Check unread count again (should be 1) + countResponse = await agent.get('/api/notifications/unread-count'); + expect(countResponse.body.count).toBe(1); + }); + + it('should not recreate dismissed notifications in cron jobs', async () => { + // Update task with due date in the past + const dueDate = new Date(Date.now() - 1000 * 60 * 60); // 1 hour ago + await task.update({ + due_date: dueDate, + }); + + // Run the due task service + const { checkDueTasks } = require('../../services/dueTaskService'); + let result = await checkDueTasks(); + + // Should create 1 notification + expect(result.notificationsCreated).toBe(1); + + // Get the notification + const notifications = await Notification.findAll({ + where: { user_id: user.id }, + }); + expect(notifications.length).toBe(1); + + const notification = notifications[0]; + + // Dismiss the notification + await notification.dismiss(); + + // Run the service again + result = await checkDueTasks(); + + // Should not create a new notification (dismissed one should be skipped) + expect(result.notificationsCreated).toBe(0); + + // Verify only one notification exists (the dismissed one) + const allNotifications = await Notification.findAll({ + where: { user_id: user.id }, + }); + expect(allNotifications.length).toBe(1); + expect(allNotifications[0].isDismissed()).toBe(true); + }); + }); + + describe('Notification model - isDismissed and dismiss methods', () => { + it('should correctly identify dismissed notifications', async () => { + const notification = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Test', + message: 'Test', + sources: [], + }); + + // Reload from database to get actual values + await notification.reload(); + + expect(notification.dismissed_at).toBeNull(); + expect(notification.isDismissed()).toBe(false); + + await notification.dismiss(); + + expect(notification.isDismissed()).toBe(true); + expect(notification.dismissed_at).not.toBeNull(); + }); + + it('should not change dismissed_at if already dismissed', async () => { + const notification = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Test', + message: 'Test', + sources: [], + }); + + await notification.dismiss(); + const firstDismissedAt = notification.dismissed_at; + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Try to dismiss again + await notification.dismiss(); + + // dismissed_at should be the same + expect(notification.dismissed_at.getTime()).toBe( + firstDismissedAt.getTime() + ); + }); + }); +}); diff --git a/backend/tests/unit/utils/notificationPreferences.test.js b/backend/tests/unit/utils/notificationPreferences.test.js new file mode 100644 index 0000000..de64d5c --- /dev/null +++ b/backend/tests/unit/utils/notificationPreferences.test.js @@ -0,0 +1,183 @@ +const { + shouldSendInAppNotification, + getDefaultNotificationPreferences, + NOTIFICATION_TYPE_MAPPING, +} = require('../../../utils/notificationPreferences'); + +describe('notificationPreferences utils', () => { + describe('getDefaultNotificationPreferences', () => { + it('should return default preferences with all in-app enabled', () => { + const defaults = getDefaultNotificationPreferences(); + + expect(defaults).toEqual({ + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }); + }); + + it('should return a new object each time', () => { + const defaults1 = getDefaultNotificationPreferences(); + const defaults2 = getDefaultNotificationPreferences(); + + expect(defaults1).not.toBe(defaults2); + expect(defaults1).toEqual(defaults2); + }); + }); + + describe('shouldSendInAppNotification', () => { + it('should return true when user has no preferences set', () => { + const user = { notification_preferences: null }; + + expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe( + true + ); + expect(shouldSendInAppNotification(user, 'task_overdue')).toBe( + true + ); + expect(shouldSendInAppNotification(user, 'project_due_soon')).toBe( + true + ); + expect(shouldSendInAppNotification(user, 'project_overdue')).toBe( + true + ); + }); + + it('should return true when user object is null', () => { + expect(shouldSendInAppNotification(null, 'task_due_soon')).toBe( + true + ); + }); + + it('should return true when notification type is enabled', () => { + const user = { + notification_preferences: { + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + }, + }; + + expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe( + true + ); + expect(shouldSendInAppNotification(user, 'task_overdue')).toBe( + true + ); + }); + + it('should return false when notification type is disabled', () => { + const user = { + notification_preferences: { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: false, email: false, push: false }, + }, + }; + + expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe( + false + ); + expect(shouldSendInAppNotification(user, 'task_overdue')).toBe( + false + ); + }); + + it('should map backend notification types correctly', () => { + const user = { + notification_preferences: { + dueTasks: { inApp: false, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: false, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + }, + }; + + // task_due_soon maps to dueTasks (disabled) + expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe( + false + ); + + // task_overdue maps to overdueTasks (enabled) + expect(shouldSendInAppNotification(user, 'task_overdue')).toBe( + true + ); + + // project_due_soon maps to dueProjects (disabled) + expect(shouldSendInAppNotification(user, 'project_due_soon')).toBe( + false + ); + + // project_overdue maps to overdueProjects (enabled) + expect(shouldSendInAppNotification(user, 'project_overdue')).toBe( + true + ); + }); + + it('should handle deferUntil type directly', () => { + const user = { + notification_preferences: { + deferUntil: { inApp: false, email: false, push: false }, + }, + }; + + expect(shouldSendInAppNotification(user, 'deferUntil')).toBe(false); + }); + + it('should default to true for unknown notification types', () => { + const user = { + notification_preferences: { + dueTasks: { inApp: true, email: false, push: false }, + }, + }; + + // Unknown type should default to enabled + expect(shouldSendInAppNotification(user, 'unknown_type')).toBe( + true + ); + }); + + it('should handle partial preferences object', () => { + const user = { + notification_preferences: { + dueTasks: { inApp: false, email: false, push: false }, + // overdueTasks not defined + }, + }; + + // Defined type should respect setting + expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe( + false + ); + + // Undefined type should default to enabled + expect(shouldSendInAppNotification(user, 'task_overdue')).toBe( + true + ); + }); + + it('should handle missing inApp property', () => { + const user = { + notification_preferences: { + dueTasks: { email: false, push: false }, + }, + }; + + // Missing inApp should default to true + expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe( + true + ); + }); + }); + + describe('NOTIFICATION_TYPE_MAPPING', () => { + it('should have correct mappings', () => { + expect(NOTIFICATION_TYPE_MAPPING).toEqual({ + task_due_soon: 'dueTasks', + task_overdue: 'overdueTasks', + project_due_soon: 'dueProjects', + project_overdue: 'overdueProjects', + }); + }); + }); +}); diff --git a/backend/utils/migration-utils.js b/backend/utils/migration-utils.js index bb7d794..e2fe834 100644 --- a/backend/utils/migration-utils.js +++ b/backend/utils/migration-utils.js @@ -2,6 +2,17 @@ async function safeAddColumns(queryInterface, tableName, columns) { try { + // First check if table exists + const tables = await queryInterface.showAllTables(); + const tableExists = tables.includes(tableName); + + if (!tableExists) { + console.log( + `Table ${tableName} does not exist, skipping column additions` + ); + return; + } + const tableInfo = await queryInterface.describeTable(tableName); for (const column of columns) { @@ -38,6 +49,17 @@ async function safeCreateTable(queryInterface, tableName, tableDefinition) { async function safeAddIndex(queryInterface, tableName, fields, options = {}) { try { + // First check if table exists + const tables = await queryInterface.showAllTables(); + const tableExists = tables.includes(tableName); + + if (!tableExists) { + console.log( + `Table ${tableName} does not exist, skipping index addition` + ); + return; + } + const indexes = await queryInterface.showIndex(tableName); const indexExists = indexes.some((index) => index.fields.some((field) => fields.includes(field.attribute)) diff --git a/backend/utils/notificationPreferences.js b/backend/utils/notificationPreferences.js new file mode 100644 index 0000000..7039387 --- /dev/null +++ b/backend/utils/notificationPreferences.js @@ -0,0 +1,62 @@ +/** + * Utility functions for checking user notification preferences + */ + +const DEFAULT_PREFERENCES = { + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, +}; + +/** + * Mapping from backend notification types to preference keys + */ +const NOTIFICATION_TYPE_MAPPING = { + task_due_soon: 'dueTasks', + task_overdue: 'overdueTasks', + project_due_soon: 'dueProjects', + project_overdue: 'overdueProjects', +}; + +/** + * Check if user has enabled in-app notifications for a specific type + * @param {Object} user - User model instance with notification_preferences field + * @param {string} notificationType - Backend notification type (e.g., 'task_due_soon', 'task_overdue') + * @returns {boolean} - True if in-app notifications are enabled for this type + */ +function shouldSendInAppNotification(user, notificationType) { + // If no user or no preferences set, default to enabled + if (!user || !user.notification_preferences) { + return true; + } + + const prefs = user.notification_preferences; + + // Map notification type to preference key + const prefKey = + NOTIFICATION_TYPE_MAPPING[notificationType] || notificationType; + + // If notification type not configured, default to enabled + if (!prefs[prefKey]) { + return true; + } + + // Check if in-app channel is enabled (default to true if not set) + return prefs[prefKey].inApp !== false; +} + +/** + * Get default notification preferences + * @returns {Object} - Default preferences object + */ +function getDefaultNotificationPreferences() { + return { ...DEFAULT_PREFERENCES }; +} + +module.exports = { + shouldSendInAppNotification, + getDefaultNotificationPreferences, + NOTIFICATION_TYPE_MAPPING, +}; diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index 3f7bb1b..97f525b 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -458,15 +458,17 @@ const Layout: React.FC = ({ />
-
+
-
{children}
+
+ {children} +
diff --git a/frontend/components/Calendar.tsx b/frontend/components/Calendar.tsx index b10fdb9..e27bc08 100644 --- a/frontend/components/Calendar.tsx +++ b/frontend/components/Calendar.tsx @@ -41,24 +41,14 @@ interface CalendarEvent { title: string; start: Date; end: Date; - type: 'task' | 'event' | 'google'; + type: 'task' | 'event'; color?: string; } -interface GoogleCalendarStatus { - connected: boolean; - email?: string; -} - const Calendar: React.FC = () => { const { t, i18n } = useTranslation(); const [currentDate, setCurrentDate] = useState(new Date()); const [view, setView] = useState<'month' | 'week' | 'day'>('month'); - const [googleStatus, setGoogleStatus] = useState({ - connected: false, - }); - const [isConnecting, setIsConnecting] = useState(false); - const [isDemoMode, setIsDemoMode] = useState(false); const [events, setEvents] = useState([]); const [isLoadingTasks, setIsLoadingTasks] = useState(false); const [selectedTask, setSelectedTask] = useState(null); @@ -71,44 +61,12 @@ const Calendar: React.FC = () => { const locale = getLocale(i18n.language); - // Load Google Calendar status and tasks on component mount + // Load tasks and projects on component mount useEffect(() => { - checkGoogleCalendarStatus(); loadTasks(); loadProjects(); - - // Check URL parameters for demo mode - const urlParams = new URLSearchParams(window.location.search); - if ( - urlParams.get('demo') === 'true' && - urlParams.get('connected') === 'true' - ) { - setGoogleStatus({ connected: true, email: 'demo@example.com' }); - setIsDemoMode(true); - // Clean up URL - window.history.replaceState( - {}, - document.title, - window.location.pathname - ); - } }, []); - const checkGoogleCalendarStatus = async () => { - try { - const response = await fetch(getApiPath('calendar/status'), { - credentials: 'include', - }); - if (response.ok) { - const status = await response.json(); - setGoogleStatus(status); - setIsDemoMode(status.demo || false); - } - } catch (error) { - console.error('Error checking Google Calendar status:', error); - } - }; - const loadTasks = async () => { setIsLoadingTasks(true); try { @@ -155,6 +113,20 @@ const Calendar: React.FC = () => { } tasks.forEach((task) => { + // Add deferred tasks with defer_until dates + if (task.defer_until) { + const deferDate = new Date(task.defer_until); + const taskEvent = { + id: `task-defer-${task.id}`, + title: `⏰ ${task.name || task.title || `Task ${task.id}`}`, + start: deferDate, + end: new Date(deferDate.getTime() + 60 * 60 * 1000), // 1 hour duration + type: 'task' as const, + color: task.completed_at ? '#22c55e' : '#f59e0b', // Green if completed, amber if deferred + }; + taskEvents.push(taskEvent); + } + // Add tasks with due dates if (task.due_date) { const dueDate = new Date(task.due_date); @@ -169,8 +141,8 @@ const Calendar: React.FC = () => { taskEvents.push(taskEvent); } - // Add tasks scheduled for today (if they don't have due_date) - if (!task.due_date && task.created_at) { + // Add tasks scheduled for today (if they don't have defer_until or due_date) + if (!task.defer_until && !task.due_date && task.created_at) { const createdDate = new Date(task.created_at); const today = new Date(); @@ -188,8 +160,8 @@ const Calendar: React.FC = () => { } } - // Always add tasks to calendar for easier debugging - if (!task.due_date && !task.created_at) { + // Always add tasks to calendar for easier debugging (only if no defer_until, due_date, or created_at) + if (!task.defer_until && !task.due_date && !task.created_at) { const taskEvent = { id: `task-fallback-${task.id}`, title: `📌 ${task.name || task.title || `Task ${task.id}`}`, @@ -219,63 +191,6 @@ const Calendar: React.FC = () => { } }; - const connectGoogleCalendar = async () => { - if (isConnecting) return; - - setIsConnecting(true); - try { - const response = await fetch(getApiPath('calendar/auth'), { - credentials: 'include', - }); - if (response.ok) { - const result = await response.json(); - if (result.demo) { - // Demo mode - simulate connection - setGoogleStatus({ - connected: true, - email: 'demo@example.com', - }); - setIsDemoMode(true); - } else { - // Real Google OAuth - redirect to auth URL - window.location.href = result.authUrl; - } - } else { - throw new Error('Failed to get authorization URL'); - } - } catch (error) { - console.error('Error connecting to Google Calendar:', error); - alert(t('calendar.connectionError')); - } finally { - setIsConnecting(false); - } - }; - - const disconnectGoogleCalendar = async () => { - try { - if (isDemoMode) { - // Demo mode - just update local state - setGoogleStatus({ connected: false }); - setIsDemoMode(false); - return; - } - - // Real disconnect API call - const response = await fetch(getApiPath('calendar/disconnect'), { - method: 'POST', - credentials: 'include', - }); - if (response.ok) { - setGoogleStatus({ connected: false }); - } else { - throw new Error('Failed to disconnect'); - } - } catch (error) { - console.error('Error disconnecting Google Calendar:', error); - alert(t('calendar.disconnectionError')); - } - }; - const navigate = (direction: 'prev' | 'next') => { setCurrentDate((prev) => { if (view === 'month') { @@ -310,8 +225,11 @@ const Calendar: React.FC = () => { const handleEventClick = (event: CalendarEvent) => { // Handle task events if (event.type === 'task') { - // Extract task ID from event ID - const taskId = event.id.replace(/^task(-created|-fallback)?-/, ''); + // Extract task ID from event ID (handles task-, task-defer-, task-created-, task-fallback-) + const taskId = event.id.replace( + /^task(-defer|-created|-fallback)?-/, + '' + ); const task = allTasks.find((t) => t.id.toString() === taskId); if (task) { @@ -395,10 +313,10 @@ const Calendar: React.FC = () => { }; return ( -
-
+
+
{/* Header */} -
+

@@ -463,80 +381,34 @@ const Calendar: React.FC = () => { )} {/* Calendar view */} - {view === 'month' && ( - - )} +
+ {view === 'month' && ( + + )} - {view === 'week' && ( - - )} + {view === 'week' && ( + + )} - {view === 'day' && ( - - )} - - {/* Google Calendar Integration Panel */} -
-

- {t('calendar.googleIntegration')} -

-
-
-

- {isDemoMode - ? 'Demo mode: Google Calendar integration simulated for testing purposes.' - : t('calendar.googleDescription')} -

-

- {t('calendar.googleStatus')}: - {googleStatus.connected ? ( - - {t('calendar.connected')} - {googleStatus.email && - ` (${googleStatus.email})`} - - ) : ( - - {t('calendar.notConnected')} - - )} -

-
- {googleStatus.connected ? ( - - ) : ( - - )} -
+ {view === 'day' && ( + + )}
{/* Event Details Modal */} diff --git a/frontend/components/Calendar/CalendarDayView.tsx b/frontend/components/Calendar/CalendarDayView.tsx index 873dd75..f44bba3 100644 --- a/frontend/components/Calendar/CalendarDayView.tsx +++ b/frontend/components/Calendar/CalendarDayView.tsx @@ -6,7 +6,7 @@ interface CalendarEvent { title: string; start: Date; end: Date; - type: 'task' | 'event' | 'google'; + type: 'task' | 'event'; color?: string; } @@ -60,7 +60,7 @@ const CalendarDayView: React.FC = ({ }; return ( -
+
{/* Header */}
@@ -131,7 +131,7 @@ const CalendarDayView: React.FC = ({
{/* Time slots */} -
+
{hours.map((hour) => { const timeSlotEvents = getEventsForTimeSlot(hour); diff --git a/frontend/components/Calendar/CalendarMonthView.tsx b/frontend/components/Calendar/CalendarMonthView.tsx index 7d07e0e..6ba7498 100644 --- a/frontend/components/Calendar/CalendarMonthView.tsx +++ b/frontend/components/Calendar/CalendarMonthView.tsx @@ -20,7 +20,7 @@ interface CalendarEvent { title: string; start: Date; end: Date; - type: 'task' | 'event' | 'google'; + type: 'task' | 'event'; color?: string; } @@ -105,7 +105,7 @@ const CalendarMonthView: React.FC = ({ }; return ( -
+
{/* Week days header */}
{weekDays.map((day) => ( @@ -119,7 +119,7 @@ const CalendarMonthView: React.FC = ({
{/* Calendar grid */} -
+
{days.map((day) => { const dayEvents = events.filter( (event) => @@ -134,7 +134,7 @@ const CalendarMonthView: React.FC = ({
handleDateClick(day)} - className={`min-h-32 p-2 border-r border-b border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 ${ + className={`p-2 border-r border-b border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 flex flex-col ${ !isCurrentMonth ? 'bg-gray-50 dark:bg-gray-800' : 'bg-white dark:bg-gray-900' diff --git a/frontend/components/Calendar/CalendarWeekView.tsx b/frontend/components/Calendar/CalendarWeekView.tsx index 88e89e2..51f9c5e 100644 --- a/frontend/components/Calendar/CalendarWeekView.tsx +++ b/frontend/components/Calendar/CalendarWeekView.tsx @@ -17,7 +17,7 @@ interface CalendarEvent { title: string; start: Date; end: Date; - type: 'task' | 'event' | 'google'; + type: 'task' | 'event'; color?: string; } @@ -87,7 +87,7 @@ const CalendarWeekView: React.FC = ({ }; return ( -
+
{/* Header with days */}
@@ -129,7 +129,7 @@ const CalendarWeekView: React.FC = ({
{/* Time slots */} -
+
{hours.map((hour) => (
= ({ {pomodoroEnabled && } + +
+ + {isOpen && ( +
+
+

+ {t('notifications.title', 'Notifications')} +

+ {unreadCount > 0 && ( + + )} +
+
+ {loading ? ( +
+

+ {t('notifications.loading', 'Loading...')} +

+
+ ) : notifications.length === 0 ? ( +
+

+ {t( + 'notifications.noNotifications', + 'No notifications yet' + )} +

+
+ ) : ( + notifications.map((notification) => ( +
+
+ {getLevelIcon(notification.level)} +
+
+
+ handleNotificationClick( + notification + ) + } + > +

+ {notification.title} +

+

+ {notification.message} +

+

+ {formatTimestamp( + notification.created_at + )} +

+
+
+ {!notification.is_read && ( + + )} + +
+
+
+
+
+ )) + )} +
+
+ )} +
+ ); +}; + +export default NotificationsDropdown; diff --git a/frontend/components/Profile/ProfileSettings.tsx b/frontend/components/Profile/ProfileSettings.tsx index 74937b6..97f9928 100644 --- a/frontend/components/Profile/ProfileSettings.tsx +++ b/frontend/components/Profile/ProfileSettings.tsx @@ -14,6 +14,7 @@ import { LightBulbIcon, KeyIcon, CheckIcon, + BellIcon, } from '@heroicons/react/24/outline'; import TelegramIcon from '../Icons/TelegramIcon'; import { useToast } from '../Shared/ToastContext'; @@ -38,6 +39,7 @@ import ApiKeysTab from './tabs/ApiKeysTab'; import ProductivityTab from './tabs/ProductivityTab'; import TelegramTab from './tabs/TelegramTab'; import AiTab from './tabs/AiTab'; +import NotificationsTab from './tabs/NotificationsTab'; import type { ProfileSettingsProps, Profile, @@ -93,6 +95,7 @@ const ProfileSettings: React.FC = ({ productivity_assistant_enabled: true, next_task_suggestion_enabled: true, pomodoro_enabled: true, + notification_preferences: null, currentPassword: '', newPassword: '', confirmPassword: '', @@ -443,6 +446,8 @@ const ProfileSettings: React.FC = ({ data.pomodoro_enabled !== undefined ? data.pomodoro_enabled : true, + notification_preferences: + data.notification_preferences || null, }); if (data.telegram_bot_token) { @@ -1013,6 +1018,11 @@ const ProfileSettings: React.FC = ({ name: t('profile.tabs.productivity', 'Productivity'), icon: , }, + { + id: 'notifications', + name: t('profile.tabs.notifications', 'Notifications'), + icon: , + }, { id: 'telegram', name: t('profile.tabs.telegram', 'Telegram'), @@ -1124,6 +1134,19 @@ const ProfileSettings: React.FC = ({ } /> + + setFormData((prev) => ({ + ...prev, + notification_preferences: preferences, + })) + } + /> + void; +} + +const DEFAULT_PREFERENCES: NotificationPreferences = { + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, +}; + +interface NotificationTypeRowProps { + icon: React.ComponentType<{ className?: string }>; + label: string; + description: string; + preferences: { inApp: boolean; email: boolean; push: boolean }; + onToggle: (channel: 'inApp' | 'email' | 'push', value: boolean) => void; +} + +const NotificationTypeRow: React.FC = ({ + icon: Icon, + label, + description, + preferences, + onToggle, +}) => { + const renderToggle = ( + channel: 'inApp' | 'email' | 'push', + isEnabled: boolean, + isAvailable: boolean + ) => ( + + ); + + return ( + + +
+ +
+
+ {label} +
+
+ {description} +
+
+
+ + + {renderToggle('inApp', preferences.inApp, true)} + + + {renderToggle('email', preferences.email, false)} + + + {renderToggle('push', preferences.push, false)} + + + ); +}; + +const NotificationsTab: React.FC = ({ + isActive, + notificationPreferences, + onChange, +}) => { + const { t } = useTranslation(); + + if (!isActive) return null; + + // Merge with defaults to ensure all types exist + const preferences: NotificationPreferences = { + ...DEFAULT_PREFERENCES, + ...notificationPreferences, + }; + + const handleToggle = ( + notificationType: keyof NotificationPreferences, + channel: 'inApp' | 'email' | 'push', + value: boolean + ) => { + const updatedPreferences = { + ...preferences, + [notificationType]: { + ...preferences[notificationType], + [channel]: value, + }, + }; + onChange(updatedPreferences); + }; + + return ( +
+

+ + {t('profile.tabs.notifications', 'Notification Preferences')} +

+

+ {t( + 'profile.notificationsDescription', + 'Choose how you want to be notified about important events.' + )} +

+ + {/* Notifications Table */} +
+ + + + + + + + + + + + handleToggle('dueTasks', channel, value) + } + /> + + handleToggle('overdueTasks', channel, value) + } + /> + + handleToggle('deferUntil', channel, value) + } + /> + + handleToggle('dueProjects', channel, value) + } + /> + + handleToggle('overdueProjects', channel, value) + } + /> + +
+ {t( + 'notifications.table.type', + 'Notification Type' + )} + + {t('notifications.channels.inApp', 'In-app')} + +
+ {t('notifications.channels.email', 'Email')} + + ({t('common.comingSoon', 'Coming Soon')} + ) + +
+
+
+ {t('notifications.channels.push', 'Push')} + + ({t('common.comingSoon', 'Coming Soon')} + ) + +
+
+
+ + {/* Help Text */} +
+

+ + {t('notifications.info.title', 'Note:')} + {' '} + {t( + 'notifications.info.message', + 'Email and Push notifications are coming soon. In-app notifications are currently available.' + )} +

+
+
+ ); +}; + +export default NotificationsTab; diff --git a/frontend/components/Profile/types.ts b/frontend/components/Profile/types.ts index 7a71ae7..9557b63 100644 --- a/frontend/components/Profile/types.ts +++ b/frontend/components/Profile/types.ts @@ -4,6 +4,14 @@ export interface ProfileSettingsProps { toggleDarkMode?: () => void; } +export interface NotificationPreferences { + dueTasks: { inApp: boolean; email: boolean; push: boolean }; + overdueTasks: { inApp: boolean; email: boolean; push: boolean }; + dueProjects: { inApp: boolean; email: boolean; push: boolean }; + overdueProjects: { inApp: boolean; email: boolean; push: boolean }; + deferUntil: { inApp: boolean; email: boolean; push: boolean }; +} + export interface Profile { uid: string; email: string; @@ -24,6 +32,7 @@ export interface Profile { productivity_assistant_enabled: boolean; next_task_suggestion_enabled: boolean; pomodoro_enabled: boolean; + notification_preferences?: NotificationPreferences | null; } export interface TelegramBotInfo { diff --git a/frontend/components/Project/ProjectItem.tsx b/frontend/components/Project/ProjectItem.tsx index 0527505..0785c8e 100644 --- a/frontend/components/Project/ProjectItem.tsx +++ b/frontend/components/Project/ProjectItem.tsx @@ -200,7 +200,9 @@ const ProjectItem: React.FC = ({ : 'items-center flex-1' }`} > -
+
= ({ > {project.name} + {viewMode === 'cards' && project.description && ( +

+ {project.description} +

+ )}
{viewMode === 'cards' ? ( @@ -379,18 +386,27 @@ const ProjectItem: React.FC = ({
+ + {(project as any).task_status + ? `${(project as any).task_status.done}/${(project as any).task_status.total}` + : '0/0'} +
)} diff --git a/package-lock.json b/package-lock.json index bb4fcde..51d7844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "tagify": "^0.1.1", "typescript-eslint": "^8.36.0", "uuid": "~11.1.0", + "web-push": "^3.6.7", "zustand": "^5.0.3" }, "devDependencies": { @@ -66,6 +67,7 @@ "@babel/preset-env": "^7.25.7", "@babel/preset-react": "^7.25.7", "@babel/preset-typescript": "^7.25.7", + "@faker-js/faker": "^10.1.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@swc/core": "^1.13.3", "@testing-library/jest-dom": "^6.0.0", @@ -2215,6 +2217,23 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -5168,6 +5187,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -5556,6 +5587,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -5716,6 +5753,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7495,6 +7538,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/editorconfig": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", @@ -9743,6 +9795,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -12098,6 +12159,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13495,7 +13577,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, "license": "ISC" }, "node_modules/minimatch": { @@ -19309,6 +19390,47 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index ceca6d0..fc9d860 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "db:reset": "cd backend && node scripts/db-reset.js", "db:status": "cd backend && node scripts/db-status.js", "db:seed": "cd backend && node scripts/seed-dev-data.js", + "db:reset-and-seed": "cd backend && NODE_ENV=development node scripts/reset-and-seed.js", "user:create": "cd backend && node scripts/user-create.js", "migration:create": "cd backend && node scripts/migration-create.js", "migration:run": "cd backend && npx sequelize-cli db:migrate", @@ -65,6 +66,7 @@ "@babel/preset-env": "^7.25.7", "@babel/preset-react": "^7.25.7", "@babel/preset-typescript": "^7.25.7", + "@faker-js/faker": "^10.1.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@swc/core": "^1.13.3", "@testing-library/jest-dom": "^6.0.0", @@ -158,6 +160,7 @@ "tagify": "^0.1.1", "typescript-eslint": "^8.36.0", "uuid": "~11.1.0", + "web-push": "^3.6.7", "zustand": "^5.0.3" } }