Each linked member has their own welcome progress, first-visit instructions, and saved questions with replies from the welcome team. 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
Each linked member has their own welcome progress, first-visit instructions, and saved questions with replies from the welcome team.
- Signed-in phone account
- Verified eligible contact
- Member server checks identity
- App database
- Coordinator review
- 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 TASKS in both server files. Replace the step titles and instructions with your actual welcome process. Keep task IDs stable after the first completion. The supplied steps cover welcome information, contact details, the first visit, and getting ready to join in.
- In campai, open More → Developers → Apps and create
Personal welcome checklist. - Open Edit → Add group, select Widget, and name it
My welcome. - Set Scope to Contact, enable Members, and require your dedicated test tag. Leave the audience restricted while testing.
- Add a second Widget group named
Welcome team. Set Scope to Admin and require Members → Read. - 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 welcome → server index.ts |
member-widget.tsx |
My welcome → Widget → index.tsx |
widget.css |
My welcome → Widget → index.css |
admin-server.ts |
Welcome team → server index.ts |
admin-widget.tsx |
Welcome team → Widget → index.tsx |
widget.css |
Welcome team → 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 Welcome team: 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.
- Read the first welcome step and complete the real activity it describes.
- Check its box. Wait until the progress count updates.
- Open the help form, enter a question such as
Where should I meet the welcome team?, and select Send request. - Check the saved question in the history.
- Switch linked members. The other member has their own checklist, visit instructions, and questions.
Open Welcome team: Simulate, or the published administrator widget, and select Refresh. A profile appears after that member completes a step or submits a question. Under First visit instructions, enter the confirmed meeting place and time for that contact and save it.
Find the question, enter a useful reply, and choose Save reply. Refresh the phone. The member sees the new first-visit instructions and their answered question. Close and reopen the app: the completed checklist steps remain checked.
The app stores one bounded state record at ['community-welcome', '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 |
|---|---|
| Complete one step, save visit instructions, and answer a question | Only that contact receives the progress, instructions, and reply after reopening. |
| 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.
Checkboxes record the member's own progress. They do not edit CRM details, confirm legal consent, schedule appointments, send invitations, or prove that an administrator checked a document. The team updates visit instructions only after arranging the visit through its usual process.
The example stores the most recent instruction text and completed task IDs. It does not keep an event-by-event audit of checkbox changes. If you need mandatory approvals or versioned policy acceptance, build a separate reviewed workflow with its own evidence and retention rules. For staff-owned onboarding, continue with the administrative onboarding board.
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 member service desk in the Community App.
Copy each file or download the ZIP. The displayed source and download are built from the same files.
The complete personal welcome checklist app
Keep a personal welcome checklist, first-visit instructions, and a reply from the welcome team for each linked member.
Download ZIPView and copy the complete source
The complete personal welcome checklist app
// Hosted campai TypeScript. Install this entire file in the indicated group.
const KEY = ['community-welcome', 'v1'] as const;
const YEAR = 2026;
const TASKS = [
{ id: 'welcome', title: 'Read your welcome information', detail: 'Your first visit starts at reception. Bring comfortable clothes and a water bottle.' },
{ id: 'details', title: 'Check your member details', detail: 'Open your contact profile in the Community App. Ask the office to correct anything that is out of date.' },
{ id: 'visit', title: 'Prepare for your first visit', detail: 'Read your first-visit instructions. Contact the welcome team if you need help getting there.' },
{ id: 'ready', title: 'Tell us you are ready', detail: 'Complete this step when you have everything you need for your first visit.' },
];
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 = 'Welcome team help'; entry.note = text(request.note, 'Your question', 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 };
}
export async function completeTask(input: unknown) {
const request = object(input), contact = contactFor(request.contactId), current = await read();
if (!TASKS.some(task => task.id === request.taskId) || typeof request.done !== 'boolean') throw new Error('Unknown welcome step.');
const person = profile(current.state, contact);
person.done = person.done.filter(task => task !== request.taskId);
if (request.done) person.done.push(String(request.taskId));
person.updatedAt = new Date().toISOString(); await save(current); return { saved: true };
}
Check your result
- Complete one step, save visit instructions, and answer a question
- Only that contact receives the progress, instructions, and reply after reopening.
- 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.