Skip to content
campaiDeveloper
campai.com

A member records completed work on their phone, a coordinator approves it, and the member sees their own annual progress and reply. 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 records completed work on their phone, a coordinator approves it, and the member sees their own annual progress and reply.

  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.

Set YEAR in both server files to the agreed reporting year before you publish. The default annual target is 480 minutes. After the first submission, the coordinator can set an individual target under Individual annual targets. Keep minutes as whole numbers; 90 means one hour and thirty minutes.

  1. In campai, open More → Developers → Apps and create Volunteer Companion.
  2. Open Edit → Add group, select Widget, and name it My contributions.
  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 Volunteer review. 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 My contributions → server index.ts
member-widget.tsx My contributions → Widget → index.tsx
widget.css My contributions → Widget → index.css
admin-server.ts Volunteer review → server index.ts
admin-widget.tsx Volunteer review → Widget → index.tsx
widget.css Volunteer review → 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 Volunteer review: 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. Select Record completed work.
  2. Enter Prepare the clubhouse, today's date in the configured year, and 60 minutes.
  3. Add an optional note and select Send request.
  4. Wait for the saved message. The entry appears as Awaiting review; approved hours remain unchanged.
  5. Switch to the other linked member. Their totals and history must remain separate.
Choose an eligible linked member. The screen loads that contact's own data.

Choose an eligible linked member. The screen loads that contact's own data.

Personal member view inside the Android Community App

Open Volunteer review: Simulate, or the published administrator widget. Select Refresh to load the phone submission. Add a decision note and select Approve work. A decline requires a reason. Once reviewed, the entry cannot be withdrawn or reviewed again in this sample.

Refresh the member screen. It now shows 1h approved, 7h remaining against the default 8h target, and the coordinator's reply in the history. Setting a different target affects only that contact.

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-volunteer', '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 one 60-minute entry The chosen member has 1h approved; the other linked member is unchanged.
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.

Dates identify the day work took place. The server accepts real calendar dates in YEAR that are no later than today's UTC date. It accepts 1–720 whole minutes per entry. The creation timestamp is a separate ISO instant. This is a review workflow in the app database; it does not write campai's native working-hours records, bill members for missing hours, or change membership data.

Use a separate app for each reporting year. Changing YEAR alone does not archive the existing entries or reset individual targets.

The sample preserves reviewed entries. Before adding corrections, design an explicit adjustment record with its own actor and reason; do not silently overwrite confirmed work. For an administrator-only ledger with correction entries, continue with the annual volunteer-hours ledger.

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 equipment borrowing in the Community App.

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

The complete volunteer companion app

Record completed work, review it, and show each linked member their approved minutes and agreed annual target.

Download ZIP
View and copy the complete source

The complete volunteer companion app

// Hosted campai TypeScript. Install this entire file in the indicated group.
const KEY = ['community-volunteer', 'v1'] as const;
const YEAR = 2026;
const TASKS: { id: string; title: string; detail: string }[] = [];
const ITEMS: { id: string; name: string; description: string; quantity: number }[] = [];
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);
    entry.title = text(request.title, 'Activity', 100); entry.note = text(request.note ?? '', 'Note', 500, true); entry.date = day(request.date);
    if (Number(entry.date.slice(0, 4)) !== YEAR || entry.date > new Date().toISOString().slice(0, 10)) throw new Error('Record completed work in the configured year.');
    if (!Number.isInteger(request.minutes) || Number(request.minutes) < 1 || Number(request.minutes) > 720) throw new Error('Enter 1 to 720 whole minutes.');
    entry.minutes = Number(request.minutes);
    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 };
    }
    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 one 60-minute entry
The chosen member has 1h approved; the other linked member is unchanged.
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 equipment borrowing in the Community App