Implement cross-club friendly match concept with invitations and shared matches
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s
- Added controllers for handling friendly match invitations and shared matches. - Created migration scripts for `friendly_match_invitation` and `friendly_match_shared` tables. - Developed models for `FriendlyMatchInvitation` and `FriendlyMatchShared`. - Established routes for managing invitations and shared matches. - Implemented services for business logic related to invitations and shared matches. - Documented the concept plan for the new feature including API endpoints and data models.
This commit is contained in:
@@ -50,4 +50,49 @@ const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
await transporter.sendMail(mailOptions);
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail };
|
||||
const sendFriendlyMatchInvitationEmail = async ({
|
||||
toEmails,
|
||||
fromClubName,
|
||||
toClubName,
|
||||
proposedDate,
|
||||
proposedStartTime,
|
||||
proposedMatchName,
|
||||
message,
|
||||
}) => {
|
||||
const recipientList = Array.isArray(toEmails) ? toEmails.filter(Boolean) : [toEmails].filter(Boolean);
|
||||
if (!recipientList.length) return;
|
||||
|
||||
const appUrl = process.env.BASE_URL || process.env.PUBLIC_SITE_URL || 'https://tt-tagebuch.de';
|
||||
const timeLabel = proposedStartTime ? ` um ${proposedStartTime}` : '';
|
||||
const messageHtml = message
|
||||
? `<p style="margin-top: 12px;"><strong>Nachricht:</strong><br>${String(message).replace(/</g, '<').replace(/>/g, '>')}</p>`
|
||||
: '';
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
to: recipientList.join(','),
|
||||
subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 700px; margin: 0 auto;">
|
||||
<h2 style="color: #1f2937;">Neue Freundschaftsspiel-Einladung</h2>
|
||||
<p>Der Verein <strong>${fromClubName}</strong> hat euren Verein zu einem Freundschaftsspiel eingeladen.</p>
|
||||
<ul>
|
||||
<li><strong>Match:</strong> ${proposedMatchName}</li>
|
||||
<li><strong>Datum:</strong> ${proposedDate}${timeLabel}</li>
|
||||
</ul>
|
||||
${messageHtml}
|
||||
<p style="margin-top: 16px;">Bitte in der App annehmen oder ablehnen:</p>
|
||||
<p>
|
||||
<a href="${appUrl}/friendly-matches"
|
||||
style="background-color:#2563eb;color:#fff;padding:10px 16px;text-decoration:none;border-radius:6px;display:inline-block;">
|
||||
Zur Einladung
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail };
|
||||
|
||||
429
backend/services/friendlyMatchSharedService.js
Normal file
429
backend/services/friendlyMatchSharedService.js
Normal file
@@ -0,0 +1,429 @@
|
||||
import { Op } from 'sequelize';
|
||||
import FriendlyMatchShared from '../models/FriendlyMatchShared.js';
|
||||
import FriendlyMatchInvitation from '../models/FriendlyMatchInvitation.js';
|
||||
import UserClub from '../models/UserClub.js';
|
||||
import User from '../models/User.js';
|
||||
import Club from '../models/Club.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
|
||||
import { sendFriendlyMatchInvitationEmail } from './emailService.js';
|
||||
|
||||
function cleanString(value, fallback = '') {
|
||||
const text = String(value ?? '').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function cleanOptionalString(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function normalizeArrayValue(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeIdList(list) {
|
||||
if (typeof list === 'string') {
|
||||
try {
|
||||
list = JSON.parse(list);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(list)) return null;
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of list) {
|
||||
const id = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(id) || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
result.push(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeTextForSearch(value) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isClubInvolved(clubId, match) {
|
||||
const id = Number.parseInt(clubId, 10);
|
||||
return Number(match.homeClubId) === id || Number(match.guestClubId) === id;
|
||||
}
|
||||
|
||||
function toSharedScheduleRow(match) {
|
||||
return {
|
||||
id: match.id,
|
||||
friendlyMatchId: match.id,
|
||||
isFriendly: true,
|
||||
isSharedFriendly: true,
|
||||
date: match.date,
|
||||
time: match.startTime,
|
||||
homeClubId: match.homeClubId,
|
||||
guestClubId: match.guestClubId,
|
||||
homeTeam: { name: match.homeTeamName },
|
||||
guestTeam: { name: match.guestTeamName },
|
||||
location: {
|
||||
name: match.locationName || 'N/A',
|
||||
address: match.locationAddress || '',
|
||||
city: match.locationCity || '',
|
||||
zip: match.locationZip || '',
|
||||
},
|
||||
leagueDetails: { name: 'Freundschaftsspiel (Vereinsuebergreifend)' },
|
||||
homeMatchPoints: match.homeMatchPoints || 0,
|
||||
guestMatchPoints: match.guestMatchPoints || 0,
|
||||
isCompleted: match.isCompleted || false,
|
||||
matchSystem: match.matchSystem,
|
||||
singlesCount: match.singlesCount,
|
||||
doublesCount: match.doublesCount,
|
||||
winningSets: match.winningSets,
|
||||
homeParticipants: normalizeArrayValue(match.homeParticipants),
|
||||
guestParticipants: normalizeArrayValue(match.guestParticipants),
|
||||
resultDetails: normalizeArrayValue(match.resultDetails),
|
||||
playersReady: normalizeArrayValue(match.playersReady),
|
||||
playersPlanned: normalizeArrayValue(match.playersPlanned),
|
||||
playersPlayed: normalizeArrayValue(match.playersPlayed),
|
||||
status: match.status,
|
||||
matchName: match.matchName,
|
||||
createdFromInvitationId: match.createdFromInvitationId,
|
||||
};
|
||||
}
|
||||
|
||||
function toInvitationDto(invitation) {
|
||||
return {
|
||||
id: invitation.id,
|
||||
fromClubId: invitation.fromClubId,
|
||||
toClubId: invitation.toClubId,
|
||||
proposedDate: invitation.proposedDate,
|
||||
proposedStartTime: invitation.proposedStartTime,
|
||||
proposedMatchName: invitation.proposedMatchName,
|
||||
message: invitation.message,
|
||||
status: invitation.status,
|
||||
createdByUserId: invitation.createdByUserId,
|
||||
acceptedByUserId: invitation.acceptedByUserId,
|
||||
acceptedAt: invitation.acceptedAt,
|
||||
createdAt: invitation.createdAt,
|
||||
updatedAt: invitation.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
class FriendlyMatchSharedService {
|
||||
async findByNameDateStartTime(userToken, clubId, query = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const nameNorm = normalizeTextForSearch(query.name);
|
||||
const date = cleanOptionalString(query.date);
|
||||
const startTime = cleanOptionalString(query.startTime);
|
||||
|
||||
const where = {
|
||||
[Op.or]: [{ homeClubId: clubId }, { guestClubId: clubId }],
|
||||
};
|
||||
if (date) where.date = date;
|
||||
if (startTime) where.startTime = startTime;
|
||||
|
||||
const matches = await FriendlyMatchShared.findAll({
|
||||
where,
|
||||
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
|
||||
});
|
||||
|
||||
const out = matches
|
||||
.map((match) => {
|
||||
const row = toSharedScheduleRow(match);
|
||||
const combined = normalizeTextForSearch([
|
||||
row.matchName,
|
||||
row.homeTeam?.name,
|
||||
row.guestTeam?.name,
|
||||
`${row.homeTeam?.name || ''} ${row.guestTeam?.name || ''}`,
|
||||
`${row.guestTeam?.name || ''} ${row.homeTeam?.name || ''}`,
|
||||
].join(' '));
|
||||
|
||||
let confidence = 'medium';
|
||||
if (nameNorm && combined === nameNorm && date && startTime) confidence = 'exact';
|
||||
else if (nameNorm && combined.includes(nameNorm) && date && startTime) confidence = 'high';
|
||||
else if (nameNorm && !combined.includes(nameNorm)) return null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
confidence,
|
||||
matchedBy: {
|
||||
name: Boolean(nameNorm),
|
||||
date: Boolean(date),
|
||||
startTime: Boolean(startTime),
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async listShared(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const matches = await FriendlyMatchShared.findAll({
|
||||
where: {
|
||||
[Op.or]: [{ homeClubId: clubId }, { guestClubId: clubId }],
|
||||
},
|
||||
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
|
||||
});
|
||||
return matches.map(toSharedScheduleRow);
|
||||
}
|
||||
|
||||
async getSharedById(userToken, clubId, matchId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
return toSharedScheduleRow(match);
|
||||
}
|
||||
|
||||
async updateShared(userToken, clubId, matchId, payload = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
for (const field of ['date', 'startTime', 'matchName', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem', 'status']) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, field)) {
|
||||
updates[field] = ['date', 'homeTeamName', 'guestTeamName', 'matchSystem', 'status'].includes(field)
|
||||
? cleanString(payload[field])
|
||||
: cleanOptionalString(payload[field]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ['singlesCount', 'doublesCount', 'winningSets', 'homeMatchPoints', 'guestMatchPoints']) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, field)) {
|
||||
updates[field] = Number.parseInt(payload[field], 10) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'isCompleted')) {
|
||||
updates.isCompleted = Boolean(payload.isCompleted);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'homeParticipants')) {
|
||||
updates.homeParticipants = Array.isArray(payload.homeParticipants) ? payload.homeParticipants : [];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'guestParticipants')) {
|
||||
updates.guestParticipants = Array.isArray(payload.guestParticipants) ? payload.guestParticipants : [];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'resultDetails')) {
|
||||
updates.resultDetails = Array.isArray(payload.resultDetails) ? payload.resultDetails : [];
|
||||
}
|
||||
|
||||
await match.update(updates);
|
||||
return toSharedScheduleRow(match);
|
||||
}
|
||||
|
||||
async updateSharedPlayers(userToken, clubId, matchId, payload = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const ready = normalizeIdList(payload.playersReady);
|
||||
const planned = normalizeIdList(payload.playersPlanned);
|
||||
const played = normalizeIdList(payload.playersPlayed);
|
||||
|
||||
await match.update({
|
||||
playersReady: ready ?? (match.playersReady || []),
|
||||
playersPlanned: planned ?? (match.playersPlanned || []),
|
||||
playersPlayed: played ?? (match.playersPlayed || []),
|
||||
});
|
||||
|
||||
return toSharedScheduleRow(match);
|
||||
}
|
||||
|
||||
async removeShared(userToken, clubId, matchId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
await match.destroy();
|
||||
return { success: true, id: Number(matchId) };
|
||||
}
|
||||
|
||||
async createInvitation(userToken, fromClubId, payload = {}) {
|
||||
await checkAccess(userToken, fromClubId);
|
||||
|
||||
const toClubId = Number.parseInt(payload.toClubId, 10);
|
||||
if (!Number.isInteger(toClubId)) {
|
||||
throw new HttpError('Zielverein fehlt oder ist ungueltig.', 400);
|
||||
}
|
||||
if (Number(toClubId) === Number(fromClubId)) {
|
||||
throw new HttpError('Ein Verein kann sich nicht selbst einladen.', 400);
|
||||
}
|
||||
|
||||
const proposedDate = cleanString(payload.date);
|
||||
const proposedMatchName = cleanString(payload.matchName);
|
||||
if (!proposedDate || !proposedMatchName) {
|
||||
throw new HttpError('Datum und Matchname sind Pflichtfelder.', 400);
|
||||
}
|
||||
|
||||
const user = await getUserByToken(userToken);
|
||||
|
||||
const invitation = await FriendlyMatchInvitation.create({
|
||||
fromClubId,
|
||||
toClubId,
|
||||
proposedDate,
|
||||
proposedStartTime: cleanOptionalString(payload.startTime),
|
||||
proposedMatchName,
|
||||
message: cleanOptionalString(payload.message),
|
||||
status: 'pending',
|
||||
createdByUserId: user?.id || null,
|
||||
});
|
||||
|
||||
this._sendInvitationEmails(invitation).catch((error) => {
|
||||
console.error('[friendly-match-invitation] email send failed:', error?.message || error);
|
||||
});
|
||||
|
||||
return toInvitationDto(invitation);
|
||||
}
|
||||
|
||||
async listIncomingInvitations(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const items = await FriendlyMatchInvitation.findAll({
|
||||
where: { toClubId: clubId },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return items.map(toInvitationDto);
|
||||
}
|
||||
|
||||
async listOutgoingInvitations(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const items = await FriendlyMatchInvitation.findAll({
|
||||
where: { fromClubId: clubId },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return items.map(toInvitationDto);
|
||||
}
|
||||
|
||||
async acceptInvitation(userToken, clubId, invitationId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const invitation = await FriendlyMatchInvitation.findOne({
|
||||
where: { id: invitationId, toClubId: clubId, status: 'pending' },
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new HttpError('Einladung nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const user = await getUserByToken(userToken);
|
||||
|
||||
const fromClub = await Club.findByPk(invitation.fromClubId, { attributes: ['id', 'name'] });
|
||||
const toClub = await Club.findByPk(invitation.toClubId, { attributes: ['id', 'name'] });
|
||||
|
||||
const shared = await FriendlyMatchShared.create({
|
||||
homeClubId: invitation.fromClubId,
|
||||
guestClubId: invitation.toClubId,
|
||||
date: invitation.proposedDate,
|
||||
startTime: invitation.proposedStartTime,
|
||||
matchName: invitation.proposedMatchName,
|
||||
homeTeamName: fromClub?.name || 'Heim',
|
||||
guestTeamName: toClub?.name || 'Gast',
|
||||
matchSystem: 'Braunschweiger System',
|
||||
singlesCount: 12,
|
||||
doublesCount: 4,
|
||||
winningSets: 3,
|
||||
homeMatchPoints: 0,
|
||||
guestMatchPoints: 0,
|
||||
isCompleted: false,
|
||||
homeParticipants: [],
|
||||
guestParticipants: [],
|
||||
resultDetails: [],
|
||||
playersReady: [],
|
||||
playersPlanned: [],
|
||||
playersPlayed: [],
|
||||
status: 'active',
|
||||
createdByUserId: user?.id || null,
|
||||
createdFromInvitationId: invitation.id,
|
||||
});
|
||||
|
||||
await invitation.update({
|
||||
status: 'accepted',
|
||||
acceptedByUserId: user?.id || null,
|
||||
acceptedAt: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
invitation: toInvitationDto(invitation),
|
||||
sharedMatch: toSharedScheduleRow(shared),
|
||||
};
|
||||
}
|
||||
|
||||
async declineInvitation(userToken, clubId, invitationId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const invitation = await FriendlyMatchInvitation.findOne({
|
||||
where: { id: invitationId, toClubId: clubId, status: 'pending' },
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new HttpError('Einladung nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const dto = toInvitationDto(invitation);
|
||||
await invitation.destroy();
|
||||
return dto;
|
||||
}
|
||||
|
||||
async _sendInvitationEmails(invitation) {
|
||||
const [fromClub, toClub] = await Promise.all([
|
||||
Club.findByPk(invitation.fromClubId, { attributes: ['id', 'name'] }),
|
||||
Club.findByPk(invitation.toClubId, { attributes: ['id', 'name'] }),
|
||||
]);
|
||||
|
||||
if (!toClub) return;
|
||||
|
||||
const recipients = await UserClub.findAll({
|
||||
where: {
|
||||
clubId: invitation.toClubId,
|
||||
approved: true,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'email'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const targetEmails = recipients
|
||||
.map((row) => row?.user?.email)
|
||||
.filter((email) => typeof email === 'string' && email.trim().length > 3);
|
||||
|
||||
if (!targetEmails.length) return;
|
||||
|
||||
await sendFriendlyMatchInvitationEmail({
|
||||
toEmails: targetEmails,
|
||||
fromClubName: fromClub?.name || `Verein ${invitation.fromClubId}`,
|
||||
toClubName: toClub?.name || `Verein ${invitation.toClubId}`,
|
||||
proposedDate: invitation.proposedDate,
|
||||
proposedStartTime: invitation.proposedStartTime,
|
||||
proposedMatchName: invitation.proposedMatchName,
|
||||
message: invitation.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new FriendlyMatchSharedService();
|
||||
@@ -230,6 +230,43 @@ export const emitScheduleMatchUpdated = (clubId, matchId, match = null) => {
|
||||
emitToClub(clubId, 'schedule:match:updated', { clubId, matchId, match });
|
||||
};
|
||||
|
||||
export const emitFriendlyInvitationCreated = (fromClubId, toClubId, invitation) => {
|
||||
emitToClub(fromClubId, 'friendly:invitation:created', { invitation });
|
||||
if (String(fromClubId) !== String(toClubId)) {
|
||||
emitToClub(toClubId, 'friendly:invitation:created', { invitation });
|
||||
}
|
||||
};
|
||||
|
||||
export const emitFriendlyInvitationAccepted = (fromClubId, toClubId, invitation) => {
|
||||
emitToClub(fromClubId, 'friendly:invitation:accepted', { invitation });
|
||||
if (String(fromClubId) !== String(toClubId)) {
|
||||
emitToClub(toClubId, 'friendly:invitation:accepted', { invitation });
|
||||
}
|
||||
};
|
||||
|
||||
export const emitFriendlyInvitationDeclined = (fromClubId, toClubId, invitationId) => {
|
||||
const payload = { invitationId };
|
||||
emitToClub(fromClubId, 'friendly:invitation:declined', payload);
|
||||
if (String(fromClubId) !== String(toClubId)) {
|
||||
emitToClub(toClubId, 'friendly:invitation:declined', payload);
|
||||
}
|
||||
};
|
||||
|
||||
export const emitFriendlySharedMatchUpdated = (homeClubId, guestClubId, match) => {
|
||||
emitToClub(homeClubId, 'friendly:shared:match:updated', { match });
|
||||
if (String(homeClubId) !== String(guestClubId)) {
|
||||
emitToClub(guestClubId, 'friendly:shared:match:updated', { match });
|
||||
}
|
||||
};
|
||||
|
||||
export const emitFriendlySharedMatchDeleted = (homeClubId, guestClubId, matchId) => {
|
||||
const payload = { matchId };
|
||||
emitToClub(homeClubId, 'friendly:shared:match:deleted', payload);
|
||||
if (String(homeClubId) !== String(guestClubId)) {
|
||||
emitToClub(guestClubId, 'friendly:shared:match:deleted', payload);
|
||||
}
|
||||
};
|
||||
|
||||
// Event wenn Spielbericht (nuscore) abgesendet wurde – matchData = vollständiges Objekt für andere Clients
|
||||
export const emitMatchReportSubmitted = (clubId, matchCode, matchData = null) => {
|
||||
emitToClub(clubId, 'schedule:match-report:submitted', { clubId, matchCode, matchData });
|
||||
|
||||
Reference in New Issue
Block a user