feat(ClubSettings): add member data quality requirements configuration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s

- Introduced new settings for member data quality requirements in club settings, allowing configuration of required fields such as street, postal code, city, phone, and email.
- Updated the backend to handle the new memberDataQualityRequirements field in club settings.
- Enhanced the frontend to display and manage these requirements in the ClubSettings view, improving user experience and data integrity.
- Added localization support for new terms related to member data quality across multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-04-15 22:15:04 +02:00
parent 4cfc82c7aa
commit 5fa34637ba
21 changed files with 237 additions and 24 deletions

0
.codex Normal file
View File

View File

@@ -60,12 +60,19 @@ export const updateClubSettings = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid } = req.params;
const { greetingText, associationMemberNumber, myTischtennisFedNickname, autoFetchRankings } = req.body;
const {
greetingText,
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings,
memberDataQualityRequirements
} = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, {
greetingText,
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings
autoFetchRankings,
memberDataQualityRequirements
});
res.status(200).json(updated);
} catch (error) {

View File

@@ -0,0 +1,6 @@
-- Migration: Add per-club member data quality requirements.
-- Controls which optional contact/address fields count as required on /members.
ALTER TABLE clubs
ADD COLUMN IF NOT EXISTS member_data_quality_requirements JSON NULL
COMMENT 'Configures which member fields are required for data quality checks';

View File

@@ -29,6 +29,12 @@ const Club = sequelize.define('Club', {
defaultValue: false,
field: 'auto_fetch_rankings',
comment: 'Enable automatic TTR/QTTR rankings fetch for this club'
},
memberDataQualityRequirements: {
type: DataTypes.JSON,
allowNull: true,
field: 'member_data_quality_requirements',
comment: 'Configures which member fields are required for data quality checks'
}
}, {
tableName: 'clubs',

View File

@@ -71,7 +71,8 @@ class ClubService {
greetingText,
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings
autoFetchRankings,
memberDataQualityRequirements
}) {
await checkAccess(userToken, clubId);
const club = await Club.findByPk(clubId);
@@ -81,9 +82,33 @@ class ClubService {
const updates = { greetingText, associationMemberNumber };
if (myTischtennisFedNickname !== undefined) updates.myTischtennisFedNickname = myTischtennisFedNickname || null;
if (autoFetchRankings !== undefined) updates.autoFetchRankings = !!autoFetchRankings;
if (memberDataQualityRequirements !== undefined) {
updates.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(memberDataQualityRequirements);
}
return await club.update(updates);
}
normalizeMemberDataQualityRequirements(settings) {
const defaults = {
requireStreet: true,
requirePostalCode: true,
requireCity: true,
requirePhone: true,
requireEmail: true
};
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
return defaults;
}
return Object.fromEntries(
Object.entries(defaults).map(([key, defaultValue]) => [
key,
typeof settings[key] === 'boolean' ? settings[key] : defaultValue
])
);
}
async approveUserClubAccess(userToken, clubId, toApproveUserId) {
await checkAccess(userToken, clubId);
const toApproveUserClub = await UserClub.findOne({

View File

@@ -644,6 +644,9 @@
"dataIssuePhone": "Telefon fehlt",
"dataIssueEmail": "E-Mail fehlt",
"dataIssueAddress": "Adresse fehlt",
"dataIssueStreet": "Strasse fehlt",
"dataIssuePostalCode": "PLZ fehlt",
"dataIssueCity": "Ort fehlt",
"dataIssueGender": "Geschlecht ungeklärt",
"dataIssueTrainingGroup": "Trainingsgruppe fehlt",
"openTasks": "Offene Aufgaben",

View File

@@ -392,6 +392,9 @@
"dataIssuePhone": "Telefon fehlt",
"dataIssueEmail": "E-Mail fehlt",
"dataIssueAddress": "Adresse fehlt",
"dataIssueStreet": "Straße fehlt",
"dataIssuePostalCode": "PLZ fehlt",
"dataIssueCity": "Ort fehlt",
"dataIssueGender": "Geschlecht ungeklärt",
"dataIssueTrainingGroup": "Trainingsgruppe fehlt",
"openTasks": "Offene Aufgaben",
@@ -1201,7 +1204,14 @@
"autoFetchRankings": "Ranglisten automatisch abrufen",
"myTischtennisFedNickname": "Verbandskürzel",
"myTischtennisFedNicknamePlaceholder": "z. B. HeTTV",
"rankingsUsesAssociationNumber": "Die Vereinsnummer für den Ranglisten-Abruf entspricht der Verbands-Mitgliedsnummer oben."
"rankingsUsesAssociationNumber": "Die Vereinsnummer für den Ranglisten-Abruf entspricht der Verbands-Mitgliedsnummer oben.",
"memberDataQuality": "Datenqualität Mitglieder",
"memberDataQualityHint": "Diese Felder zählen auf der Mitgliederseite als nötig. Alle Felder bleiben weiterhin eingebbar.",
"requireStreet": "Straße nötig",
"requirePostalCode": "PLZ nötig",
"requireCity": "Ort nötig",
"requirePhone": "Telefonnummer nötig",
"requireEmail": "E-Mail-Adresse nötig"
},
"predefinedActivities": {
"title": "Vordefinierte Aktivitäten",

View File

@@ -641,6 +641,9 @@
"dataIssuePhone": "Phone missing",
"dataIssueEmail": "Email missing",
"dataIssueAddress": "Address missing",
"dataIssueStreet": "Street missing",
"dataIssuePostalCode": "Postcode missing",
"dataIssueCity": "Suburb/city missing",
"dataIssueGender": "Gender unclear",
"dataIssueTrainingGroup": "Training group missing",
"openTasks": "Open tasks",

View File

@@ -916,6 +916,9 @@
"dataIssuePhone": "Phone missing",
"dataIssueEmail": "Email missing",
"dataIssueAddress": "Address missing",
"dataIssueStreet": "Street missing",
"dataIssuePostalCode": "Postcode missing",
"dataIssueCity": "Town/city missing",
"dataIssueGender": "Gender unclear",
"dataIssueTrainingGroup": "Training group missing",
"openTasks": "Open tasks",

View File

@@ -641,6 +641,9 @@
"dataIssuePhone": "Phone missing",
"dataIssueEmail": "Email missing",
"dataIssueAddress": "Address missing",
"dataIssueStreet": "Street missing",
"dataIssuePostalCode": "Postal code missing",
"dataIssueCity": "City missing",
"dataIssueGender": "Gender unclear",
"dataIssueTrainingGroup": "Training group missing",
"openTasks": "Open tasks",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "Falta el teléfono",
"dataIssueEmail": "Falta el correo",
"dataIssueAddress": "Falta la dirección",
"dataIssueStreet": "Falta la calle",
"dataIssuePostalCode": "Falta el código postal",
"dataIssueCity": "Falta la ciudad",
"dataIssueGender": "Sexo no definido",
"dataIssueTrainingGroup": "Falta el grupo de entrenamiento",
"openTasks": "Tareas abiertas",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "Kulang ang telepono",
"dataIssueEmail": "Kulang ang email",
"dataIssueAddress": "Kulang ang address",
"dataIssueStreet": "Kulang ang kalye",
"dataIssuePostalCode": "Kulang ang postal code",
"dataIssueCity": "Kulang ang lungsod",
"dataIssueGender": "Hindi malinaw ang kasarian",
"dataIssueTrainingGroup": "Kulang ang grupo ng pagsasanay",
"openTasks": "Open tasks",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "Téléphone manquant",
"dataIssueEmail": "E-mail manquant",
"dataIssueAddress": "Adresse manquante",
"dataIssueStreet": "Rue manquante",
"dataIssuePostalCode": "Code postal manquant",
"dataIssueCity": "Ville manquante",
"dataIssueGender": "Sexe non défini",
"dataIssueTrainingGroup": "Groupe d'entraînement manquant",
"openTasks": "Tâches ouvertes",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "Telefono mancante",
"dataIssueEmail": "E-mail mancante",
"dataIssueAddress": "Indirizzo mancante",
"dataIssueStreet": "Via mancante",
"dataIssuePostalCode": "CAP mancante",
"dataIssueCity": "Città mancante",
"dataIssueGender": "Genere non definito",
"dataIssueTrainingGroup": "Gruppo di allenamento mancante",
"openTasks": "Attività aperte",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "電話番号がありません",
"dataIssueEmail": "メールアドレスがありません",
"dataIssueAddress": "住所がありません",
"dataIssueStreet": "番地がありません",
"dataIssuePostalCode": "郵便番号がありません",
"dataIssueCity": "市区町村がありません",
"dataIssueGender": "性別が未設定です",
"dataIssueTrainingGroup": "練習グループがありません",
"openTasks": "Open tasks",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "Brak telefonu",
"dataIssueEmail": "Brak e-maila",
"dataIssueAddress": "Brak adresu",
"dataIssueStreet": "Brak ulicy",
"dataIssuePostalCode": "Brak kodu pocztowego",
"dataIssueCity": "Brak miasta",
"dataIssueGender": "Płeć nieokreślona",
"dataIssueTrainingGroup": "Brak grupy treningowej",
"openTasks": "Otwarte zadania",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "ไม่มีหมายเลขโทรศัพท์",
"dataIssueEmail": "ไม่มีอีเมล",
"dataIssueAddress": "ไม่มีที่อยู่",
"dataIssueStreet": "ไม่มีถนน",
"dataIssuePostalCode": "ไม่มีรหัสไปรษณีย์",
"dataIssueCity": "ไม่มีเมือง",
"dataIssueGender": "เพศยังไม่ชัดเจน",
"dataIssueTrainingGroup": "ไม่มีกลุ่มฝึกซ้อม",
"openTasks": "Open tasks",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "Kulang ang telepono",
"dataIssueEmail": "Kulang ang email",
"dataIssueAddress": "Kulang ang address",
"dataIssueStreet": "Kulang ang kalye",
"dataIssuePostalCode": "Kulang ang postal code",
"dataIssueCity": "Kulang ang lungsod",
"dataIssueGender": "Hindi malinaw ang kasarian",
"dataIssueTrainingGroup": "Kulang ang grupo ng pagsasanay",
"openTasks": "Open tasks",

View File

@@ -604,6 +604,9 @@
"dataIssuePhone": "缺少电话号码",
"dataIssueEmail": "缺少电子邮箱地址",
"dataIssueAddress": "缺少地址",
"dataIssueStreet": "缺少街道",
"dataIssuePostalCode": "缺少邮政编码",
"dataIssueCity": "缺少城市",
"dataIssueGender": "性别未明确",
"dataIssueTrainingGroup": "缺少训练组",
"openTasks": "Open tasks",

View File

@@ -69,6 +69,33 @@
<p v-if="autoFetchRankings" class="hint">{{ $t('clubSettings.rankingsUsesAssociationNumber') }}</p>
</section>
<section v-if="currentClub && !loading" class="card">
<h2>{{ $t('clubSettings.memberDataQuality') }}</h2>
<p class="hint">{{ $t('clubSettings.memberDataQualityHint') }}</p>
<div class="quality-options">
<label class="checkbox-label">
<input type="checkbox" v-model="memberDataQualityRequirements.requireStreet" />
{{ $t('clubSettings.requireStreet') }}
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="memberDataQualityRequirements.requirePostalCode" />
{{ $t('clubSettings.requirePostalCode') }}
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="memberDataQualityRequirements.requireCity" />
{{ $t('clubSettings.requireCity') }}
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="memberDataQualityRequirements.requirePhone" />
{{ $t('clubSettings.requirePhone') }}
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="memberDataQualityRequirements.requireEmail" />
{{ $t('clubSettings.requireEmail') }}
</label>
</div>
</section>
<section v-if="currentClub && !loading" class="card actions-card">
<div class="actions">
<button class="btn btn-primary" @click="save">{{ $t('clubSettings.save') }}</button>
@@ -98,6 +125,14 @@ import apiClient from '../apiClient';
import TrainingGroupsTab from '../components/TrainingGroupsTab.vue';
import TrainingTimesTab from '../components/TrainingTimesTab.vue';
const defaultMemberDataQualityRequirements = () => ({
requireStreet: true,
requirePostalCode: true,
requireCity: true,
requirePhone: true,
requireEmail: true,
});
export default {
name: 'ClubSettings',
components: {
@@ -111,6 +146,7 @@ export default {
associationMemberNumber: '',
myTischtennisFedNickname: '',
autoFetchRankings: false,
memberDataQualityRequirements: defaultMemberDataQualityRequirements(),
saved: false,
loading: false,
loadError: null,
@@ -134,6 +170,7 @@ export default {
this.associationMemberNumber = '';
this.myTischtennisFedNickname = '';
this.autoFetchRankings = false;
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
this.loadError = null;
return;
}
@@ -146,16 +183,38 @@ export default {
this.associationMemberNumber = club?.associationMemberNumber ?? '';
this.myTischtennisFedNickname = club?.myTischtennisFedNickname ?? '';
this.autoFetchRankings = !!club?.autoFetchRankings;
this.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(club?.memberDataQualityRequirements);
} catch (e) {
this.loadError = this.$t('clubSettings.loadFailed');
this.greeting = '';
this.associationMemberNumber = '';
this.myTischtennisFedNickname = '';
this.autoFetchRankings = false;
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
} finally {
this.loading = false;
}
},
normalizeMemberDataQualityRequirements(settings) {
const defaults = defaultMemberDataQualityRequirements();
const parsed = typeof settings === 'string' ? this.parseJsonSetting(settings) : settings;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return defaults;
}
return Object.fromEntries(
Object.keys(defaults).map(key => [
key,
typeof parsed[key] === 'boolean' ? parsed[key] : defaults[key],
])
);
},
parseJsonSetting(value) {
try {
return JSON.parse(value);
} catch (e) {
return null;
}
},
async save() {
if (!this.currentClub) {
alert(this.$t('clubSettings.noClubSelected'));
@@ -167,6 +226,7 @@ export default {
associationMemberNumber: this.associationMemberNumber,
myTischtennisFedNickname: this.myTischtennisFedNickname || null,
autoFetchRankings: this.autoFetchRankings,
memberDataQualityRequirements: this.normalizeMemberDataQualityRequirements(this.memberDataQualityRequirements),
});
this.saved = true;
setTimeout(() => (this.saved = false), 1500);
@@ -204,6 +264,7 @@ export default {
.text-input { width: 100%; border: 1px solid #ddd; border-radius: 6px; padding: 8px; font-size: 14px; }
.rankings-row { margin-bottom: 12px; }
.rankings-fields { margin-top: 12px; }
.quality-options { display: grid; gap: 10px; margin-top: 12px; }
.field-group label { display: block; margin-bottom: 4px; font-weight: 500; color: #333; }
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.checkbox-label input[type="checkbox"] { width: auto; }
@@ -246,4 +307,3 @@ export default {
}
</style>

View File

@@ -195,20 +195,20 @@
<div class="new-member-form">
<label><span>{{ $t('members.firstName') }}:</span> <input type="text" v-model="newFirstname"></label>
<label><span>{{ $t('members.lastName') }}:</span> <input type="text" v-model="newLastname"></label>
<label :class="{ 'member-field-warning': editorHasIssue('address') }">
<label :class="{ 'member-field-warning': editorHasIssue('street') }">
<span>{{ $t('members.street') }}:</span>
<input type="text" v-model="newStreet" :class="{ 'input-warning': editorHasIssue('address') }">
<small v-if="editorHasIssue('address')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
<input type="text" v-model="newStreet" :class="{ 'input-warning': editorHasIssue('street') }">
<small v-if="editorHasIssue('street')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<label :class="{ 'member-field-warning': editorHasIssue('address') }">
<label :class="{ 'member-field-warning': editorHasIssue('postal-code') }">
<span>{{ $t('members.postalCode') }}:</span>
<input type="text" v-model="newPostalCode" maxlength="10" :class="{ 'input-warning': editorHasIssue('address') }">
<small v-if="editorHasIssue('address')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
<input type="text" v-model="newPostalCode" maxlength="10" :class="{ 'input-warning': editorHasIssue('postal-code') }">
<small v-if="editorHasIssue('postal-code')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<label :class="{ 'member-field-warning': editorHasIssue('address') }">
<label :class="{ 'member-field-warning': editorHasIssue('city') }">
<span>{{ $t('members.city') }}:</span>
<input type="text" v-model="newCity" :class="{ 'input-warning': editorHasIssue('address') }">
<small v-if="editorHasIssue('address')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
<input type="text" v-model="newCity" :class="{ 'input-warning': editorHasIssue('city') }">
<small v-if="editorHasIssue('city')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<label :class="{ 'member-field-warning': editorHasIssue('birthdate') }">
<span>{{ $t('members.birthdate') }}:</span>
@@ -612,6 +612,15 @@ import MemberOrdersDialog from '../components/MemberOrdersDialog.vue';
import MembersOverviewSection from '../components/members/MembersOverviewSection.vue';
import { debounce } from '../utils/debounce.js';
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js';
const defaultMemberDataQualityRequirements = () => ({
requireStreet: true,
requirePostalCode: true,
requireCity: true,
requirePhone: true,
requireEmail: true,
});
export default {
name: 'MembersView',
components: {
@@ -844,18 +853,25 @@ export default {
editorDataQualityIssues() {
const issues = [];
const requirements = this.memberDataQualityRequirements;
if (!this.newBirthdate) {
issues.push({ key: 'birthdate', label: this.$t('members.dataIssueBirthdate') });
}
if (!this.hasEditorPhone) {
if (requirements.requirePhone && !this.hasEditorPhone) {
issues.push({ key: 'phone', label: this.$t('members.dataIssuePhone') });
}
if (!this.hasEditorEmail) {
if (requirements.requireEmail && !this.hasEditorEmail) {
issues.push({ key: 'email', label: this.$t('members.dataIssueEmail') });
}
if (!this.hasEditorAddress) {
issues.push({ key: 'address', label: this.$t('members.dataIssueAddress') });
if (requirements.requireStreet && !this.hasTextValue(this.newStreet)) {
issues.push({ key: 'street', label: this.$t('members.dataIssueStreet') });
}
if (requirements.requirePostalCode && !this.hasTextValue(this.newPostalCode)) {
issues.push({ key: 'postal-code', label: this.$t('members.dataIssuePostalCode') });
}
if (requirements.requireCity && !this.hasTextValue(this.newCity)) {
issues.push({ key: 'city', label: this.$t('members.dataIssueCity') });
}
if (!this.newGender || this.newGender === 'unknown') {
issues.push({ key: 'gender', label: this.$t('members.dataIssueGender') });
@@ -1011,7 +1027,8 @@ export default {
isLoadingMembers: false,
membersLoadError: '',
selectedMemberPreview: null,
selectedPreviewTrainingGroups: []
selectedPreviewTrainingGroups: [],
memberDataQualityRequirements: defaultMemberDataQualityRequirements()
}
},
created() {
@@ -1022,6 +1039,7 @@ export default {
},
async mounted() {
await this.loadTrainingGroups();
await this.loadMemberDataQualityRequirements();
await this.init();
},
methods: {
@@ -1061,6 +1079,42 @@ export default {
async init() {
await this.loadMembers();
},
async loadMemberDataQualityRequirements() {
if (!this.currentClub) {
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
return;
}
try {
const response = await apiClient.get(`/clubs/${this.currentClub}`);
this.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(response.data?.memberDataQualityRequirements);
} catch (error) {
console.error('[loadMemberDataQualityRequirements] error:', error);
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
}
},
normalizeMemberDataQualityRequirements(settings) {
const defaults = defaultMemberDataQualityRequirements();
const parsed = typeof settings === 'string' ? this.parseJsonSetting(settings) : settings;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return defaults;
}
return Object.fromEntries(
Object.keys(defaults).map(key => [
key,
typeof parsed[key] === 'boolean' ? parsed[key] : defaults[key],
])
);
},
parseJsonSetting(value) {
try {
return JSON.parse(value);
} catch (error) {
return null;
}
},
hasTextValue(value) {
return value !== null && value !== undefined && String(value).trim() !== '';
},
async loadMembers() {
this.isLoadingMembers = true;
this.membersLoadError = '';
@@ -2281,7 +2335,7 @@ export default {
if (!member) return [];
const issues = [];
const compactAddress = this.getCompactAddress(member);
const requirements = this.memberDataQualityRequirements;
const hasPhone = this.getFormattedPhoneNumbers(member) !== '';
const hasEmail = this.getFormattedEmails(member) !== '';
const hasTrainingGroup = this.getMemberTrainingGroups(member).length > 0;
@@ -2289,14 +2343,20 @@ export default {
if (!member.birthDate) {
issues.push({ key: 'birthdate', label: this.$t('members.dataIssueBirthdate') });
}
if (!hasPhone) {
if (requirements.requirePhone && !hasPhone) {
issues.push({ key: 'phone', label: this.$t('members.dataIssuePhone') });
}
if (!hasEmail) {
if (requirements.requireEmail && !hasEmail) {
issues.push({ key: 'email', label: this.$t('members.dataIssueEmail') });
}
if (!compactAddress) {
issues.push({ key: 'address', label: this.$t('members.dataIssueAddress') });
if (requirements.requireStreet && !this.hasTextValue(member.street)) {
issues.push({ key: 'street', label: this.$t('members.dataIssueStreet') });
}
if (requirements.requirePostalCode && !this.hasTextValue(member.postalCode)) {
issues.push({ key: 'postal-code', label: this.$t('members.dataIssuePostalCode') });
}
if (requirements.requireCity && !this.hasTextValue(member.city)) {
issues.push({ key: 'city', label: this.$t('members.dataIssueCity') });
}
if (!member.gender || member.gender === 'unknown') {
issues.push({ key: 'gender', label: this.$t('members.dataIssueGender') });