Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ ENV NODE_ENV=production \
SWAGGER_ENABLED=false \
FF_ENABLE_BACKUPS=false \
FF_ENABLE_CALENDAR=false \
FF_ENABLE_HABITS=false
FF_ENABLE_HABITS=false \
VAPID_PUBLIC_KEY="" \
VAPID_PRIVATE_KEY="" \
VAPID_SUBJECT="mailto:admin@localhost"

HEALTHCHECK --interval=60s --timeout=3s --start-period=10s --retries=2 \
CMD ["wget", "-q", "--spider", "http://127.0.0.1:3002/api/health"]
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,49 @@ curl -X POST \

For full API documentation, visit `/api-docs` after authentication or check the Swagger schema definitions in [`backend/config/swagger.js`](backend/config/swagger.js).

## 🔔 Push Notifications

Tududi supports browser push notifications (PWA) for tasks and project updates.

### Setup

**1. Generate VAPID keys:**
```bash
npm run vapid:generate
```

**2. Development:** Add to `backend/.env`:
```bash
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:your-email@example.com
```

**3. Production:** Set environment variables based on your deployment:

**Docker Compose:** Edit `docker-compose.yml`:
```yaml
environment:
- VAPID_PUBLIC_KEY=your_public_key_here
- VAPID_PRIVATE_KEY=your_private_key_here
- VAPID_SUBJECT=mailto:your-email@example.com
```

**Docker CLI:**
```bash
docker run \
-e VAPID_PUBLIC_KEY=your_public_key_here \
-e VAPID_PRIVATE_KEY=your_private_key_here \
-e VAPID_SUBJECT=mailto:your-email@example.com \
# ... other options
```

**Cloud/Kubernetes:** Use platform secrets management

### Usage

Once configured, users can enable push notifications in **Settings → Notifications → Browser Push Notifications**.

## 🤝 Contributing

Contributions to tududi are welcome! Whether it's bug fixes, new features, documentation improvements, or translations, we appreciate your help.
Expand Down
8 changes: 8 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ TUDUDI_USER_EMAIL=admin@example.com
TUDUDI_USER_PASSWORD=change-me-to-secure-password
TUDUDI_SESSION_SECRET=your-random-64-character-hex-string-here

# VAPID keys: generate with "npm run vapid:generate"
# VAPID Public Key (safe to expose to frontend)
VAPID_PUBLIC_KEY=
# VAPID Private Key (KEEP SECRET - never commit to repo)
VAPID_PRIVATE_KEY=
# VAPID Subject (your contact email or website URL)
VAPID_SUBJECT=mailto:admin@example.com

ENABLE_EMAIL=false
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
Expand Down
6 changes: 6 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ app.use(
})
);

// Service Worker scope header - allow SW at /pwa/sw.js to control root scope
app.get('/pwa/sw.js', (req, res, next) => {
res.set('Service-Worker-Allowed', '/');
next();
});

// Static files
if (config.production) {
app.use(express.static(path.join(__dirname, 'dist')));
Expand Down
56 changes: 56 additions & 0 deletions backend/migrations/20251227000001-add-push-subscriptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use strict';

const { safeAddIndex } = require('../utils/migration-utils');

module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('push_subscriptions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
onDelete: 'CASCADE',
},
endpoint: {
type: Sequelize.TEXT,
allowNull: false,
},
keys: {
type: Sequelize.JSON,
allowNull: false,
comment: 'Contains p256dh and auth keys for encryption',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});

await safeAddIndex(queryInterface, 'push_subscriptions', ['user_id'], {
name: 'push_subscriptions_user_id_idx',
});

await safeAddIndex(queryInterface, 'push_subscriptions', ['endpoint'], {
name: 'push_subscriptions_endpoint_idx',
unique: true,
});
},

async down(queryInterface) {
await queryInterface.dropTable('push_subscriptions');
},
};
9 changes: 9 additions & 0 deletions backend/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const Notification = require('./notification')(sequelize);
const RecurringCompletion = require('./recurringCompletion')(sequelize);
const TaskAttachment = require('./task_attachment')(sequelize);
const Backup = require('./backup')(sequelize);
const PushSubscription = require('./push_subscription')(sequelize);

User.hasMany(Area, { foreignKey: 'user_id' });
Area.belongsTo(User, { foreignKey: 'user_id' });
Expand Down Expand Up @@ -188,6 +189,13 @@ TaskAttachment.belongsTo(Task, { foreignKey: 'task_id' });
User.hasMany(Backup, { foreignKey: 'user_id', as: 'Backups' });
Backup.belongsTo(User, { foreignKey: 'user_id', as: 'User' });

// PushSubscription associations
User.hasMany(PushSubscription, {
foreignKey: 'user_id',
as: 'PushSubscriptions',
});
PushSubscription.belongsTo(User, { foreignKey: 'user_id', as: 'User' });

module.exports = {
sequelize,
User,
Expand All @@ -208,4 +216,5 @@ module.exports = {
RecurringCompletion,
TaskAttachment,
Backup,
PushSubscription,
};
27 changes: 26 additions & 1 deletion backend/models/notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ module.exports = (sequelize) => {
if (!Array.isArray(value)) {
throw new Error('Sources must be an array');
}
const validSources = ['telegram', 'mobile', 'email'];
const validSources = [
'telegram',
'mobile',
'email',
'push',
];
const invalidSources = value.filter(
(s) => !validSources.includes(s)
);
Expand Down Expand Up @@ -168,9 +173,29 @@ module.exports = (sequelize) => {
);
}

if (sources.includes('push')) {
await sendWebPushNotification(userId, {
title,
message,
data,
type,
});
}

return notification;
};

async function sendWebPushNotification(userId, notification) {
try {
const webPushService = require('../services/webPushService');
if (webPushService.isWebPushConfigured()) {
await webPushService.sendPushNotification(userId, notification);
}
} catch (error) {
console.error('Failed to send Web Push notification:', error);
}
}

async function sendEmailNotification(
userId,
title,
Expand Down
42 changes: 42 additions & 0 deletions backend/models/push_subscription.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const { DataTypes } = require('sequelize');

module.exports = (sequelize) => {
const PushSubscription = sequelize.define(
'PushSubscription',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
endpoint: {
type: DataTypes.TEXT,
allowNull: false,
},
keys: {
type: DataTypes.JSON,
allowNull: false,
},
},
{
tableName: 'push_subscriptions',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ fields: ['user_id'] },
{ fields: ['endpoint'], unique: true },
],
}
);

return PushSubscription;
};
64 changes: 64 additions & 0 deletions backend/modules/notifications/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,70 @@ const notificationsController = {
next(error);
}
},

async getVapidKey(req, res, next) {
try {
const result = await notificationsService.getVapidKey();
res.json(result);
} catch (error) {
next(error);
}
},

async subscribe(req, res, next) {
try {
const userId = requireUserId(req);
const result = await notificationsService.subscribe(
userId,
req.body.subscription
);
res.json(result);
} catch (error) {
next(error);
}
},

async unsubscribe(req, res, next) {
try {
const userId = requireUserId(req);
const result = await notificationsService.unsubscribe(
userId,
req.body.endpoint
);
res.json(result);
} catch (error) {
next(error);
}
},

async triggerTest(req, res, next) {
try {
const userId = requireUserId(req);
const result = await notificationsService.triggerTest(
userId,
req.body.type
);
res.json(result);
} catch (error) {
// Handle ValidationError with availableTypes
if (error.availableTypes) {
return res.status(error.statusCode || 400).json({
error: error.message,
availableTypes: error.availableTypes,
});
}
next(error);
}
},

async getTestTypes(req, res, next) {
try {
const result = await notificationsService.getTestTypes();
res.json(result);
} catch (error) {
next(error);
}
},
};

module.exports = notificationsController;
11 changes: 11 additions & 0 deletions backend/modules/notifications/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,16 @@ router.post(
notificationsController.markAllAsRead
);
router.delete('/notifications/:id', notificationsController.dismiss);
router.get(
'/notifications/push/vapid-key',
notificationsController.getVapidKey
);
router.post('/notifications/push/subscribe', notificationsController.subscribe);
router.delete(
'/notifications/push/unsubscribe',
notificationsController.unsubscribe
);
router.post('/notifications/test/trigger', notificationsController.triggerTest);
router.get('/notifications/test/types', notificationsController.getTestTypes);

module.exports = router;
Loading