Skip to content
campaiDeveloper
campai.com

A member requests club equipment, sees collection instructions, and follows the loan through collection and return. The complete sample includes a mobile member screen and a separate coordinator screen, with all records saved in the same app database.

This is a Developer App hosted inside campai, opened inside the Community App. It uses the signed-in user and their eligible linked contacts. It does not require an external server, API key, package installation, or phone-address-book access.

What you’ll have at the end

A member requests club equipment, sees collection instructions, and follows the loan through collection and return.

  1. Signed-in phone account
  2. Verified eligible contact
  3. Member server checks identity
  4. App database
  5. Coordinator review
  6. Personal result on the phone
Before you start
  • Permission to manage Developer Apps
  • A Community App account with two synthetic linked members
  • A dedicated test tag and Database access enabled
On this page

You need permission to manage Developer Apps, a Community App account, and two synthetic member contacts in the same organization. Give those contacts a dedicated test tag. Link them to your test account through the contact's Community App invitation link or QR code. Treat invitation links as private: they grant access to the contact. You can copy the link without sending an invitation email.

Download the complete source. Review the built-in app objects and client access before editing. Your runtime must expose verified server context.caller; the sample fails closed when it is unavailable.

Edit ITEMS in both server files together: stable item ID, name, description, and quantity. The initial catalogue contains training rackets, team jerseys, and practice kits. Keep item IDs stable after collecting requests. Each request reserves one unit when approved.

  1. In campai, open More → Developers → Apps and create Equipment borrowing.
  2. Open Edit → Add group, select Widget, and name it Borrow equipment.
  3. Set Scope to Contact, enable Members, and require your dedicated test tag. Leave the audience restricted while testing.
  4. Add a second Widget group named Equipment desk. Set Scope to Admin and require Members → Read.
  5. Open Settings and enable Database access. Leave Server permissions empty, Outbound hosts unset, and Secrets empty.

The Admin requirement checks the visitor's existing permission; it does not grant CRM access to the server. Everyone admitted to the coordinator group can see and review this app's records. Use a narrower existing administrative permission if your organization needs a smaller coordinator audience.

Replace each destination file completely. Wait until the editor shows unsaved changes, save, and wait for the save to finish before switching files:

Downloaded file Destination
member-server.ts Borrow equipment → server index.ts
member-widget.tsx Borrow equipment → Widget → index.tsx
widget.css Borrow equipment → Widget → index.css
admin-server.ts Equipment desk → server index.ts
admin-widget.tsx Equipment desk → Widget → index.tsx
widget.css Equipment desk → Widget → index.css

Keep the two server projects separate. The member server has no review, configuration, or export actions. Installing administrative exports in the Contact group would bypass that separation. Both groups belong to one app so they can share its database.

The .ts files use campai's hosted TypeScript runtime and built-in storage. The .tsx files use the Widget's React runtime and call server exports through action(name, input). CSS stays in the Widget project. The downloadable README repeats the installation map.

Select the coordinator Widget's index.tsx and run Equipment desk: Simulate. Confirm that it loads an empty queue. Save every file, return to Overview, and publish the app with the test audience still restricted.

Copy the Contact group's Community App link from the app overview and open it on the phone. Review the app's consent screen. Once opened, the greeting uses the verified account name. With one eligible contact the screen loads that contact; with several it asks Who is this for? before loading personal data.

The contact picker comes from server context.caller.contacts. The server derives that list from authenticated account links and the group's contact restrictions. It is not the phone's address book or a list of every member. context.user in the browser can supply display context, but must not authorize a request. Every server action checks the selected contact ID against the current verified list.

  1. Choose an item from the catalogue.
  2. Select the collection date and planned return date. Add a note that helps the team prepare it.
  3. Select Send request and wait for the saved confirmation.
  4. Check that the request is Awaiting review. A pending request does not reserve stock yet.
  5. Switch to the other linked member. They can see aggregate availability, but not the first member's request or note.
Choose from the equipment catalogue. Availability is shared; request history belongs to the selected contact.

Choose from the equipment catalogue. Availability is shared; request history belongs to the selected contact.

Personal member view inside the Android Community App

Open Equipment desk: Simulate, or the published administrator widget, and select Refresh. Enter collection instructions, then choose Approve loan. The server checks available stock again before reserving one unit. If none remain, approval fails without overbooking.

Refresh the phone. The approved request now includes the collection instructions. At handover select Mark collected in the desk; the phone shows On loan. After the item is physically returned, select Mark returned. Refresh the phone again: the history shows Returned and stock becomes available for another approval.

Refresh the phone to read the saved result from the shared app database.

Refresh the phone to read the saved result from the shared app database.

Saved result and coordinator response inside the Android Community App

The app stores one bounded state record at ['community-equipment', 'v1'], with at most 100 contact profiles and 300 request entries. Profiles and requests use the immutable contact ID. Each request also records the acting user's ID and display name. A second account linked to the same contact sees the same contact history. Unrelated contacts do not.

Close and reopen the phone screen, select the same contact, and check the saved state. It lives in the app database, not widgetStorage or the phone's local storage. The coordinator's Keep a copy section provides the complete JSON export, including acting-user IDs. Keep that export within the administrative team. Agree on retention before using real records; this sample has no automatic deletion or archival process.

Try this Expected result
Approve, collect, and return a practice kit The member sees each state; availability drops on approval and recovers on return.
Switch between two linked contacts The chosen contact's records appear; the other contact's records and notes are absent.
Close and reopen the app Saved requests and profile settings return.
Remove the selected contact's required tag, then refresh Later server calls exclude that contact; stale data is cleared on a failed load.
Open with no eligible linked contacts Access is refused; no member data is returned.
Disable Database access A visible load failure replaces the data view.
Review from two old coordinator views The stale decision fails and requires a fresh queue.

Test the published link as a member as well as the administrator simulation. The group restriction is enforced by campai; the server repeats the selected-contact check for every operation.

A create request gets a secure reference before it is sent. The phone keeps that reference while the form remains open. If the response is lost, refresh and inspect the history first. If the entry is absent, resubmit the unchanged form. The same reference with the same actor, contact, and contents returns the existing result; changed contents are rejected.

Closing or reloading the screen loses an unsent form and its reference. Check history before creating a replacement request. The code never automatically retries a create. It uses crypto.getRandomValues for references because the isolated mobile WebView does not always expose crypto.randomUUID.

Each write uses an atomic database version check. Concurrent changes cannot silently replace each other. Coordinator decisions also carry the version shown in the queue. When a conflict or uncertain response appears, reload and inspect the current state before making another decision.

Collection and return values are calendar dates. Collection must be today or later using UTC, and the return date cannot precede collection. A reservation occupies one unit from approval until return; this sample does not calculate overlapping date ranges, recurring bookings, late fees, deposits, or payments.

Pending requests can be withdrawn. An approved loan must pass through the coordinator's collection and return steps. A decline needs a reason. If your club needs an approved-reservation cancellation, add that explicit transition before using it, including who may perform it and when stock is released. Keep physical handover checks in your team's process.

Keep the contact check in every new member action, even if the UI hides an option. Do not accept a client-supplied acting user or trust the selected name as identity. For larger workloads, design per-record storage, pagination, and retention before raising the sample's bounds.

When the checks pass, review the final audience and publish the current version. Remove the test audience or unpublish the test app when the verification is finished. Continue with Build a personal welcome checklist in the Community App.

Copy each file or download the ZIP. The displayed source and download are built from the same files.

The complete equipment borrowing app

Request an item, approve its reservation, and track collection and return.

Download ZIP
View and copy the complete source

The complete equipment borrowing app

// Hosted campai TypeScript. Install this entire file in the indicated group.
const KEY = ['community-equipment', 'v1'] as const;
const YEAR = 2026;
const TASKS: { id: string; title: string; detail: string }[] = [];
const ITEMS = [
    { id: 'racket', name: 'Training racket', description: 'A beginner-friendly racket with a protective cover.', quantity: 3 },
    { id: 'jersey', name: 'Training jersey', description: 'Ask the equipment team about sizing at collection.', quantity: 6 },
    { id: 'kit', name: 'Practice kit', description: 'Cones, markers and a carry bag for a small group.', quantity: 2 },
];
type Person = { userId: string; name: string };
type Profile = { contactId: string; name: string; targetMinutes: number; done: string[]; visit: string; updatedAt: string };
type Entry = { id: string; contactId: string; name: string; actor: Person; createdAt: string; status: string; title: string; note: string; date: string; minutes: number; itemId: string; returnDate: string; category: string; reply: string; reviewedBy: Person | null; updatedAt: string };
type State = { schema: 1; profiles: Profile[]; entries: Entry[] };
function text(value: unknown, label: string, max = 500, optional = false): string {
    if (typeof value !== 'string' || value.trim().length > max || (!optional && !value.trim())) throw new Error(`${label} is required (maximum ${max} characters).`);
    return value.trim();
}
function object(input: unknown): Record<string, unknown> {
    if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid request.');
    return input as Record<string, unknown>;
}
function day(value: unknown): string {
    const result = text(value, 'Date', 10);
    if (!/^\d{4}-\d{2}-\d{2}$/.test(result) || !Number.isFinite(Date.parse(result + 'T00:00:00Z')) || new Date(result + 'T00:00:00Z').toISOString().slice(0, 10) !== result) throw new Error('Enter a valid calendar date.');
    return result;
}
function caller() {
    if (typeof context === 'undefined' || !context.caller?.userId) throw new Error('Sign in to use this app. The runtime must support verified caller context.');
    return context.caller;
}
function actor(): Person { const user = caller(); return { userId: user.userId, name: user.name }; }
async function read() {
    const entry = await storage.get<State>(KEY);
    const state = entry.versionstamp === null ? { schema: 1 as const, profiles: [], entries: [] } : entry.value;
    if (!state || state.schema !== 1 || !Array.isArray(state.profiles) || !Array.isArray(state.entries) || state.profiles.length > 100 || state.entries.length > 300) throw new Error('Stored data needs review. No changes were made.');
    return { state, version: entry.versionstamp };
}
async function save(current: Awaited<ReturnType<typeof read>>) {
    if (current.state.profiles.length > 100 || current.state.entries.length > 300) throw new Error('This sample has reached its storage limit. Export and review the records before extending it.');
    const result = await storage.atomic().check({ key: KEY, versionstamp: current.version }).set(KEY, current.state).commit();
    if (!result.ok) throw new Error('Another action changed the data. Refresh and review before trying again.');
}
function profile(state: State, contact: { contactId: string; name: string }) {
    let found = state.profiles.find(row => row.contactId === contact.contactId);
    if (!found) { found = { ...contact, targetMinutes: 480, done: [], visit: 'Meet the welcome team at reception. Ask us to confirm your first visit before travelling.', updatedAt: new Date().toISOString() }; state.profiles.push(found); }
    found.name = contact.name;
    return found;
}
function requestId(value: unknown) { const id = text(value, 'Request reference', 36); if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error('Invalid request reference.'); return id; }
function newEntry(input: Record<string, unknown>, contact: { contactId: string; name: string }): Entry {
    const now = new Date().toISOString();
    return { id: requestId(input.id), contactId: contact.contactId, name: contact.name, actor: actor(), createdAt: now, updatedAt: now, status: 'pending', title: '', note: '', date: '', minutes: 0, itemId: '', returnDate: '', category: '', reply: '', reviewedBy: null };
}
// Contact scope only. This project deliberately has no administrative exports.
function contactFor(value: unknown) {
    const user = caller();
    const contact = user.contacts.find(row => row.contactId === value);
    if (!contact) throw new Error('This contact is not available to your account. Refresh your contacts.');
    return contact;
}
function available(state: State, itemId: string) {
    return (ITEMS.find(item => item.id === itemId)?.quantity ?? 0) - state.entries.filter(row => row.itemId === itemId && ['approved', 'collected'].includes(row.status)).length;
}
export async function loadMember(contactId?: string) {
    const user = caller();
    if (!user.contacts.length) throw new Error('No eligible linked contacts. Ask your organization to check your access.');
    const contact = contactFor(contactId ?? (user.contacts.length === 1 ? user.contacts[0].contactId : ''));
    const { state } = await read();
    const person = state.profiles.find(row => row.contactId === contact.contactId) ?? { ...contact, targetMinutes: 480, done: [], visit: 'Meet the welcome team at reception. Ask us to confirm your first visit before travelling.' };
    return { name: user.name, contacts: user.contacts.map(row => ({ contactId: row.contactId, name: row.name })), profile: person, year: YEAR,
        entries: state.entries.filter(row => row.contactId === contact.contactId).map(({ actor: by, reviewedBy, ...row }) => ({ ...row, submittedBy: by.name, reviewedBy: reviewedBy?.name ?? null })).reverse(),
        tasks: TASKS, items: ITEMS.map(item => ({ ...item, available: available(state, item.id) })) };
}
export async function getContacts() {
    const user = caller();
    if (!user.contacts.length) throw new Error('No eligible linked contacts. Ask your organization to check your access.');
    return { name: user.name, contacts: user.contacts.map(row => ({ contactId: row.contactId, name: row.name })) };
}
export async function submit(input: unknown) {
    const request = object(input), contact = contactFor(request.contactId), current = await read();
    const id = requestId(request.id);
    const entry = newEntry(request, contact);
    const item = ITEMS.find(row => row.id === request.itemId);
    if (!item) throw new Error('Choose an item from the catalogue.');
    entry.itemId = item.id; entry.title = item.name; entry.date = day(request.date); entry.returnDate = day(request.returnDate);
    if (entry.returnDate < entry.date) throw new Error('Choose a return date on or after collection.');
    entry.note = text(request.note ?? '', 'Note', 500, true);
    const previous = current.state.entries.find(row => row.id === id);
    if (previous) {
        if (previous.contactId !== contact.contactId || previous.actor.userId !== caller().userId) throw new Error('This reference is already in use.');
        if (['title', 'note', 'date', 'minutes', 'itemId', 'returnDate', 'category'].some(key => (previous as any)[key] !== (entry as any)[key])) throw new Error('This reference belongs to a different request. Refresh your history.');
        return { id: previous.id, saved: true };
    }
    if (entry.date < new Date().toISOString().slice(0, 10)) throw new Error('Choose today or a future collection date.');
    profile(current.state, contact);
    current.state.entries.push(entry);
    await save(current);
    return { id: entry.id, saved: true };
}
export async function cancel(input: unknown) {
    const request = object(input), contact = contactFor(request.contactId), current = await read();
    const entry = current.state.entries.find(row => row.id === request.id && row.contactId === contact.contactId);
    if (!entry || entry.status !== 'pending') throw new Error('Only a pending request can be withdrawn. Refresh to see its current status.');
    entry.status = 'withdrawn'; entry.updatedAt = new Date().toISOString();
    await save(current); return { saved: true };
}

Check your result

Approve, collect, and return a practice kit
The member sees each state; availability drops on approval and recovers on return.
Switch linked contacts
Personal records remain scoped to the selected contact.
Revoke the required tag
Later calls exclude that contact.
Close and reopen
Saved results return from the app database.
Continue with Build a personal welcome checklist in the Community App