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,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();