Client Portal module
The Client Portal module is a branded surface for tenants to collaborate with external clients — share deliverables, exchange messages, request file uploads, send report PDFs. Clients authenticate via email-magic-link, not full tenant logins, so inviting a client doesn't consume a seat.
Entities
| Entity | What it represents |
|---|---|
| Portal project | A workspace for one client engagement. Has name, status (active/completed/on_hold), optional link to an internal projects.id, optional client_id for a registered client user. |
| Portal milestone | Visible roadmap entry on the portal page. Has title, due_date, status. |
| Portal deliverable | A file delivered to the client. Has title, file URL, status (pending_review/approved/revision_requested/delivered), reviewer feedback. |
| Portal message | A message exchanged between the team and the client. Has sender_id (team) or sender_email (client guest), is_internal flag for team-only notes. |
| Portal report | An AI-generated status report scoped to the portal project. |
| Portal file request | A "please upload" request issued to the client; has expiry, status. |
| Portal notification | In-app notifications to the team when client activity arrives. |
Schema under portal with public.* shims.
Client experience
Clients land on client.<basedomain>/projects/[id] after clicking
their magic-link email. From there:
- Timeline — milestones + deliverables in chronological order.
- Messages — chat with the team. Internal-only notes from team members aren't visible.
- Files — deliverables to download, requested files to upload.
- Reports — AI-generated status updates pinned to the project.
No tenant nav, no module access — clients see only the portal.
Inbound client messages
When a client posts a message at POST /api/client/projects/[id]/messages:
- Row inserts into
portal_messageswithsender_id=null+sender_email=<client>+is_internal=false. - We resolve internal recipients via the linked
projects.project_id→project_members.user_id, falling back to tenant admins if no link. - Enqueue a
send-notificationworker job → notifications rows land for every team member. - Emit
portal.message.receivedso webhook subscribers fire.
All three are fire-and-forget — a notification failure doesn't
fail the message post; the message is durable in portal_messages.
Branding
Settings → Branding lets a tenant override the portal's logo +
primary color + custom domain. The portal subdomain
client.<basedomain> serves every tenant; custom domains require
DNS setup + are gated to the Enterprise tier.
Deliverable review
Operator uploads a deliverable → status pending_review. Client
opens the portal → can approve or request_revision with
feedback text. Approved deliverables flip to delivered and
appear in the client's "completed" list.
File requests
Operator clicks Request file → enters title + due date. Portal shows the client a one-time upload widget. Captured file lives in the tenant's storage bucket; the request's status flips on upload.
Cross-module insights
- Portal × Projects: the linked internal project's task list
- activity feed inform the portal's status view (without exposing internal task detail to the client).
- Portal × Finance: deliverable approval can auto-issue an invoice line item; this is opt-in per portal-project.
- Portal × CRM: every portal client is backed by a CRM contact; messaging captured as activities on the contact timeline.
Permissions
Portal access for team members: portal.projects.read|update,
portal.messages.send. Internal-only notes need the same role +
the team-member-of-this-project membership.
Client guest authentication: tenant-issued magic links signed with
PORTAL_SESSION_SECRET. Sessions are 7-day TTL.
Read next
- Portal API — team + client-guest endpoint split.
- CRM module — every portal client is a CRM contact under the hood.