One form submission that registers an entire team
15 March 2024
A senior manager needs to register their team for a training event. There are 22 people on the team. Each person needs an RSVP record with the right role, status, and location assignment. The manager also needs to nominate a trainer from the team — but only if the group has 18 or more people. Below 18, a trainer is assigned separately.
Building this as 22 individual registration forms would be miserable for the manager and error-prone for everyone. So I built it as a single form submission that creates all the records at once.
This was for a corporate training platform. Three training formats — group events, train-the-trainer sessions, and mixed programmes. The group booking flow was the most complex because it involved one person making decisions for many.
The team list comes from the auth store
When a senior manager logs in, their profile includes an owned_contacts array — every person in their team. The auth store filters this to only people assigned to the group track:
const teamMembers = computed(() => {
const contacts = []
user.value.ownedContacts.forEach((c) => {
if (c.track === 'group') {
contacts.push({
code: c.code,
firstName: c.firstName,
lastName: c.lastName,
role: c.role,
})
}
})
return contacts
})
The form page renders this as a numbered list of cards. The manager doesn’t add or remove people — the team composition comes from the backend. They’re just confirming who’s going.
Trainer nomination threshold
Groups of 18 or more need an internal trainer. The manager picks one from a dropdown populated from the team list, filtered by role:
const mustNominateTrainer = computed(() => {
return teamMembers.value.length >= 18
})
const availableTrainers = computed(() => {
const byRole = { teamLead: [], standard: [] }
teamMembers.value.forEach((c) =>
byRole[c.role].push({
value: c.code,
label: `${c.lastName}, ${c.firstName}`,
})
)
return byRole
})
Team leads are shown first in the dropdown. If there are no team leads available, the manager can select from standard team members — or check a box to request an external trainer instead.
One submission creates everything
The submit handler maps every team member into a guest record, determines their role and status based on the trainer nomination, and sends it all in a single API call:
const submitForm = async (data) => {
const booking = cloneDeep(form.value)
booking.rsvp.status = 'confirmed'
booking.rsvp.role = 'trainer'
booking.guests = teamMembers.value.map((member) => {
const rsvp = {
status: 'invited',
role: 'participant',
area: 'individual',
}
const contact = { ...member }
if (data.nominatedTrainer === member.code) {
rsvp.role = 'trainer'
rsvp.status = 'requested'
} else if (data.nominatedStandard === member.code) {
rsvp.role = 'trainer'
rsvp.status = 'requested'
contact.role = 'teamLead' // Promote for this event
}
return { rsvp, contact }
})
// External trainer fallback
if (data.externalTrainer) {
booking.guests.push({
contact: {
firstName: 'External Trainer',
email: 'training@company.com',
role: 'teamLead',
isExternal: true,
},
rsvp: {
status: 'requested',
role: 'trainer',
area: userLocation.value,
},
})
}
const result = await saveBooking(booking)
if (result) {
await refreshEvents()
navigateTo('/registration')
}
}
A few things to note about this.
The manager’s own record is the booking object. Their RSVP status is confirmed and their role is trainer (they’re running the event). The team members are nested as guests inside that booking. The API creates all records atomically — either everyone gets registered or nobody does.
Nominated trainers get status: 'requested' instead of 'invited'. This routes them through an approval step. Regular participants are 'invited' and land directly in the confirming flow when they log in.
The external trainer is a synthetic record. If the manager checks the “request external trainer” box, the form injects a placeholder guest with a generic email and an isExternal flag. The backend knows to handle this differently — it creates a request rather than a real guest record.
Access gating
Not everyone can access the group booking page. The route guard checks three things: the user is a senior manager, they’re on the group track, and they haven’t already confirmed a trainer booking:
const allowed =
userMeta.value.groupSlot.owner &&
!bookingsMeta.value.GroupTrainer.confirmed.length
if (!allowed) navigateTo('/registration')
And the group booking button on the registration hub only appears when both a pre-condition is met — the manager needs to have a confirmed train-the-trainer booking before they can book for their group:
if (
!bookingsMeta.value.TrainTheTrainer.confirmed.length &&
!bookingsMeta.value.GroupTrainer.confirmed.length
) {
showRestrictionDialog.value = true
return
}
showBookingDialog.value = true
If they haven’t completed their own training, a dialog redirects them to the train-the-trainer flow first. The group booking unlocks only after that prerequisite is done.
The whole thing is one form, one submit, one API call. The manager sees their team, picks a trainer if needed, and confirms. Twenty-two people get their invitations. No spreadsheet, no per-person data entry, no “I forgot to add someone” emails the next day.