Add member profile questions, admin tooling, legal pages, and fast tests

- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
  - Add admin UI for managing profile questions and member profile answers
  - Add volunteer level/profile data support across backend schemas, models, API, and migration
  - Update dashboard/profile UI, super admin menu, membership service types, and related styling
  - Add privacy policy, terms of service, cookie notice, and footer links
  - Add frontend Vitest coverage for profile question logic
  - Add backend pytest coverage for profile answer normalization and validation
  - Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
  - Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
    - Protect feature flag reload behind super-admin access
    - Restrict admin-triggered password resets so admins can only reset member accounts
    - Replace email template HTML preview rendering with escaped text preview
    - Update docs for feature flag reload access, password reset scope, and email template preview safety

    -- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
This commit is contained in:
2026-05-04 22:05:58 +01:00
parent 74a4e3ede8
commit 632e66e21d
34 changed files with 3932 additions and 749 deletions
@@ -0,0 +1,310 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ProfileAnswerInput, ProfileQuestionForUser } from '../services/membershipService';
import {
answerToComparable,
canEditProfileQuestion,
isProfileQuestionVisible,
ProfileQuestionAnswerValue
} from '../utils/profileQuestionLogic';
interface ProfileQuestionsFormProps {
title: string;
description?: string;
questions: ProfileQuestionForUser[];
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
saveLabel?: string;
allowAdminManagedEdit?: boolean;
}
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
if (value === null || value === undefined || value === '') {
return 'Not set';
}
if (question.input_type === 'boolean') {
return value === true || value === 'true' ? 'Yes' : 'No';
}
if (question.input_type === 'select') {
const matchingOption = question.options.find((option) => option.value === String(value));
return matchingOption?.label || String(value);
}
return String(value);
};
const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
title,
description,
questions,
onSave,
saveLabel = 'Save Answers',
allowAdminManagedEdit = false
}) => {
const initialAnswers = useMemo(() => {
const values: Record<number, ProfileQuestionAnswerValue> = {};
questions.forEach((question) => {
values[question.id] = question.answer ?? null;
});
return values;
}, [questions]);
const [answers, setAnswers] = useState<Record<number, ProfileQuestionAnswerValue>>(initialAnswers);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
setAnswers(initialAnswers);
setSuccessMessage(null);
setError(null);
}, [initialAnswers]);
const visibleQuestions = useMemo(() => {
const byId = new Map<number, ProfileQuestionForUser>();
questions.forEach((question) => byId.set(question.id, question));
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
}, [questions, answers]);
const filteredQuestions = useMemo(() => {
const searchTerm = search.trim().toLowerCase();
return visibleQuestions
.filter((question) => {
if (!searchTerm) {
return true;
}
return (
question.label.toLowerCase().includes(searchTerm) ||
question.key.toLowerCase().includes(searchTerm) ||
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
}, [visibleQuestions, search]);
const paginatedQuestions = useMemo(() => {
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page]);
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
setAnswers((prev) => ({
...prev,
[questionId]: value
}));
setSuccessMessage(null);
setError(null);
};
useEffect(() => {
setPage(1);
}, [search]);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
const changedAnswers: ProfileAnswerInput[] = questions
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
.filter((question) => {
const current = answerToComparable(answers[question.id] ?? null);
const initial = answerToComparable(initialAnswers[question.id] ?? null);
return current !== initial;
})
.map((question) => ({
question_id: question.id,
value: answers[question.id] ?? null
}));
await onSave(changedAnswers);
setSuccessMessage(changedAnswers.length > 0 ? 'Saved successfully.' : 'No changes to save.');
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save answers.');
} finally {
setSaving(false);
}
};
const renderField = (question: ProfileQuestionForUser) => {
const value = answers[question.id] ?? null;
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
if (disabled && !saving) {
return (
<div className="profile-question-readonly">
{formatAnswerForDisplay(question, value)}
</div>
);
}
if (question.input_type === 'boolean') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === '') {
setAnswerValue(question.id, null);
} else {
setAnswerValue(question.id, nextValue === 'true');
}
}}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">Prefer not to say</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (question.input_type === 'select') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">Select an option</option>
{question.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
if (question.input_type === 'date') {
return (
<input
type="date"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
}
if (question.input_type === 'number') {
return (
<input
type="number"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
}
return (
<input
type="text"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
};
return (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '8px' }}>{title}</h3>
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}>
<input
type="text"
placeholder="Search questions..."
value={search}
onChange={(event) => setSearch(event.target.value)}
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{successMessage && (
<div className="alert alert-success">
{successMessage}
</div>
)}
{filteredQuestions.length === 0 ? (
<p style={{ color: '#666' }}>No questions available.</p>
) : (
<div style={{ display: 'grid', gap: '16px' }}>
{paginatedQuestions.map((question) => (
<div key={question.id} className="profile-question-row">
<div className="profile-question-meta">
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}>
{question.label}
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>}
{question.admin_only_edit && (
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
</label>
{question.help_text && (
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p>
)}
</div>
<div className="profile-question-answer">{renderField(question)}</div>
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}>
This field can only be changed by an admin.
</p>
)}
</div>
))}
</div>
)}
{filteredQuestions.length > pageSize && (
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
<span style={{ fontSize: '13px', color: '#525a66' }}>
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
Previous
</button>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
Next
</button>
</div>
</div>
)}
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : saveLabel}
</button>
</div>
</div>
);
};
export default ProfileQuestionsForm;