feat(MemberGroupPhoto): implement group photo management functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s
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:
65
backend/controllers/memberGroupPhotoController.js
Normal file
65
backend/controllers/memberGroupPhotoController.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
28
backend/migrations/20260415_create_member_group_photo.sql
Normal file
28
backend/migrations/20260415_create_member_group_photo.sql
Normal 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
|
||||
);
|
||||
80
backend/models/MemberGroupPhoto.js
Normal file
80
backend/models/MemberGroupPhoto.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
25
backend/routes/memberGroupPhotoRoutes.js
Normal file
25
backend/routes/memberGroupPhotoRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
177
backend/services/memberGroupPhotoService.js
Normal file
177
backend/services/memberGroupPhotoService.js
Normal 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();
|
||||
Reference in New Issue
Block a user