Give your membership secretary one place to find missing email and postal-address details, review a member, and save selected corrections. The widget shows a complete scan, searchable issue lists, and a confirmation after the saved values have been read back from campai.
This is a Developer App hosted inside campai. Choose it when administrators should do the work in an internal campai interface. A website signup form or a background synchronization service belongs in the external API track; those run on your own infrastructure and use an API key.
What you’ll have at the end
An internal campai widget that finds missing member details, saves selected corrections, and verifies their saved values.
- Administrator scans members
- Server reads the fixed mandate
- Widget shows missing details
- Selected correction is saved and read back
Before you start
- Permission to create an app and configure its server access
- The intended mandate ID and designated test member IDs
- Administrators authorized to review the members in that mandate
On this page
You need access to create a Developer App and configure its server permissions, the intended mandate ID, and designated test members. Start with one member missing both details and another with a complete address and email. Use an address you control for any real correction.
The checks here mean “an email value exists” and “street, city, and country exist.” They do not prove email deliverability or postal accuracy. The report covers members in one mandate, including former members returned by the member query; it is not an active-members-only report.
Download the complete source before opening the editor. Its server starts with corrections disabled.
- Open More → Developers → Apps, create an app named
Member data quality desk, and open Edit. - Choose Add group → Widget and name the group
Member review. - Set Scope to Admin. Require Members → Read and Members → Edit for this correction interface.
- Open the app's Settings → Edit server access. Grant Mandates → Read and Members → Read / Edit. Leave other permissions off. This app needs neither Database access nor outbound hosts.
The two access settings serve different purposes. The group's Admin scope controls who can invoke it. Server permissions control what the app can read or change. The managed key's grant applies across the organization; it has no mandate restriction selector. Every request in the supplied server uses the fixed MANDATE_ID. Only give this interface to administrators permitted to review that mandate, and keep it unpublished during setup.
Replace each file completely. Select all existing code before pasting; keeping the generated component below the new source creates duplicate exports.
| File from the download | Destination in the group | Responsibility |
|---|---|---|
server.ts |
index.ts directly below Member review |
Reads members, validates corrections, saves and verifies selected fields. |
widget.tsx |
Widget → index.tsx |
Renders the scan, issue filters, search, and review dialog. |
widget.css |
Widget → index.css |
Imports the supplied Tailwind runtime and styles the dialog backdrop. |
In the server file, replace YOUR_MANDATE_ID with the intended mandate. Put your designated test member IDs in TEST_CONTACT_IDS, for example ['YOUR_FIRST_TEST_CONTACT_ID', 'YOUR_SECOND_TEST_CONTACT_ID']. These must be actual 24-character IDs. Leave ALLOW_WRITES as false for the first run.
The widget calls action('scanMembers') and action('saveCorrection', input). Those names match the server exports. The server uses the built-in api; the runtime supplies authentication. Do not paste an API key into either project. The interface uses its bundled React and Tailwind environment; there is no separate npm installation or helper client.
Save all changes, choose Member review: Simulate, wait for the build, and select Scan members. Rendering the widget does not start a scan by itself.
The four cards show members scanned, missing emails, missing addresses, and members that passed both checks. A member missing both values appears once in the queue and contributes to both missing-value counts. Complete members contribute to “Checks passed” and do not need a review row.
Select Missing email or Missing address, or search by name or email. The list displays 25 rows per page. Review opens the current values; in read-only mode its save controls are disabled.
With test IDs configured, only those records are read. With an empty test-ID list, the server reads all member pages, 100 at a time, up to 5,000 records. It rejects failed pages, duplicate IDs, unavailable fields, and oversized scans instead of displaying partial success. A scan is not a frozen database snapshot; refresh when another administrator has been changing records.
- Confirm the test IDs and mandate in the server. Set
ALLOW_WRITEStotrue, save, and restart the simulation. - Run Scan members again and open Review on your incomplete test member.
- Select Update email, enter the intended address, and leave Update postal address unselected for an email-only correction.
- Select Save selected corrections. Wait for “Saved and verified” before continuing.
- Open that member's normal campai record and verify the email. Confirm that the postal address, phone numbers, and mailing preferences stayed unchanged.
- Refresh, review the same member, and test a postal-address correction. Enter the two-letter country code and the fields required for that country.
The server accepts only the selected email/address changes. An email update preserves the current phone numbers, mailing preferences, and unsubscribe history. Passing an empty dataPropagationTargets list prevents this operation from selecting linked contacts or debtor records for propagation. The app sends no message.
Before writing, the server reads the contact again and compares the email and address with the values you reviewed. A mismatch asks you to refresh. This is a useful stale-edit check, but the update API has no compare-and-swap condition: a simultaneous edit between the re-read and write remains possible.
After writing, the server reads the saved record and checks the selected fields. When both checks pass, the member leaves the queue. If only the email was fixed, a missing address keeps that row in the queue.
| Try this | Expected behavior |
|---|---|
Keep YOUR_MANDATE_ID unchanged |
A configuration error appears; no successful total is invented. |
Keep ALLOW_WRITES false |
Review works; saving is disabled and the server rejects correction calls. |
| Configure one test ID | No other member appears in the scan; a forged correction for another ID is rejected. |
| Edit a reviewed email in campai before saving in the widget | The stale correction is rejected. Refresh and review again. |
| Select only the email | Only email changes; phone, address, and mailing settings remain intact. |
| Lose access or interrupt a request | The UI shows an error. A failed scan preserves the previous complete report. |
An unconfirmed save disables further corrections until a fresh scan. Inspect the member's current values before trying again. The code does not automatically retry a write: a lost response does not prove that no change happened.
Once the test cases pass, review the intended administrators and change TEST_CONTACT_IDS to an empty list for the complete mandate scan. Keep the 5,000-member bound unless you implement a larger, resumable workflow. Change project() if your club needs different completeness checks, and update the visible labels to match.
The report stays in browser memory. Closing the widget discards it; corrections already confirmed in campai remain saved. No app database or permanent copy of member records is created by this example.
Publish the intended version separately, then test its shared entry point as an authorized administrator and as a signed-out visitor. The latter must not reach this Admin-scoped interface. Owner simulation tests the code, not the published audience boundary. See Publish and share an app for that final check.
Copy all three files or download the ZIP. Replace the configuration values in server.ts; keep test IDs and read-only mode for your first run. These are the same files used by the walkthrough.
The complete member data quality app
Copy server.ts into the group’s index.ts, widget.tsx into Widget/index.tsx, and widget.css into Widget/index.css. Set the mandate and test contact IDs in the server. Corrections start disabled.
Download ZIPView and copy the complete source
The complete member data quality app
// Paste into the Widget group's server index.ts. No SDK or API key is needed.
const MANDATE_ID = 'YOUR_MANDATE_ID';
const ALLOW_WRITES = false; // Enable only after reviewing the Admin scope and permissions.
const TEST_CONTACT_IDS: string[] = []; // Set fixture IDs here to limit your first scan and edits.
const PAGE_SIZE = 100;
const MAX_MEMBERS = 5000;
type Address = { country: string; state?: string; zip: string; city: string; addressLine: string; details1?: string; details2?: string };
type ReviewMember = { id: string; name: string; email: string; address: Address | null; issues: string[]; revision: string };
function id(value: unknown): string {
const text = typeof value === 'string' ? value : value && typeof value === 'object' && '$oid' in value ? String(value.$oid) : String(value);
if (!/^[a-f0-9]{24}$/i.test(text)) throw new Error('Configure a valid mandate/contact ID.');
return text;
}
function scope() {
return { mandateId: id(MANDATE_ID) };
}
function project(contact: any): ReviewMember {
if (!contact || !Array.isArray(contact.types) || !contact.types.includes('member')) throw new Error('This record is not a member.');
if (typeof contact.communication?.email !== 'string' || !Object.prototype.hasOwnProperty.call(contact, 'address')) {
throw new Error('Email or address fields are unavailable. Review the app field permissions.');
}
const email = contact.communication.email;
const address = contact.address;
const issues: string[] = [];
if (!email.trim()) issues.push('email');
if (!address || !address.addressLine?.trim() || !address.city?.trim() || !address.country?.trim()) issues.push('address');
// Compare the displayed fields before saving. This is not a database transaction lock.
const revision = JSON.stringify({ email, address });
return { id: id(contact._id), name: String(contact.name || 'Unnamed member'), email, address, issues, revision };
}
export async function scanMembers() {
const members: ReviewMember[] = [];
const seen = new Set<string>();
const allowed = new Set(TEST_CONTACT_IDS.map(id));
if (allowed.size) {
for (const contactId of allowed) {
const contact = await api.crm.contacts.getContact({ ...scope(), contactId });
members.push(project(contact));
}
} else {
let complete = false;
for (let offset = 0; offset <= MAX_MEMBERS; offset += PAGE_SIZE) {
const result = await api.crm.contacts.listContacts({ ...scope(), types: ['member'], sort: { _id: 'asc' }, limit: PAGE_SIZE, offset });
if (!Array.isArray(result.contacts) || result.contacts.length > PAGE_SIZE) throw new Error('Unexpected member-list response.');
for (const contact of result.contacts) {
const member = project(contact);
if (seen.has(member.id)) throw new Error('The member list moved during scanning. Refresh before reviewing.');
seen.add(member.id);
members.push(member);
if (members.length > MAX_MEMBERS) throw new Error('This example supports up to 5,000 members. Split the scan before continuing.');
}
if (result.contacts.length < PAGE_SIZE) { complete = true; break; }
}
if (!complete) throw new Error('The scan did not finish. No partial review was returned.');
}
return {
scanned: members.length,
missingEmail: members.filter(member => member.issues.includes('email')).length,
missingAddress: members.filter(member => member.issues.includes('address')).length,
rows: members.filter(member => member.issues.length),
checkedAt: new Date().toISOString(),
canWrite: ALLOW_WRITES,
fixtureOnly: Boolean(allowed.size),
};
}
function checkedAddress(value: unknown): Address {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Enter a complete postal address.');
const input = value as Record<string, unknown>;
const result: Record<string, string> = {};
for (const field of ['country', 'state', 'zip', 'city', 'addressLine', 'details1', 'details2']) {
if (typeof input[field] !== 'string') {
if (['state', 'details1', 'details2'].includes(field) && input[field] === undefined) continue;
throw new Error('An address field is missing.');
}
const text = (input[field] as string).trim();
if (text.length > (field === 'zip' ? 10 : 45)) throw new Error('An address field is too long.');
result[field] = text;
}
result.country = result.country.toUpperCase();
if (!/^[A-Z]{2}$/.test(result.country) || !result.addressLine || !result.city) throw new Error('Enter the street, city, and two-letter country code.');
// campai performs country-specific postal-code and state validation.
return result as Address;
}
export async function saveCorrection(input: unknown) {
if (!ALLOW_WRITES) throw new Error('This app is in read-only mode. Enable writes in the server source first.');
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid correction.');
const value = input as Record<string, unknown>;
const contactId = id(value.id);
if (TEST_CONTACT_IDS.length && !TEST_CONTACT_IDS.includes(contactId)) throw new Error('This member is outside the test fixture selection.');
if (typeof value.revision !== 'string' || value.revision.length > 3000) throw new Error('Refresh the member before saving.');
if (value.changeEmail !== true && value.changeAddress !== true) throw new Error('Select at least one correction.');
const current = await api.crm.contacts.getContact({ ...scope(), contactId });
const before = project(current);
if (before.revision !== value.revision) throw new Error('This member changed after your scan. Refresh and review the current details.');
let communication: typeof current.communication | undefined;
let address: Address | undefined;
if (value.changeEmail === true) {
if (typeof value.email !== 'string' || value.email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email.trim())) throw new Error('Enter a valid email address.');
// Preserve phone numbers, mailing preferences, and unsubscribe history.
communication = { ...current.communication, email: value.email.trim() };
}
if (value.changeAddress === true) address = checkedAddress(value.address);
try {
await api.crm.contacts.updateContact({
...scope(), contactId,
...(communication ? { communication } : {}),
...(address ? { address } : {}),
dataPropagationTargets: [],
});
} catch {
throw new Error('Save was not confirmed. Refresh this member and inspect the current values before trying again.');
}
let saved: ReviewMember;
try { saved = project(await api.crm.contacts.getContact({ ...scope(), contactId })); }
catch { throw new Error('The save returned, but read-back failed. Refresh before making another change.'); }
if ((communication && saved.email !== communication.email) || (address && Object.entries(address).some(([key, value]) => saved.address?.[key as keyof Address] !== value))) {
throw new Error('Read-back differs from your correction. Refresh and check the record in campai.');
}
return saved;
}
Check your result
- Read-only scan
- No record changes; complete members are outside the issue queue.
- Email-only correction
- Email changes while address, phones, and mailing preferences stay intact.
- Changed record or unconfirmed save
- The app asks for a fresh review and does not retry the write.