# WebSocket protocol

One JSON object per text frame, always with a `type` field. Connect, then
send `hello` with a session token (from `POST /api/login.php`) before
anything else — there's no more free-form username claim.

## Client → Server

| type | fields | notes |
|---|---|---|
| `hello` | `token` | required first message. Server resolves the real user (id/username/role) from `sessions`. Replies `welcome` + `state.full`, then broadcasts `presence` |
| `sync` | — | request a fresh full snapshot (already filtered to what you can see) |
| `lead.create` | `name`, `company` | you become the owner |
| `lead.update_state` | `lead_id`, `state` | `New/Contacted/Qualified/Negotiation/Won/Lost`, but it's a free string |
| `lead.update_info` | `lead_id`, `name?`, `company?`, `industry?`, `phone?`, `email?`, `website?`, `address?` | the "Company info" popup. Only sent fields are updated |
| `lead.delete` | `lead_id` | |
| `lead.share` | `lead_id`, `username` | grants a salesrep access. Requires you already have access to the lead |
| `lead.unshare` | `lead_id`, `username` | revokes access |
| `node.create` | `lead_id`, `node_type`, `title`, `parent_id?`, `pos_x?`, `pos_y?`, `assigned_to?`, `client_ref?` | `node_type`: `begin/gather/boss/note/closed`. Omit `parent_id` for a root node (starts a new branch). Omit `pos_x`/`pos_y` and the server auto-places it. If the parent node is assigned to someone, the child **automatically inherits that assignment** unless `assigned_to` is explicitly given. `client_ref` (any string) is echoed back untouched on the resulting `node.created` broadcast, for correlating a create with its reply — used by node import to sequence a tree of creates |
| `node.move` | `node_id`, `pos_x`, `pos_y` | free-drag repositioning. Not audited — too noisy to log every drag |
| `node.set_parent` | `node_id`, `parent_id?` | rewires which node it branches from. Rejected with an error if it would create a cycle. Omit `parent_id` (or send `null`) to make it a root |
| `node.update` | `node_id`, `title?`, `note?`, `status?`, `milestone_at?` | only sent fields are updated. `status`: `todo`/`done`. `milestone_at`: `"YYYY-MM-DD HH:mm:ss"`, or explicit `null` to clear it |
| `node.change_type` | `node_id`, `node_type` | |
| `node.assign` | `node_id`, `assigned_to?` | who owns this task. Omit/`null` to unassign. Target must already have access to the lead (owner or collaborator) — rejected otherwise |
| `lead.assignable` | `lead_id` | who a node on this lead COULD be assigned to (its owner + collaborators + all global owners) |
| `node.delete` | `node_id` | |
| `attachment.add_link` | `node_id`, `name`, `url` | for images/audio/files use the HTTP upload endpoint instead |
| `attachment.remove` | `attachment_id` | |
| `attachment.import` | `node_id`, `attachment_type` (`image/audio/file/link`), `name`, `url`, `size_bytes?` | recreates a typed attachment against an already-existing URL, without uploading bytes — used by node import. Only meaningful if that URL is actually reachable (e.g. importing back into the same server an export came from) |
| `lead.archive` | `lead_id` | soft-hide from the normal view; still fully accessible via `lead.list_archived` |
| `lead.unarchive` | `lead_id` | restores it to the normal view |
| `lead.list_archived` | — | returns just this user's archived leads (owners: all archived leads) |
| `audit.get` | `lead_id?` | omit for the global feed (owners see everything; salesreps only entries for leads they can access) |
| `user.list` | — | **owner only** — every user account |
| `user.create` | `username`, `password`, `role` | **owner only** — creates an account, broadcasts a fresh `user.list` to all connected owners |

Every action is checked server-side against the caller's access before it
touches the database — a salesrep sending `node.update` for a node on a
lead they can't see gets an `error`, not silently-filtered data.

## TimeTracker RPC

The adjacent TimeTracker app (served at `/timetrack/`) uses the same
authenticated WebSocket and opaque Flowuri session established by `hello`.
It is not included in `state.full`.
Each call has a client-generated correlation ID:

```json
{"type":"tt.request","request_id":"b7f1","action":"time.current","payload":{}}
```

Success and failure replies are always correlated and sent only to the caller:

```json
{"type":"tt.response","request_id":"b7f1","ok":true,"data":null}
{"type":"tt.response","request_id":"b7f1","ok":false,"error":"Administrator only"}
```

Available actions:

- Identity/team: `auth.me`, `users.departments`, `users.list`, `users.get`,
  `users.create`, `users.update`, `users.toggle`, `users.delete`.
- Catalog: `customers.list`, `customers.get`, `customers.create`,
  `customers.update`, `customers.delete`, `projects.list`, `projects.get`,
  `projects.create`, `projects.update`, `projects.delete`.
- Time: `time.clock_in`, `time.clock_out`, `time.break_start`,
  `time.break_end`, `time.current`, `time.my_stats`, `time.entries`,
  `time.manual`, `time.update`, `time.delete`, `time.approve`, `time.reject`.
- Administration: `settings.get`, `settings.update`, `audit.list`.
- Reports: `reports.dashboard`, `reports.chart`, `reports.employee_summary`,
  `reports.daily`, `reports.weekly`, `reports.monthly`, `reports.overtime`,
  `reports.missing_clockouts`, `reports.manual_adjustments`,
  `reports.custom_period`, `reports.by_project`, `reports.by_customer`.

Payload fields mirror the former HTTP API's query/body fields. IDs are sent
as JSON strings for compatibility with the React types but are MySQL integer
keys. `users.delete` only deactivates the TimeTracker profile and never deletes
the shared Flowuri identity. Flowuri `owner` maps to `ADMIN`; `salesrep` maps
to `EMPLOYEE`.

CSV report and audit requests use `"format":"csv"` in the payload. Since this
is WebSocket JSON rather than HTTP, `data` is:

```json
{"csv":"header,row\n","filename":"weekly-2026-08-29.csv","contentType":"text/csv"}
```

Mutations may also push `{"type":"tt.event","event":"...","data":...}`.
`session.changed` is scoped to the affected user. `dashboard.changed` and
`approval.changed` go only to owners. `catalog.changed` and `settings.changed`
go to every authenticated connection. Clients should re-fetch the affected
resource; events are invalidation hints, not a complete replicated state.

## Billing RPC

The adjacent Billing app (served at `/billing/`) uses the same
authenticated WebSocket and opaque Flowuri session established by `hello`.
It is not included in `state.full`.
Each call has a client-generated correlation ID:

```json
{"type":"bill.request","request_id":"c3a1","action":"docs.list","payload":{"kind":"estimate"}}
```

Success and failure replies are always correlated and sent only to the caller:

```json
{"type":"bill.response","request_id":"c3a1","ok":true,"data":[]}
{"type":"bill.response","request_id":"c3a1","ok":false,"error":"Owner only"}
```

Available actions:

- Identity: `auth.me` — Flowuri user (`id` as a string, `username`, `role`, `color`).
- Documents: `docs.list`, `docs.get`, `docs.create`, `docs.update`,
  `docs.reorder`, `docs.comment`, `docs.transition`, `docs.delete`.
- Clients: `clients.list`, `clients.save`, `clients.delete`.
- Settings: `settings.get`, `settings.update` (owner only).
- Activity: `activity.list`.
- TimeTrack hours: `hours.import`, `hours.apply`.

IDs are sent as JSON strings of MySQL integer keys. Every logged-in user can
see every billing document. Owners can void invoices, convert accepted
estimates, and edit company settings. Salesreps cannot edit a document once
it leaves `draft` (comments are still allowed).

Customer share links are **not** WebSocket: `GET/POST /api/billing_share.php`
with a 32-character `shareId`. Opening a sent estimate marks it `viewed`.
Accept/decline writes through the same tables and may push `bill.event`.

Mutations may also push `{"type":"bill.event","event":"...","data":...}` to
every authenticated connection. `doc.updated` carries the document (or
`{document, invoice}` after convert). Clients should apply the payload or
re-fetch.

## Calendar RPC

The adjacent Calendar app (served at `/calendar/`) uses the same
authenticated WebSocket and opaque Flowuri session established by `hello`.
It is not included in `state.full`.
Each call has a client-generated correlation ID:

```json
{"type":"cal.request","request_id":"d4b2","action":"meetings.list","payload":{"from":"2026-09-21T00:00:00.000Z","to":"2026-09-28T00:00:00.000Z"}}
```

Success and failure replies are always correlated and sent only to the caller:

```json
{"type":"cal.response","request_id":"d4b2","ok":true,"data":[]}
{"type":"cal.response","request_id":"d4b2","ok":false,"error":"That time overlaps another meeting."}
```

Available actions:

- Identity: `auth.me` — Flowuri user (`id` as a string, `username`, `role`, `color`).
- Team: `team.list` — active employees with their Calendar profile
  (`displayName`, `color`, `shareId`, `isBookable`). Any of them can be a
  meeting's `hostId`.
- Employees (owner only, the same TimeTrack directory): `people.list`,
  `people.create`, `people.update`, `people.toggle`, `people.delete`.
  Payload matches TimeTrack `users.*` (`name`, `email`, `password`, `role`
  `ADMIN`|`EMPLOYEE`, `department`, `jobTitle`). Deactivate keeps the
  Flowuri login and only turns the employee profile off.
- Meetings: `meetings.list` (`from`, `to`, optional `hostId`), `meetings.get`,
  `meetings.create` (optional `hostId`, defaults to caller), `meetings.update`,
  `meetings.cancel`, `meetings.delete`.
- Availability: `availability.get` (optional `userId`), `availability.update`
  (optional `userId` — owner only when it is not the caller).
- Settings: `settings.get`, `settings.update` (owner only).
- Activity: `activity.list`.

Times are ISO-8601 and stored as UTC. Company timezone (default
`Europe/Bucharest`) is used when generating open slots. Every logged-in user
can see every meeting. Host, creator, or owner can edit or cancel.

Customer booking pages are **not** WebSocket: `GET/POST /api/calendar_share.php`.

- `GET ?shareId=` — host profile + open slots for that user's booking page.
- `POST {shareId, startsAt, guestName, guestEmail, …}` — book a slot; writes
  a confirmed meeting and may push `cal.event`.
- `GET ?meetingShareId=` — public confirmation for one meeting.
- `GET ?ics=` — download a `text/calendar` `.ics` for that meeting.

Mutations may also push `{"type":"cal.event","event":"...","data":...}` to
every authenticated connection. `meeting.updated` / `meeting.deleted` /
`availability.changed` / `settings.changed` are invalidation hints; clients
should apply the payload or re-fetch.

## Server → Client

| type | fields |
|---|---|
| `welcome` | `you` (your user row), `salesreps` (id/username/color, for the share picker) |
| `presence` | `online` (array of `{username, color, role}` currently connected) |
| `state.full` | `leads` (nested: leads → nodes → attachments; each lead includes `collaborators`), already scoped to what you can see |
| `lead.created` / `lead.updated` | `lead` |
| `lead.access_changed` | `lead` (full object), `audience_user_ids` — sent to everyone affected by a share/unshare. If your id isn't in `audience_user_ids` (and you're not an owner), drop the lead locally; otherwise upsert it |
| `lead.deleted` | `id` |
| `lead.archived_list` | `leads` (reply to `lead.list_archived`) |
| `lead.assignable_list` | `lead_id`, `users` (reply to `lead.assignable`) |
| `node.created` | `lead_id`, `node`, `client_ref` (echoed from the request, or `null`) |
| `node.updated` | `node` |
| `node.deleted` | `id`, `lead_id` |
| `attachment.added` | `attachment` |
| `attachment.removed` | `id`, `node_id` |
| `audit.list` | `lead_id`, `entries` (oldest → newest) |
| `user.list` | `users` (full roster with roles) — reply to `user.list`, and pushed to all connected owners after `user.create` |
| `error` | `message` |

## Internal (not for the browser)

`{"type":"internal_broadcast","secret":"...","lead_id":123,"payload":{...}}`
— used only by `api/upload.php` via `WsPush` to push an `attachment.added`
event after saving a file, scoped to that lead's audience. Requires
`LEADFLOW_WS_SECRET`; a real client sending this without the right secret
is ignored. Omitting `lead_id` broadcasts to everyone (used only by
internal callers that genuinely mean everyone, e.g. none currently).

## Design notes

- The server is authoritative: every action gets access-checked, persisted
  to MySQL, and then broadcast back to everyone allowed to see it —
  *including the sender* — so the UI never assumes its own optimistic view
  is correct, it waits for the round-trip.
- Visibility is enforced twice, deliberately redundantly: once in what gets
  broadcast (`broadcastToLeadAudience`), and independently in what
  `Repository::getFullState()` / `getLead()` return on a fresh `sync`. A
  client that only trusted broadcasts could theoretically drift; a fresh
  sync always reflects the database's actual access rules.
- `node.update` is naive last-write-wins. Fine for a small team's
  sequential note-taking; would need operational-transform/CRDT work for
  true simultaneous co-editing of one field.
- Fragmented WebSocket frames are supported (the server reassembles
  continuation frames), but no message is expected to be large — attachments
  go over HTTP, not the socket.
