diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index c331b36..c236bfc 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -43,8 +43,12 @@ const uploadMemberImage = async (req, res) => { try { const { clubId, memberId } = req.params; const { authcode: userToken } = req.headers; - const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer); - res.status(result.status).json(result.message ? { message: result.message } : { error: result.error }); + const makePrimary = + req.body?.makePrimary === true || + req.body?.makePrimary === 'true' || + req.query?.makePrimary === 'true'; + const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer, { makePrimary }); + res.status(result.status).json(result.response ?? { success: false, error: 'Unknown upload result' }); } catch (error) { console.error('[uploadMemberImage] - Error:', error); res.status(500).json({ error: 'Failed to upload image' }); @@ -53,9 +57,9 @@ const uploadMemberImage = async (req, res) => { const getMemberImage = async (req, res) => { try { - const { clubId, memberId } = req.params; + const { clubId, memberId, imageId } = req.params; const { authcode: userToken } = req.headers; - const result = await MemberService.getMemberImage(userToken, clubId, memberId); + const result = await MemberService.getMemberImage(userToken, clubId, memberId, imageId || null); if (result.status === 200) { res.sendFile(result.imagePath); } else { @@ -81,7 +85,7 @@ const updateRatingsFromMyTischtennis = async (req, res) => { const rotateMemberImage = async (req, res) => { try { - const { clubId, memberId } = req.params; + const { clubId, memberId, imageId } = req.params; const { direction } = req.body; const { authcode: userToken } = req.headers; @@ -92,7 +96,7 @@ const rotateMemberImage = async (req, res) => { }); } - const result = await MemberService.rotateMemberImage(userToken, clubId, memberId, direction); + const result = await MemberService.rotateMemberImage(userToken, clubId, memberId, imageId, direction); res.status(result.status).json(result.response); } catch (error) { console.error('[rotateMemberImage] - Error:', error); @@ -100,6 +104,30 @@ const rotateMemberImage = async (req, res) => { } }; +const deleteMemberImage = async (req, res) => { + try { + const { clubId, memberId, imageId } = req.params; + const { authcode: userToken } = req.headers; + const result = await MemberService.deleteMemberImage(userToken, clubId, memberId, imageId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[deleteMemberImage] - Error:', error); + res.status(500).json({ success: false, error: 'Failed to delete image' }); + } +}; + +const setPrimaryMemberImage = async (req, res) => { + try { + const { clubId, memberId, imageId } = req.params; + const { authcode: userToken } = req.headers; + const result = await MemberService.setPrimaryMemberImage(userToken, clubId, memberId, imageId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[setPrimaryMemberImage] - Error:', error); + res.status(500).json({ success: false, error: 'Failed to update primary image' }); + } +}; + const quickUpdateTestMembership = async (req, res) => { try { const { clubId, memberId } = req.params; @@ -168,4 +196,18 @@ const transferMembers = async (req, res) => { } }; -export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember }; \ No newline at end of file +export { + getClubMembers, + getWaitingApprovals, + setClubMembers, + uploadMemberImage, + getMemberImage, + updateRatingsFromMyTischtennis, + rotateMemberImage, + transferMembers, + quickUpdateTestMembership, + quickUpdateMemberFormHandedOver, + quickDeactivateMember, + deleteMemberImage, + setPrimaryMemberImage +}; \ No newline at end of file diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js index 2e5d43c..fe962a5 100644 --- a/backend/middleware/authMiddleware.js +++ b/backend/middleware/authMiddleware.js @@ -6,6 +6,9 @@ export const authenticate = async (req, res, next) => { if (!token) { token = req.headers['authcode']; } + if (!token) { + token = req.query?.authcode || req.query?.token || null; + } if (!token) { return res.status(401).json({ error: 'Unauthorized: Token fehlt' }); } diff --git a/backend/migrations/20251111_add_member_images.sql b/backend/migrations/20251111_add_member_images.sql new file mode 100644 index 0000000..884c3de --- /dev/null +++ b/backend/migrations/20251111_add_member_images.sql @@ -0,0 +1,17 @@ +-- Create table for storing multiple images per member +CREATE TABLE IF NOT EXISTS `member_image` ( + `id` INT NOT NULL AUTO_INCREMENT, + `member_id` INT NOT NULL, + `file_name` VARCHAR(255) NOT NULL, + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_member_image_member_id` (`member_id`), + CONSTRAINT `fk_member_image_member` + FOREIGN KEY (`member_id`) + REFERENCES `member` (`id`) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/backend/models/MemberImage.js b/backend/models/MemberImage.js new file mode 100644 index 0000000..a984b90 --- /dev/null +++ b/backend/models/MemberImage.js @@ -0,0 +1,40 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; + +const MemberImage = sequelize.define('MemberImage', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + memberId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'member_id', + references: { + model: 'member', + key: 'id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + fileName: { + type: DataTypes.STRING(255), + allowNull: false, + field: 'file_name' + }, + sortOrder: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'sort_order' + } +}, { + tableName: 'member_image', + underscored: true, + timestamps: true +}); + +export default MemberImage; + diff --git a/backend/models/index.js b/backend/models/index.js index ca3337b..217421a 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -41,6 +41,7 @@ import MyTischtennisFetchLog from './MyTischtennisFetchLog.js'; import ApiLog from './ApiLog.js'; import MemberTransferConfig from './MemberTransferConfig.js'; import MemberContact from './MemberContact.js'; +import MemberImage from './MemberImage.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -250,6 +251,9 @@ MemberTransferConfig.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); Member.hasMany(MemberContact, { foreignKey: 'memberId', as: 'contacts' }); MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); +Member.hasMany(MemberImage, { foreignKey: 'memberId', as: 'images' }); +MemberImage.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); + export { User, Log, @@ -293,4 +297,5 @@ export { ApiLog, MemberTransferConfig, MemberContact, + MemberImage, }; diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 39ac51d..0c25a41 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -1,4 +1,18 @@ -import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember } from '../controllers/memberController.js'; +import { + getClubMembers, + getWaitingApprovals, + setClubMembers, + uploadMemberImage, + getMemberImage, + updateRatingsFromMyTischtennis, + rotateMemberImage, + transferMembers, + quickUpdateTestMembership, + quickUpdateMemberFormHandedOver, + quickDeactivateMember, + deleteMemberImage, + setPrimaryMemberImage +} from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; import { authorize } from '../middleware/authorizationMiddleware.js'; @@ -10,12 +24,15 @@ const storage = multer.memoryStorage(); const upload = multer({ storage: storage }); router.post('/image/:clubId/:memberId', authenticate, authorize('members', 'write'), upload.single('image'), uploadMemberImage); +router.get('/image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'read'), getMemberImage); router.get('/image/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberImage); +router.delete('/image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'write'), deleteMemberImage); +router.post('/image/:clubId/:memberId/:imageId/primary', authenticate, authorize('members', 'write'), setPrimaryMemberImage); router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers); router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers); router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals); router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis); -router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members', 'write'), rotateMemberImage); +router.post('/rotate-image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'write'), rotateMemberImage); router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers); router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership); router.post('/quick-update-member-form/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateMemberFormHandedOver); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 97ecf94..7be41bc 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -1,6 +1,7 @@ import UserClub from "../models/UserClub.js"; import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js"; import Member from "../models/Member.js"; +import MemberImage from "../models/MemberImage.js"; import Participant from "../models/Participant.js"; import DiaryDate from "../models/DiaryDates.js"; import path from 'path'; @@ -31,51 +32,59 @@ class MemberService { where['active'] = true; } const MemberContact = (await import('../models/MemberContact.js')).default; - return await Member.findAll({ - where, - include: [{ - model: MemberContact, - as: 'contacts', - required: false, - attributes: ['id', 'memberId', 'type', 'value', 'isParent', 'parentName', 'isPrimary', 'createdAt', 'updatedAt'] - }], - raw: false // Ensure we get model instances, not plain objects, so getters are called - }) - .then(members => { - return members.map(member => { - const imagePath = path.join('images', 'members', `${member.id}.jpg`); - const hasImage = fs.existsSync(imagePath); - const memberJson = member.toJSON(); - // Ensure contacts are properly serialized - access via model instance to trigger getters - if (member.contacts && Array.isArray(member.contacts)) { - memberJson.contacts = member.contacts.map(contact => { - // Access properties through the model instance to trigger getters - return { - id: contact.id, - memberId: contact.memberId, - type: contact.type, - value: contact.value, // Getter should decrypt this - isParent: contact.isParent, - parentName: contact.parentName, // Getter should decrypt this - isPrimary: contact.isPrimary, - createdAt: contact.createdAt, - updatedAt: contact.updatedAt - }; - }); + try { + const members = await Member.findAll({ + where, + include: [ + { + model: MemberContact, + as: 'contacts', + required: false, + attributes: ['id', 'memberId', 'type', 'value', 'isParent', 'parentName', 'isPrimary', 'createdAt', 'updatedAt'] + }, + { + model: MemberImage, + as: 'images', + required: false, + attributes: ['id', 'memberId', 'fileName', 'sortOrder', 'createdAt', 'updatedAt'] } - return { - ...memberJson, - hasImage: hasImage - }; - }); - }) - .then(membersWithImageStatus => { - return membersWithImageStatus; - }) - .catch(error => { - console.error('[getClubMembers] - Error:', error); - throw error; + ], + raw: false }); + + const results = []; + for (const member of members) { + const memberJson = member.toJSON(); + + if (member.contacts && Array.isArray(member.contacts)) { + memberJson.contacts = member.contacts.map(contact => ({ + id: contact.id, + memberId: contact.memberId, + type: contact.type, + value: contact.value, + isParent: contact.isParent, + parentName: contact.parentName, + isPrimary: contact.isPrimary, + createdAt: contact.createdAt, + updatedAt: contact.updatedAt + })); + } + + const imageData = await this._prepareMemberImages(member, { forceReload: true }); + memberJson.images = imageData.images; + memberJson.primaryImageId = imageData.primaryImageId; + memberJson.primaryImageUrl = imageData.primaryImageUrl; + memberJson.imageUrl = imageData.primaryImageUrl; + memberJson.hasImage = imageData.hasImage; + + results.push(memberJson); + } + + return results; + } catch (error) { + console.error('[getClubMembers] - Error:', error); + throw error; + } } async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false, @@ -182,38 +191,118 @@ class MemberService { } } - async uploadMemberImage(userToken, clubId, memberId, imageBuffer) { + async uploadMemberImage(userToken, clubId, memberId, imageBuffer, options = {}) { + const { makePrimary = false } = options; + let transaction = null; try { - devLog('------>', userToken, clubId, memberId, imageBuffer); await checkAccess(userToken, clubId); const member = await Member.findOne({ where: { id: memberId, clubId: clubId } }); if (!member) { - return { status: 404, error: 'Member not found in this club' }; + return { status: 404, response: { success: false, error: 'Member not found in this club' } }; } - const imagePath = path.join('images', 'members', `${memberId}.jpg`); - await sharp(imageBuffer) - .resize(600, 600) - .jpeg({ quality: 80 }) - .toFile(imagePath); - return { status: 200, message: 'Image uploaded successfully' }; + await this._migrateLegacyImage(memberId); + + transaction = await MemberImage.sequelize.transaction(); + + const maxSortOrder = await MemberImage.max('sortOrder', { + where: { memberId }, + transaction + }); + const sortOrder = Number.isFinite(maxSortOrder) ? (maxSortOrder + 1) : 1; + + const imageRecord = await MemberImage.create({ + memberId, + fileName: '', + sortOrder + }, { transaction }); + + const directoryPath = await this._ensureImageDirectory(memberId); + const fileName = `${imageRecord.id}.jpg`; + const targetPath = path.join(directoryPath, fileName); + + await sharp(imageBuffer) + .resize(600, 600, { fit: 'inside' }) + .jpeg({ quality: 80 }) + .toFile(targetPath); + + imageRecord.fileName = fileName; + await imageRecord.save({ transaction }); + + if (makePrimary) { + await this._setPrimaryImage(memberId, imageRecord.id, { transaction }); + } + + await transaction.commit(); + transaction = null; + + const imageData = await this._prepareMemberImages(member, { forceReload: true }); + const createdImage = imageData.images.find(img => img.id === imageRecord.id) || null; + + return { + status: 200, + response: { + success: true, + image: createdImage, + images: imageData.images, + primaryImageId: imageData.primaryImageId, + primaryImageUrl: imageData.primaryImageUrl + } + }; } catch (error) { + if (transaction) { + try { + await transaction.rollback(); + } catch (rollbackError) { + console.error('[uploadMemberImage] - Rollback failed:', rollbackError); + } + } console.error('[uploadMemberImage] - Error:', error); - return { status: 500, error: 'Failed to upload image' }; + return { status: 500, response: { success: false, error: 'Failed to upload image' } }; } } - async getMemberImage(userToken, clubId, memberId) { + async getMemberImage(userToken, clubId, memberId, imageId = null) { try { await checkAccess(userToken, clubId); const member = await Member.findOne({ where: { id: memberId, clubId: clubId } }); if (!member) { return { status: 404, error: 'Member not found in this club' }; } - const imagePath = path.join('images', 'members', `${memberId}.jpg`); + const migratedImage = await this._migrateLegacyImage(memberId); + if (migratedImage && (!imageId || imageId === migratedImage.id)) { + imageId = migratedImage.id; + } + + let imageRecord = null; + if (imageId) { + imageRecord = await MemberImage.findOne({ + where: { id: imageId, memberId } + }); + } + + if (!imageRecord) { + imageRecord = await MemberImage.findOne({ + where: { memberId }, + order: [['sortOrder', 'ASC'], ['id', 'ASC']] + }); + } + + if (!imageRecord) { + const legacyPath = this._findLegacyImagePath(memberId); + if (legacyPath) { + return { status: 200, imagePath: path.resolve(legacyPath) }; + } + return { status: 404, error: 'Image not found' }; + } + + const directoryPath = path.join('images', 'members', String(memberId)); + const imagePath = path.join(directoryPath, imageRecord.fileName); + if (!fs.existsSync(imagePath)) { return { status: 404, error: 'Image not found' }; } + return { status: 200, imagePath: path.resolve(imagePath) }; } catch (error) { console.error('[getMemberImage] - Error:', error); @@ -527,7 +616,7 @@ class MemberService { return this._updateRatingsInternal(userId, clubId); } - async rotateMemberImage(userToken, clubId, memberId, direction) { + async rotateMemberImage(userToken, clubId, memberId, imageId, direction) { try { await checkAccess(userToken, clubId); const member = await Member.findOne({ where: { id: memberId, clubId: clubId } }); @@ -535,35 +624,49 @@ class MemberService { if (!member) { return { status: 404, response: { success: false, error: 'Member not found in this club' } }; } - - const imagePath = path.join('images', 'members', `${memberId}.jpg`); - if (!fs.existsSync(imagePath)) { + + await this._migrateLegacyImage(memberId); + + const imageRecord = await MemberImage.findOne({ + where: { id: imageId, memberId } + }); + + if (!imageRecord) { return { status: 404, response: { success: false, error: 'Image not found' } }; } - - // Read the image + + const imagePath = path.join('images', 'members', String(memberId), imageRecord.fileName); + if (!fs.existsSync(imagePath)) { + return { status: 404, response: { success: false, error: 'Image file not found on disk' } }; + } + const imageBuffer = await fs.promises.readFile(imagePath); - - // Calculate rotation angle (-90 for left, +90 for right) const rotationAngle = direction === 'left' ? -90 : 90; - - // Rotate the image + const rotatedBuffer = await sharp(imageBuffer) .rotate(rotationAngle) .jpeg({ quality: 80 }) .toBuffer(); - - // Save the rotated image + await fs.promises.writeFile(imagePath, rotatedBuffer); - - return { - status: 200, - response: { - success: true, + + await imageRecord.update({ updatedAt: new Date() }); + + const imageData = await this._prepareMemberImages(member, { forceReload: true }); + const updatedImage = imageData.images.find(img => img.id === imageRecord.id) || null; + + return { + status: 200, + response: { + success: true, message: `Bild um ${rotationAngle}° gedreht`, - direction: direction, - rotation: rotationAngle - } + direction, + rotation: rotationAngle, + image: updatedImage, + images: imageData.images, + primaryImageId: imageData.primaryImageId, + primaryImageUrl: imageData.primaryImageUrl + } }; } catch (error) { console.error('[rotateMemberImage] - Error:', error); @@ -577,6 +680,121 @@ class MemberService { } } + async deleteMemberImage(userToken, clubId, memberId, imageId) { + let transaction = null; + try { + await checkAccess(userToken, clubId); + const member = await Member.findOne({ where: { id: memberId, clubId } }); + if (!member) { + return { status: 404, response: { success: false, error: 'Member not found in this club' } }; + } + + await this._migrateLegacyImage(memberId); + + transaction = await MemberImage.sequelize.transaction(); + const imageRecord = await MemberImage.findOne({ + where: { id: imageId, memberId }, + transaction, + lock: transaction.LOCK.UPDATE + }); + + if (!imageRecord) { + await transaction.rollback(); + transaction = null; + return { status: 404, response: { success: false, error: 'Image not found' } }; + } + + const imagePath = path.join('images', 'members', String(memberId), imageRecord.fileName); + + await imageRecord.destroy({ transaction }); + await this._resequenceMemberImages(memberId, { transaction }); + + await transaction.commit(); + transaction = null; + + if (fs.existsSync(imagePath)) { + await fs.promises.unlink(imagePath).catch(err => { + console.warn('[deleteMemberImage] - Failed to remove image file:', err); + }); + } + + const imageData = await this._prepareMemberImages(member, { forceReload: true }); + + return { + status: 200, + response: { + success: true, + images: imageData.images, + primaryImageId: imageData.primaryImageId, + primaryImageUrl: imageData.primaryImageUrl + } + }; + } catch (error) { + if (transaction) { + try { + await transaction.rollback(); + } catch (rollbackError) { + console.error('[deleteMemberImage] - Rollback failed:', rollbackError); + } + } + console.error('[deleteMemberImage] - Error:', error); + return { status: 500, response: { success: false, error: 'Failed to delete image' } }; + } + } + + async setPrimaryMemberImage(userToken, clubId, memberId, imageId) { + let transaction = null; + try { + await checkAccess(userToken, clubId); + const member = await Member.findOne({ where: { id: memberId, clubId } }); + if (!member) { + return { status: 404, response: { success: false, error: 'Member not found in this club' } }; + } + + await this._migrateLegacyImage(memberId); + + transaction = await MemberImage.sequelize.transaction(); + const imageRecord = await MemberImage.findOne({ + where: { id: imageId, memberId }, + transaction, + lock: transaction.LOCK.UPDATE + }); + + if (!imageRecord) { + await transaction.rollback(); + transaction = null; + return { status: 404, response: { success: false, error: 'Image not found' } }; + } + + await this._setPrimaryImage(memberId, imageId, { transaction }); + + await transaction.commit(); + transaction = null; + + const imageData = await this._prepareMemberImages(member, { forceReload: true }); + + return { + status: 200, + response: { + success: true, + images: imageData.images, + primaryImageId: imageData.primaryImageId, + primaryImageUrl: imageData.primaryImageUrl + } + }; + } catch (error) { + if (transaction) { + try { + await transaction.rollback(); + } catch (rollbackError) { + console.error('[setPrimaryMemberImage] - Rollback failed:', rollbackError); + } + } + console.error('[setPrimaryMemberImage] - Error:', error); + return { status: 500, response: { success: false, error: 'Failed to set primary image' } }; + } + } + async quickUpdateTestMembership(userToken, clubId, memberId) { try { await checkAccess(userToken, clubId); @@ -651,6 +869,206 @@ class MemberService { return { status: 500, response: { error: 'Failed to deactivate member' } }; } } + + async _ensureImageDirectory(memberId) { + const directoryPath = path.join('images', 'members', String(memberId)); + await fs.promises.mkdir(directoryPath, { recursive: true }); + return directoryPath; + } + + async _fetchMemberImages(memberId, transaction = null) { + return MemberImage.findAll({ + where: { memberId }, + order: [['sortOrder', 'ASC'], ['id', 'ASC']], + transaction + }); + } + + async _prepareMemberImages(member, options = {}) { + const { forceReload = false } = options; + let images = []; + + if (!forceReload && Array.isArray(member.images) && member.images.length > 0) { + images = member.images; + } else { + images = await this._fetchMemberImages(member.id); + } + + if (!images || images.length === 0) { + const migrated = await this._migrateLegacyImage(member.id); + if (migrated) { + images = await this._fetchMemberImages(member.id); + } + } + + if (!images || images.length === 0) { + return { + images: [], + primaryImageId: null, + primaryImageUrl: null, + hasImage: false + }; + } + + const mappedImages = images + .map(image => this._mapMemberImageRecord(member, image)) + .sort((a, b) => { + const sortA = Number.isFinite(a.sortOrder) ? a.sortOrder : parseInt(a.sortOrder || '0', 10); + const sortB = Number.isFinite(b.sortOrder) ? b.sortOrder : parseInt(b.sortOrder || '0', 10); + if (sortA !== sortB) { + return sortA - sortB; + } + return (a.id || 0) - (b.id || 0); + }); + + const primary = mappedImages[0] || null; + + mappedImages.forEach((image, index) => { + image.isPrimary = index === 0; + }); + + return { + images: mappedImages, + primaryImageId: primary ? primary.id : null, + primaryImageUrl: primary ? primary.url : null, + hasImage: mappedImages.length > 0 + }; + } + + _mapMemberImageRecord(member, image) { + const data = image && typeof image.toJSON === 'function' ? image.toJSON() : image; + const updatedAt = data.updatedAt ? new Date(data.updatedAt) : null; + const cacheParam = updatedAt && !Number.isNaN(updatedAt.getTime()) ? updatedAt.getTime() : Date.now(); + + return { + id: data.id, + memberId: member.id, + fileName: data.fileName, + sortOrder: data.sortOrder, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + url: `/api/clubmembers/image/${member.clubId}/${member.id}/${data.id}?t=${cacheParam}` + }; + } + + async _setPrimaryImage(memberId, imageId, options = {}) { + const { transaction = null } = options; + const images = await this._fetchMemberImages(memberId, transaction); + if (!images || images.length === 0) { + return; + } + + let nextOrder = 2; + for (const image of images) { + if (image.id === imageId) { + image.sortOrder = 1; + } else { + image.sortOrder = nextOrder; + nextOrder += 1; + } + await image.save({ transaction }); + } + } + + async _resequenceMemberImages(memberId, options = {}) { + const { transaction = null } = options; + const images = await this._fetchMemberImages(memberId, transaction); + let order = 1; + for (const image of images) { + if (image.sortOrder !== order) { + image.sortOrder = order; + await image.save({ transaction }); + } + order += 1; + } + return images; + } + + async _migrateLegacyImage(memberId) { + const legacyPath = this._findLegacyImagePath(memberId); + if (!legacyPath) { + return null; + } + + const existing = await MemberImage.findOne({ where: { memberId } }); + const directoryPath = await this._ensureImageDirectory(memberId); + + if (existing) { + const fileName = existing.fileName || `${existing.id}.jpg`; + const targetPath = path.join(directoryPath, fileName); + await this._processLegacyImage(legacyPath, targetPath); + if (!existing.fileName || existing.fileName !== fileName) { + existing.fileName = fileName; + await existing.save(); + } + return existing; + } + + let transaction = null; + try { + transaction = await MemberImage.sequelize.transaction(); + const imageRecord = await MemberImage.create({ + memberId, + fileName: '', + sortOrder: 1 + }, { transaction }); + + const fileName = `${imageRecord.id}.jpg`; + const targetPath = path.join(directoryPath, fileName); + await this._processLegacyImage(legacyPath, targetPath); + + imageRecord.fileName = fileName; + await imageRecord.save({ transaction }); + await transaction.commit(); + transaction = null; + return imageRecord; + } catch (error) { + if (transaction) { + try { + await transaction.rollback(); + } catch (rollbackError) { + console.error('[MemberService] - Legacy migration rollback failed:', rollbackError); + } + } + console.error('[MemberService] - Legacy image migration failed:', error); + return null; + } + } + + _findLegacyImagePath(memberId) { + const directory = path.join('images', 'members'); + const extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + for (const ext of extensions) { + const lowerPath = path.join(directory, `${memberId}.${ext}`); + if (fs.existsSync(lowerPath)) { + return lowerPath; + } + const upperPath = path.join(directory, `${memberId}.${ext.toUpperCase()}`); + if (fs.existsSync(upperPath)) { + return upperPath; + } + } + return null; + } + + async _processLegacyImage(sourcePath, targetPath) { + try { + await sharp(sourcePath) + .resize(600, 600, { fit: 'inside' }) + .jpeg({ quality: 80 }) + .toFile(targetPath); + } catch (error) { + console.error('[MemberService] - Failed to process legacy image, falling back to copy:', error); + await fs.promises.copyFile(sourcePath, targetPath); + } + + try { + await fs.promises.unlink(sourcePath); + } catch (unlinkError) { + // Datei konnte nicht gelöscht werden – loggen, aber nicht blockieren + console.warn('[MemberService] - Unable to remove legacy image after migration:', unlinkError.message || unlinkError); + } + } } export default new MemberService(); \ No newline at end of file diff --git a/frontend/src/apiClient.js b/frontend/src/apiClient.js index 75d5f4e..0c46f5c 100644 --- a/frontend/src/apiClient.js +++ b/frontend/src/apiClient.js @@ -1,8 +1,10 @@ import axios from 'axios'; import store from './store'; +export const backendBaseUrl = import.meta.env.VITE_BACKEND || 'http://localhost:3005'; + const apiClient = axios.create({ - baseURL: `${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api`, + baseURL: `${backendBaseUrl}/api`, }); apiClient.interceptors.request.use(config => { diff --git a/frontend/src/components/ImageViewerDialog.vue b/frontend/src/components/ImageViewerDialog.vue index ab0fa26..b0f0962 100644 --- a/frontend/src/components/ImageViewerDialog.vue +++ b/frontend/src/components/ImageViewerDialog.vue @@ -9,56 +9,103 @@ :close-on-overlay="true" @close="handleClose" > -