Docs / Admin

Managing project intake submissions

Summary — The public portal collects investment opportunity submissions and investor registrations without requiring login. Staff review submissions in the workbench, move them through a status workflow, and convert accepted ones into pipeline opportunities.

Purpose — Understand where submissions come from, the review statuses and transitions, how to convert a submission into an opportunity (with organization/contact linking), and how public tracking works.

Audience — Staff, reviewer, lead, and admin roles who triage incoming project submissions and investor registrations.

Prerequisites — None. This is the entry point before an organization or opportunity exists in the system.


Overview

A submission is a public-facing intake record created when someone fills out the /submit wizard. A related but separate flow, /register, lets an organization register directly (no submission record — it writes straight to Organizations/Contacts). /track lets the original submitter check status later using a tracking code.

Key concepts:

  • Submission — One record per public intake, holding submitter details and proposal details. Lives in the submissions table, module intake.
  • Tracking code — Format 1PVN-YYMM-XXXXXX (year, month, 6-char random uppercase alphanumeric), generated server-side on create. Shown to the submitter once, used at /track.
  • Status — One of draft, submitted, intake_review, accepted, returned, withdrawn. draft exists in the type system but is not produced by the current /submit flow (all public submissions start at submitted).
  • Convert to Opportunity — A staff action on an accepted (or in-review) submission that creates a pipeline opportunity, optionally links/creates an organization, and optionally adds the submitter as a deduped contact.
  • Registration — A separate public form (/register) for organizations to self-register; finds-or-creates the organization by name (case-insensitive) and inserts a primary contact.

Screenshot: Project Intake list (/submissions) showing tracking code, title, submitter, sector, status badge, and date columns with status/sector filters.


The public submission flow (/submit)

Path: Public site → /submit (no auth) → SubmitWizard component, a 3-step wizard.

Step 1 — Organization & Contact

  • Organization Name * (submitterOrg)
  • Contact Person * (submitterName)
  • Email * (submitterEmail)
  • Phone (submitterPhone, optional)

Step 2 — Submission Details

  • Submission Title * (title, max 300 chars)
  • Description * (description, max 5000 chars)
  • Sector (optional, select) — agriculture, energy, infrastructure, manufacturing, tourism, ict, health, education, finance, other
  • Investment Type (optional, select) — fdi (Foreign Direct Investment), joint_venture, technical_assistance, trade, other
  • Target LocationGeoSelect cascading picker (region minimum level), stored as an entity_locations row of type submission once the submission is created

Step 3 — Review & Submit

Read-only summary of steps 1–2, then a Submit button. On success, the page swaps to a confirmation card titled "Submission Received" showing the tracking code and the note "Use this code to check the status of your submission at any time." The submit step also shows a Privacy Policy / Terms of Use notice below the button.

Validation is server-side via createSubmissionSchema (Zod): submitterName 1–200 chars, submitterEmail valid email, title 1–300 chars, description up to 5000 chars, estimatedValueUsd positive number if present. There is no estimatedValueUsd or cluster field on the public wizard UI itself, even though the schema and database support them — those are filled in later during conversion or by staff editing the opportunity directly.

Rate limit: 5 submissions per hour per submitter email. Exceeding it throws "Too many submissions. Please try again later."

Result: A submission row created at status submitted, organizationId: null (the free-text submitterOrg is not auto-linked to an organizations row at creation time — that linking happens later, at conversion).


The registration flow (/register)

Path: Public site → /register (no auth) → RegisterForm component.

This is a different, simpler path than /submit: it does not create a submissions row. It directly finds-or-creates an organizations record (case-insensitive name match, is_archived = false) and inserts a contacts row for the named contact (ON CONFLICT DO NOTHING — no dedupe error surfaced, existing contact is left alone). Required fields: Organization Name, Contact Name, Email — missing any throws "Required fields missing".

Rate limit: 3 registrations per hour per email.

Use /register when a partner wants to appear in Stakeholders directly (e.g., register as an investor); use /submit when someone has a specific opportunity or inquiry to route through review.


Tracking a submission (/track)

Path: Public site → /track (no auth) → TrackForm component.

Submitter enters their tracking code (auto-uppercased, e.g. 1PVN-2607-ABC123). Rate limited to 20 lookups per minute per code. Displays: title, status badge, tracking code, submitted date, submitter name, and organization if present. Status labels shown to the public:

Status valuePublic label
draftDraft
submittedSubmitted
intake_reviewUnder Review
acceptedAccepted
returnedReturned
withdrawnWithdrawn

If the code is not found (or rate-limited), the page shows "Submission not found" with "Please check your tracking code and try again."

Gap: the tracking lookup query does not exclude archived submissions, so an archived submission is still trackable publicly by code.


Reviewing submissions in the workbench

Path: Dashboard → Project Intake (/submissions)

The list shows tracking code, title, submitter (+ org in parens), sector, status badge, and date, with status and sector multi-select filters. Click a row's tracking code to open the detail page at /submissions/{id}.

Submission detail page

The detail page (/submissions/[id]) shows:

  • Entity header — title, tracking code subtitle, status badge.
  • Linked entities — badges for the linked Organization (links to /stakeholders/{id}) and any Pipeline opportunities created from this submission (links to /pipeline/{id}, shown with stage).
  • Submitter card — name, email, phone, organization (linked if resolved).
  • Proposal card — description, sector, investment type, cluster, estimated value, target regions.
  • Convert to Opportunity panel (see below) — hidden once status is returned, withdrawn, or draft.
  • Review History — pulled from the immutable audit log (listAuditByEntity("submissions", id)), one entry per status change with actor email, timestamp, and any review notes.
  • Review card — a notes textarea plus status-transition buttons, shown only if the current status has any allowed transitions.

Status transitions

Current statusAvailable actions
submittedMark Under Review → intake_review, Accept → accepted, Return to Submitter → returned, Withdraw → withdrawn
intake_reviewAccept → accepted, Return to Submitter → returned, Withdraw → withdrawn
acceptedReopen for Review → intake_review, Withdraw → withdrawn
returnedReopen for Review → intake_review, Withdraw → withdrawn
withdrawnReopen for Review → intake_review

Every transition is a call to reviewSubmissionAction, which requires the reviewer role or above, validates via reviewSubmissionSchema, updates reviewed_by/reviewed_at/review_notes, and logs an audit event submission.{status} (e.g., submission.accepted).


Converting a submission to an opportunity

Path: Submission detail page → Convert to Opportunity panel → click Convert

Requires staff role or above (convertSubmissionAction). The panel is prefilled from the submission and lets you edit before creating:

  1. OrganizationOrgPicker to search an existing org or quick-create one. The help text reads: "Search the list to reuse an existing org; use the + to create one only if it doesn't exist."
  2. Add submitter as contact (checkbox, checked by default) — first name, last name, email, phone. Deduped by email: if a contact with that email already exists, the submitter is not duplicated — the toast reports "(linked existing contact)" instead of "(contact added)".
  3. Opportunity fields — Title (required), Description, Sector, Cluster (cluster_1/cluster_2/cluster_3), Investment Type, Estimated Value (USD).

Clicking Create Opportunity runs three steps server-side:

  1. Optionally creates/reuses the contact under the chosen organization.
  2. Creates the opportunity with source: "submission" and submissionId set to this submission (so it shows up under "Pipeline" on the submission detail page from then on).
  3. Marks the submission accepted and links organizationId.

Client-side guard: clicking Convert without a title shows "Opportunity title is required"; checking "Add submitter as contact" without an organization selected shows "Pick or create an organization to add the contact to".

Result toast: "Opportunity created" (plus contact suffix as above), then redirect to /pipeline/{opportunityId}.


Sharing intake links

Path: /submissionsShare Intake button (top right)

Opens a dialog with:

  • Share cards (link + presumably QR, via IntakeShareCard) for the /submit and /register public URLs.
  • An invite-by-email form: Email * , Recipient Name (optional), Custom Message (optional) → Send Invite button.

sendIntakeInviteAction requires staff role, sends a branded HTML email (subject: "1pvn Investment Facilitation Program — Submit an Opportunity or Register") with buttons linking to both /submit and /register for the current tenant, and logs an audit event send_intake_invite. On failure it returns { success: false, error } and the dialog shows a toast with that message or the fallback "Failed to send invite".


Attachments

The intake module exposes uploadSubmissionAttachmentAction, listSubmissionAttachmentsAction, deleteSubmissionAttachmentAction, and getAttachmentDownloadUrlAction (all require staff role, store files via the platform storage layer, signed download URLs valid 1 hour).

Gap: no page or component in the current UI calls these attachment actions. The capability exists in the module but is not wired to the submission detail page — a dedicated guide/UI is planned.


Roles and permissions

RoleView submissionsReview / change statusConvert to opportunityArchiveSend intake invite
staff
reviewer
lead
admin
public✗ (only via tracking code)

Archive requires lead or admin (archiveSubmissionAction calls requireRole("lead", ...)), though — as noted above — no UI button currently triggers it.


Best practices

  1. Search before creating an organization during conversion. The OrgPicker help text exists because duplicate organizations fragment the CRM and matching data.
  2. Leave "Add submitter as contact" checked unless the person is not a real point of contact. Email-based dedupe means it is safe to leave on even if the contact might already exist.
  3. Use Return, not Withdraw, when more information is needed from the submitter. returned still allows reopening; use withdrawn only when the submission is truly closed.
  4. Add review notes on every transition. Notes persist to the audit-backed Review History and help downstream staff understand why a submission was accepted, returned, or withdrawn.
  5. Convert promptly once accepted. Submissions with status accepted still need an explicit Convert step to appear in the pipeline — accepting alone does not create an opportunity.

Warnings

draft status exists in the type system but is unreachable from the public flow. The /submit wizard always creates submissions at submitted. Do not expect to see draft submissions from public traffic.

Rate limits are per email/code, not per IP. 5 submissions/hour/email on /submit, 3 registrations/hour/email on /register, 20 lookups/minute/tracking-code on /track.

Tracking lookups are not filtered by archive status. An archived submission can still be found and displayed via /track if the submitter still has the code.

The Convert panel disappears for returned, withdrawn, and draft statuses. Reopen the submission to intake_review first if you need to convert it after returning or withdrawing.


Troubleshooting

"Too many submissions. Please try again later."

Symptom: The public /submit form throws this error on the final step.

Cause: More than 5 submissions from the same email within the past hour (rate limit).

Fix: Wait for the hourly window to reset, or have the submitter use a different email if this is expected legitimate volume (e.g., testing).


"Too many registrations. Please try again later."

Symptom: /register throws this error.

Cause: More than 3 registrations from the same email within the past hour.

Fix: Same as above — wait for the window, or use a different email.


"Required fields missing"

Symptom: /register throws this error and does not create anything.

Cause: Organization Name, Contact Name, or Email was blank.

Fix: Fill in all three required fields before submitting.


"Could not register organization. Please try again."

Symptom: /register fails after the organization name lookup finds no existing match.

Cause: The INSERT INTO organizations failed to return a row (database-level issue).

Fix: Retry. If it persists, check database connectivity/tenant context; this is not a validation error.


"Submission not found" on /track

Symptom: Track page shows this message.

Cause: Tracking code does not exist, was mistyped, or the 20/minute rate limit on that code was hit.

Fix: Double-check the code format (1PVN-YYMM-XXXXXX). If rate-limited, wait a minute and retry.


"Opportunity title is required" / "Pick or create an organization to add the contact to"

Symptom: Clicking Create Opportunity in the Convert panel does nothing but shows a toast.

Cause: Client-side validation — title field empty, or "Add submitter as contact" is checked with no organization selected.

Fix: Fill the Title field; either select/create an organization or uncheck "Add submitter as contact".


FAQ

Does accepting a submission automatically create a pipeline opportunity? No. Accept only changes status. Use the Convert to Opportunity panel to explicitly create the opportunity — this is a separate, deliberate staff action.

What happens to the free-text submitterOrg field — does it become an Organization record? Not automatically. It is stored as plain text on the submission. During conversion, staff use the OrgPicker to link an existing organization or create a new one; the submission's organizationId is only set at that point.

Can a submission be re-converted or converted twice? The UI does not prevent re-opening Convert after a first conversion, but doing so would create a second linked opportunity. There is no built-in duplicate-conversion guard — use judgment and check the "Pipeline" badges on the detail page first.

Is there a difference between /submit and /register for the public user? Yes. /submit is for a specific investment opportunity or inquiry that goes through staff review before anything is created in Stakeholders/Pipeline. /register immediately creates (or reuses) an Organization and Contact with no review step.

Can submissions be deleted? No. Only archiveSubmissionAction (lead/admin) exists, which is a soft delete (is_archived = true). There is currently no UI button wired to trigger it.


Related articles