Skip to content
campaiDeveloper
campai.com

Your club needs a member file for its federation. The secretary should be able to choose the reporting date, see which records need attention, and inspect the file before downloading it. Build that workflow as a Developer App hosted inside campai.

This guide produces a provider-neutral CSV. You will adapt its fields and file format to your federation's specification. Use an external API integration when a federation system must collect updates automatically across clubs; this app is for a person reviewing one club's file inside campai.

What you’ll have at the end

An internal member-export desk with a reporting date, required-field review, exact CSV preview, and a complete file you can adapt to your federation.

  1. Administrator chooses the date and required fields
  2. Built-in API reads the configured member selection
  3. Server checks membership dates and the file contract
  4. Widget shows review issues or the complete CSV
Before you start
  • Permission to create an Admin-scoped Developer App Widget
  • Mandates Read and Members Read for the selected fields
  • Three disposable test members and the receiving federation’s file specification
On this page

Get the federation's current field list and an accepted sample or import validator. Record the club identifier, reporting date rule, required fields, code mappings and file encoding. A file that opens in a spreadsheet is not necessarily accepted by the recipient.

The supplied contract starts with these columns:

Column campai source Example rule
club_number Configured once in the server Preserve the federation's identifier as text.
member_number contactNumbers.member Required and unique within this export.
first_name / last_name person.firstName / person.lastName Required under this example's contract.
entry_date member.entryAt Calendar date as YYYY-MM-DD.
birthdate person.birthdate Included only when selected and required by the recipient.

The file uses UTF-8 without BOM, semicolon separators, CRLF line endings, quoted cells and doubled embedded quotes. Member numbers stay strings. If your federation requires another contract, update the mapping and validation together before submitting the output. This guide does not claim compatibility with a named federation format.

  1. Open More > Developers > Apps, create Federation member export, and add a Widget group named Member export.
  2. Set Scope to Admin and require Members > Read from visitors.
  3. Grant the app server Mandates > Read and Members > Read, including the fields needed for your export.
  4. Leave Database access disabled and Outbound hosts empty.

Only give the app to people allowed to review and export the selected member data. The server grant applies across the organization; this source fixes the mandate for its calls. The visitor requirement checks the user's existing permissions and does not grant access to the app server.

Copy server.ts into the group's server index.ts, widget.tsx into Widget > index.tsx, and retain the standard Tailwind import in Widget > index.css. The complete source is at the end of this guide.

Set MANDATE_ID and CLUB_NUMBER in the server. Use the full mandate ID and the club identifier assigned by your federation; these are different identifiers. Start with designated synthetic members by placing their full contact IDs in TEST_CONTACT_IDS. That mode restricts the same paginated member-list request to those records and rejects missing or unexpected IDs. Leave the app unpublished while testing.

The server uses the built-in api.crm.contacts. It requests no writes and stores no export in the app database. The widget calls action('prepareExport', input); there is no external server, package installation or API key in the client.

Create three disposable test members without debtors or subscriptions. Give two an entry date equal to your reporting date and the third an entry date one day later. Give each a unique member number, including one with leading zeroes. Use an embedded quote in one first name, such as Maya "May", and leave the other current member’s first name empty to test the review list. Put all three full IDs in TEST_CONTACT_IDS before scanning.

Save the source, select the Widget's index.tsx, and start Member export: Simulate. Choose a reporting date. Leave Include birthdates off unless the receiving file requires them, then select Prepare export.

The app reads the complete member selection before displaying a result. It includes both the entry and exit day. Future starts and exits before the reporting date are omitted. Institution members are counted separately and omitted from this person-only file.

The first scan should show 3 Records scanned, 1 Row ready, 1 Row to review, and 1 Outside membership dates. Download stays disabled until the missing name is resolved.

The preview shows the first 20 eligible rows. The download includes every eligible row from the completed scan. Birthdates, email addresses, phone numbers and payment information do not belong in an export simply because the API can return them; this example sends only its selected columns to the widget.

Find the incomplete test member under Resolve these rows in campai. No partial CSV is available while that list has an issue.

Resolve the source issue before preparing a new export.

Resolve the source issue before preparing a new export.

Missing first-name review for a synthetic member, with no partial file available

Open People > Members, find that exact test member, and choose Edit in its Person section. Enter the missing first name and select Save changes. Return to the widget and choose Prepare export again. You should now have two ready rows, zero rows to review, and one future member excluded.

Correct the member record in campai, then refresh the export.

Correct the member record in campai, then refresh the export.

Native Edit person dialog with the missing first name entered and the synthetic membership number visible

The example blocks the whole file for missing required fields, reversed membership intervals, duplicate member numbers, or cells beginning like spreadsheet formulas. It does not quietly omit an invalid member or rewrite an identifier. Missing field permissions fail the scan; a returned null value is treated as missing source data.

Adjust a validation rule only when your receiving contract allows it. For example, campai permits an empty first name, but this sample file contract requires one. A federation that accepts single names may need a different rule.

Once the review count is zero, select Download CSV. The complete CSV also appears below the preview. If the browser blocks its download, use Copy CSV or select the text and copy it with your keyboard, then save it as a UTF-8 file with the displayed filename. A text area normalizes line endings to LF; choose CRLF in your text editor when the recipient requires the download’s exact line endings. Compare its row count, header order, member numbers and dates with the preview and source records. Check a member number with a leading zero and a name containing an embedded quotation mark. Quoted CSV fields preserve those characters; spreadsheet import settings can still change them.

Inspect the reporting date, exact columns, and excluded records before saving the file.

Inspect the reporting date, exact columns, and excluded records before saving the file.

Completed member export with two ready rows, no review issues, and one future membership excluded

Run the recipient's validator or test import before submitting actual member data. Downloading is local to your browser; the app does not upload the file to any federation. Keep it within the same access rules as the source member records.

Change the reporting date or birthdate option after a successful preview. Download and the copy fallback must be unavailable until another scan succeeds. With birthdates selected, an empty birthdate becomes a review issue; without it, the column and its values stay out of the widget result and CSV.

Move the reporting date forward one day and turn birthdates off. All three test members should now be included. If the next scan fails, the prior dated preview remains visible with a warning and no active download.

A failed scan preserves the earlier preview without offering it as a refreshed export.

A failed scan preserves the earlier preview without offering it as a refreshed export.

Failed refresh after read access was removed, preserving the prior dated preview and disabling Download CSV

The server reads pages of 100 and stops at 5,000 records. It rejects overlapping pages and failed requests instead of producing a partial file. Offset pagination is not a transactional snapshot, so prepare the export while records are stable. For a larger or audit-sensitive process, design a versioned export before increasing the limit.

Current entry and exit dates do not reconstruct deleted records or previous membership periods. Confirm that this selection matches the federation's reporting rule, especially for historical reports. Never present it as an audited historical roster.

Remove the synthetic-ID restriction only after reviewing the real mandate and intended audience. Update COLUMNS, the row projection, validation and CSV formatting for the recipient's actual contract. Add any sport or department codes through an explicit mapping; do not guess recipient codes from campai labels.

Keep fields to those required for the submission. This example has no automatic transfer or stored export history. Test access using an authorized administrator and a signed-out visitor before publishing, as described in Publish and share an app.

These files run in campai’s hosted app runtime. Copy each complete file into its destination or download all four together.

The complete federation member export

Copy the server, widget, and CSS into an Admin Widget group. Configure the mandate, club identifier, and synthetic test IDs. Adapt the CSV contract to the recipient’s specification.

Download ZIP
View and copy the complete source

The complete federation member export

// Hosted Widget server. Provider-neutral CSV; adapt the contract to the recipient's specification.
const MANDATE_ID = 'YOUR_MANDATE_ID';
const CLUB_NUMBER = 'YOUR_CLUB_NUMBER';
const TEST_CONTACT_IDS: string[] = [];
const PAGE_SIZE = 100;
const MAX_MEMBERS = 5000;
const DELIMITER = ';';
const COLUMNS = [
    { key: 'clubNumber', title: 'club_number' },
    { key: 'memberNumber', title: 'member_number' },
    { key: 'firstName', title: 'first_name' },
    { key: 'lastName', title: 'last_name' },
    { key: 'entryDate', title: 'entry_date' },
];
type Row = {
    clubNumber: string;
    memberNumber: string;
    firstName: string;
    lastName: string;
    entryDate: string;
    birthdate?: string;
};

function id(value: unknown) {
    const result =
        value && typeof value === 'object' && '$oid' in value ? String(value.$oid) : String(value);
    if (!/^[a-f0-9]{24}$/i.test(result))
        throw new Error('Configure valid full mandate and test contact IDs.');
    return result;
}
function day(value: unknown) {
    if (
        typeof value !== 'string' ||
        !/^\d{4}-\d{2}-\d{2}$/.test(value) ||
        !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
        new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value
    )
        throw new Error('Choose a real YYYY-MM-DD reporting date.');
    return value;
}
function storedDay(value: unknown) {
    if (value === null) return '';
    const iso = value instanceof Date ? value.toISOString() : value;
    if (typeof iso !== 'string' || !/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/.test(iso))
        throw new Error(
            'A required date field is unavailable or has an unexpected representation. Review field permissions.',
        );
    return day(iso.slice(0, 10));
}
function text(value: unknown) {
    if (typeof value !== 'string')
        throw new Error('A required text field is unavailable. Review member field permissions.');
    return value;
}
function unsafeCell(value: string) {
    return /^[\s]*[=+@-]|^[\t\r\n]/.test(value);
}
function csvCell(value: string) {
    return `"${value.replace(/"/g, '""')}"`;
}

export async function prepareExport(raw: unknown) {
    if (!raw || typeof raw !== 'object') throw new Error('Choose the reporting date and fields.');
    const input = raw as Record<string, unknown>;
    const asOf = day(input.asOf);
    if (typeof input.includeBirthdate !== 'boolean')
        throw new Error('Choose whether birthdates are required.');
    const includeBirthdate = input.includeBirthdate;
    if (!CLUB_NUMBER.trim() || CLUB_NUMBER.trim() === 'YOUR_CLUB_NUMBER' || unsafeCell(CLUB_NUMBER))
        throw new Error('Configure your federation club number before exporting.');
    const mandateId = id(MANDATE_ID);
    const fixtureIds = [...new Set(TEST_CONTACT_IDS.map(id))];
    if (fixtureIds.length > 1000) throw new Error('Use at most 1,000 test contact IDs.');
    const contacts: any[] = [];
    const seen = new Set<string>();
    const append = (contact: any) => {
        const contactId = id(contact?._id);
        if (fixtureIds.length && !fixtureIds.includes(contactId))
            throw new Error('A returned member is outside the test selection. No export was returned.');
        if (seen.has(contactId))
            throw new Error('The list changed while scanning. No partial export was prepared.');
        if (!Array.isArray(contact.types) || !contact.types.includes('member'))
            throw new Error('A returned contact is not a member.');
        seen.add(contactId);
        contacts.push(contact);
        if (contacts.length > MAX_MEMBERS)
            throw new Error('Split this export before scanning more than 5,000 records.');
    };
    let complete = false;
    for (let offset = 0; offset <= MAX_MEMBERS; offset += PAGE_SIZE) {
        const page = await api.crm.contacts.listContacts({
            mandateId,
            types: ['member'],
            sort: { _id: 'asc' },
            offset,
            limit: PAGE_SIZE,
            ...(fixtureIds.length ? { selection: { ids: fixtureIds } } : {}),
        });
        if (!Array.isArray(page.contacts) || page.contacts.length > PAGE_SIZE)
            throw new Error('Unexpected member-list response.');
        page.contacts.forEach(append);
        if (page.contacts.length < PAGE_SIZE) {
            complete = true;
            break;
        }
    }
    if (!complete) throw new Error('The scan did not finish. No partial export was prepared.');
    if (fixtureIds.length && seen.size !== fixtureIds.length)
        throw new Error('Some test contacts were not returned. Check the IDs, mandate, and member type.');
    const rows: Row[] = [];
    const issues: { memberNumber: string; name: string; problems: string[] }[] = [];
    let outsideDate = 0,
        institutions = 0;
    for (const contact of contacts) {
        if (!contact.member || !Object.prototype.hasOwnProperty.call(contact, 'person'))
            throw new Error('Membership or person fields are unavailable.');
        const entryDate = storedDay(contact.member.entryAt);
        const exitDate = storedDay(contact.member.exitAt);
        // Missing starts and reversed intervals cannot establish whether someone belongs in this export.
        const intervalProblem = !entryDate
            ? 'Membership start is missing'
            : exitDate && exitDate < entryDate
              ? 'Membership exit precedes entry'
              : '';
        if (!intervalProblem && (entryDate > asOf || (exitDate && exitDate < asOf))) {
            outsideDate++;
            continue;
        }
        if (contact.person === null) {
            institutions++;
            continue;
        }
        const row: Row = {
            clubNumber: CLUB_NUMBER,
            memberNumber: text(contact.contactNumbers?.member),
            firstName: text(contact.person.firstName),
            lastName: text(contact.person.lastName),
            entryDate,
        };
        if (includeBirthdate) row.birthdate = storedDay(contact.person.birthdate);
        const problems = intervalProblem ? [intervalProblem] : [];
        for (const [field, value] of Object.entries(row)) {
            if (!value.trim()) problems.push(`${field} is empty`);
            if (unsafeCell(value)) problems.push(`${field} starts like a spreadsheet formula`);
            if (/[\r\n\t]/.test(value)) problems.push(`${field} contains a line break or tab`);
        }
        if (includeBirthdate && row.birthdate && row.birthdate > asOf)
            problems.push('Birthdate is after the reporting date');
        if (problems.length)
            issues.push({
                memberNumber: row.memberNumber,
                name: [row.firstName, row.lastName].filter(Boolean).join(' '),
                problems,
            });
        else rows.push(row);
    }
    const counts = new Map<string, number>();
    for (const row of rows) counts.set(row.memberNumber, (counts.get(row.memberNumber) || 0) + 1);
    for (const row of rows)
        if (counts.get(row.memberNumber)! > 1)
            issues.push({
                memberNumber: row.memberNumber,
                name: `${row.firstName} ${row.lastName}`,
                problems: ['Member number appears more than once'],
            });
    const ready = rows.filter((row) => counts.get(row.memberNumber) === 1);
    const columns = includeBirthdate ? [...COLUMNS, { key: 'birthdate', title: 'birthdate' }] : COLUMNS;
    ready.sort((a, b) => a.memberNumber.localeCompare(b.memberNumber));
    // Refuse a partial file. The issue list and exclusions remain visible for review.
    const csv = issues.length
        ? null
        : [
              columns.map((column) => csvCell(column.title)).join(DELIMITER),
              ...ready.map((row) =>
                  columns.map((column) => csvCell(row[column.key as keyof Row] ?? '')).join(DELIMITER),
              ),
          ].join('\r\n') + '\r\n';
    return {
        asOf,
        includeBirthdate,
        clubNumber: CLUB_NUMBER,
        scanned: contacts.length,
        eligible: ready.length,
        outsideDate,
        institutions,
        issues,
        columns,
        rows: ready.slice(0, 20),
        csv,
        filename: `member-export-${asOf}.csv`,
        checkedAt: new Date().toISOString(),
        fixtureOnly: TEST_CONTACT_IDS.length > 0,
    };
}

Check your result

Leave a required first name empty
The review list identifies it and no partial CSV is available.
Correct that source record
A fresh scan offers the complete expected file.
Move the reporting date onto a future entry day
That member joins the selection on the inclusive entry day.
Change settings or remove read access
The previous preview remains dated; download and copying are disabled.
Continue with Publish and share an app