feat(MemberGroupPhoto): implement group photo management functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s

- Added MemberGroupPhoto model and established relationships with Club and User models.
- Introduced new routes for managing group photos in the backend.
- Enhanced frontend components to support group photo cropping and member image updates.
- Updated localization files to include new terms related to group photo processing across multiple languages.
- Refactored server.js to include MemberGroupPhoto in the synchronization process.
This commit is contained in:
Torsten Schulz (local)
2026-04-15 22:45:35 +02:00
parent 5fa34637ba
commit 1dd7bb24ea
26 changed files with 1384 additions and 2 deletions

View File

@@ -0,0 +1,65 @@
import memberGroupPhotoService from '../services/memberGroupPhotoService.js';
export const listMemberGroupPhotos = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const photos = await memberGroupPhotoService.list(userToken, clubId);
res.status(200).json({ success: true, photos });
} catch (error) {
console.error('[listMemberGroupPhotos] error:', error);
res.status(500).json({ success: false, error: 'Failed to list group photos' });
}
};
export const createMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await memberGroupPhotoService.create(userToken, clubId, req.file, req.body);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[createMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to save group photo' });
}
};
export const updateMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.update(userToken, clubId, photoId, req.body);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[updateMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to update group photo' });
}
};
export const deleteMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.remove(userToken, clubId, photoId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[deleteMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to delete group photo' });
}
};
export const getMemberGroupPhotoImage = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.getImage(userToken, clubId, photoId);
if (result.status === 200) {
res.setHeader('Content-Type', 'image/jpeg');
return res.sendFile(result.imagePath);
}
return res.status(result.status).json({ success: false, error: result.error });
} catch (error) {
console.error('[getMemberGroupPhotoImage] error:', error);
res.status(500).json({ success: false, error: 'Failed to load group photo' });
}
};

View File

@@ -0,0 +1,28 @@
-- Migration: Store group photos for later member photo cropping.
CREATE TABLE IF NOT EXISTS `member_group_photo` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`file_name` VARCHAR(255) NOT NULL,
`original_file_name` VARCHAR(255) NULL,
`mime_type` VARCHAR(100) NULL,
`file_size` INT NULL,
`width` INT NULL,
`height` INT NULL,
`taken_at` DATETIME NULL,
`created_by_user_id` INT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
INDEX `idx_member_group_photo_club_id` (`club_id`),
CONSTRAINT `fk_member_group_photo_club`
FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `fk_member_group_photo_created_by`
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
);

View File

@@ -0,0 +1,80 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberGroupPhoto = sequelize.define('MemberGroupPhoto', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
references: {
model: 'clubs',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
title: {
type: DataTypes.STRING(255),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
fileName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'file_name'
},
originalFileName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'original_file_name'
},
mimeType: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'mime_type'
},
fileSize: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'file_size'
},
width: {
type: DataTypes.INTEGER,
allowNull: true
},
height: {
type: DataTypes.INTEGER,
allowNull: true
},
takenAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'taken_at'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id',
references: {
model: 'user',
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
}
}, {
tableName: 'member_group_photo',
underscored: true,
timestamps: true
});
export default MemberGroupPhoto;

View File

@@ -49,6 +49,7 @@ import ApiLog from './ApiLog.js';
import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
import MemberGroupPhoto from './MemberGroupPhoto.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import MemberPlayInterest from './MemberPlayInterest.js';
import MemberOrder from './MemberOrder.js';
@@ -102,6 +103,11 @@ MemberPlayInterest.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Club.hasMany(MemberPlayInterest, { as: 'memberPlayInterests', foreignKey: 'clubId' });
MemberPlayInterest.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
Club.hasMany(MemberGroupPhoto, { as: 'memberGroupPhotos', foreignKey: 'clubId' });
MemberGroupPhoto.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
User.hasMany(MemberGroupPhoto, { as: 'createdMemberGroupPhotos', foreignKey: 'createdByUserId' });
MemberGroupPhoto.belongsTo(User, { as: 'createdByUser', foreignKey: 'createdByUserId' });
Member.hasMany(MemberOrder, { as: 'orders', foreignKey: 'memberId' });
MemberOrder.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Club.hasMany(MemberOrder, { as: 'memberOrders', foreignKey: 'clubId' });
@@ -442,6 +448,7 @@ export {
MemberTransferConfig,
MemberContact,
MemberImage,
MemberGroupPhoto,
MemberTtrHistory,
MemberPlayInterest,
MemberOrder,

View File

@@ -0,0 +1,25 @@
import express from 'express';
import multer from 'multer';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
listMemberGroupPhotos,
createMemberGroupPhoto,
updateMemberGroupPhoto,
deleteMemberGroupPhoto,
getMemberGroupPhotoImage
} from '../controllers/memberGroupPhotoController.js';
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 15 * 1024 * 1024 }
});
router.get('/:clubId', authenticate, authorize('members', 'read'), listMemberGroupPhotos);
router.post('/:clubId', authenticate, authorize('members', 'write'), upload.single('image'), createMemberGroupPhoto);
router.get('/:clubId/:photoId/image', authenticate, authorize('members', 'read'), getMemberGroupPhotoImage);
router.put('/:clubId/:photoId', authenticate, authorize('members', 'write'), updateMemberGroupPhoto);
router.delete('/:clubId/:photoId', authenticate, authorize('members', 'write'), deleteMemberGroupPhoto);
export default router;

View File

@@ -14,7 +14,7 @@ import {
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest
, MemberOrder, MemberOrderHistory
, MemberOrder, MemberOrderHistory, MemberGroupPhoto
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -56,6 +56,7 @@ import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -300,6 +301,7 @@ app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -542,6 +544,7 @@ app.use((err, req, res, next) => {
await safeSync(ApiLog);
await safeSync(MemberTransferConfig);
await safeSync(MemberContact);
await safeSync(MemberGroupPhoto);
await safeSync(MemberTtrHistory);
await safeSync(MemberPlayInterest);
await safeSync(MemberOrder);

View File

@@ -0,0 +1,177 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import MemberGroupPhoto from '../models/MemberGroupPhoto.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
class MemberGroupPhotoService {
async list(userToken, clubId) {
await checkAccess(userToken, clubId);
const photos = await MemberGroupPhoto.findAll({
where: { clubId },
order: [['createdAt', 'DESC']]
});
return photos.map(photo => this.mapPhoto(photo));
}
async create(userToken, clubId, file, payload = {}) {
await checkAccess(userToken, clubId);
if (!file?.buffer) {
return { status: 400, response: { success: false, error: 'No image provided' } };
}
if (!String(file.mimetype || '').startsWith('image/')) {
return { status: 400, response: { success: false, error: 'File must be an image' } };
}
const user = await getUserByToken(userToken);
const title = this.sanitizeText(payload.title, 255) || this.defaultTitle();
const description = this.sanitizeText(payload.description, 5000) || null;
const takenAt = this.parseDate(payload.takenAt);
let photo = null;
try {
photo = await MemberGroupPhoto.create({
clubId,
title,
description,
fileName: 'pending.jpg',
originalFileName: this.sanitizeText(file.originalname, 255) || null,
mimeType: 'image/jpeg',
fileSize: file.size || file.buffer.length || null,
takenAt,
createdByUserId: user?.id || null
});
const directory = await this.ensurePhotoDirectory(clubId);
const fileName = `${photo.id}.jpg`;
const targetPath = path.join(directory, fileName);
const info = await sharp(file.buffer)
.rotate()
.resize(2400, 2400, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 88 })
.toFile(targetPath);
await photo.update({
fileName,
fileSize: info.size || file.size || file.buffer.length || null,
width: info.width || null,
height: info.height || null
});
return { status: 200, response: { success: true, photo: this.mapPhoto(photo) } };
} catch (error) {
if (photo?.id) {
await photo.destroy().catch(() => {});
await fs.promises.unlink(this.getPhotoPath(clubId, `${photo.id}.jpg`)).catch(() => {});
}
console.error('[MemberGroupPhotoService.create] error:', error);
return { status: 500, response: { success: false, error: 'Failed to save group photo' } };
}
}
async update(userToken, clubId, photoId, payload = {}) {
await checkAccess(userToken, clubId);
const photo = await this.findPhoto(clubId, photoId);
if (!photo) {
return { status: 404, response: { success: false, error: 'Group photo not found' } };
}
const updates = {};
if (payload.title !== undefined) {
updates.title = this.sanitizeText(payload.title, 255) || photo.title;
}
if (payload.description !== undefined) {
updates.description = this.sanitizeText(payload.description, 5000) || null;
}
if (payload.takenAt !== undefined) {
updates.takenAt = this.parseDate(payload.takenAt);
}
await photo.update(updates);
return { status: 200, response: { success: true, photo: this.mapPhoto(photo) } };
}
async remove(userToken, clubId, photoId) {
await checkAccess(userToken, clubId);
const photo = await this.findPhoto(clubId, photoId);
if (!photo) {
return { status: 404, response: { success: false, error: 'Group photo not found' } };
}
const imagePath = this.getPhotoPath(clubId, photo.fileName);
await photo.destroy();
await fs.promises.unlink(imagePath).catch((error) => {
console.warn('[MemberGroupPhotoService.remove] file removal failed:', error.message || error);
});
return { status: 200, response: { success: true } };
}
async getImage(userToken, clubId, photoId) {
await checkAccess(userToken, clubId);
const photo = await this.findPhoto(clubId, photoId);
if (!photo) {
return { status: 404, error: 'Group photo not found' };
}
const imagePath = this.getPhotoPath(clubId, photo.fileName);
if (!fs.existsSync(imagePath)) {
return { status: 404, error: 'Group photo file not found' };
}
return { status: 200, imagePath: path.resolve(imagePath) };
}
async findPhoto(clubId, photoId) {
return MemberGroupPhoto.findOne({ where: { id: photoId, clubId } });
}
mapPhoto(photo) {
const data = typeof photo?.toJSON === 'function' ? photo.toJSON() : photo;
return {
id: data.id,
clubId: data.clubId,
title: data.title,
description: data.description || '',
fileName: data.fileName,
originalFileName: data.originalFileName || '',
mimeType: data.mimeType || 'image/jpeg',
fileSize: data.fileSize || null,
width: data.width || null,
height: data.height || null,
takenAt: data.takenAt || null,
createdByUserId: data.createdByUserId || null,
createdAt: data.createdAt || null,
updatedAt: data.updatedAt || null,
imageUrl: `/api/member-group-photos/${data.clubId}/${data.id}/image?t=${new Date(data.updatedAt || Date.now()).getTime()}`
};
}
async ensurePhotoDirectory(clubId) {
const directory = path.join('images', 'member-group-photos', String(clubId));
await fs.promises.mkdir(directory, { recursive: true });
return directory;
}
getPhotoPath(clubId, fileName) {
return path.join('images', 'member-group-photos', String(clubId), fileName);
}
sanitizeText(value, maxLength) {
const text = String(value || '').trim();
if (!text) return '';
return text.slice(0, maxLength);
}
parseDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
defaultTitle() {
return `Gruppenfoto ${new Date().toLocaleDateString('de-DE')}`;
}
}
export default new MemberGroupPhotoService();