Forms Controller
π Overviewβ
Controllers/forms.js is the heart of the Forms module. It creates and updates form documents, manages per-account taxonomies (categories/tags), distributes fill requests to clients, and drives the dashboard widgets. The controller orchestrates multiple utilitiesβshort-link generation, email/SMS senders, campaign automation guards, and recursive aggregationsβto keep authored content, pending requests, and submissions in sync.
ποΈ Collections touchedβ
| Collection | Purpose |
|---|---|
forms | Stores the draft/published form document, layout JSON, status, and metadata. |
forms.sent.requests | Tracks every request link sent to a contact, including status transitions and resend history. |
forms.userresponse | Read in widget aggregations to compute submission counts. |
forms-user-categories / forms-user-tags | Per-account taxonomies surfaced in the UI filters. |
campaign.data | Linked campaigns that should be soft-deactivated when a form is deleted. |
crm.contacts | Resolves recipient details for email/SMS payloads. |
_users | Used indirectly when pending requests target internal reps rather than CRM contacts. |
π Middleware & validationβ
All routes in Routes/forms.js run through the shared authorization stack:
verifyAuthorization()β extracts auth context and JWT.verifyAccessAndStatus({ accountIDRequired: true })β blocks suspended or orphaned sub-accounts.verifyScope(['forms', ...])β fine-grained permission slices (create/update/delete/read buckets).validateLimitβ standard pagination limits, applied to list endpoints.validateRequestSchemaV2(validators)β Joi map invalidators/index.jsvalidates params, body, and query strings. Highlights:- Accepts either ObjectId or UUID for
:formid, enabling public share links to hit the same controller. - Bulk delete requires
totalto match server-side counts whenall=true; mismatches throwFORMS_COUNT_MISMATCH. POST /formssupports cloning via?isclone=true&form_id=<FormId>and enforces Instasites/Instareports payload shape whenform_typeis set.
- Accepts either ObjectId or UUID for
π£οΈ Route mapβ
| Method | Path (internal service) | Controller method | Notes |
|---|---|---|---|
POST | /forms | saveForm | Creates drafts or clones; requires forms.create scope. |
GET | /forms | getForms | Lists published forms owned by the account. |
GET | /forms/getclientforms | getClientForms | Lists published forms for child accounts (uses parent_account). |
PUT | /forms/:formid | updateForm | Upserts by form_secret_id; automatically publishes if status missing. |
GET | /forms/:formid | getFormById | Accepts ObjectId or form_secret_id; validates request invite status. |
DELETE | /forms/:formid | deleteForm | Single delete + campaign soft-delete. |
DELETE | /forms | deleteForms | Bulk delete with count safeguard. |
GET | /forms/formswidget | getformsWidgetData | Returns submissions vs pending request counts. |
POST | /forms/:formid/sendrequest | sendRequest | Issues invite links and optional notifications. |
POST | /forms/resendrequest | reSendRequest | Resends existing invites with custom contact info. |
POST | /forms/:formid/userresponse | userresponseController.saveResponse | Public submission entry point (included here for completeness). |
GET | /forms/usercategories | getUserCategories | Pagination + search across account categories. |
POST | /forms/usercategories | userCategories | Creates a category for the account. |
PUT | /forms/usercategories/:categoryid | updateUserCategories | Handles duplicate key error -> CATEGORY_ALREADY_EXIST. |
DELETE | /forms/usercategories/:categoryid | deleteUserCategories | Hard delete category. |
GET | /forms/usertags | getUserTags | Tag listing. |
POST | /forms/usertags | userTags | Tag creation. |
PUT | /forms/usertags/:tagid | updateUserTags | Tag rename. |
DELETE | /forms/usertags/:tagid | deleteUserTags | Tag delete. |
βοΈ Form lifecycleβ
saveFormβ
- Generates a new
form_secret_id(UUID v4) and stores owner/account IDs fromreq.auth. - When cloning (
?isclone=true&form_id=<id>), copies layout, styling, thank-you copy, and status from the source form, then resolves a unique name throughgetFormCopyName. The helper recursively appends "copy", "copy 1", etc., while skipping the current form ID when renaming clones of clones. - Returns
{ formdata, domain }, wheredomaincomes fromutilities.getActiveDomain({ req, proto: true })to help the UI render public links immediately.
getForms & getClientFormsβ
- Both delegate to
formsModel.getAllForms, which handles text search, date filtering, category/tag filters, sort mapping, aggregation, and pagination. - Account owner lists (
getForms) filter byaccount_idand default tostatus: 'published'. Client lists (getClientForms) useparent_accountto collect sub-account-ready forms. formsModel.getAllFormsaugments each document with response counts, pending request counts, andiscampaignConnectedby looking upcampaign.data.
updateFormβ
- Resolves by
form_secret_id+ account to avoid exposing Mongo IDs in public share links. - Enforces
status: 'published'if the payload omitted a status, ensuring the form becomes visible on first save from the builder. - Returns the updated document (lean,
_idremapped toid).
getFormByIdβ
- Accepts either the UUID secret or a raw ObjectId; uses regex to detect the format.
- When a
rid(recipient secret) is provided, verifies the invite is still active. If the request is alreadysubmittedorcancelled, the controller short-circuits with a 400 error and descriptive message (ALREADY_SUBMITTED,REQUEST_CANCELLED). - Returns the hydrated form array from
formsModel.getForms(which already injects campaign connection metadata).
Deletion (deleteForm, deleteForms)β
- Single delete blocks if the form does not belong to the current account.
- Bulk delete opens a Mongo session/transaction, recalculates the expected count server-side via
formsModel.getFormCount, and throwsFORMS_COUNT_MISMATCHif the client-suppliedtotalno longer matches. Whenall=true, it first enumerates matching form IDs before callingdeleteMany. - Both paths mark any linked
campaign.datarecords asis_deleted=trueto prevent future inbound automation from firing against a missing form.
Widgets (getformsWidgetData)β
- Aggregates published forms for the account to compute:
pendingrequests: count of invites still instatus: 'sent'.allsubmissions: count offorms.userresponseentries withis_deleted=false.
- Used in dashboard widgets to provide quick stats without running the heavier
getFormsaggregation.
π·οΈ Categories & tagsβ
userCategories / userTagsβ
- Attach
account_idandownerdirectly from the auth context before saving. - Insert operations bubble Mongo duplicate key errors; categories convert duplicates into a
customerror with codeCATEGORY_ALREADY_EXIST, tags leave the raw Mongo messageDUPLICATE_TAGSfor the client to translate.
getUserCategories / getUserTagsβ
- Support search via case-insensitive regex (
namefield). - Aggregation adds a
countproperty by$lookup-ing intoformsto show how many forms currently reference the category/tag. - Pagination uses
limit/skipsemantics consistent with other list endpoints.
updateUserCategories / updateUserTagsβ
- Run
findOneAndUpdatewith{ new: true }so the API returns the mutated document (with_idremapped toid).
deleteUserCategories / deleteUserTagsβ
- Hard deletes the taxonomy entry. The controller does not cascade to forms; existing forms retain the stale ID. The UI should surface these as "(deleted)" until updated.
βοΈ Request distributionβ
sendRequestβ
- Ensures
sendrequestpayload exists; otherwise returns a 400 withinfo is required!. - Loads the form by
form_secret_id(must be published). - Generates a unique
recipient_secret_idfor each contact, persists aforms.sent.requestsdocument withstatus: 'sent', and composes the share URL:${activeDomain}/${share_path}?rid=<recipientSecret>&rqid=<requestId>- Passes the URL to the internal URL shortener (
POST http://localhost:${PORT}/v1/url) using the same JWT + account headers. - Replaces with
process.env.SHORT_DOMAIN/<code>for final delivery.
- Based on
notification_type, triggerssendMailand/orsendSMSto notify the recipient.
reSendRequestβ
- Accepts a request ID (
rqid), recipient secret (receipientSecretId), optional override email/phone, and share path. - Reconstructs the link, shortens it, and replays the chosen channels. Uses the already stored
recipient_idto look up contact details when overrides are missing.
Email helper (sendMail)β
- Loads the CRM contact for personalization. Falls back to deriving a name from the email prefix when missing.
- Sends through the shared
Mailutility with originformsso analytics/notifications stay grouped. - Supports overriding the recipient email via
custom_email(used during resend).
SMS helper (sendSMS)β
- Requires the account to own a Twilio number (
TwillioNumbercollection). ThrowsNO_NUMBER_PURCHASEDotherwise. - Falls back to the contact record when
custom_numberis empty. - Uses the shared
SMSutility and labels the originforms.
shortenUrlβ
- Internal helper that reuses the caller's JWT (
req.authToken) andX-Account-Idheaders. Any failure bubbles up so the request endpoint responds with a 500.
β οΈ Edge cases & safeguardsβ
- Invite replay β
getFormByIdprotects against multiple submissions from the same invite. - Clone naming β
getFormCopyNameprevents infinite loops by checkingcopysuffixes with numeric increments. - Bulk delete drift β Abort the transaction if the client-side
totalno longer matches the recalculated count to avoid surprise data loss. - Notification personalization β Email content greets recipients by first name when it can split the CRM contact's
namefield. - Domain resolution β All send/resend flows rely on
utilities.getActiveDomainso links stay accurate for sub-accounts or white-labeled domains.
π Related docsβ
- Forms module index β high-level architecture and dependencies.
- Templates controller β template CRUD and restricted scopes.
- User response controller β submission ingestion, analytics, and onboarding hooks.