# Portal File Sharing

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 141  
**Tier:** All tiers  
**Depends on:** `tenant-portals`, `customer-portal-access-control`, `customers-module`, `projects-module`, `foundation-auth-rbac`  
**Referenced by:** `customer-portal-settings-ui`, `customer-portal-access-control`, `customers-module`

---

## Overview

Spec 136 (`customer-portal-settings-ui`) adds a "Files" visibility toggle for the customer portal and a `portal_can_upload_files` permission toggle. Spec 41 (`unified-attachments`) handles internal attachments on tasks/expenses/tickets. Neither covers the staff-to-client file sharing use case: uploading deliverables, contracts, or documents that clients can download through the portal. This spec defines that flow.

---

## Data Model

```sql
CREATE TABLE portal_files (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
  uploaded_by UUID REFERENCES users(id),                 -- null for client uploads
  uploaded_by_portal_user UUID REFERENCES customer_portal_users(id),  -- null for staff uploads
  -- exactly one of uploaded_by / uploaded_by_portal_user must be non-null (enforced app-layer)
  filename TEXT NOT NULL,
  r2_key TEXT NOT NULL UNIQUE,         -- {tenantId}/portal/{customerId}/{uuid}-{filename}
  file_size_bytes INTEGER NOT NULL,
  mime_type TEXT NOT NULL,
  description TEXT,
  visible_to_portal BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT now(),
  expires_at TIMESTAMPTZ              -- null = no expiry; non-null = auto-hide after date
);
CREATE INDEX idx_portal_files_customer ON portal_files(tenant_id, customer_id, created_at DESC);
```

---

## Staff Upload UI

### From Customer Detail

Customer detail (spec 9, `customers-module`) → **[Files]** tab:

```
┌──────────────────────────────────────────────────────────────┐
│  Acme Corp — Files                           [Upload file]   │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  Q1 Report.pdf         2.4 MB   Project: Website      │   │
│  │  Uploaded 2026-06-01 by Yossi    Visible to client ✓  │   │
│  │  [Download] [Edit] [Remove]                           │   │
│  └──────────────────────────────────────────────────────┘   │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  Final_Design_v3.fig    18 MB   No project            │   │
│  │  Uploaded 2026-05-28 by Alex     Visible to client ✓  │   │
│  │  [Download] [Edit] [Remove]                           │   │
│  └──────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘
```

**[Upload file]** → sheet:

```
┌──────────────────────────────────────────────────────────────┐
│  Upload file for Acme Corp                                   │
│                                                              │
│  File        [Drop here or browse_________________]          │
│              Max 100 MB · All file types                     │
│                                                              │
│  Description [Quarterly deliverables report___________]      │
│                                                              │
│  Link to project                                             │
│  [No project ▾]  Website Redesign / Mobile App               │
│                                                              │
│  Visibility  ● Visible to client                             │
│              ○ Internal only (staff can see, client cannot)  │
│                                                              │
│  Expires     ○ Never  ● On date: [2026-12-31__]              │
│                                                              │
│  [Cancel]    [Upload]                                        │
└──────────────────────────────────────────────────────────────┘
```

Upload via `POST /api/portal-files/upload` — returns a signed R2 PUT URL (same pattern as unified-attachments spec 41). Client-side PUT to R2, then `POST /api/portal-files` to create the record.

### From Project Detail

Project detail → **[Files]** tab: same UI scoped to that project's customer + project.

---

## Client Portal View

In `/portal/{tenantSlug}/files` (visible when `portal_visibility.show_files = true`):

```
┌──────────────────────────────────────────────────────────────┐
│  Files                                                       │
│                                                              │
│  Shared by Acme Agency                                       │
│                                                              │
│  Q1 Report.pdf              2.4 MB   Jun 1, 2026             │
│  Website Redesign project                                    │
│  [↓ Download]                                                │
│                                                              │
│  Final_Design_v3.fig        18 MB    May 28, 2026            │
│  [↓ Download]                                                │
│                                                              │
│  ─────────────────────────────────────────────────────────  │
│  Upload files                                                │
│  (visible only if portal_can_upload_files = true)            │
│                                                              │
│  [↑ Upload file]                                             │
└──────────────────────────────────────────────────────────────┘
```

Portal shows only `visible_to_portal = true` and `expires_at IS NULL OR expires_at > now()` files for the authenticated customer.

### Client Upload (if enabled)

When `portal_can_upload_files = true`, clients can upload files back to the tenant. Client uploads use the same `portal_files` table with `uploaded_by = NULL` and `uploaded_by_portal_user = portal_user.id` (from `customer_portal_users`). Staff see these in the customer's Files tab with an "Uploaded by client" badge.

---

## Download URL

`GET /api/portal-files/:id/download` → generates a signed R2 URL (300s TTL). Returns `{ url }`. Client-side redirect to signed URL.

Both staff and portal users hit this endpoint (different auth middleware checks ownership):
- Staff: tenant must match
- Portal: `customer_id` must match JWT's `customerId`

---

## API

```
GET /api/customers/:customerId/portal-files
    → list files for customer (staff view, includes internal files)
      query: { project_id?, page? }
      Requires: customers:read

POST /api/portal-files/upload-url
     → get signed R2 PUT URL for upload
       body: { filename, mime_type, file_size_bytes, customer_id }
       Returns: { upload_url, r2_key }
       Requires: customers:write

POST /api/portal-files
     → create record after successful R2 upload
       body: { customer_id, project_id?, r2_key, filename, mime_type,
               file_size_bytes, description?, visible_to_portal, expires_at? }
       Requires: customers:write

PATCH /api/portal-files/:id
      → update metadata (description, visible_to_portal, expires_at)
        Requires: customers:write

DELETE /api/portal-files/:id
       → delete file + R2 object
         Requires: customers:write

GET /api/portal-files/:id/download
    → generate signed download URL
      Returns: { url, filename }
      Requires: authenticated (staff or portal)

-- Portal-side endpoints (portal JWT required):
GET /api/portal/files
    → list visible files for authenticated customer
      Requires: portal:authenticated

POST /api/portal/files/upload-url
     → client uploads file (only if portal_can_upload_files = true)
       Requires: portal:authenticated
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Separate `portal_files` table | Not extend `attachments` | `attachments` (spec 41) is for entity-scoped internal items (task messages, expenses); portal files are customer-scoped cross-entity deliverables — different lifecycle and visibility model |
| Signed download URLs | Not public R2 URLs | Files may be sensitive deliverables; all downloads go through auth check + short-lived signed URL |
| `visible_to_portal` flag | Not separate internal table | Same record covers both staff-only and client-visible files; toggling visibility without re-uploading is a common use case |
| 100 MB max | Not smaller | Design deliverables, video clips, and source files routinely exceed 25 MB; 100 MB is practical for most deliverables |
