From 22e6913005398a1e70630cd350295995636a2b14 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 11 Nov 2025 15:53:21 +0100 Subject: [PATCH] Implement member image management features in backend and frontend This commit introduces new functionalities for managing member images, including uploading, deleting, and setting primary images. The memberController and memberService have been updated to handle these operations, while new routes have been added to facilitate image management. The frontend has been enhanced with an ImageViewerDialog component that supports image rotation, deletion, and setting primary images. Additionally, improvements to the member view allow for better image handling and display. These changes enhance the overall user experience and functionality of the member management system. --- backend/controllers/memberController.js | 56 +- backend/middleware/authMiddleware.js | 3 + .../migrations/20251111_add_member_images.sql | 17 + backend/models/MemberImage.js | 40 ++ backend/models/index.js | 5 + backend/routes/memberRoutes.js | 21 +- backend/services/memberService.js | 568 +++++++++++++++--- frontend/src/apiClient.js | 4 +- frontend/src/components/ImageViewerDialog.vue | 395 +++++++++--- frontend/src/views/MembersView.vue | 320 ++++++++-- 10 files changed, 1210 insertions(+), 219 deletions(-) create mode 100644 backend/migrations/20251111_add_member_images.sql create mode 100644 backend/models/MemberImage.js 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" > -
-
- -
- Kein Bild verfügbar +
+ + +
+ +
+ Kein Bild verfügbar +
+ +
- - -
- - - +
- - + +
+ +
+ +
+
+ + Primär +
+
+
-