Add watcher routes to backend and frontend; implement routing and UI components for watcher management

This commit is contained in:
Torsten Schulz (local)
2025-10-17 23:50:25 +02:00
parent c7a0316ca0
commit ac3720fb61
12 changed files with 750 additions and 10 deletions

View File

@@ -0,0 +1,28 @@
-- SQL Script: Watcher-Tabelle erstellen
-- Speichert Email-Adressen, die die Arbeitszeiten eines Users sehen dürfen
-- Ausführen mit: mysql -u root -p stechuhr < create-watcher-table.sql
USE stechuhr;
-- Erstelle Watcher-Tabelle falls nicht vorhanden
CREATE TABLE IF NOT EXISTS watcher (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT 'User dessen Arbeitszeiten gesehen werden dürfen',
watcher_email VARCHAR(255) NOT NULL COMMENT 'Email-Adresse der berechtigten Person',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Foreign Keys
CONSTRAINT fk_watcher_user FOREIGN KEY (user_id)
REFERENCES user(id) ON DELETE CASCADE,
-- Indices für Performance
INDEX idx_watcher_user (user_id),
INDEX idx_watcher_email (watcher_email),
-- Unique constraint: Ein Watcher pro User/Email-Kombination
UNIQUE KEY idx_watcher_user_email (user_id, watcher_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT 'Watcher-Tabelle erfolgreich erstellt oder bereits vorhanden.' AS status;

View File

@@ -83,6 +83,7 @@ class Database {
const Timefix = require('../models/Timefix');
const Timewish = require('../models/Timewish');
const Invitation = require('../models/Invitation');
const Watcher = require('../models/Watcher');
// Models mit Sequelize-Instanz initialisieren
User.initialize(this.sequelize);
@@ -100,6 +101,7 @@ class Database {
Timefix.initialize(this.sequelize);
Timewish.initialize(this.sequelize);
Invitation.initialize(this.sequelize);
Watcher.initialize(this.sequelize);
// Assoziationen definieren
this.defineAssociations();
@@ -111,7 +113,7 @@ class Database {
* Model-Assoziationen definieren
*/
defineAssociations() {
const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Holiday, HolidayState, Vacation, Sick, SickType, Timefix, Timewish, Invitation } = this.sequelize.models;
const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Holiday, HolidayState, Vacation, Sick, SickType, Timefix, Timewish, Invitation, Watcher } = this.sequelize.models;
// User Assoziationen
User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' });
@@ -181,6 +183,10 @@ class Database {
// Invitation Assoziationen
Invitation.belongsTo(User, { foreignKey: 'inviter_user_id', as: 'inviter' });
User.hasMany(Invitation, { foreignKey: 'inviter_user_id', as: 'invitations' });
// Watcher Assoziationen
Watcher.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
User.hasMany(Watcher, { foreignKey: 'user_id', as: 'watchers' });
}
/**

View File

@@ -0,0 +1,73 @@
const WatcherService = require('../services/WatcherService');
/**
* Controller für Watcher (Berechtigungen)
* Verarbeitet HTTP-Requests und delegiert an WatcherService
*/
class WatcherController {
/**
* Holt alle Watcher des aktuellen Users
*/
async getWatchers(req, res) {
try {
const userId = req.user?.id || 1;
const watchers = await WatcherService.getWatchers(userId);
res.json(watchers);
} catch (error) {
console.error('Fehler beim Abrufen der Beobachter:', error);
res.status(500).json({
message: 'Fehler beim Abrufen der Beobachter',
error: error.message
});
}
}
/**
* Fügt einen Watcher hinzu
*/
async addWatcher(req, res) {
try {
const userId = req.user?.id || 1;
const { email } = req.body;
if (!email) {
return res.status(400).json({
message: 'Email-Adresse ist erforderlich'
});
}
const watcher = await WatcherService.addWatcher(userId, email);
res.status(201).json(watcher);
} catch (error) {
console.error('Fehler beim Hinzufügen des Beobachters:', error);
res.status(error.message.includes('bereits eingetragen') ? 409 : 500).json({
message: error.message
});
}
}
/**
* Entfernt einen Watcher
*/
async removeWatcher(req, res) {
try {
const userId = req.user?.id || 1;
const watcherId = parseInt(req.params.id);
if (isNaN(watcherId)) {
return res.status(400).json({ message: 'Ungültige ID' });
}
await WatcherService.removeWatcher(userId, watcherId);
res.json({ message: 'Beobachter entfernt' });
} catch (error) {
console.error('Fehler beim Entfernen des Beobachters:', error);
res.status(error.message.includes('nicht gefunden') ? 404 : 500).json({
message: error.message
});
}
}
}
module.exports = new WatcherController();

View File

@@ -110,6 +110,10 @@ app.use('/api/roles', authenticateToken, rolesRouter);
const inviteRouter = require('./routes/invite');
app.use('/api/invite', authenticateToken, inviteRouter);
// Watcher routes (geschützt) - MIT ID-Hashing
const watcherRouter = require('./routes/watcher');
app.use('/api/watcher', authenticateToken, watcherRouter);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);

View File

@@ -0,0 +1,79 @@
const { Model, DataTypes } = require('sequelize');
/**
* Watcher Model
* Repräsentiert Personen, die die Arbeitszeiten eines Users sehen dürfen
*/
class Watcher extends Model {
static initialize(sequelize) {
Watcher.init(
{
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: {
model: 'user',
key: 'id'
},
comment: 'Der User dessen Arbeitszeiten gesehen werden dürfen'
},
watcher_email: {
type: DataTypes.STRING(255),
allowNull: false,
comment: 'Email-Adresse der Person, die sehen darf'
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
},
{
sequelize,
tableName: 'watcher',
timestamps: false,
indexes: [
{
name: 'idx_watcher_user',
fields: ['user_id']
},
{
name: 'idx_watcher_email',
fields: ['watcher_email']
},
{
name: 'idx_watcher_user_email',
fields: ['user_id', 'watcher_email'],
unique: true
}
]
}
);
return Watcher;
}
/**
* Definiert Assoziationen mit anderen Models
*/
static associate(models) {
Watcher.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user'
});
}
}
module.exports = Watcher;

View File

@@ -16,6 +16,7 @@ const SickType = require('./SickType');
const Timefix = require('./Timefix');
const Timewish = require('./Timewish');
const Invitation = require('./Invitation');
const Watcher = require('./Watcher');
module.exports = {
User,
@@ -30,6 +31,7 @@ module.exports = {
SickType,
Timefix,
Timewish,
Invitation
Invitation,
Watcher
};

View File

@@ -0,0 +1,20 @@
const express = require('express');
const router = express.Router();
const WatcherController = require('../controllers/WatcherController');
const unhashRequestIds = require('../middleware/unhashRequest');
/**
* Routen für Watcher (Berechtigungen)
*/
// GET /api/watcher - Alle Watcher abrufen
router.get('/', WatcherController.getWatchers.bind(WatcherController));
// POST /api/watcher - Neuen Watcher hinzufügen
router.post('/', WatcherController.addWatcher.bind(WatcherController));
// DELETE /api/watcher/:id - Watcher entfernen
router.delete('/:id', unhashRequestIds, WatcherController.removeWatcher.bind(WatcherController));
module.exports = router;

View File

@@ -0,0 +1,139 @@
const database = require('../config/database');
/**
* Service-Klasse für Watcher (Berechtigungen)
* Verwaltet, wer die Arbeitszeiten eines Users sehen darf
*/
class WatcherService {
/**
* Holt alle Watcher eines Users
* @param {number} userId - User-ID
* @returns {Promise<Array>} Array von Watchern
*/
async getWatchers(userId) {
const { Watcher } = database.getModels();
const watchers = await Watcher.findAll({
where: {
user_id: userId,
is_active: true
},
order: [['created_at', 'DESC']],
raw: true
});
return watchers.map(w => ({
id: w.id,
email: w.watcher_email,
createdAt: w.created_at ? new Date(w.created_at).toISOString() : null
}));
}
/**
* Fügt einen Watcher hinzu
* @param {number} userId - User-ID
* @param {string} watcherEmail - Email-Adresse des Watchers
* @returns {Promise<Object>} Erstellter Watcher
*/
async addWatcher(userId, watcherEmail) {
const { Watcher } = database.getModels();
// Validiere Email
if (!watcherEmail || !this._isValidEmail(watcherEmail)) {
throw new Error('Ungültige Email-Adresse');
}
// Prüfe ob bereits existiert
const existing = await Watcher.findOne({
where: {
user_id: userId,
watcher_email: watcherEmail
}
});
if (existing) {
if (existing.is_active) {
throw new Error('Diese Email-Adresse ist bereits als Beobachter eingetragen');
} else {
// Reaktiviere inaktiven Watcher
existing.is_active = true;
await existing.save();
return {
id: existing.id,
email: existing.watcher_email,
createdAt: existing.created_at
};
}
}
// Erstelle neuen Watcher
const watcher = await Watcher.create({
user_id: userId,
watcher_email: watcherEmail,
is_active: true,
created_at: new Date()
});
return {
id: watcher.id,
email: watcher.watcher_email,
createdAt: watcher.created_at
};
}
/**
* Entfernt einen Watcher (soft delete)
* @param {number} userId - User-ID
* @param {number} watcherId - Watcher-ID
* @returns {Promise<void>}
*/
async removeWatcher(userId, watcherId) {
const { Watcher } = database.getModels();
const watcher = await Watcher.findByPk(watcherId);
if (!watcher) {
throw new Error('Beobachter nicht gefunden');
}
// Prüfe Berechtigung
if (watcher.user_id !== userId) {
throw new Error('Keine Berechtigung für diesen Eintrag');
}
// Soft delete: Setze is_active auf false
watcher.is_active = false;
await watcher.save();
}
/**
* Prüft ob eine Email die Arbeitszeiten eines Users sehen darf
* @param {number} userId - User-ID
* @param {string} watcherEmail - Email-Adresse des Watchers
* @returns {Promise<boolean>} True wenn berechtigt
*/
async canWatch(userId, watcherEmail) {
const { Watcher } = database.getModels();
const watcher = await Watcher.findOne({
where: {
user_id: userId,
watcher_email: watcherEmail,
is_active: true
}
});
return !!watcher;
}
/**
* Validiert Email-Format
*/
_isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
module.exports = new WatcherService();