Skip to content
campaiDeveloper
campai.com

A member submits a service request on their phone and sees its progress and the team’s final response without re-entering their identity. 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 submits a service request on their phone and sees its progress and the team’s final response without re-entering their identity.

  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.

Review the category allowlist in member-server.ts and the matching options in member-widget.tsx. The supplied categories are membership confirmation, replacement card, and general question. Keep their values aligned and use categories your team can actually fulfill.

  1. In campai, open More → Developers → Apps and create Member service desk.
  2. Open Edit → Add group, select Widget, and name it My requests.
  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 Service 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 My requests → server index.ts
member-widget.tsx My requests → Widget → index.tsx
widget.css My requests → Widget → index.css
admin-server.ts Service desk → server index.ts
admin-widget.tsx Service desk → Widget → index.tsx
widget.css Service 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 Service 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. Open the request form.
  2. Choose Membership confirmation and enter what is needed, for example Please prepare a membership confirmation for collection.
  3. Select Send request and wait for the saved confirmation.
  4. The request appears as Awaiting review, with the verified acting user's name.
  5. Switch linked members. Requests belong to the selected contact; the account holder's name remains the acting user.
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 Service desk: Simulate, or the published administrator widget, and select Refresh. Find the submitted request, add a reply, and choose Start work. Refresh the phone to see In progress and the update.

Fulfill the request through your normal campai or office workflow. Enter a clear completion message, such as where the member can collect their confirmation, and select Resolve with reply. After refreshing, the phone shows Resolved and the final response. The desk's All history view retains finished requests.

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-service', '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
Start and resolve a member request The phone shows In progress, then Resolved with the coordinator’s reply.
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.

This desk stores requests and the latest team reply. It does not generate membership PDFs, issue replacement cards, attach documents, send push notifications, or email the member. A resolved status records the team's completion decision; carry out the actual service before selecting it.

The acting user comes from verified server context. A parent submitting for a linked child is recorded as the actor while the request belongs to the child’s contact. If several accounts can access the same contact, they share that contact’s request history. Add a separate account-private record model if your workflow needs a different boundary.

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 Publish and share an app.

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

The complete member service desk app

Submit a request, follow its progress, and read the administrative team’s response.

Download ZIP
View and copy the complete source

The complete member service desk app

// Hosted campai TypeScript. Install this entire file in the indicated group.
const KEY = ['community-service', '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);
    if (!['Membership confirmation', 'Replacement card', 'General question'].includes(String(request.category))) throw new Error('Choose a supported request type.');
    entry.category = String(request.category); entry.title = entry.category; entry.note = text(request.note, 'Request details', 500);
    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

Start and resolve a member request
The phone shows In progress, then Resolved with the coordinator’s reply.
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 Publish and share an app