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
This commit is contained in:
parent
b2a5a4c63e
commit
18c7785b13
38 changed files with 3350 additions and 361 deletions
|
|
@ -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
|
||||
|
|
|
|||
90
backend/migrations/20251124000002-create-notifications.js
Normal file
90
backend/migrations/20251124000002-create-notifications.js
Normal file
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
@ -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'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
315
backend/models/notification.js
Normal file
315
backend/models/notification.js
Normal file
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
159
backend/routes/notifications.js
Normal file
159
backend/routes/notifications.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
107
backend/scripts/reset-and-seed.js
Executable file
107
backend/scripts/reset-and-seed.js
Executable file
|
|
@ -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();
|
||||
56
backend/scripts/run-notification-services.js
Normal file
56
backend/scripts/run-notification-services.js
Normal file
|
|
@ -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 };
|
||||
234
backend/scripts/seed-notification-test-data.js
Normal file
234
backend/scripts/seed-notification-test-data.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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...');
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
184
backend/services/deferredTaskService.js
Normal file
184
backend/services/deferredTaskService.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
178
backend/services/dueProjectService.js
Normal file
178
backend/services/dueProjectService.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
176
backend/services/dueTaskService.js
Normal file
176
backend/services/dueTaskService.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
199
backend/tests/integration/notification-preferences.test.js
Normal file
199
backend/tests/integration/notification-preferences.test.js
Normal file
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
230
backend/tests/integration/notification-soft-delete.test.js
Normal file
230
backend/tests/integration/notification-soft-delete.test.js
Normal file
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
183
backend/tests/unit/utils/notificationPreferences.test.js
Normal file
183
backend/tests/unit/utils/notificationPreferences.test.js
Normal file
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
62
backend/utils/notificationPreferences.js
Normal file
62
backend/utils/notificationPreferences.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -458,15 +458,17 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
/>
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
|
||||
className={`transition-all duration-300 ease-in-out ${mainContentMarginLeft} h-screen flex flex-col`}
|
||||
>
|
||||
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto">
|
||||
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 flex-1 overflow-hidden">
|
||||
<div
|
||||
className={`flex-grow py-0 px-0 transition-all duration-300 ${
|
||||
className={`flex-1 flex flex-col py-0 px-0 transition-all duration-300 ${
|
||||
isMobileSearchOpen ? 'pt-32' : 'pt-20'
|
||||
} md:pt-20 ${isUpcomingView ? '' : 'md:px-4'}`}
|
||||
} md:pt-20 ${isUpcomingView ? '' : 'md:px-4'} overflow-hidden`}
|
||||
>
|
||||
<div className="w-full">{children}</div>
|
||||
<div className="w-full h-full overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<GoogleCalendarStatus>({
|
||||
connected: false,
|
||||
});
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(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 (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-6xl">
|
||||
<div className="h-full flex flex-col px-4 py-4">
|
||||
<div className="w-full flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-2xl font-light flex items-center">
|
||||
<CalendarIcon className="h-6 w-6 mr-2" />
|
||||
|
|
@ -463,80 +381,34 @@ const Calendar: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* Calendar view */}
|
||||
{view === 'month' && (
|
||||
<CalendarMonthView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{view === 'month' && (
|
||||
<CalendarMonthView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'week' && (
|
||||
<CalendarWeekView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
onTimeSlotClick={handleTimeSlotClick}
|
||||
/>
|
||||
)}
|
||||
{view === 'week' && (
|
||||
<CalendarWeekView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
onTimeSlotClick={handleTimeSlotClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'day' && (
|
||||
<CalendarDayView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onEventClick={handleEventClick}
|
||||
onTimeSlotClick={handleTimeSlotClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Google Calendar Integration Panel */}
|
||||
<div className="mt-6 bg-white dark:bg-gray-900 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium mb-4 text-gray-900 dark:text-gray-100">
|
||||
{t('calendar.googleIntegration')}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{isDemoMode
|
||||
? 'Demo mode: Google Calendar integration simulated for testing purposes.'
|
||||
: t('calendar.googleDescription')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{t('calendar.googleStatus')}:
|
||||
{googleStatus.connected ? (
|
||||
<span className="text-green-500 ml-1">
|
||||
{t('calendar.connected')}
|
||||
{googleStatus.email &&
|
||||
` (${googleStatus.email})`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500 ml-1">
|
||||
{t('calendar.notConnected')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{googleStatus.connected ? (
|
||||
<button
|
||||
onClick={disconnectGoogleCalendar}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
{t('calendar.disconnectGoogle')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={connectGoogleCalendar}
|
||||
disabled={isConnecting}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isConnecting
|
||||
? t('calendar.connecting')
|
||||
: t('calendar.connectGoogle')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{view === 'day' && (
|
||||
<CalendarDayView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onEventClick={handleEventClick}
|
||||
onTimeSlotClick={handleTimeSlotClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
|
|
|
|||
|
|
@ -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<CalendarDayViewProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
|
||||
<div className="h-full bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-center">
|
||||
|
|
@ -131,7 +131,7 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{hours.map((hour) => {
|
||||
const timeSlotEvents = getEventsForTimeSlot(hour);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CalendarMonthViewProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
|
||||
<div className="h-full bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden flex flex-col">
|
||||
{/* Week days header */}
|
||||
<div className="grid grid-cols-7 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
{weekDays.map((day) => (
|
||||
|
|
@ -119,7 +119,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
<div className="grid grid-cols-7 flex-1 min-h-0 auto-rows-fr">
|
||||
{days.map((day) => {
|
||||
const dayEvents = events.filter(
|
||||
(event) =>
|
||||
|
|
@ -134,7 +134,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
<div
|
||||
key={day.toString()}
|
||||
onClick={() => 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'
|
||||
|
|
|
|||
|
|
@ -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<CalendarWeekViewProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
|
||||
<div className="h-full bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden flex flex-col">
|
||||
{/* Header with days */}
|
||||
<div className="grid grid-cols-8 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -129,7 +129,7 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { EnvelopeIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import PomodoroTimer from './Shared/PomodoroTimer';
|
||||
import UniversalSearch from './UniversalSearch/UniversalSearch';
|
||||
import NotificationsDropdown from './Notifications/NotificationsDropdown';
|
||||
import { getApiPath } from '../config/paths';
|
||||
|
||||
interface NavbarProps {
|
||||
|
|
@ -210,6 +211,8 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</button>
|
||||
{pomodoroEnabled && <PomodoroTimer />}
|
||||
|
||||
<NotificationsDropdown isDarkMode={isDarkMode} />
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
|
|
|
|||
384
frontend/components/Notifications/NotificationsDropdown.tsx
Normal file
384
frontend/components/Notifications/NotificationsDropdown.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
BellIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
InformationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ExclamationCircleIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getApiPath } from '../../config/paths';
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
level: 'info' | 'warning' | 'error' | 'success';
|
||||
source: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
data?: {
|
||||
taskUid?: string;
|
||||
projectUid?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationsDropdownProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
const NotificationsDropdown: React.FC<NotificationsDropdownProps> = ({
|
||||
isDarkMode,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiPath('notifications/unread-count'),
|
||||
{
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUnreadCount(data.count || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching unread count:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiPath('notifications?limit=20&includeRead=true'),
|
||||
{
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setNotifications(data.notifications || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (id: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiPath(`notifications/${id}/read`),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
fetchUnreadCount();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiPath('notifications/mark-all-read'),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => ({ ...n, is_read: true }))
|
||||
);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await fetch(getApiPath(`notifications/${id}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
fetchUnreadCount();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'success':
|
||||
return (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500 flex-shrink-0" />
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 flex-shrink-0" />
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<ExclamationCircleIcon className="h-5 w-5 text-red-500 flex-shrink-0" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-500 flex-shrink-0" />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return t('notifications.justNow', 'Just now');
|
||||
if (minutes < 60)
|
||||
return t('notifications.minutesAgo', '{{count}} min ago', {
|
||||
count: minutes,
|
||||
});
|
||||
if (hours < 24)
|
||||
return t('notifications.hoursAgo', '{{count}}h ago', {
|
||||
count: hours,
|
||||
});
|
||||
if (days < 7)
|
||||
return t('notifications.daysAgo', '{{count}}d ago', {
|
||||
count: days,
|
||||
});
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const handleNotificationClick = (notification: Notification) => {
|
||||
if (notification.data?.taskUid) {
|
||||
setIsOpen(false);
|
||||
navigate(`/task/${notification.data.taskUid}`);
|
||||
} else if (notification.data?.projectUid) {
|
||||
setIsOpen(false);
|
||||
navigate(`/project/${notification.data.projectUid}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="relative flex items-center focus:outline-none"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<BellIcon className="h-6 w-6 text-gray-700 dark:text-gray-300" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] rounded-full h-4 min-w-4 px-1 flex items-center justify-center font-medium">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute right-0 mt-2 w-96 rounded-lg shadow-lg z-50 ${
|
||||
isDarkMode ? 'bg-gray-800' : 'bg-white'
|
||||
} border ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-4 border-b flex items-center justify-between ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t('notifications.title', 'Notifications')}
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{t(
|
||||
'notifications.markAllRead',
|
||||
'Mark all as read'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('notifications.loading', 'Loading...')}
|
||||
</p>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'notifications.noNotifications',
|
||||
'No notifications yet'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 border-b ${
|
||||
isDarkMode
|
||||
? 'border-gray-700'
|
||||
: 'border-gray-200'
|
||||
} ${
|
||||
!notification.is_read
|
||||
? isDarkMode
|
||||
? 'bg-gray-700/50'
|
||||
: 'bg-blue-50'
|
||||
: ''
|
||||
} hover:${
|
||||
isDarkMode
|
||||
? 'bg-gray-700'
|
||||
: 'bg-gray-50'
|
||||
} transition-colors`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getLevelIcon(notification.level)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className={`flex-1 ${
|
||||
notification.data
|
||||
?.taskUid ||
|
||||
notification.data
|
||||
?.projectUid
|
||||
? 'cursor-pointer'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleNotificationClick(
|
||||
notification
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{formatTimestamp(
|
||||
notification.created_at
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 ml-2">
|
||||
{!notification.is_read && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(
|
||||
notification.id
|
||||
);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
title={t(
|
||||
'notifications.markAsRead',
|
||||
'Mark as read'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(
|
||||
notification.id
|
||||
);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
title={t(
|
||||
'notifications.delete',
|
||||
'Delete'
|
||||
)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsDropdown;
|
||||
|
|
@ -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<ProfileSettingsProps> = ({
|
|||
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<ProfileSettingsProps> = ({
|
|||
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<ProfileSettingsProps> = ({
|
|||
name: t('profile.tabs.productivity', 'Productivity'),
|
||||
icon: <ClockIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
name: t('profile.tabs.notifications', 'Notifications'),
|
||||
icon: <BellIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: 'telegram',
|
||||
name: t('profile.tabs.telegram', 'Telegram'),
|
||||
|
|
@ -1124,6 +1134,19 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||
}
|
||||
/>
|
||||
|
||||
<NotificationsTab
|
||||
isActive={activeTab === 'notifications'}
|
||||
notificationPreferences={
|
||||
formData.notification_preferences
|
||||
}
|
||||
onChange={(preferences) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
notification_preferences: preferences,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<TelegramTab
|
||||
isActive={activeTab === 'telegram'}
|
||||
formData={formData}
|
||||
|
|
|
|||
270
frontend/components/Profile/tabs/NotificationsTab.tsx
Normal file
270
frontend/components/Profile/tabs/NotificationsTab.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BellIcon,
|
||||
BellAlertIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { NotificationPreferences } from '../types';
|
||||
|
||||
interface NotificationsTabProps {
|
||||
isActive: boolean;
|
||||
notificationPreferences: NotificationPreferences | null | undefined;
|
||||
onChange: (preferences: NotificationPreferences) => 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<NotificationTypeRowProps> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
preferences,
|
||||
onToggle,
|
||||
}) => {
|
||||
const renderToggle = (
|
||||
channel: 'inApp' | 'email' | 'push',
|
||||
isEnabled: boolean,
|
||||
isAvailable: boolean
|
||||
) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isAvailable && onToggle(channel, !isEnabled)}
|
||||
disabled={!isAvailable}
|
||||
className={`
|
||||
relative inline-flex h-5 w-9 items-center rounded-full
|
||||
transition-colors duration-200 ease-in-out
|
||||
${isAvailable ? 'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2' : 'cursor-not-allowed opacity-50'}
|
||||
${isEnabled && isAvailable ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'}
|
||||
`}
|
||||
aria-label={`Toggle ${channel} for ${label}`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-3 w-3 transform rounded-full
|
||||
bg-white transition-transform duration-200 ease-in-out
|
||||
${isEnabled && isAvailable ? 'translate-x-5' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
{renderToggle('inApp', preferences.inApp, true)}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
{renderToggle('email', preferences.email, false)}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
{renderToggle('push', preferences.push, false)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationsTab: React.FC<NotificationsTabProps> = ({
|
||||
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 (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
|
||||
<BellIcon className="w-6 h-6 mr-3 text-purple-500" />
|
||||
{t('profile.tabs.notifications', 'Notification Preferences')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
{t(
|
||||
'profile.notificationsDescription',
|
||||
'Choose how you want to be notified about important events.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Notifications Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-300 dark:border-gray-600">
|
||||
<th className="py-3 px-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t(
|
||||
'notifications.table.type',
|
||||
'Notification Type'
|
||||
)}
|
||||
</th>
|
||||
<th className="py-3 px-4 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('notifications.channels.inApp', 'In-app')}
|
||||
</th>
|
||||
<th className="py-3 px-4 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{t('notifications.channels.email', 'Email')}
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-500 font-normal">
|
||||
({t('common.comingSoon', 'Coming Soon')}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="py-3 px-4 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{t('notifications.channels.push', 'Push')}
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-500 font-normal">
|
||||
({t('common.comingSoon', 'Coming Soon')}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<NotificationTypeRow
|
||||
icon={BellAlertIcon}
|
||||
label={t(
|
||||
'notifications.types.dueTasks',
|
||||
'Due Tasks'
|
||||
)}
|
||||
description={t(
|
||||
'notifications.descriptions.dueTasks',
|
||||
'Tasks that are due within 24 hours'
|
||||
)}
|
||||
preferences={preferences.dueTasks}
|
||||
onToggle={(channel, value) =>
|
||||
handleToggle('dueTasks', channel, value)
|
||||
}
|
||||
/>
|
||||
<NotificationTypeRow
|
||||
icon={ExclamationTriangleIcon}
|
||||
label={t(
|
||||
'notifications.types.overdueTasks',
|
||||
'Overdue Tasks'
|
||||
)}
|
||||
description={t(
|
||||
'notifications.descriptions.overdueTasks',
|
||||
'Tasks that have passed their due date'
|
||||
)}
|
||||
preferences={preferences.overdueTasks}
|
||||
onToggle={(channel, value) =>
|
||||
handleToggle('overdueTasks', channel, value)
|
||||
}
|
||||
/>
|
||||
<NotificationTypeRow
|
||||
icon={ClockIcon}
|
||||
label={t(
|
||||
'notifications.types.deferUntil',
|
||||
'Defer Until'
|
||||
)}
|
||||
description={t(
|
||||
'notifications.descriptions.deferUntil',
|
||||
'Tasks that are now available to work on'
|
||||
)}
|
||||
preferences={preferences.deferUntil}
|
||||
onToggle={(channel, value) =>
|
||||
handleToggle('deferUntil', channel, value)
|
||||
}
|
||||
/>
|
||||
<NotificationTypeRow
|
||||
icon={FolderIcon}
|
||||
label={t(
|
||||
'notifications.types.dueProjects',
|
||||
'Due Projects'
|
||||
)}
|
||||
description={t(
|
||||
'notifications.descriptions.dueProjects',
|
||||
'Projects that are due within 24 hours'
|
||||
)}
|
||||
preferences={preferences.dueProjects}
|
||||
onToggle={(channel, value) =>
|
||||
handleToggle('dueProjects', channel, value)
|
||||
}
|
||||
/>
|
||||
<NotificationTypeRow
|
||||
icon={FolderOpenIcon}
|
||||
label={t(
|
||||
'notifications.types.overdueProjects',
|
||||
'Overdue Projects'
|
||||
)}
|
||||
description={t(
|
||||
'notifications.descriptions.overdueProjects',
|
||||
'Projects that have passed their due date'
|
||||
)}
|
||||
preferences={preferences.overdueProjects}
|
||||
onToggle={(channel, value) =>
|
||||
handleToggle('overdueProjects', channel, value)
|
||||
}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<span className="font-medium">
|
||||
{t('notifications.info.title', 'Note:')}
|
||||
</span>{' '}
|
||||
{t(
|
||||
'notifications.info.message',
|
||||
'Email and Push notifications are coming soon. In-app notifications are currently available.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsTab;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,9 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
: 'items-center flex-1'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`flex ${viewMode === 'cards' ? 'flex-col' : 'items-center'}`}
|
||||
>
|
||||
<Link
|
||||
to={
|
||||
project.uid
|
||||
|
|
@ -218,6 +220,11 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
{viewMode === 'cards' && project.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative dropdown-container">
|
||||
{viewMode === 'cards' ? (
|
||||
|
|
@ -379,18 +386,27 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
<div className="absolute bottom-4 left-0 right-0 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"
|
||||
title={t('projectItem.completionPercentage', {
|
||||
percentage: getCompletionPercentage(),
|
||||
})}
|
||||
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 cursor-help"
|
||||
title={
|
||||
(project as any).task_status
|
||||
? `${(project as any).task_status.done} of ${(project as any).task_status.total} tasks completed (${getCompletionPercentage()}%)`
|
||||
: t('projectItem.completionPercentage', {
|
||||
percentage: getCompletionPercentage(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${getCompletionPercentage()}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium whitespace-nowrap">
|
||||
{(project as any).task_status
|
||||
? `${(project as any).task_status.done}/${(project as any).task_status.total}`
|
||||
: '0/0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
124
package-lock.json
generated
124
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue