feat/#24 WebOS Completion #25

Merged
sayan merged 8 commits from sayan/Project_Velocity:feat/#24 into main 2026-04-18 18:59:09 +05:30
459 changed files with 11713 additions and 3853 deletions

View File

@@ -0,0 +1,591 @@
## Desineuron Stable Ingress Handoff
Date: 2026-04-08
### Chapters
1. Outcome
2. Final Architecture
3. AWS Resources
4. Linux Origin State
5. Migration Changes Applied
6. Validation Results
7. ComfyUI Recovery and GPU Route
8. Files and Config Artifacts
9. Dynamic Home IP Sync
10. Operational Commands
11. Future Service Mapping Runbook
12. Security Notes
13. Remaining Improvement Ideas
14. Rollback
15. Team Summary
16. Current Status Snapshot - 2026-04-12
17. Linux Ops Control Plane
### Outcome
The Cloudflare Tunnel dependency for the six public `desineuron.in` services has been replaced with a self-hosted AWS ingress layer:
- Public edge: AWS EC2 `t4g.micro`
- Stable public IP: `98.87.120.120`
- TLS termination: `Caddy` on the ingress node
- Private backend relay: `rathole`
- Origin: Linux box at `192.168.1.4`
- DNS: Cloudflare, `DNS only`
Public hostnames now route through AWS instead of Cloudflare Tunnel:
- `office.desineuron.in`
- `git.desineuron.in`
- `cloud.desineuron.in`
- `projects.desineuron.in`
- `talk.desineuron.in`
- `vpn.desineuron.in`
- `comfy.desineuron.in` (ingress route created for AWS GPU ComfyUI)
- `ops.desineuron.in` (private operator control surface on the Linux box)
### Final Architecture
```text
Internet
-> Cloudflare DNS
-> 98.87.120.120
-> EC2 ingress: desineuron-ingress-01
-> Caddy :443
-> rathole server (control on 2333, local relay on 127.0.0.1:8443)
-> Linux origin tunnel client
-> Linux nginx :443
-> per-host upstream routing
-> Gitea
-> Nextcloud
-> Taiga
-> OnlyOffice
-> NetBird
-> comfy.desineuron.in
-> EC2 ingress Caddy
-> private proxy to AWS GPU box `172.31.46.190:8188`
-> ComfyUI endpoints on systemd-managed GPU service
```
### AWS Resources
- Instance name: `desineuron-ingress-01`
- Instance ID: `i-094df09acafb72494`
- Type: `t4g.micro`
- Region: `us-east-1`
- Subnet: `subnet-03d684ed15f327151`
- VPC: `vpc-081d2397920aad268`
- Root disk: `20 GB gp3`
- Elastic IP: `98.87.120.120`
- IAM role: `desineuron-ingress-role`
- Instance profile: `desineuron-ingress-profile`
- Security group: `sg-0721b8b48e12c531d`
Current GPU worker:
- Instance ID: `i-0e4eab5fe67cf9abe`
- Type: `g6.12xlarge`
- Region: `us-east-1`
- Private IP: `172.31.46.190`
- Current public IP: `100.31.64.121`
- Launch time: `2026-04-11T06:14:04Z`
Open ingress ports:
- `80/tcp` from internet
- `443/tcp` from internet
- `22/tcp` restricted to the current home public IP and auto-synced from the Linux origin
- `2333/tcp` from internet for `rathole` control and data relay
GPU node security posture for ComfyUI:
- public `8118/tcp` removed
- public `8188/tcp` removed
- `8188/tcp` now allowed only from ingress security group `sg-0721b8b48e12c531d`
### Linux Origin State
Services exposed to local nginx:
- `git.desineuron.in` -> `127.0.0.1:3000` (`gitea`)
- `cloud.desineuron.in` -> `127.0.0.1:11000` (`nextcloud_app`)
- `talk.desineuron.in` -> `127.0.0.1:11000` (`nextcloud_app`, Talk-focused hostname)
- `projects.desineuron.in` -> `127.0.0.1:9100` (`taiga-gateway`)
- `office.desineuron.in` -> `127.0.0.1:9980` (`nextcloud_onlyoffice`)
- `vpn.desineuron.in` -> `127.0.0.1:8080` / `127.0.0.1:8081` (`netbird`)
Tunnel state:
- `rathole-client.service` active on Linux
- `rathole-server.service` active on AWS
- `cloudflared` inactive on Linux
### Migration Changes Applied
#### Cloudflare
Old CNAME tunnel records were removed for the six public hostnames.
New records were created:
- Type: `A`
- Value: `98.87.120.120`
- Proxy status: `DNS only`
- TTL: `300`
#### AWS Ingress
Installed and configured:
- `Caddy`
- `rathole`
- `amazon-ssm-agent`
- Linux-driven SSH allowlist sync for the ingress node
TLS:
- Existing valid certificate/key pair from the Linux origin was copied to the ingress node.
- Caddy now terminates HTTPS at the edge.
#### Linux Origin
nginx was already routing by hostname and remains the origin router.
Nextcloud was adjusted so `talk.desineuron.in` no longer canonicalizes to `cloud.desineuron.in`:
- removed `overwritehost` pin
- added `talk.desineuron.in` to trusted domains
- restarted `nextcloud_app`
### Validation Results
Public hostname checks through the new ingress:
- `office.desineuron.in` -> `200 /welcome/`
- `git.desineuron.in` -> `200`
- `cloud.desineuron.in` -> `200 /login`
- `projects.desineuron.in` -> `200`
- `talk.desineuron.in` -> `200 /login` on `talk.desineuron.in`
- `vpn.desineuron.in` -> `200`
- `ops.desineuron.in/login` -> `200`
- `comfy.desineuron.in` -> `200`
Important note:
- `talk.desineuron.in` now stays on the `talk` hostname.
- It is still backed by the same Nextcloud origin and presents the Nextcloud login flow, which is expected given the current Linux-side app layout.
### ComfyUI Recovery and GPU Route
Root cause of the earlier `502`:
- ingress route and TLS were correct
- the GPU spot node had lost the actual `/opt/dlami/nvme/ComfyUI` app tree
- nothing was listening on `172.31.46.190:8188`
Permanent fix applied:
- restored `/opt/dlami/nvme/ComfyUI` from upstream source control
- installed ComfyUI Python requirements on the GPU node
- created `systemd` unit `comfyui.service`
- enabled `comfyui.service` at boot with automatic restart
- kept `comfy.desineuron.in` mapped through ingress Caddy
- removed direct public access to `8118` and `8188`
- allowed `8188` only from ingress security group
Current live path:
- `https://comfy.desineuron.in`
-> ingress `98.87.120.120`
-> Caddy reverse proxy
-> GPU private IP `172.31.46.190:8188`
-> `comfyui.service`
Current public result:
- `comfy.desineuron.in` currently returns `200 OK`
- ingress route is now managed dynamically instead of hardcoded to one GPU private IP
Current GPU service:
- `comfyui.service`
- app path: `/opt/dlami/nvme/ComfyUI`
- log path: `/var/log/comfyui/service.log`
- port: `8188/tcp`
Current backend state on `2026-04-12`:
- `comfyui.service` is `active`
- `main.py` is present under `/opt/dlami/nvme/ComfyUI`
- the process is listening on `0.0.0.0:8188`
- the public ingress path is healthy again
Auto-healing fix applied:
- ComfyUI `systemd` service now runs an `ExecStartPre` recovery script at `/usr/local/bin/desineuron-ensure-comfyui.sh`
- that script reclones/repairs `/opt/dlami/nvme/ComfyUI` if the tree is missing or damaged
- Linux now runs `desineuron-comfy-route-sync.timer`
- the timer updates the managed Caddy route for `comfy.desineuron.in` to the current private IP of the AWS instance tagged `DesineuronRole=comfyui`
- this protects the public route from GPU instance IP drift without manual Caddy edits
Expected endpoints:
- `https://comfy.desineuron.in/`
- `https://comfy.desineuron.in/prompt`
- `https://comfy.desineuron.in/history/{prompt_id}`
- `https://comfy.desineuron.in/queue`
- `https://comfy.desineuron.in/upload/image`
### Files and Config Artifacts
Infrastructure artifacts in repo:
- [README.md](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/README.md)
- [Caddyfile](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/Caddyfile)
- [rathole-server.toml](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/rathole-server.toml)
- [rathole-client.toml](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/rathole-client.toml)
- [install_linux_rathole_client.sh](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/install_linux_rathole_client.sh)
- [user_data.sh](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/user_data.sh)
- [install_gpu_comfyui_service.sh](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/install_gpu_comfyui_service.sh)
- [map_gpu_comfy_security.ps1](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/map_gpu_comfy_security.ps1)
- [sync_ingress_home_ip.py](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/sync_ingress_home_ip.py)
- [desineuron-ingress-home-ip-sync.service](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/desineuron-ingress-home-ip-sync.service)
- [desineuron-ingress-home-ip-sync.timer](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/desineuron-ingress-home-ip-sync.timer)
- [install_linux_ingress_ip_sync.sh](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/install_linux_ingress_ip_sync.sh)
- [sync_comfy_route.py](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/sync_comfy_route.py)
- [desineuron-comfy-route-sync.service](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/desineuron-comfy-route-sync.service)
- [desineuron-comfy-route-sync.timer](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/desineuron-comfy-route-sync.timer)
- [install_linux_comfy_route_sync.sh](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/install_linux_comfy_route_sync.sh)
- [README.md](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/ops_control_plane/README.md)
- [Desineuron Ops Control Plane Bibel.md](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/.Agent%20Context/Bibels/Desineuron%20Ops%20Control%20Plane%20Bibel.md)
Linux origin files touched:
- `/etc/nginx/sites-enabled/desineuron.conf`
- `/mnt/ServerStorage/docker_apps/nextcloud/.env`
- `/mnt/ServerStorage/docker_apps/nextcloud/data/config/config.php`
- `/mnt/ServerStorage/docker_apps/nextcloud/data/config/reverse-proxy.config.php`
Backups created on Linux:
- `/mnt/ServerStorage/docker_apps/nextcloud/.env.pre_ingress_backup_2026-04-08`
- `/mnt/ServerStorage/docker_apps/nextcloud/data/config/reverse-proxy.config.php.pre_ingress_backup_2026-04-08`
### Dynamic Home IP Sync
Purpose:
- Keep ingress `22/tcp` restricted to the current Airtel public IP even when the ISP changes it
- Prevent future manual outages for SSH fallback caused by stale home-IP security-group rules
Design:
- Linux origin runs `desineuron-ingress-home-ip-sync.timer`
- Timer fires on boot and every 5 minutes
- Service resolves the current home public IP via `https://api.ipify.org`
- Service updates only the ingress security group `sg-0721b8b48e12c531d`
- Only the SSH fallback rule is mutated
- `rathole` is no longer dependent on the Airtel IP because `2333/tcp` remains open on the ingress
Installed Linux paths:
- `/usr/local/bin/sync_ingress_home_ip.py`
- `/etc/systemd/system/desineuron-ingress-home-ip-sync.service`
- `/etc/systemd/system/desineuron-ingress-home-ip-sync.timer`
- `/etc/desineuron-ingress-home-ip-sync.env`
- `/opt/desineuron-ingress-ip-sync/.venv`
- `/var/lib/desineuron-ingress-ip-sync/current_ip.txt`
Current state:
- Timer: enabled and active
- Last recorded home public IP: `223.185.28.89`
- Ingress SSH rule CIDR: `223.185.28.89/32`
### Dynamic Comfy Route Sync
Purpose:
- keep `comfy.desineuron.in` mapped to the correct AWS GPU private IP even if the GPU instance public/private IP changes
- remove the need to hand-edit `/etc/caddy/Caddyfile` for ComfyUI moves
Design:
- Linux runs `desineuron-comfy-route-sync.timer`
- timer fires on boot and every 2 minutes
- service looks for the newest running EC2 instance tagged `DesineuronRole=comfyui`
- service reads its current private IP
- service connects to the ingress node and updates the managed Caddy route with `/usr/local/bin/manage_desineuron_routes.py`
- Caddy is validated and reloaded only after a successful route update
Installed Linux paths:
- `/usr/local/bin/sync_comfy_route.py`
- `/etc/systemd/system/desineuron-comfy-route-sync.service`
- `/etc/systemd/system/desineuron-comfy-route-sync.timer`
- `/etc/desineuron-comfy-route-sync.env`
- `/opt/desineuron-comfy-route-sync/.venv`
- `/var/lib/desineuron-comfy-route-sync/current_target.txt`
Current state:
- Timer: enabled and active
- Current synced target: `172.31.46.190`
- Current target instance tag: `DesineuronRole=comfyui`
### Operational Commands
Check AWS ingress status:
```powershell
aws ec2 describe-instances --instance-ids i-094df09acafb72494 --region us-east-1
aws ec2 describe-addresses --allocation-ids eipalloc-0d54fc0f827450e7b --region us-east-1
```
Check ingress services:
```powershell
aws ssm send-command --region us-east-1 --instance-ids i-094df09acafb72494 --document-name AWS-RunShellScript --parameters commands="sudo systemctl status caddy rathole-server --no-pager"
```
Check GPU ComfyUI service:
```powershell
aws ssm send-command --region us-east-1 --instance-ids i-0e4eab5fe67cf9abe --document-name AWS-RunShellScript --parameters commands="sudo systemctl status comfyui --no-pager","ss -ltnp | grep 8188 || true","tail -n 40 /var/log/comfyui/service.log || true"
```
Check Linux origin services:
```powershell
ssh -i "$env:USERPROFILE\.ssh\id_ed25519_desineuron_lan" desineuron-node-01@192.168.1.4 "echo '***' | sudo -S systemctl status rathole-client nginx"
ssh -i "$env:USERPROFILE\.ssh\id_ed25519_desineuron_lan" desineuron-node-01@192.168.1.4 "echo '***' | sudo -S systemctl status desineuron-ingress-home-ip-sync.service desineuron-ingress-home-ip-sync.timer"
ssh -i "$env:USERPROFILE\.ssh\id_ed25519_desineuron_lan" desineuron-node-01@192.168.1.4 "echo '***' | sudo -S journalctl -u desineuron-ingress-home-ip-sync -n 50 --no-pager"
ssh -i "$env:USERPROFILE\.ssh\id_ed25519_desineuron_lan" desineuron-node-01@192.168.1.4 "echo '***' | sudo -S systemctl status desineuron-ops-control-plane.service --no-pager"
ssh -i "$env:USERPROFILE\.ssh\id_ed25519_desineuron_lan" desineuron-node-01@192.168.1.4 "echo '***' | sudo -S docker compose -f /opt/desineuron-ops-control-plane/docker-compose.yml ps"
ssh -i "$env:USERPROFILE\.ssh\id_ed25519_desineuron_lan" desineuron-node-01@192.168.1.4 "echo '***' | sudo -S systemctl status desineuron-comfy-route-sync.service desineuron-comfy-route-sync.timer --no-pager"
```
Public endpoint validation:
```powershell
curl.exe -I https://office.desineuron.in
curl.exe -I https://git.desineuron.in
curl.exe -I https://cloud.desineuron.in
curl.exe -I https://projects.desineuron.in
curl.exe -I https://talk.desineuron.in
curl.exe -I https://vpn.desineuron.in
curl.exe -I https://comfy.desineuron.in
curl.exe -I https://ops.desineuron.in/login
```
### Future Service Mapping Runbook
Use this pattern for any future public service behind the stable ingress layer.
1. Decide the backend location.
- Linux origin behind `rathole`
- AWS GPU/private EC2 node
- another private backend later
2. Decide whether the service should terminate TLS at ingress.
- default: yes
- Caddy on ingress should own the public hostname and certificate
3. Create the DNS record in Cloudflare.
- type: `A`
- value: `98.87.120.120`
- proxy mode: `DNS only`
- low TTL during rollout
4. Add the ingress route in [`Caddyfile`](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/desineuron_ingress/Caddyfile).
Patterns:
- Linux-origin service:
- proxy to `https://127.0.0.1:8443`
- preserve `Host`
- private AWS backend service:
- proxy to `http://<private-ip>:<port>` or `https://<private-ip>:<port>`
5. Restrict backend network access.
- never leave backend app ports open to `0.0.0.0/0` unless absolutely necessary
- prefer security-group rule allowing traffic only from ingress security group
- for home-origin services, keep them private behind `rathole`
6. Reload ingress.
```powershell
ssh -i "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity\desineuron-l4-node.pem" ec2-user@98.87.120.120 "sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy"
```
7. Validate TLS and app response.
- check certificate subject matches hostname
- check `curl -I https://<host>`
- check login page or health endpoint
- check browser behavior
8. If the backend is stateful, create a persistent service.
- prefer `systemd`
- enable restart on failure
- log to a stable path
- record service name, working directory, ports, and restart policy in this handoff doc
9. Update team docs immediately.
- hostname
- DNS record type
- ingress route target
- backend service owner
- service name
- health check command
- rollback step
### Security Notes
- Public traffic terminates only at the AWS edge.
- The Linux box no longer needs Cloudflare Tunnel for these six routes.
- The Linux origin is reached through an outbound tunnel, not by directly exposing the home machine to the public for app traffic.
- SSH on the Linux box remains key-only.
- The AWS ingress IAM role is limited to SSM core.
- ComfyUI is no longer directly exposed on the GPU public IP; only the ingress layer can reach `8188`.
- Ingress `22/tcp` stays restricted and is now auto-synced from the Linux origin.
- Ingress `2333/tcp` is intentionally open so `rathole` survives Airtel IP changes without operator action.
### Remaining Improvement Ideas
- Move the Linux nginx certificate issuance/renewal model to the AWS edge permanently instead of copying an existing certificate.
- Clean up nginx warnings about duplicated protocol options.
- Separate `talk.desineuron.in` more fully from general Nextcloud if a distinct Talk-only UX is desired.
- Add authentication in front of `comfy.desineuron.in`; internet scanners started hitting the route immediately after it went live.
- Consider putting Basic Auth or an allowlist in front of `comfy.desineuron.in` before broader team rollout.
- Add monitoring and alerting on:
- `caddy`
- `rathole-server`
- `rathole-client`
- public HTTPS checks
- Add infrastructure-as-code for the EC2 ingress node if this should be reproducible by the team without manual AWS CLI steps.
### Rollback
If rollback is needed:
1. Recreate Cloudflare CNAME/tunnel routes or repoint the DNS records away from `98.87.120.120`.
2. Stop `caddy` and `rathole-server` on AWS.
3. Stop `rathole-client` on Linux.
4. Restore Nextcloud files from:
- `.env.pre_ingress_backup_2026-04-08`
- `reverse-proxy.config.php.pre_ingress_backup_2026-04-08`
5. Restart `nextcloud_app` and nginx.
### Team Summary
This migration is complete.
Cloudflare Tunnel is no longer the production path for the six public service hostnames. The stable production ingress is now the AWS `t4g.micro` node with Elastic IP `98.87.120.120`, and the Linux machine remains the private origin behind `rathole`.
Additional mapped route:
- `comfy.desineuron.in` now terminates on the same stable ingress and forwards to the GPU node's private address `172.31.46.190:8188`.
- No further DNS change is needed for ComfyUI.
- The backend is supervised by `systemd` and currently healthy.
- The route is now auto-synced from Linux based on the tagged AWS ComfyUI worker, so future IP changes do not require manual ingress edits.
- The team can use:
- `https://comfy.desineuron.in/prompt`
- `https://comfy.desineuron.in/history/{prompt_id}`
- `https://comfy.desineuron.in/queue`
- `https://comfy.desineuron.in/upload/image`
### Current Status Snapshot - 2026-04-12
Live public service state:
- `office.desineuron.in` -> `200`
- `git.desineuron.in` -> `200`
- `cloud.desineuron.in` -> `200`
- `projects.desineuron.in` -> `200`
- `talk.desineuron.in` -> `200`
- `vpn.desineuron.in` -> `200`
- `ops.desineuron.in/login` -> `200`
- `comfy.desineuron.in` -> `200`
Linux-origin health:
- `nginx.service` -> `active`
- `rathole-client.service` -> `active`
- `desineuron-ingress-home-ip-sync.timer` -> `active`
- `desineuron-ops-control-plane.service` -> `active`
Linux ops stack containers:
- `desineuron-ops-api` -> `Up`
- `desineuron-ops-db` -> `Up (healthy)`
- `desineuron-ops-worker` -> `Up`
Ingress health:
- `caddy` -> `active`
- `rathole-server` -> `active`
- `comfy.desineuron.in` Caddy route is present in `/etc/caddy/Caddyfile`
GPU ComfyUI state:
- `comfyui.service` -> `active`
- `main.py` present under `/opt/dlami/nvme/ComfyUI`
- listener present on `0.0.0.0:8188`
- public ingress path is healthy
Comfy auto-heal state:
- `desineuron-comfy-route-sync.timer` -> `active`
- synced target file -> `/var/lib/desineuron-comfy-route-sync/current_target.txt`
- current synced target -> `172.31.46.190`
### Linux Ops Control Plane
The Linux box now also hosts the private AWS control surface for the team.
Public operator URL:
- `https://ops.desineuron.in/login`
Purpose:
- launch/stop/terminate AWS machines
- view spot/on-demand market data
- track runtime and estimated cost
- ingest model directories from the Linux box into S3
- hydrate models from S3 to AWS GPU nodes
- manage ingress routes through the `t4g.micro`
- export session/cost CSVs
Linux runtime paths:
- stack root: `/opt/desineuron-ops-control-plane`
- env file: `/opt/desineuron-ops-control-plane/.env`
- exports: `/opt/desineuron-ops-control-plane/exports`
- state: `/opt/desineuron-ops-control-plane/state`
Canonical S3 bucket:
- `desineuron-ops-control-plane-819079556187-us-east-1`
Model library source on Linux:
- `/mnt/ServerStorage/ai-models/models`
Current operator accounts:
- `sagnik@desineuron.in`
- `sayan@desineuron.in`
- `sourik@desineuron.in`
Reference docs:
- [README.md](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/infrastructure/ops_control_plane/README.md)
- [Desineuron Ops Control Plane Bibel.md](/F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/.Agent%20Context/Bibels/Desineuron%20Ops%20Control%20Plane%20Bibel.md)

View File

@@ -0,0 +1,87 @@
# Sayan Multi-Surface and Oracle Delivery Pack Guide
**Date:** 2026-04-16
**Status:** Active work packet
**Owner:** Sagnik
**Primary Assignee:** Sayan
**Reviewers:** Sagnik, integration owners for WebOS, iOS, backend, and schema
**Scope:** Multi-surface product expansion across iPad, iPhone Edge, Android Tab, Android Phone Edge, Oracle schema expansion, template database, inventory ingestion, and admin control plane
**Purpose:** Provide a complete, implementation-grade handoff set for Sayan so he can execute a bounded but broad workstream without reconstructing architecture from scattered files.
## 1. Why This Pack Exists
Sayan is not being assigned a single screen or a single file. He is being assigned a coordinated expansion of Project Velocity across:
- existing iPad app completion
- new iPhone edge app
- new Android tablet app
- new Android phone edge app
- Oracle schema expansion
- Oracle template book and JSON template database
- Kimi Synthetic Data follow-on path
- inventory loading pipeline
- admin control plane
Without a disciplined packet, this will fragment into duplicated UI, duplicated schemas, unsupported mobile assumptions, and merge conflicts.
## 2. Current Repository Truth
Existing repository centers already matter:
- WebOS frontend: `Project_Velocity/app/`
- FastAPI backend: `Project_Velocity/backend/`
- native iPad foundation: `Project_Velocity/iOS/`
- Oracle schema and routes: `Project_Velocity/backend/oracle/`
- inventory asset corpus: `Project_Velocity/db assets/Inventory/`
- operational ingress and deployment truth: `Project_Velocity/infrastructure/`
This packet extends those surfaces. It does not replace them.
## 3. Reading Order
Read these in order:
1. [Introduction for Sayan.md](./Introduction%20for%20Sayan.md)
2. [Sayan Work Assignment_ Multi-Surface Platform and Oracle Expansion.md](./Sayan%20Work%20Assignment_%20Multi-Surface%20Platform%20and%20Oracle%20Expansion.md)
3. [Sayan Work Assignment_ Sprint 1 Execution Slice.md](./Sayan%20Work%20Assignment_%20Sprint%201%20Execution%20Slice.md)
4. [14 - Platform Reality and Communications Capture Strategy.md](./14%20-%20Platform%20Reality%20and%20Communications%20Capture%20Strategy.md)
5. [01 - First Principles_ Multi-Surface Platform and Oracle Expansion.md](./01%20-%20First%20Principles_%20Multi-Surface%20Platform%20and%20Oracle%20Expansion.md)
6. [02 - PRD_ Multi-Surface Platform and Oracle Expansion.md](./02%20-%20PRD_%20Multi-Surface%20Platform%20and%20Oracle%20Expansion.md)
7. [03 - SRS_ Multi-Surface Platform and Oracle Expansion.md](./03%20-%20SRS_%20Multi-Surface%20Platform%20and%20Oracle%20Expansion.md)
8. [04 - Sprint Plan_ Multi-Surface Platform and Oracle Expansion.md](./04%20-%20Sprint%20Plan_%20Multi-Surface%20Platform%20and%20Oracle%20Expansion.md)
9. [05 - Implementation Blueprint_ Multi-Surface Apps WebOS Edge and Oracle Delivery.md](./05%20-%20Implementation%20Blueprint_%20Multi-Surface%20Apps%20WebOS%20Edge%20and%20Oracle%20Delivery.md)
10. [06 - Execution Backlog_ Multi-Surface Apps Oracle Templates Inventory Admin.md](./06%20-%20Execution%20Backlog_%20Multi-Surface%20Apps%20Oracle%20Templates%20Inventory%20Admin.md)
11. [07 - Contracts and JSON Schemas_ Templates Inventory Edge Capture.md](./07%20-%20Contracts%20and%20JSON%20Schemas_%20Templates%20Inventory%20Edge%20Capture.md)
12. [08 - Adapter Detailed Implementation Spec_ Mobile Edge Inventory Admin.md](./08%20-%20Adapter%20Detailed%20Implementation%20Spec_%20Mobile%20Edge%20Inventory%20Admin.md)
13. [09 - Oracle Schema and Root API Spec_ Multi-Surface Platform.md](./09%20-%20Oracle%20Schema%20and%20Root%20API%20Spec_%20Multi-Surface%20Platform.md)
14. [10 - Shared Surface Module Spec_ WebOS iPad Android Edge.md](./10%20-%20Shared%20Surface%20Module%20Spec_%20WebOS%20iPad%20Android%20Edge.md)
15. [11 - Delivery Roles and Ownership Spec_ Mobile Oracle Admin.md](./11%20-%20Delivery%20Roles%20and%20Ownership%20Spec_%20Mobile%20Oracle%20Admin.md)
16. [12 - Deployment Operations and Release Readiness_ Multi-Surface Platform.md](./12%20-%20Deployment%20Operations%20and%20Release%20Readiness_%20Multi-Surface%20Platform.md)
17. [13 - Implementation Ticket Breakdown and Dependency Matrix_ Multi-Surface Platform.md](./13%20-%20Implementation%20Ticket%20Breakdown%20and%20Dependency%20Matrix_%20Multi-Surface%20Platform.md)
## 4. Main Design Rule
One workstream, one packet, one truth.
The packet assumes:
- WebOS remains the canonical broad operator surface
- the current iPad app remains the native pattern reference for tablet field work
- phone edge apps are narrow operator control and capture surfaces, not full WebOS clones
- Oracle remains the analytical composition center
- backend schema authority remains in the current FastAPI root
## 5. Hard Constraints
- do not create parallel backend authority
- do not fork schema truth outside the current backend
- do not assume unrestricted call recording or message interception on iPhone or Android
- do not assume WhatsApp API alone can solve every voice and video ingestion need
- do not downgrade the business requirement for communication memory just because some capture paths are blocked
- solve the requirement through supported telephony, supported business messaging APIs, explicit imports, and operator-assist flows
- do not break existing iPad or WebOS code while extending it
- do not build merge-hostile ownership overlaps
## 6. Bottom Line
This is not a brainstorming folder. It is the implementation and handoff packet for a major product expansion that must respect the current Velocity root while opening the next execution lane for Sayan.

View File

@@ -0,0 +1,125 @@
# Project Velocity - First Principles_ Multi-Surface Platform and Oracle Expansion
**Date:** 2026-04-16
**Status:** Active handoff baseline
**Owner:** Sayan
**Reviewers:** Sagnik, WebOS owner, backend owner
**Scope:** iPad residual completion, phone edge apps, Android tablet parity, Oracle schema and template DB expansion, inventory loading pipeline, admin control plane
**Purpose:** Define the real problem, intended behavior, and architectural logic before implementation extends across multiple product surfaces.
## Executive Summary
Project Velocity currently has a meaningful but uneven product surface:
- WebOS is broad
- iPad has a real native start
- Oracle exists and already has route and schema direction
- inventory assets exist
- backend authority already exists
What is missing is surface completion and normalization.
The next expansion is not "make more screens." The real objective is:
- finish the tablet field product properly
- create narrow edge apps for phone operators
- create durable communication memory so the system can remind operators about prior calls, messages, promises, and follow-up windows
- align all surfaces to one backend and one schema truth
- turn Oracle templates into a governed data product
## Assumptions and Constraints
### Assumptions
- WebOS remains the broad operator shell
- the current iPad Swift app is the native parity reference for tablet work
- Android tablet should mirror the iPad information architecture, not reinvent it
- phone edge apps are lower-density, control-oriented, and communication-aware
- Oracle template generation will be backed by JSON templates and database persistence
- the business requirement includes remembering communication history and follow-up commitments across supported channels
### Constraints
- current repo ownership must be respected
- inventory source files are only partially present and more client data is still pending
- phone call capture and transcription must remain within OS, API, and consent constraints
- WhatsApp, email, and message ingestion must be built around supported business surfaces
- a managed or dedicated work phone does not remove iOS, Android, carrier, or provider restrictions
- backend authority remains in `Project_Velocity/backend/`
## Reference Sources and Rationale
### Local Sources
- `Project_Velocity/README.md`
- `Project_Velocity/app/`
- `Project_Velocity/iOS/velocity/velocity/`
- `Project_Velocity/backend/oracle/router_v1.py`
- `Project_Velocity/backend/oracle/schema_oracle.sql`
- `Project_Velocity/backend/api/routes_oracle.py`
- `Project_Velocity/db assets/Inventory/`
### Upstream or External Sources
- Apple and Android platform rules matter for capture and telephony constraints
- WhatsApp Business Platform capability limits must be respected
- no external framework should override the current Velocity architecture
## Problem Statement
Velocity currently risks product asymmetry:
- WebOS can do more than native surfaces
- iPad exists but is not yet the finished field product
- there is no dedicated phone edge experience for brand reps or realtors
- Oracle template strategy is present but not yet converted into a full template book and DB program
- inventory ingestion is still operator-heavy
- there is no dedicated admin control surface to operate the moving parts coherently
## System Vision
The intended end state is:
- iPad and Android tablet act as fully capable field tablets
- iPhone and Android phone edge apps act as mobile control and communication surfaces
- Oracle becomes the composition and analytical runtime across all surfaces
- templates become governed JSON assets with synthetic expansion capability
- inventory loading becomes an explicit pipeline, not ad hoc manual work
- admin gets a controlled operational surface
## First-Principles Architecture
### Principle 1: Surface specialization is mandatory
Each device class should do the work it is physically good at.
### Principle 2: Backend truth stays centralized
The current FastAPI backend already owns auth, Oracle, CRM direction, and route authority.
### Principle 3: Oracle templates are product assets
Templates must become categorized, versioned, queryable, and persistable.
### Principle 4: Inventory data must become pipelineable
Manual import as the permanent model is not acceptable.
### Principle 5: Mobile capture must be consent-first and platform-realistic
Do not promise unsupported device capture behavior.
### Principle 6: Communication memory is a core product feature
The real requirement is not raw recording for its own sake. The real requirement is durable operator memory:
- what happened with this lead
- what was promised
- when the client said they would return, call back, or decide
- what reminder should fire later
That requirement stays in scope even when some direct OS-level capture paths do not.
## Bottom Line
This work is a multi-surface normalization program around the current Velocity root. The correct implementation path is shared contracts, centralized backend truth, specialized device roles, and a real Oracle template data model.

View File

@@ -0,0 +1,116 @@
# Product Requirements Document (PRD)_ Multi-Surface Platform and Oracle Expansion
**Date:** 2026-04-16
**Status:** Active planning artifact
**Owner:** Sayan
**Reviewers:** Sagnik, product and integration reviewers
**Scope:** User-facing and operator-facing outcomes for tablets, phone edge apps, Oracle template system, inventory loading, and admin control plane
**Purpose:** Define what product value this work must deliver.
## 1. Product Goal
Expand Project Velocity from a partially asymmetric surface set into a coherent suite where:
- tablets can run real field workflows
- phones can monitor and control the edge of the system
- Oracle can serve structured templates across all surfaces
- inventory can be loaded and maintained systematically
- admins can operate the whole suite safely
## 2. Target Users
- realtor and brand representatives
- internal operator teams
- property sales directors
- platform admins
- content and data operators
## 3. Core Product Outcomes
### 3.1 iPad App
- finish the residual pages
- update components to match current product direction
### 3.2 iPhone Edge App
- narrow mobile control surface
- lead and communication awareness
- alerts, status, notes, and fast actions
- communication-memory reminders and follow-up surfaces
- dedicated business communications surface for WhatsApp messaging and calling where supported
- cellular-call context and recording-import flow
- consent-based recording import and transcription workflows where supported
- provider-routed business communication ingestion where direct device capture is unavailable
- personal calendar surface for the assigned user
### 3.3 Android Tablet App
- same structural product as iPad
- Android-native presentation and layout discipline
### 3.4 Android Phone Edge App
- Android counterpart of iPhone edge app
- same product scope, platform-specific implementation
- optional deeper capture hooks only where default-handler, enterprise, or approved provider conditions make them defensible
### 3.5 Oracle Template Product
- chaptered template book
- subchapter categorization
- seed JSON template database
- later synthetic expansion through Kimi
### 3.6 Inventory Pipeline
- operator-friendly add/edit flow
- backend ingestion path
- future client self-service readiness
### 3.7 Admin Control Plane
- login
- status visibility
- installation and debugging surface
- system operation tooling
### 3.8 Calendar and Insight Loop
- exclusive user calendar
- reminders derived from conversations
- transcript insight extraction
- CRM and QD-score update recommendations
## 4. Product Boundaries
In scope:
- native and near-native product surfaces
- backend-facing contracts
- schema and template expansion
- admin operations surface
- communication memory built from supported capture, supported provider APIs, and operator-assisted imports
- user calendar derived from communication outcomes
- transcript and speaker-segregation pipeline
Out of scope for this run:
- unsupported device-level interception features
- uncontrolled consumer messaging hacks
- replacing the current backend architecture
## 5. Success Criteria
- iPad residual work is clearly closed or ticketed with acceptance
- Android tablet plan maps directly to iPad structure
- phone edge apps have bounded, platform-realistic MVP scope
- communication-memory strategy is explicit per channel, not hand-waved
- user calendar and insight loop are explicitly modeled
- Oracle template book and DB model are explicit
- inventory ingestion path is defined and buildable
- admin control plane scope is concrete
## 6. Bottom Line
The value of this work is not more screens. The value is a coherent, device-aware, Oracle-backed operating suite that can be extended without multiplying technical debt.

View File

@@ -0,0 +1,84 @@
# Software Requirements Specification (SRS)_ Multi-Surface Platform and Oracle Expansion
**Date:** 2026-04-16
**Status:** Active technical baseline
**Owner:** Sayan
**Reviewers:** Sagnik, frontend lead, backend lead
**Scope:** Technical requirements for tablet apps, phone edge apps, Oracle template DB, inventory ingestion, and admin control plane
**Purpose:** Define the system requirements, interfaces, constraints, and acceptance boundaries for implementation.
## 1. Purpose
Specify the technical system needed to extend Velocity across tablets, phone edge apps, Oracle template infrastructure, inventory ingestion, and admin operations without breaking the current root architecture.
## 2. System Context
The system lives inside current Project Velocity:
- `Project_Velocity/app/` for WebOS
- `Project_Velocity/backend/` for backend authority
- `Project_Velocity/iOS/` for current native tablet reference
- `Project_Velocity/db assets/Inventory/` for current seed data
## 3. Assumptions and Constraints
- backend remains canonical
- Oracle route and schema direction already exists
- current iPad app is the tablet reference implementation
- Android and phone apps must not create a second backend center
- mobile capture must follow supported platform and consent paths
## 4. Functional Requirements
- preserve a single backend authority in `Project_Velocity/backend/`
- support completion of the existing iPad app residual pages and component updates
- support an Android tablet product with the same feature architecture as the iPad app
- support phone edge apps as narrow operator surfaces
- support durable communication-memory capture, import, retrieval, and reminder generation
- support per-user calendar entities and calendar action flows
- support transcription with speaker segregation for supported recordings
- support downstream insight extraction for CRM and QD-score recommendations
- support Oracle template catalog persistence
- support Kimi Synthetic Data as a follow-on program using the seed template DB
- support inventory property ingest and edit workflows
- support an admin control plane
## 5. Interface Requirements
- existing backend auth APIs
- Oracle route surface in `routes_oracle.py` and `oracle/router_v1.py`
- inventory APIs to be added through current backend
- admin APIs to be added through current backend
- business telephony and messaging provider adapters where channel support exists
- calendar APIs through the current backend
- transcript processing interfaces for diarized transcript outputs
## 6. Data Model
Core entities:
- `surface_sessions`
- `edge_events`
- `communication_memory_facts`
- `calendar_events`
- `transcription_jobs`
- `transcript_segments`
- `insight_recommendations`
- `inventory_properties`
- `inventory_media_assets`
- `oracle_template_chapters`
- `oracle_template_subchapters`
- `oracle_component_templates`
- `oracle_template_examples`
- `admin_action_events`
## 7. Acceptance Criteria
The engineering gate is met when:
- all workstreams are mapped to real files, contracts, and schema
- no parallel schema center is introduced
- phone edge scope is technically defensible
- communication-memory flow exists for supported channels and fallback paths
- calendar and transcript-intelligence flow exist as first-class technical requirements
- Oracle template DB seed path is explicit

View File

@@ -0,0 +1,73 @@
# Sprint Plan_ Multi-Surface Platform and Oracle Expansion
**Date:** 2026-04-16
**Status:** Sprint definition
**Owner:** Sayan
**Reviewers:** Sagnik and integration reviewers
**Scope:** Sequence the implementation so contracts and backend truths land before broad UI divergence.
## 1. Sprint Goal
Create a safe execution path for the multi-surface expansion by landing shared contracts, schema direction, and ownership boundaries before device-specific implementation fragments.
## 2. Sprint Milestones
### Milestone 1: Orientation and inventory of current truth
- audit current iPad app
- audit WebOS modules
- audit Oracle routes and schema
- audit inventory corpus and current backend shapes
### Milestone 2: Shared contract and schema baseline
- define shared entities
- define Oracle template chapter model
- define inventory ingest contracts
- define admin auth and role boundaries
### Milestone 3: Tablet and phone architecture split
- finalize iPad residual list
- define Android tablet parity map
- define phone edge MVP scope
### Milestone 4: Backend route and data readiness
- extend current backend routes and schema
- avoid parallel service drift
### Milestone 5: Surface implementation starts
- iPad residual completion
- Android tablet scaffold
- phone edge MVP scaffold
- admin control plane scaffold
## 3. Mandatory Order
The team should not start with broad mobile UI polish.
Correct order:
1. contracts
2. schema
3. backend routes
4. tablet parity map
5. phone edge MVP map
6. UI implementation
## 4. Deliverables This Sprint Must Produce
- filled architectural packet
- real file ownership map
- Oracle schema extension plan
- template book outline
- inventory ingest plan
- admin surface plan
## 5. Known Dependencies
- inventory source files from Sagnik are still pending
- communications ingestion provider choices may still need final confirmation
- Android implementation should not start deep UI work before iPad parity mapping is complete

View File

@@ -0,0 +1,90 @@
# Implementation Blueprint_ Multi-Surface Apps WebOS Edge and Oracle Delivery
**Date:** 2026-04-16
**Status:** Implementation map
**Owner:** Sayan
**Reviewers:** Sagnik, frontend/backend integration reviewers
**Scope:** Repository mapping and execution boundaries for the multi-surface workstream.
## 1. Repository Centers To Respect
- WebOS: `Project_Velocity/app/`
- backend: `Project_Velocity/backend/`
- iPad native: `Project_Velocity/iOS/velocity/velocity/`
- inventory assets: `Project_Velocity/db assets/Inventory/`
## 2. Recommended Workstream Ownership
### WebOS and shared web contracts
- `Project_Velocity/app/src/`
- focus:
- shared UI semantics
- Oracle and inventory contracts
- admin control plane surface
### iPad residual completion
- `Project_Velocity/iOS/velocity/velocity/Features/`
- focus:
- Dashboard
- Inventory
- Oracle
- Sentinel
- Settings
### Android tablet
Recommended new path:
- `Project_Velocity/android-tablet/`
### iPhone edge
Recommended new path:
- `Project_Velocity/iOS/velocity-edge-phone/`
### Android phone edge
Recommended new path:
- `Project_Velocity/android-edge-phone/`
### backend extension
- `Project_Velocity/backend/api/`
- `Project_Velocity/backend/oracle/`
- `Project_Velocity/backend/services/`
## 3. Backend Extension Areas
Recommended route families:
- `routes_mobile_edge.py`
- `routes_inventory.py`
- existing Oracle route extension instead of Oracle route replacement
- `routes_admin_surface.py`
## 4. Schema Extension Areas
Existing Oracle schema reference:
- `Project_Velocity/backend/oracle/schema_oracle.sql`
Recommended additions:
- template chapter tables
- template example tables
- inventory property and ingestion tables
- transcription job and communication event tables where approved
- admin action audit tables
## 5. Merge Safety Rule
Keep ownership disjoint:
- tablet native work isolated by platform directory
- backend route additions isolated by route family
- schema changes staged and reviewed centrally
- WebOS changes limited to the specific modules under Sayan scope

View File

@@ -0,0 +1,33 @@
# Execution Backlog_ Multi-Surface Apps Oracle Templates Inventory Admin
**Date:** 2026-04-16
**Status:** Ordered delivery backlog
**Owner:** Sayan
**Purpose:** Convert the workstream into a sequence that minimizes conflict and ambiguity.
## Backlog Order
1. Audit current iPad residual pages and components.
2. Audit current iOS app structure and create feature parity matrix.
3. Audit WebOS modules and identify reusable contracts.
4. Extend Oracle schema plan for component coverage across all surfaces.
5. Design template chapter and subchapter taxonomy.
6. Define seed JSON examples for each chapter and subchapter.
7. Define Kimi Synthetic Data downstream generation process.
8. Define inventory property ingest contract and edit contract.
9. Define admin control plane scope and role boundaries.
10. Define phone edge MVP capability set with platform-safe capture constraints.
11. Define communication capture matrix by channel: PSTN, SMS, email, Facebook or Instagram business messaging, WhatsApp business messaging, WhatsApp voice, WhatsApp video.
12. Define memory extraction and reminder pipeline from transcripts, messages, and operator notes.
13. Define Android tablet parity implementation structure.
14. Begin iPad residual implementation.
15. Begin backend route and schema implementation.
16. Begin Android tablet scaffold.
17. Begin iPhone and Android phone edge app scaffolds.
18. Begin admin surface implementation.
## Known Blockers
- full property file handoff is pending
- final communications provider approval may still be pending
- some mobile communication expectations may require product narrowing

View File

@@ -0,0 +1,175 @@
# Contracts and JSON Schemas_ Templates Inventory Edge Capture
**Date:** 2026-04-16
**Status:** Contract baseline
**Owner:** Sayan
**Purpose:** Define the minimum JSON and contract entities for this workstream.
## 1. Template Catalog Contracts
### TemplateChapter
- `chapterId`
- `name`
- `description`
- `sortOrder`
### TemplateSubchapter
- `subchapterId`
- `chapterId`
- `name`
- `description`
- `sortOrder`
### OracleComponentTemplate
- `templateId`
- `chapterId`
- `subchapterId`
- `name`
- `componentType`
- `acceptedShapes`
- `jsonTemplate`
- `origin`
- `status`
- `version`
### OracleTemplateSeedExample
- `exampleId`
- `templateId`
- `title`
- `exampleJson`
- `qualityNotes`
## 2. Kimi Synthetic Data Contracts
### SyntheticExpansionJob
- `jobId`
- `templateId`
- `chapterId`
- `subchapterId`
- `model`
- `status`
- `requestedCount`
- `acceptedCount`
## 3. Inventory Contracts
### InventoryProperty
- `propertyId`
- `sourceId`
- `projectName`
- `developerName`
- `location`
- `propertyType`
- `priceBands`
- `unitMix`
- `amenities`
- `mediaRefs`
- `status`
### InventoryImportBatch
- `batchId`
- `sourceType`
- `submittedBy`
- `status`
- `startedAt`
- `completedAt`
## 4. Edge Capture Contracts
### CommunicationEvent
- `eventId`
- `leadId`
- `channel`
- `direction`
- `provider`
- `captureMode`
- `timestamp`
- `summary`
- `rawReference`
- `recordingRef`
### CommunicationMemoryFact
- `factId`
- `leadId`
- `eventId`
- `factType`
- `factText`
- `effectiveDate`
- `confidence`
- `extractedFrom`
### TranscriptSegment
- `segmentId`
- `eventId`
- `speakerLabel`
- `startMs`
- `endMs`
- `text`
- `confidence`
### CalendarEvent
- `calendarEventId`
- `ownerUserId`
- `leadId`
- `sourceEventId`
- `title`
- `description`
- `startAt`
- `endAt`
- `status`
- `createdBy`
### InsightRecommendation
- `recommendationId`
- `leadId`
- `sourceEventId`
- `recommendationType`
- `summary`
- `suggestedAction`
- `targetSystem`
- `status`
### TranscriptionJob
- `transcriptionJobId`
- `eventId`
- `mediaType`
- `status`
- `transcriptRef`
- `consentState`
## 5. Admin Contracts
### AdminActionRequest
- `actionId`
- `actionType`
- `targetType`
- `targetId`
- `requestedBy`
- `payload`
### AdminActionResult
- `actionId`
- `status`
- `message`
- `artifacts`
## 6. Contract Rules
- all contracts must be backend-owned
- mobile and web surfaces consume, not redefine
- communication capture contracts must include consent and provider provenance

View File

@@ -0,0 +1,85 @@
# Adapter Detailed Implementation Spec_ Mobile Edge Inventory Admin
**Date:** 2026-04-16
**Status:** Implementation-focused adapter spec
**Owner:** Sayan
**Purpose:** Define the first adapter surfaces that need careful treatment.
## 1. Mobile Edge Adapter
The phone edge apps should adapt backend communication state into mobile-safe views.
They should expose:
- alerts
- lead summary
- note entry
- transcription status
- communication memory reminders
- personal calendar entries and follow-up suggestions
- business WhatsApp conversation and call surface where supported
- quick actions
They should not assume:
- unrestricted audio interception
- hidden OS access to every message channel
- unsupported WhatsApp or telephony capture flows
They should support three capture modes:
- direct supported API ingestion
- provider-routed server-side ingestion
- operator-assisted import or note confirmation
They should also surface post-capture intelligence:
- diarized transcript review
- reminder suggestions
- calendar creation or edit suggestions
- CRM and QD-score recommendation review
## 2. Inventory Adapter
The inventory adapter should normalize incoming property material into:
- canonical property rows
- media references
- amenity sets
- pricing and unit data
It must support:
- create
- update
- validation errors
- ingest batch status
## 3. Oracle Template Adapter
The Oracle template adapter should convert:
- chapter and subchapter metadata
- template JSON
- seed examples
into:
- catalog query outputs
- synthesis seeds
- reviewable template records
## 4. Admin Surface Adapter
The admin surface should adapt backend system health and operational actions into:
- health cards
- deployment and install state
- queue visibility
- debug event timelines
## 5. Integration Rules
- adapters translate existing domain truth
- adapters do not become domain owners
- adapters remain thin where possible

View File

@@ -0,0 +1,75 @@
# Oracle Schema and Root API Spec_ Multi-Surface Platform
**Date:** 2026-04-16
**Status:** Schema extension direction
**Owner:** Sayan
**Reviewers:** backend and schema reviewers
**Purpose:** Define how the current Oracle and root API shape should be extended for this workstream.
## 1. Current Truth
Existing Oracle schema already includes:
- canvas pages
- revisions
- components
- prompt executions
- component templates
- forks
- merge requests
- lineage
- audit
Reference:
- `Project_Velocity/backend/oracle/schema_oracle.sql`
## 2. Required Extensions
Recommended additions:
- `oracle_template_chapters`
- `oracle_template_subchapters`
- `oracle_template_seed_examples`
- `oracle_synthetic_generation_jobs`
- `inventory_properties`
- `inventory_import_batches`
- `inventory_media_assets`
- `edge_communication_events`
- `edge_communication_memory_facts`
- `edge_transcription_jobs`
- `edge_transcript_segments`
- `user_calendar_events`
- `insight_recommendations`
- `admin_action_events`
## 3. Root API Additions
Recommended route families:
- `GET /oracle/template-chapters`
- `GET /oracle/template-subchapters`
- `GET /oracle/component-templates`
- `POST /oracle/component-templates/seed`
- `POST /oracle/component-templates/synthetic-jobs`
- `POST /inventory/import-batches`
- `PATCH /inventory/properties/{propertyId}`
- `GET /mobile-edge/events`
- `GET /mobile-edge/memory`
- `POST /mobile-edge/imports`
- `POST /mobile-edge/notes`
- `GET /mobile-edge/calendar`
- `POST /mobile-edge/calendar`
- `PATCH /mobile-edge/calendar/{calendarEventId}`
- `GET /mobile-edge/transcripts/{eventId}`
- `GET /mobile-edge/insights/{leadId}`
- `GET /admin-surface/health`
- `POST /admin-surface/actions`
## 4. Schema Rules
- do not replace current Oracle tables
- extend cleanly
- keep lifecycle and audit fields
- keep provider provenance for communication records
- keep role and tenancy fields where current Oracle patterns already use them

View File

@@ -0,0 +1,58 @@
# Shared Surface Module Spec_ WebOS iPad Android Edge
**Date:** 2026-04-16
**Status:** Internal module mapping
**Owner:** Sayan
**Purpose:** Define how the multi-surface work should be decomposed at module level.
## 1. WebOS Modules To Respect
Current broad modules already exist:
- Dashboard
- Oracle
- Sentinel
- Inventory
- Catalyst
- Settings
## 2. iPad Module Families
Current native families:
- `Features/Dashboard`
- `Features/Inventory`
- `Features/Oracle`
- `Features/Sentinel`
- `Features/Settings`
## 3. Android Tablet Module Recommendation
Mirror the same families:
- dashboard
- inventory
- oracle
- sentinel
- settings
## 4. Phone Edge Module Recommendation
Keep smaller families:
- auth
- alerts
- lead-summary
- communications
- notes
- transcriptions
- settings
## 5. Admin Surface Module Recommendation
- health
- queues
- installs
- debugging
- logs
- operator actions

View File

@@ -0,0 +1,34 @@
# Delivery Roles and Ownership Spec_ Mobile Oracle Admin
**Date:** 2026-04-16
**Status:** Ownership guide
**Owner:** Sayan
**Purpose:** Prevent merge chaos and ambiguous responsibility across the workstream.
## 1. Sayan Primary Ownership
Sayan owns:
- iPad residual scope definition and completion plan
- Android tablet parity plan and implementation base
- phone edge app product framing and initial implementation
- Oracle template book and seed template DB direction
- inventory ingest pipeline planning
- admin control surface planning
## 2. Integration Ownership
Backend owners retain authority over:
- auth
- route architecture
- schema review
- Oracle canonical persistence
## 3. Merge Discipline
- narrow PRs by workstream
- do not reformat unrelated files
- do not rename broad app trees without review
- isolate backend route additions by route family
- isolate schema changes and get review before stacking UI on top

View File

@@ -0,0 +1,31 @@
# Deployment Operations and Release Readiness_ Multi-Surface Platform
**Date:** 2026-04-16
**Status:** Operations planning artifact
**Owner:** Sayan
**Purpose:** Define what release readiness means for this work.
## 1. Minimum Deployment Readiness
- backend schema migrations reviewed
- route additions tested
- tablet builds run locally
- phone edge builds run locally
- admin surface gated by auth
## 2. Operational Safety
- communication ingestion features must have explicit provider and consent assumptions documented
- admin actions must be auditable
- inventory imports must be replayable
## 3. Demo Readiness
- tablets must show coherent flows
- phone edge apps must show real operator value
- Oracle template book must look like a governed catalog, not raw JSON dumps
- admin control panel must show health and bounded actions
## 4. Rollout Rule
Do not ship phone communication features before the supported provider path is technically proven.

View File

@@ -0,0 +1,72 @@
# Implementation Ticket Breakdown and Dependency Matrix_ Multi-Surface Platform
**Date:** 2026-04-16
**Status:** Ticket slicing artifact
**Owner:** Sayan
**Purpose:** Break the work into merge-safe chunks.
## Ticket Matrix
### T-01 Current iPad residual audit
- depends on: current iOS repo review
- produces: residual page list and component gap list
### T-02 Shared contract package
- depends on: backend and Oracle route review
- produces: shared JSON and API contracts
### T-03 Oracle template taxonomy
- depends on: shared contract package
- produces: chapter and subchapter map
### T-04 Oracle template seed DB
- depends on: taxonomy
- produces: seed examples for all current categories
### T-05 Kimi Synthetic Data downstream plan
- depends on: seed DB
- produces: synthetic expansion job definition
### T-06 Inventory ingest schema and API
- depends on: backend schema review
- produces: import and edit route plan
### T-07 Admin control plane schema and routes
- depends on: shared contracts
- produces: admin route and action model
### T-08 Android tablet parity scaffold
- depends on: iPad audit
- produces: Android tablet app structure
### T-09 iPhone edge app MVP
- depends on: phone edge contract and provider constraints
- produces: iPhone edge scaffold
### T-10 Android phone edge app MVP
- depends on: phone edge contract and provider constraints
- produces: Android phone scaffold
### T-11 iPad residual implementation
- depends on: iPad audit
- produces: actual page and component completion
### T-12 Admin surface implementation
- depends on: admin route definitions
- produces: operable admin UI
## Dependency Rule
Contract and schema tickets must land before broad UI implementation tickets.

View File

@@ -0,0 +1,233 @@
# Platform Reality and Communications Capture Strategy
**Date:** 2026-04-17
**Status:** Second-pass reality check and execution guardrail
**Owner:** Sagnik
**Primary Reader:** Sayan
**Purpose:** Keep the communication-memory requirement fully in scope while removing technically false assumptions about what iPhone, Android, telephony, and messaging platforms will actually allow.
## 1. Business Requirement
The requirement is valid and important:
- remember who called or messaged
- remember what they said
- remember what was promised
- remember when to follow up
- surface this back to the operator later as reminders and context
This requirement stays in scope.
What changes is the implementation assumption.
The product should not be specified as "the app can always directly record every call and read every message thread on the phone." That assumption is false on multiple platforms.
For this Sprint 1 planning pass, the active priority channels are:
- cellular calls
- WhatsApp messages
- WhatsApp voice calls
- optional later-stage email ingestion
SMS is not a Sprint 1 focus.
## 2. What Actually Stops Direct Capture
### iPhone
iPhone does not expose a public general-purpose API for third-party apps to directly read iMessage or standard SMS thread history the way a device owner may imagine. CallKit integrates calling apps with the system and lets apps observe call state for app-appropriate scenarios, but it does not turn a third-party business assistant app into a universal recorder for carrier calls or a universal reader for all message content.
Practical implication:
- do not design the iPhone edge app around unrestricted Phone app call audio capture
- do not design it around unrestricted iMessage or SMS history extraction
- do design it around supported provider integrations, explicit imports, notes, reminders, and app-owned or business-owned channels
- if cellular call recording is required on iPhone, treat provider-routed or explicit import workflows as the baseline, not silent in-app interception
### Android
Android is less restrictive in some areas, but it is still not correct to assume universal call or message access. Deeper integration depends on whether the app is the default dialer, default SMS app, enterprise-managed, paired companion, or using an approved provider path. Even then, capture and storage behavior must be treated as channel-specific and permission-sensitive.
Practical implication:
- Android phone edge can go deeper than iPhone in some approved scenarios
- it still must be designed around explicit capability gates, not blanket assumptions
### WhatsApp
WhatsApp business integration is real, but it must be treated as business-platform integration, not as a magical full-device backdoor. Messaging support is a first-class supported path. Voice and video capabilities must be treated as provider- and rollout-dependent, with business API constraints checked explicitly before product promises are made.
Practical implication:
- supported WhatsApp business messaging is in scope
- use a dedicated business messaging and calling surface in our edge apps and WebOS where WhatsApp Business Platform integration supports it
- do not promise universal WhatsApp call recording as the default implementation assumption
- if voice or video calling support is used, it must be routed through the approved business stack and documented as such
### Cellular Calling
Cellular calling remains a valid business requirement, but its implementation path should be treated as channel engineering, not as a generic mobile feature toggle.
Practical implication:
- if office-assigned numbers can be moved behind a business telephony provider, that path is preferable because call recording, transcription, and metadata become server-governed
- if office-assigned numbers remain plain carrier numbers, iPhone and Android behavior must be handled separately and any recording path must be explicit and compliant
- an audible recording disclosure is acceptable from a product standpoint, but the recording architecture still needs to be supported by the chosen channel setup
### Email, Facebook, Instagram, Business Telephony
These are more realistic channels for durable ingestion because they already support server-side, provider-side, or webhook-based integration patterns.
Practical implication:
- these channels should be treated as priority sources for communication memory
## 3. Correct Product Framing
The product feature is:
- communication memory
The product feature is not:
- universal hidden interception of all device communications
That means the app stack should be designed to answer:
- what happened with this lead
- what reminders should exist
- what transcript, message, or note supports that reminder
instead of assuming every communication path can be captured identically.
## 4. Required Three-Mode Strategy
All communication ingestion must fall into one of these modes.
### Mode A: Direct Supported Ingestion
Use this where the platform or provider explicitly supports it.
Examples:
- business messaging webhooks
- approved provider call metadata
- app-owned VoIP or in-app communication flows
- approved default-handler scenarios on Android where the team intentionally accepts that role
### Mode B: Provider-Routed Server-Side Ingestion
This is the most important path for durable enterprise behavior.
Examples:
- business telephony provider records call metadata and recordings server-side
- WhatsApp business messaging enters through provider or business API webhooks
- WhatsApp business calling events and recordings enter through approved business-calling infrastructure where available
- email is mirrored or ingested through mailbox integration
- Facebook and Instagram business messaging arrive through official business APIs
### Mode C: Operator-Assisted Import and Confirmation
This is the fallback that keeps the business requirement alive when direct capture is blocked.
Examples:
- user uploads a recording
- user confirms a note after a call
- user marks a follow-up promise manually
- system converts note plus metadata into a communication memory fact
## 5. Product Consequences for Sayan
### iPhone Edge App
Treat the iPhone app as:
- control surface
- lead context viewer
- reminder surface
- business WhatsApp surface where supported
- note and import surface
- provider-routed communication memory viewer
Do not treat it as a guaranteed universal recorder or inbox scraper.
### Android Phone Edge App
Treat the Android app as:
- the same baseline as iPhone
- plus optional deeper integration only when a specific supported pathway is chosen and documented
### Backend
Backend must own:
- communication event persistence
- transcription job state
- speaker-separated transcript storage
- memory fact extraction
- reminder scheduling
- calendar action suggestions and confirmed calendar writes
- provider provenance
- import workflows
### Oracle
Oracle must be able to reason over:
- communication events
- extracted memory facts
- follow-up dates
- reminder confidence and provenance
## 6. Channel Matrix Sayan Must Produce
Sayan should explicitly map, for each channel:
- can we directly ingest it
- do we need provider routing
- do we only support import or notes
- what consent or policy gate applies
- what reminder data can be extracted
Minimum channels:
- PSTN or business telephony calls
- WhatsApp business messages
- WhatsApp voice calls
- optional email integration
- calendar events created from communication-derived follow-ups
- CRM and QD score side effects from confirmed insights
- PSTN or business telephony calls
- WhatsApp business messages
- WhatsApp voice calls
## 7. Calendar and Insight Consequences
The communications stack is not complete if it only stores recordings and transcripts. It must also drive:
- user-exclusive calendar events
- follow-up reminders
- CRM updates
- QD score updates
- operator-facing insight summaries
NemoClaw should operate on top of stored recordings, transcripts, and extracted memory facts, but only confirmed actions should write into critical systems such as calendar, CRM, and QD score unless product explicitly allows autopilot behavior later.
## 8. Bottom Line
Nothing about platform restrictions removes the business need.
What it removes is lazy product wording.
The correct plan is:
- keep communication memory as a critical feature
- model channel reality explicitly
- route what can be routed through supported providers
- capture what can be captured directly
- import and confirm the rest
That is the version of this feature that can actually ship.

View File

@@ -0,0 +1,88 @@
# Introduction for Sayan_ Multi-Surface Platform and Oracle Expansion
**Date:** 2026-04-16
**Status:** Team onboarding artifact
**Primary Reader:** Sayan
**Purpose:** Provide one entry point into the multi-surface expansion work so you can understand what this project is, where it lives, what already exists, and how to approach it without causing architectural drift.
## 1. What This Project Is
This project is the next product expansion layer for Project Velocity.
It is not a greenfield app exercise. It is not a mobile hack sprint. It is not a backend rewrite.
It is a structured effort to extend the current Velocity product into:
- a completed iPad field app
- an Android tablet equivalent
- a narrow iPhone edge app
- a narrow Android phone edge app
- a communication-memory and calendar loop for assigned office phones
- a broader Oracle schema and template system
- an inventory loading pipeline
- an admin control plane
## 2. What Already Exists
The current repo already has real centers of gravity:
- WebOS frontend in `Project_Velocity/app/`
- FastAPI backend in `Project_Velocity/backend/`
- native iPad app foundation in `Project_Velocity/iOS/velocity/velocity/`
- Oracle route and schema direction in `Project_Velocity/backend/oracle/`
- inventory seed assets in `Project_Velocity/db assets/Inventory/`
This means your job is integration-first.
## 3. Architectural Truth You Should Assume
Assume these decisions are already closed:
1. FastAPI backend remains canonical.
2. Oracle remains the current analytical center.
3. iPad is the tablet reference implementation.
4. Android tablet should mirror iPad structure rather than inventing a new product model.
5. Phone edge apps are narrow control and capture surfaces, not full WebOS clones.
6. Template catalog and inventory data must live under current backend ownership.
7. Admin control must be bounded and auditable.
8. Communication memory is a first-class product outcome, but capture must follow supported channels and approved imports.
9. Sprint 1 communication scope is centered on cellular calls, WhatsApp messages, WhatsApp voice calls, and calendar-driven follow-up.
## 4. Why This Work Matters
Right now Velocity has asymmetry:
- WebOS is broad
- iPad is partial but real
- Android tablet is absent
- phone edge surfaces are absent
- Oracle template strategy is incomplete
- inventory ingestion is not yet normalized
- admin operations are too diffuse
If those remain unresolved, the product stays harder to deploy, demo, and scale.
## 5. Where To Focus First
The highest-value docs for you are:
- [Sayan Work Assignment_ Multi-Surface Platform and Oracle Expansion.md](./Sayan%20Work%20Assignment_%20Multi-Surface%20Platform%20and%20Oracle%20Expansion.md)
- [Sayan Work Assignment_ Sprint 1 Execution Slice.md](./Sayan%20Work%20Assignment_%20Sprint%201%20Execution%20Slice.md)
- [14 - Platform Reality and Communications Capture Strategy.md](./14%20-%20Platform%20Reality%20and%20Communications%20Capture%20Strategy.md)
- [05 - Implementation Blueprint_ Multi-Surface Apps WebOS Edge and Oracle Delivery.md](./05%20-%20Implementation%20Blueprint_%20Multi-Surface%20Apps%20WebOS%20Edge%20and%20Oracle%20Delivery.md)
- [09 - Oracle Schema and Root API Spec_ Multi-Surface Platform.md](./09%20-%20Oracle%20Schema%20and%20Root%20API%20Spec_%20Multi-Surface%20Platform.md)
- [07 - Contracts and JSON Schemas_ Templates Inventory Edge Capture.md](./07%20-%20Contracts%20and%20JSON%20Schemas_%20Templates%20Inventory%20Edge%20Capture.md)
- [13 - Implementation Ticket Breakdown and Dependency Matrix_ Multi-Surface Platform.md](./13%20-%20Implementation%20Ticket%20Breakdown%20and%20Dependency%20Matrix_%20Multi-Surface%20Platform.md)
## 6. What You Must Not Do
- do not create a second backend authority
- do not create a second schema center
- do not assume unsupported mobile call or message capture paths
- do not confuse "critical feature" with "unsupported direct OS interception"
- do not build merge-hostile broad rewrites
- do not treat phone edge apps as mini WebOS copies
## 7. Bottom Line
Your task is to make the current Velocity product more complete across tablets, phones, Oracle data assets, and admin operations without breaking the current root.

View File

@@ -0,0 +1,183 @@
# Sayan Work Assignment_ Multi-Surface Platform and Oracle Expansion
**Date:** 2026-04-16
**Status:** Active assignment handoff
**Assignee:** Sayan
**Purpose:** Define the exact work Sayan owns in this run, including current repository truths, scope boundaries, sequence, and merge discipline.
## 1. Assignment Summary
You are being assigned the multi-surface product expansion lane for Project Velocity.
This includes:
1. iPad app residual pages and component updates
2. iPhone edge app
3. Android tablet parity app
4. Android phone edge app
5. Oracle schema coverage for these component classes
6. Oracle template book and seed JSON template DB
7. Kimi Synthetic Data downstream plan
8. inventory loading and edit pipeline
9. admin control panel surface
10. communication-memory strategy across supported business channels
11. user calendar and transcript-intelligence path for NemoClaw-assisted follow-up
This is a broad assignment, but it is not a freeform rewrite.
## 2. Current Repo Truth You Must Respect
### WebOS
- `Project_Velocity/app/`
Relevant current files:
- `Project_Velocity/app/src/App.tsx`
- `Project_Velocity/app/src/app/oracle/page.tsx`
- `Project_Velocity/app/src/hooks/useCrmBootstrap.ts`
- `Project_Velocity/app/src/components/modules/`
### iPad app
- `Project_Velocity/iOS/velocity/velocity/`
Relevant current feature folders:
- `Features/Dashboard`
- `Features/Inventory`
- `Features/Oracle`
- `Features/Sentinel`
- `Features/Settings`
### Backend and Oracle
- `Project_Velocity/backend/`
Relevant files:
- `Project_Velocity/backend/main.py`
- `Project_Velocity/backend/api/routes_oracle.py`
- `Project_Velocity/backend/api/routes_crm.py`
- `Project_Velocity/backend/oracle/router_v1.py`
- `Project_Velocity/backend/oracle/schema_oracle.sql`
### Inventory source corpus
- `Project_Velocity/db assets/Inventory/`
## 3. Your Concrete Scope
### 3.1 iPad App
You own:
- residual page audit
- component gap audit
- completion plan
- implementation of the open gaps you can safely land
### 3.2 Android Tablet App
You own:
- parity map against current iPad information architecture
- recommended project structure
- initial implementation scaffold
### 3.3 Phone Edge Apps
You own:
- iPhone edge app definition
- Android phone edge app definition
- shared edge MVP scope
- explicit platform-safe feature boundaries
These phone apps are not full WebOS copies. They are compact operator surfaces for:
- quick lead and communication awareness
- alerts
- note capture
- transcription visibility
- communication-memory reminders
- calendar follow-up actions
- approved communication control hooks
### 3.4 Oracle Template System
You own:
- template chapter taxonomy
- subchapter taxonomy
- seed JSON examples
- DB model proposal for storing them
### 3.5 Kimi Synthetic Data
You own the downstream plan. The expected output is a separate Kimi Synthetic Data workstream definition that consumes the template seed DB.
### 3.6 Inventory Loading Pipeline
You own:
- schema and contract planning
- operator-friendly add/edit flow planning
- ingest pipeline planning
Sagnik still needs to provide additional property source files. Build the pipeline shape now, not fake completeness.
### 3.7 Admin Control Panel
You own:
- admin surface scope
- route and action plan
- implementation blueprint
## 4. What You Must Read First
1. [Introduction for Sayan.md](./Introduction%20for%20Sayan.md)
2. [Sayan Work Assignment_ Sprint 1 Execution Slice.md](./Sayan%20Work%20Assignment_%20Sprint%201%20Execution%20Slice.md)
3. [14 - Platform Reality and Communications Capture Strategy.md](./14%20-%20Platform%20Reality%20and%20Communications%20Capture%20Strategy.md)
4. [01 - First Principles_ Multi-Surface Platform and Oracle Expansion.md](./01%20-%20First%20Principles_%20Multi-Surface%20Platform%20and%20Oracle%20Expansion.md)
5. [05 - Implementation Blueprint_ Multi-Surface Apps WebOS Edge and Oracle Delivery.md](./05%20-%20Implementation%20Blueprint_%20Multi-Surface%20Apps%20WebOS%20Edge%20and%20Oracle%20Delivery.md)
6. [07 - Contracts and JSON Schemas_ Templates Inventory Edge Capture.md](./07%20-%20Contracts%20and%20JSON%20Schemas_%20Templates%20Inventory%20Edge%20Capture.md)
7. [09 - Oracle Schema and Root API Spec_ Multi-Surface Platform.md](./09%20-%20Oracle%20Schema%20and%20Root%20API%20Spec_%20Multi-Surface%20Platform.md)
8. [13 - Implementation Ticket Breakdown and Dependency Matrix_ Multi-Surface Platform.md](./13%20-%20Implementation%20Ticket%20Breakdown%20and%20Dependency%20Matrix_%20Multi-Surface%20Platform.md)
## 5. What You Must Not Do
- do not create a second backend center
- do not create a second Oracle schema truth
- do not build phone capture around unsupported or hidden OS behavior
- do not assume a managed work phone automatically grants system-level message or call access
- do not casually rewrite active WebOS modules owned by others
- do not block other branches by broad refactors
## 6. Collaboration and Merge Discipline
Respect each other's code.
- narrow PRs by workstream
- do not reformat unrelated files
- do not rename broad folders casually
- isolate backend route additions by route family
- isolate schema changes and get review before stacking UI on top
## 7. Recommended Execution Order
1. audit current truth
2. define shared contracts
3. define schema additions
4. define iPad residual list
5. define Android tablet parity map
6. define phone edge MVPs with platform-safe constraints
7. define template book and DB seed plan
8. define inventory ingest path
9. define admin control plane
10. implement in bounded slices
## 8. Bottom Line
Your job is to turn the current Velocity codebase into a more complete multi-surface product without breaking the existing center of gravity. Start from contracts and schema. Then build the surfaces.

View File

@@ -0,0 +1,170 @@
# Sayan Work Assignment_ Sprint 1 Execution Slice
**Date:** 2026-04-17
**Status:** Active execution slice
**Assignee:** Sayan
**Purpose:** Reduce a broad multi-surface assignment into the Sprint 1 slice that must be completed before broader implementation fans out.
## 1. Why This Slice Exists
Your broader assignment is intentionally large. Sprint 1 should not try to fully build every tablet, every phone surface, the entire Oracle template library, the complete inventory pipeline, and the full admin system simultaneously.
Sprint 1 must establish the execution truth that later implementation will build on.
The active communications priority for this slice is:
- cellular calls
- WhatsApp messages
- WhatsApp voice calls
- user calendar integration
- transcript and insight pipeline
SMS is deferred.
## 2. Sprint 1 Deliverables
### 2.1 iPad Residual Audit
Produce:
- page-by-page residual inventory
- component gap list
- acceptance criteria for each unfinished area
- safe implementation order
### 2.2 Android Tablet Parity Map
Produce:
- information-architecture parity against iPad
- screen map
- shared modules versus Android-specific modules
- implementation scaffold recommendation
### 2.3 Phone Edge MVP Definition
Produce:
- shared iPhone and Android phone edge MVP
- channel-by-channel communication capture matrix for cellular calls and WhatsApp
- control-surface scope
- reminder and follow-up scope
- note and import flows
- business WhatsApp inbox and calling surface scope
This is a product-definition and contract-definition step first, not a blind UI sprint.
### 2.4 Oracle Template Taxonomy
Produce:
- chapter tree
- subchapter tree
- component families
- two to three seed JSON examples per representative class
### 2.5 Inventory Ingest Contract Draft
Produce:
- property ingest entity model
- media ingest model
- operator edit model
- batch import lifecycle
- validation and error surfaces
### 2.6 Admin Bounded Action List
Produce:
- admin roles
- supported actions
- unsafe actions explicitly excluded
- required audit trail events
### 2.7 Backend Schema Extension Draft
Produce:
- Oracle schema additions
- mobile-edge route additions
- template DB route additions
- communication-memory entities
- calendar entities and action contracts
- transcript and diarization entities
### 2.8 Calendar and Agent Action Slice
Produce:
- user-exclusive calendar model
- NemoClaw write permissions and confirmation rules
- event creation, edit, and reminder contract
- relationship between communications, reminders, and calendar entries
## 3. Communication Capture Matrix You Must Deliver
For each of these channels, classify:
- supported direct ingestion
- provider-routed ingestion
- operator-assisted import only
- blocked or deferred
Channels:
- PSTN or business telephony calls
- WhatsApp business messages
- WhatsApp voice calls
- optional email follow-on note
- calendar writeback trigger path
- CRM or QD-score side effect path
- WhatsApp business messages
- WhatsApp voice calls
For each channel, also define:
- consent model
- transcription path
- speaker segregation path
- reminder extraction path
- backend provenance field requirements
## 4. Mandatory Sprint 1 Output Shape
By the end of Sprint 1, Sayan should have produced:
- a concrete parity matrix for iPad and Android tablet
- a bounded edge-app MVP with channel reality accounted for
- schema and contract extensions for communication memory
- schema and contract extensions for calendar and transcript intelligence
- Oracle template taxonomy and seed examples
- inventory ingest contract shape
- admin action boundary list
## 5. What Sprint 1 Must Not Attempt
- full cross-platform implementation all at once
- unsupported mobile interception experiments
- broad backend rewrites
- speculative schema expansion without route and contract alignment
## 6. Sequence
1. Audit current iPad and WebOS truth.
2. Produce Android tablet parity map.
3. Produce communication capture matrix for cellular and WhatsApp.
4. Produce communication-memory contract additions.
5. Produce calendar model and NemoClaw action rules.
6. Produce Oracle template taxonomy and seed JSON structure.
7. Produce inventory ingest contract draft.
8. Produce admin action boundary list.
9. Only then begin bounded implementation slices.
## 7. Bottom Line
Sprint 1 is the execution-shaping sprint.
If this slice is done correctly, later implementation will be parallelizable and reviewable.
If this slice is skipped, the broader assignment will fragment into conflicting mobile assumptions, weak schemas, and hard-to-merge surface work.

View File

@@ -0,0 +1,60 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.desineuron.velocity.edgephone"
compileSdk = 35
defaultConfig {
applicationId = "com.desineuron.velocity.edgephone"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
}

View File

@@ -0,0 +1 @@
# MVP scaffold: no custom rules yet.

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="Velocity Edge"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,96 @@
package com.desineuron.velocity.edgephone
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import com.desineuron.velocity.edgephone.features.AlertsScreen
import com.desineuron.velocity.edgephone.features.CommunicationsScreen
import com.desineuron.velocity.edgephone.features.LeadSummaryScreen
import com.desineuron.velocity.edgephone.features.NotesScreen
import com.desineuron.velocity.edgephone.features.SettingsScreen
import com.desineuron.velocity.edgephone.features.TranscriptionsScreen
import com.desineuron.velocity.edgephone.ui.theme.VelocityEdgePhoneTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
VelocityEdgePhoneTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
EdgePhoneApp()
}
}
}
}
}
private data class PhoneDestination(val route: String, val label: String)
private val phoneDestinations = listOf(
PhoneDestination("alerts", "Alerts"),
PhoneDestination("lead-summary", "Lead"),
PhoneDestination("communications", "Comms"),
PhoneDestination("notes", "Notes"),
PhoneDestination("transcriptions", "Transcripts"),
PhoneDestination("settings", "Settings"),
)
@Composable
private fun EdgePhoneApp() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
Scaffold(
bottomBar = {
NavigationBar {
phoneDestinations.forEach { destination ->
val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {},
label = { Text(destination.label) },
)
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "alerts",
modifier = Modifier.fillMaxSize(),
) {
composable("alerts") { AlertsScreen(innerPadding) }
composable("lead-summary") { LeadSummaryScreen(innerPadding) }
composable("communications") { CommunicationsScreen(innerPadding) }
composable("notes") { NotesScreen(innerPadding) }
composable("transcriptions") { TranscriptionsScreen(innerPadding) }
composable("settings") { SettingsScreen(innerPadding) }
}
}
}

View File

@@ -0,0 +1,14 @@
package com.desineuron.velocity.edgephone.features
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
@Composable
fun AlertsScreen(paddingValues: PaddingValues) {
PhoneScaffold(
paddingValues = paddingValues,
title = "Alerts",
subtitle = "High-urgency nudges for unread responses, callbacks, and showroom events.",
actionLabel = "Respond to whale-lead unread thread",
)
}

View File

@@ -0,0 +1,14 @@
package com.desineuron.velocity.edgephone.features
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
@Composable
fun CommunicationsScreen(paddingValues: PaddingValues) {
PhoneScaffold(
paddingValues = paddingValues,
title = "Communications",
subtitle = "Call, WhatsApp, and operator-import touchpoints on a single edge rail.",
actionLabel = "Log a manual note after callback",
)
}

View File

@@ -0,0 +1,14 @@
package com.desineuron.velocity.edgephone.features
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
@Composable
fun LeadSummaryScreen(paddingValues: PaddingValues) {
PhoneScaffold(
paddingValues = paddingValues,
title = "Lead Summary",
subtitle = "Compact account memory, qualification, and next-best action.",
actionLabel = "Review Mohammed Al-Rashid context",
)
}

View File

@@ -0,0 +1,14 @@
package com.desineuron.velocity.edgephone.features
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
@Composable
fun NotesScreen(paddingValues: PaddingValues) {
PhoneScaffold(
paddingValues = paddingValues,
title = "Notes",
subtitle = "Fast capture for memory facts, objections, and promised follow-ups.",
actionLabel = "Create note with memory extraction hint",
)
}

View File

@@ -0,0 +1,52 @@
package com.desineuron.velocity.edgephone.features
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun PhoneScaffold(
paddingValues: PaddingValues,
title: String,
subtitle: String,
actionLabel: String,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF05070B))
.padding(paddingValues)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, color = Color.White)
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8))
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF0B1220), RoundedCornerShape(24.dp))
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Edge action", style = MaterialTheme.typography.labelLarge, color = Color(0xFF38BDF8))
Text(actionLabel, style = MaterialTheme.typography.titleMedium, color = Color.White)
Text(
"This narrow surface is ready for backend hookup to `/api/mobile-edge` once auth and install registration are connected.",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF94A3B8),
)
}
}
}

View File

@@ -0,0 +1,14 @@
package com.desineuron.velocity.edgephone.features
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
@Composable
fun SettingsScreen(paddingValues: PaddingValues) {
PhoneScaffold(
paddingValues = paddingValues,
title = "Settings",
subtitle = "Install registration, operator identity, and API connection state.",
actionLabel = "Verify surface heartbeat and app version",
)
}

View File

@@ -0,0 +1,14 @@
package com.desineuron.velocity.edgephone.features
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
@Composable
fun TranscriptionsScreen(paddingValues: PaddingValues) {
PhoneScaffold(
paddingValues = paddingValues,
title = "Transcriptions",
subtitle = "Imported voice artifacts and segment-level summaries for the field operator.",
actionLabel = "Review pending recording import",
)
}

View File

@@ -0,0 +1,26 @@
package com.desineuron.velocity.edgephone.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val EdgeColors = darkColorScheme(
primary = Color(0xFF38BDF8),
secondary = Color(0xFF14B8A6),
tertiary = Color(0xFF22C55E),
background = Color(0xFF05070B),
surface = Color(0xFF0B1220),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
)
@Composable
fun VelocityEdgePhoneTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = EdgeColors,
content = content,
)
}

View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "velocity-android-edge-phone"
include(":app")

View File

@@ -0,0 +1,63 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.desineuron.velocity.tablet"
compileSdk = 35
defaultConfig {
applicationId = "com.desineuron.velocity.tablet"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
}

1
android-tablet/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# MVP scaffold: no custom rules yet.

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="Velocity Tablet"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,117 @@
package com.desineuron.velocity.tablet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.desineuron.velocity.tablet.features.DashboardScreen
import com.desineuron.velocity.tablet.features.InventoryScreen
import com.desineuron.velocity.tablet.features.OracleScreen
import com.desineuron.velocity.tablet.features.SettingsScreen
import com.desineuron.velocity.tablet.features.SentinelScreen
import com.desineuron.velocity.tablet.ui.theme.VelocityTabletTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
VelocityTabletTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
TabletApp()
}
}
}
}
}
private data class TabletDestination(val route: String, val label: String)
private val tabletDestinations = listOf(
TabletDestination("dashboard", "Dashboard"),
TabletDestination("inventory", "Inventory"),
TabletDestination("oracle", "Oracle"),
TabletDestination("sentinel", "Sentinel"),
TabletDestination("settings", "Settings"),
)
@Composable
private fun TabletApp() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
Row(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF05070B)),
) {
NavigationRail(
modifier = Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
containerColor = Color(0xFF0D1118),
header = {
Column(
modifier = Modifier.padding(bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Velocity", color = Color.White, style = MaterialTheme.typography.titleMedium)
Text("Tablet parity", color = Color(0xFF8A93A6), style = MaterialTheme.typography.labelSmall)
}
},
) {
tabletDestinations.forEach { destination ->
val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true
NavigationRailItem(
selected = selected,
onClick = {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Box(modifier = Modifier.padding(4.dp)) },
label = { Text(destination.label) },
)
}
}
NavHost(
navController = navController,
startDestination = "dashboard",
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
) {
composable("dashboard") { DashboardScreen() }
composable("inventory") { InventoryScreen() }
composable("oracle") { OracleScreen() }
composable("sentinel") { SentinelScreen() }
composable("settings") { SettingsScreen() }
}
}
}

View File

@@ -0,0 +1,12 @@
package com.desineuron.velocity.tablet.features
import androidx.compose.runtime.Composable
@Composable
fun DashboardScreen() {
FeatureScaffold(
title = "Dashboard",
subtitle = "Sales, sentiment, and operational posture for the field team.",
chips = listOf("Visitors live", "Revenue outlook", "Queue health"),
)
}

View File

@@ -0,0 +1,64 @@
package com.desineuron.velocity.tablet.features
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun FeatureScaffold(
title: String,
subtitle: String,
chips: List<String>,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF05070B))
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.headlineMedium, color = Color.White)
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8))
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
chips.forEach { chip ->
Text(
text = chip,
color = Color.White,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.background(Color(0xFF111827), RoundedCornerShape(14.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF0B1220), RoundedCornerShape(28.dp))
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Scaffold state", style = MaterialTheme.typography.titleMedium, color = Color.White)
Text(
"This screen is wired into the tablet navigation graph and is ready for the shared contract package once the API clients are connected.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF94A3B8),
)
}
}
}

View File

@@ -0,0 +1,12 @@
package com.desineuron.velocity.tablet.features
import androidx.compose.runtime.Composable
@Composable
fun InventoryScreen() {
FeatureScaffold(
title = "Inventory",
subtitle = "Property catalog, media assets, and ingest lifecycle visibility.",
chips = listOf("Import batches", "Listings", "Validation state"),
)
}

View File

@@ -0,0 +1,12 @@
package com.desineuron.velocity.tablet.features
import androidx.compose.runtime.Composable
@Composable
fun OracleScreen() {
FeatureScaffold(
title = "Oracle",
subtitle = "Template-guided intelligence views for pipeline and scheduling.",
chips = listOf("Pipeline", "Lead map", "Calendar tasks"),
)
}

View File

@@ -0,0 +1,12 @@
package com.desineuron.velocity.tablet.features
import androidx.compose.runtime.Composable
@Composable
fun SentinelScreen() {
FeatureScaffold(
title = "Sentinel",
subtitle = "Biometric and sentiment awareness stream for visitor sessions.",
chips = listOf("Live session", "Journey river", "QD overlays"),
)
}

View File

@@ -0,0 +1,12 @@
package com.desineuron.velocity.tablet.features
import androidx.compose.runtime.Composable
@Composable
fun SettingsScreen() {
FeatureScaffold(
title = "Settings",
subtitle = "Surface registration, connection state, and operator preferences.",
chips = listOf("Install info", "API endpoint", "Operator profile"),
)
}

View File

@@ -0,0 +1,26 @@
package com.desineuron.velocity.tablet.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val VelocityDarkColors = darkColorScheme(
primary = Color(0xFF3B82F6),
secondary = Color(0xFF14B8A6),
tertiary = Color(0xFF22C55E),
background = Color(0xFF05070B),
surface = Color(0xFF0B1220),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
)
@Composable
fun VelocityTabletTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = VelocityDarkColors,
content = content,
)
}

View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "velocity-android-tablet"
include(":app")

View File

@@ -2,23 +2,23 @@
import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-GRXJTWBV.js";
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
import {
require_shim
} from "./chunk-642Z5WD3.js";
} from "./chunk-J4JAFMOP.js";
import {
createSlot
} from "./chunk-5HUACAZ7.js";
import "./chunk-HPBHRBIF.js";
} from "./chunk-YWBEB5PG.js";
import "./chunk-2VUH7NEY.js";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
} from "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__toESM
} from "./chunk-G3PMV62Z.js";

View File

@@ -2,20 +2,20 @@
import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-GRXJTWBV.js";
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
} from "./chunk-J4JAFMOP.js";
import {
composeRefs,
useComposedRefs
} from "./chunk-HPBHRBIF.js";
} from "./chunk-2VUH7NEY.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
} from "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__toESM
} from "./chunk-G3PMV62Z.js";

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@ import {
Slottable,
createSlot,
createSlottable
} from "./chunk-5HUACAZ7.js";
import "./chunk-HPBHRBIF.js";
import "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-YWBEB5PG.js";
import "./chunk-2VUH7NEY.js";
import "./chunk-2YVA4HRZ.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export {
Slot as Root,

View File

@@ -1,18 +1,12 @@
import {
_extends
} from "./chunk-H4GSM2WL.js";
create
} from "./chunk-7GZ4CI6Q.js";
import {
subscribeWithSelector
} from "./chunk-XGWIEMTH.js";
} from "./chunk-O4L7C4YS.js";
import {
Events
} from "./chunk-OAEA5FZL.js";
import {
require_client
} from "./chunk-AFNBKP7P.js";
import {
create
} from "./chunk-QJTQF54Q.js";
import {
addAfterEffect,
addEffect,
@@ -28,7 +22,8 @@ import {
useInstanceHandle,
useLoader,
useThree
} from "./chunk-JRJA23OI.js";
} from "./chunk-5ESDTKMP.js";
import "./chunk-NJ4V5H3P.js";
import {
AddEquation,
AdditiveBlending,
@@ -222,15 +217,20 @@ import {
WebGLRenderer,
WireframeGeometry,
ZeroFactor
} from "./chunk-INS7YHTD.js";
import "./chunk-QURGMCZB.js";
import "./chunk-LTNRPUSL.js";
import "./chunk-YLZ34CCM.js";
import "./chunk-642Z5WD3.js";
import "./chunk-USXRE7Q2.js";
} from "./chunk-L3Z576C2.js";
import {
require_client
} from "./chunk-6MXH2QM6.js";
import "./chunk-GUQHL3N7.js";
import {
_extends
} from "./chunk-EQCCHGRT.js";
import "./chunk-TXHHHGR3.js";
import "./chunk-YF4B4G2L.js";
import "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS,
__toESM
@@ -109302,7 +109302,7 @@ var FaceLandmarker = (0, import_react25.forwardRef)(({
const {
FilesetResolver,
FaceLandmarker: FaceLandmarker2
} = await import("./vision_bundle-ZAS5UOAV.js");
} = await import("@mediapipe/tasks-vision");
const vision = await FilesetResolver.forVisionTasks(basePath);
return FaceLandmarker2.createFromOptions(vision, options);
}, [basePath, opts]);

File diff suppressed because one or more lines are too long

View File

@@ -28,13 +28,13 @@ import {
useLoader,
useStore,
useThree
} from "./chunk-JRJA23OI.js";
import "./chunk-INS7YHTD.js";
import "./chunk-QURGMCZB.js";
import "./chunk-LTNRPUSL.js";
import "./chunk-642Z5WD3.js";
import "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-5ESDTKMP.js";
import "./chunk-NJ4V5H3P.js";
import "./chunk-L3Z576C2.js";
import "./chunk-GUQHL3N7.js";
import "./chunk-TXHHHGR3.js";
import "./chunk-2YVA4HRZ.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export {
Canvas,

View File

@@ -1,133 +1,133 @@
{
"hash": "48124858",
"configHash": "2be684c6",
"lockfileHash": "dbdb05fd",
"browserHash": "bc295ff7",
"hash": "4594f192",
"configHash": "1dd3b956",
"lockfileHash": "e8550e82",
"browserHash": "7e7e8c10",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "ca909492",
"fileHash": "bc0c1f26",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "9ea60d36",
"fileHash": "36a8d9c0",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "f778ce34",
"fileHash": "3d8f6460",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "afe32f9c",
"fileHash": "6f4aca26",
"needsInterop": true
},
"@radix-ui/react-avatar": {
"src": "../../@radix-ui/react-avatar/dist/index.mjs",
"file": "@radix-ui_react-avatar.js",
"fileHash": "78604ab7",
"fileHash": "2a702dd2",
"needsInterop": false
},
"@radix-ui/react-dropdown-menu": {
"src": "../../@radix-ui/react-dropdown-menu/dist/index.mjs",
"file": "@radix-ui_react-dropdown-menu.js",
"fileHash": "7e6567c2",
"fileHash": "a5efb9bf",
"needsInterop": false
},
"@radix-ui/react-slot": {
"src": "../../@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js",
"fileHash": "4f153a2d",
"fileHash": "986d9c0d",
"needsInterop": false
},
"@react-three/drei": {
"src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js",
"fileHash": "313a1f02",
"fileHash": "6cd60875",
"needsInterop": false
},
"@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js",
"fileHash": "5e5643b4",
"fileHash": "27a7d4df",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "69d37784",
"fileHash": "b0c32b93",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "861ef14c",
"fileHash": "c855e729",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "3f8d4bda",
"fileHash": "e0841dfa",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "f8a0e731",
"fileHash": "4d79a586",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "bb1fb188",
"fileHash": "2e02376b",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "69a5af47",
"fileHash": "bd4cf4c4",
"needsInterop": false
},
"recharts": {
"src": "../../recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "8c3719f7",
"fileHash": "b44545db",
"needsInterop": false
},
"sonner": {
"src": "../../sonner/dist/index.mjs",
"file": "sonner.js",
"fileHash": "ff7fef4b",
"fileHash": "02632b99",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "b1f20ce9",
"fileHash": "ab22bcc4",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "cae86099",
"fileHash": "43012f83",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "60f9f3ea",
"fileHash": "dbfba0e2",
"needsInterop": false
},
"zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js",
"fileHash": "3b17a615",
"fileHash": "e524c2dc",
"needsInterop": false
}
},
@@ -135,59 +135,56 @@
"hls-Q6LDPZPT": {
"file": "hls-Q6LDPZPT.js"
},
"vision_bundle-ZAS5UOAV": {
"file": "vision_bundle-ZAS5UOAV.js"
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-H4GSM2WL": {
"file": "chunk-H4GSM2WL.js"
"chunk-J4JAFMOP": {
"file": "chunk-J4JAFMOP.js"
},
"chunk-XGWIEMTH": {
"file": "chunk-XGWIEMTH.js"
"chunk-YWBEB5PG": {
"file": "chunk-YWBEB5PG.js"
},
"chunk-2VUH7NEY": {
"file": "chunk-2VUH7NEY.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-AFNBKP7P": {
"file": "chunk-AFNBKP7P.js"
"chunk-5ESDTKMP": {
"file": "chunk-5ESDTKMP.js"
},
"chunk-QJTQF54Q": {
"file": "chunk-QJTQF54Q.js"
"chunk-NJ4V5H3P": {
"file": "chunk-NJ4V5H3P.js"
},
"chunk-JRJA23OI": {
"file": "chunk-JRJA23OI.js"
"chunk-L3Z576C2": {
"file": "chunk-L3Z576C2.js"
},
"chunk-INS7YHTD": {
"file": "chunk-INS7YHTD.js"
"chunk-6MXH2QM6": {
"file": "chunk-6MXH2QM6.js"
},
"chunk-QURGMCZB": {
"file": "chunk-QURGMCZB.js"
"chunk-GUQHL3N7": {
"file": "chunk-GUQHL3N7.js"
},
"chunk-LTNRPUSL": {
"file": "chunk-LTNRPUSL.js"
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-GRXJTWBV": {
"file": "chunk-GRXJTWBV.js"
"chunk-YF4B4G2L": {
"file": "chunk-YF4B4G2L.js"
},
"chunk-YLZ34CCM": {
"file": "chunk-YLZ34CCM.js"
"chunk-2YVA4HRZ": {
"file": "chunk-2YVA4HRZ.js"
},
"chunk-642Z5WD3": {
"file": "chunk-642Z5WD3.js"
},
"chunk-5HUACAZ7": {
"file": "chunk-5HUACAZ7.js"
},
"chunk-HPBHRBIF": {
"file": "chunk-HPBHRBIF.js"
},
"chunk-USXRE7Q2": {
"file": "chunk-USXRE7Q2.js"
},
"chunk-ZNKPWGXJ": {
"file": "chunk-ZNKPWGXJ.js"
"chunk-WUR7D6NS": {
"file": "chunk-WUR7D6NS.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"

View File

@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../class-variance-authority/dist/index.mjs"],
"sourcesContent": ["/**\r\n * Copyright 2022 Joe Bell. All rights reserved.\r\n *\r\n * This file is licensed to you under the Apache License, Version 2.0\r\n * (the \"License\"); you may not use this file except in compliance with the\r\n * License. You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT\r\n * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the\r\n * License for the specific language governing permissions and limitations under\r\n * the License.\r\n */ import { clsx } from \"clsx\";\r\nconst falsyToString = (value)=>typeof value === \"boolean\" ? `${value}` : value === 0 ? \"0\" : value;\r\nexport const cx = clsx;\r\nexport const cva = (base, config)=>(props)=>{\r\n var _config_compoundVariants;\r\n if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\r\n const { variants, defaultVariants } = config;\r\n const getVariantClassNames = Object.keys(variants).map((variant)=>{\r\n const variantProp = props === null || props === void 0 ? void 0 : props[variant];\r\n const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];\r\n if (variantProp === null) return null;\r\n const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);\r\n return variants[variant][variantKey];\r\n });\r\n const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param)=>{\r\n let [key, value] = param;\r\n if (value === undefined) {\r\n return acc;\r\n }\r\n acc[key] = value;\r\n return acc;\r\n }, {});\r\n const getCompoundVariantClassNames = config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param)=>{\r\n let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;\r\n return Object.entries(compoundVariantOptions).every((param)=>{\r\n let [key, value] = param;\r\n return Array.isArray(value) ? value.includes({\r\n ...defaultVariants,\r\n ...propsWithoutUndefined\r\n }[key]) : ({\r\n ...defaultVariants,\r\n ...propsWithoutUndefined\r\n })[key] === value;\r\n }) ? [\r\n ...acc,\r\n cvClass,\r\n cvClassName\r\n ] : acc;\r\n }, []);\r\n return cx(base, getVariantClassNames, getCompoundVariantClassNames, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\r\n };\r\n\r\n"],
"sourcesContent": ["/**\n * Copyright 2022 Joe Bell. All rights reserved.\n *\n * This file is licensed to you under the Apache License, Version 2.0\n * (the \"License\"); you may not use this file except in compliance with the\n * License. You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT\n * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the\n * License for the specific language governing permissions and limitations under\n * the License.\n */ import { clsx } from \"clsx\";\nconst falsyToString = (value)=>typeof value === \"boolean\" ? `${value}` : value === 0 ? \"0\" : value;\nexport const cx = clsx;\nexport const cva = (base, config)=>(props)=>{\n var _config_compoundVariants;\n if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\n const { variants, defaultVariants } = config;\n const getVariantClassNames = Object.keys(variants).map((variant)=>{\n const variantProp = props === null || props === void 0 ? void 0 : props[variant];\n const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];\n if (variantProp === null) return null;\n const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);\n return variants[variant][variantKey];\n });\n const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param)=>{\n let [key, value] = param;\n if (value === undefined) {\n return acc;\n }\n acc[key] = value;\n return acc;\n }, {});\n const getCompoundVariantClassNames = config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param)=>{\n let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;\n return Object.entries(compoundVariantOptions).every((param)=>{\n let [key, value] = param;\n return Array.isArray(value) ? value.includes({\n ...defaultVariants,\n ...propsWithoutUndefined\n }[key]) : ({\n ...defaultVariants,\n ...propsWithoutUndefined\n })[key] === value;\n }) ? [\n ...acc,\n cvClass,\n cvClassName\n ] : acc;\n }, []);\n return cx(base, getVariantClassNames, getCompoundVariantClassNames, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\n };\n\n"],
"mappings": ";;;;;;AAeA,IAAM,gBAAgB,CAAC,UAAQ,OAAO,UAAU,YAAY,GAAG,KAAK,KAAK,UAAU,IAAI,MAAM;AACtF,IAAM,KAAK;AACX,IAAM,MAAM,CAAC,MAAM,WAAS,CAAC,UAAQ;AACpC,MAAI;AACJ,OAAK,WAAW,QAAQ,WAAW,SAAS,SAAS,OAAO,aAAa,KAAM,QAAO,GAAG,MAAM,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,SAAS;AACvN,QAAM,EAAE,UAAU,gBAAgB,IAAI;AACtC,QAAM,uBAAuB,OAAO,KAAK,QAAQ,EAAE,IAAI,CAAC,YAAU;AAC9D,UAAM,cAAc,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO;AAC/E,UAAM,qBAAqB,oBAAoB,QAAQ,oBAAoB,SAAS,SAAS,gBAAgB,OAAO;AACpH,QAAI,gBAAgB,KAAM,QAAO;AACjC,UAAM,aAAa,cAAc,WAAW,KAAK,cAAc,kBAAkB;AACjF,WAAO,SAAS,OAAO,EAAE,UAAU;AAAA,EACvC,CAAC;AACD,QAAM,wBAAwB,SAAS,OAAO,QAAQ,KAAK,EAAE,OAAO,CAAC,KAAK,UAAQ;AAC9E,QAAI,CAAC,KAAK,KAAK,IAAI;AACnB,QAAI,UAAU,QAAW;AACrB,aAAO;AAAA,IACX;AACA,QAAI,GAAG,IAAI;AACX,WAAO;AAAA,EACX,GAAG,CAAC,CAAC;AACL,QAAM,+BAA+B,WAAW,QAAQ,WAAW,SAAS,UAAU,2BAA2B,OAAO,sBAAsB,QAAQ,6BAA6B,SAAS,SAAS,yBAAyB,OAAO,CAAC,KAAK,UAAQ;AAC/O,QAAI,EAAE,OAAO,SAAS,WAAW,aAAa,GAAG,uBAAuB,IAAI;AAC5E,WAAO,OAAO,QAAQ,sBAAsB,EAAE,MAAM,CAACA,WAAQ;AACzD,UAAI,CAAC,KAAK,KAAK,IAAIA;AACnB,aAAO,MAAM,QAAQ,KAAK,IAAI,MAAM,SAAS;AAAA,QACzC,GAAG;AAAA,QACH,GAAG;AAAA,MACP,EAAE,GAAG,CAAC,IAAK;AAAA,QACP,GAAG;AAAA,QACH,GAAG;AAAA,MACP,EAAG,GAAG,MAAM;AAAA,IAChB,CAAC,IAAI;AAAA,MACD,GAAG;AAAA,MACH;AAAA,MACA;AAAA,IACJ,IAAI;AAAA,EACR,GAAG,CAAC,CAAC;AACL,SAAO,GAAG,MAAM,sBAAsB,8BAA8B,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,SAAS;AAChM;",
"names": ["param"]
}

View File

@@ -1,9 +1,9 @@
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
} from "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS,
__export,

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__export,
__toESM

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-YF4B4G2L.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_react_dom();

View File

@@ -1,8 +1,8 @@
import {
require_client
} from "./chunk-AFNBKP7P.js";
import "./chunk-QURGMCZB.js";
import "./chunk-YLZ34CCM.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-6MXH2QM6.js";
import "./chunk-GUQHL3N7.js";
import "./chunk-YF4B4G2L.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_client();

View File

@@ -1,5 +1,5 @@
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_react();

View File

@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS
} from "./chunk-G3PMV62Z.js";

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-2YVA4HRZ.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_jsx_runtime();

View File

@@ -1,15 +1,15 @@
import {
_extends
} from "./chunk-H4GSM2WL.js";
import {
clsx_default
} from "./chunk-U7P2NEEE.js";
import {
_extends
} from "./chunk-EQCCHGRT.js";
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
} from "./chunk-YF4B4G2L.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS,
__export,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -435,7 +435,7 @@ import {
setConsoleFunction,
warn,
warnOnce
} from "./chunk-INS7YHTD.js";
} from "./chunk-L3Z576C2.js";
import "./chunk-G3PMV62Z.js";
export {
ACESFilmicToneMapping,

View File

@@ -1,11 +1,11 @@
import {
create,
useStore
} from "./chunk-QJTQF54Q.js";
} from "./chunk-7GZ4CI6Q.js";
import {
createStore
} from "./chunk-LTNRPUSL.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-NJ4V5H3P.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export {
create,

View File

@@ -6,7 +6,7 @@ import {
redux,
ssrSafe,
subscribeWithSelector
} from "./chunk-XGWIEMTH.js";
} from "./chunk-O4L7C4YS.js";
import "./chunk-G3PMV62Z.js";
export {
combine,

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useStore } from '@/store/useStore';
@@ -13,6 +13,14 @@ import { Catalyst } from '@/components/modules/Catalyst';
import { NotificationCenter } from '@/components/layout/NotificationCenter';
import { useCrmBootstrap } from '@/hooks/useCrmBootstrap';
import type { ModuleId } from '@/types';
import AdminPage from '@/app/admin/page';
import {
clearVelocityToken,
getVelocityMe,
getVelocityToken,
isAdminRole,
normalizeVelocityRole,
} from '@/lib/velocityPlatformClient';
import {
MoreVertical,
@@ -35,6 +43,7 @@ export const MODULE_ROUTES: Array<{
path: string;
title: string;
component: React.ComponentType;
adminOnly?: boolean;
}> = [
{ id: 'dashboard', path: '/dashboard', title: 'Dashboard', component: Dashboard },
{ id: 'oracle', path: '/oracle', title: 'The Oracle', component: Oracle },
@@ -42,6 +51,7 @@ export const MODULE_ROUTES: Array<{
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
];
export const PATH_TO_MODULE = Object.fromEntries(
@@ -75,14 +85,23 @@ function RouteModuleSync() {
// ── Main authenticated layout ─────────────────────────────────────────────────
function MainLayout() {
const { activeModule, setActiveModule, sidebarExpanded, logout } = useStore();
const { activeModule, setActiveModule, sidebarExpanded, logout, user } = useStore();
useCrmBootstrap();
const navigate = useNavigate();
const location = useLocation();
const availableRoutes = MODULE_ROUTES.filter((route) => !route.adminOnly || isAdminRole(user?.role));
// Current route title
const currentRoute = MODULE_ROUTES.find((r) => r.path === location.pathname);
const currentRoute = availableRoutes.find((r) => r.path === location.pathname);
const pageTitle = currentRoute?.title ?? 'Velocity';
const roleLabel = formatRoleLabel(user?.role);
const userLabel = user?.name?.trim() || user?.id || 'Authenticated User';
const initials = userLabel
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('') || 'AU';
// Navigate to settings from dropdown (keeps router in sync)
const goToSettings = () => {
@@ -138,8 +157,8 @@ function MainLayout() {
<div className="text-right">
<p className="text-sm font-medium text-white">Ahmed Al-Farsi</p>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>Sales Director</p>
<p className="text-sm font-medium text-white">{userLabel}</p>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>{roleLabel}</p>
</div>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-semibold"
@@ -148,7 +167,7 @@ function MainLayout() {
color: 'hsl(var(--accent-fg))',
}}
>
AA
{initials}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -191,8 +210,12 @@ function MainLayout() {
>
{/* Nested module routes rendered here */}
<Routes>
{MODULE_ROUTES.map(({ path, component: Component }) => (
<Route key={path} path={path} element={<Component />} />
{availableRoutes.map(({ path, component: Component, adminOnly }) => (
<Route
key={path}
path={path}
element={adminOnly && !isAdminRole(user?.role) ? <Navigate to="/dashboard" replace /> : <Component />}
/>
))}
{/* Default: redirect / → /dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
@@ -211,7 +234,79 @@ function MainLayout() {
// ── Root App ──────────────────────────────────────────────────────────────────
function App() {
const { isAuthenticated } = useStore();
const { isAuthenticated, login, logout } = useStore();
const [authBootstrapped, setAuthBootstrapped] = useState(false);
useEffect(() => {
let cancelled = false;
const token = getVelocityToken();
if (!token) {
setAuthBootstrapped(true);
if (isAuthenticated) {
logout();
}
return () => {
cancelled = true;
};
}
void getVelocityMe()
.then((me) => {
if (cancelled) return;
login({
id: me.user_id,
name: me.user_id,
role: normalizeVelocityRole(me.role),
});
setAuthBootstrapped(true);
})
.catch(() => {
if (cancelled) return;
clearVelocityToken();
logout();
setAuthBootstrapped(true);
});
return () => {
cancelled = true;
};
}, [isAuthenticated, login, logout]);
useEffect(() => {
if (!isAuthenticated || !authBootstrapped) {
return;
}
let cancelled = false;
void getVelocityMe()
.then((me) => {
if (cancelled) return;
login({
id: me.user_id,
name: me.user_id,
role: normalizeVelocityRole(me.role),
});
})
.catch(() => {
if (cancelled) return;
clearVelocityToken();
logout();
});
return () => {
cancelled = true;
};
}, [authBootstrapped, isAuthenticated, login, logout]);
if (!authBootstrapped) {
return (
<div className="min-h-screen flex items-center justify-center bg-black text-zinc-300 text-sm">
Validating live Velocity session...
</div>
);
}
return (
<AnimatePresence mode="wait">
@@ -253,3 +348,15 @@ function App() {
}
export default App;
function formatRoleLabel(role: string | undefined) {
const normalized = normalizeVelocityRole(role);
if (!normalized) {
return 'Authenticated User';
}
return normalized
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

479
app/src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,479 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import {
Activity,
AlertTriangle,
Boxes,
FileClock,
PhoneIncoming,
ShieldCheck,
Sparkles,
Users,
} from 'lucide-react';
import {
getAdminHealth,
getAdminInstalls,
getAdminQueues,
listAdminActions,
listInventoryImportBatches,
submitAdminAction,
type AdminActionRecord,
type AdminActionRequest,
type AdminHealthSnapshot,
type AdminInstallSnapshot,
type AdminQueueSnapshot,
type InventoryImportBatchSummary,
} from '@/lib/velocityPlatformClient';
type MetricCard = {
label: string;
value: string;
detail: string;
icon: React.ComponentType<{ className?: string }>;
accent: string;
};
export default function AdminPage() {
const [health, setHealth] = useState<AdminHealthSnapshot | null>(null);
const [queues, setQueues] = useState<AdminQueueSnapshot | null>(null);
const [installs, setInstalls] = useState<AdminInstallSnapshot | null>(null);
const [actions, setActions] = useState<AdminActionRecord[]>([]);
const [batches, setBatches] = useState<InventoryImportBatchSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [actionDraft, setActionDraft] = useState<AdminActionRequest>({
action_type: 'inventory_batch_approve',
target_type: 'inventory_batch',
target_id: '',
payload: {},
});
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [submittingAction, setSubmittingAction] = useState(false);
async function loadAdminSurface(cancelled = false) {
try {
const [healthRes, queuesRes, installsRes, actionsRes, batchesRes] = await Promise.all([
getAdminHealth(),
getAdminQueues(),
getAdminInstalls(),
listAdminActions(12),
listInventoryImportBatches(8),
]);
if (cancelled) return;
setHealth(healthRes);
setQueues(queuesRes);
setInstalls(installsRes);
setActions(actionsRes.actions);
setBatches(batchesRes.batches);
setError(null);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Failed to load admin surface data');
} finally {
if (!cancelled) setLoading(false);
}
}
useEffect(() => {
let cancelled = false;
void loadAdminSurface(cancelled);
const interval = window.setInterval(() => {
void loadAdminSurface(cancelled);
}, 15000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, []);
const metrics: MetricCard[] = useMemo(() => [
{
label: 'Active installs',
value: String(installs?.installs.reduce((sum, item) => sum + item.session_count, 0) ?? 0),
detail: 'Across WebOS, iPad, edge phone, and tablet surfaces',
icon: Users,
accent: '#22c55e',
},
{
label: 'Pending transcriptions',
value: String(health?.queues.pending_transcriptions ?? 0),
detail: 'Operator-import and direct mobile edge jobs waiting',
icon: PhoneIncoming,
accent: '#3b82f6',
},
{
label: 'Inventory batches',
value: String(health?.queues.pending_inventory_batches ?? 0),
detail: 'Pending validating and processing batches',
icon: Boxes,
accent: '#f59e0b',
},
{
label: 'Synthetic jobs',
value: String(health?.queues.pending_synthetic_jobs ?? 0),
detail: 'Oracle template downstream generation requests',
icon: Sparkles,
accent: '#14b8a6',
},
], [health, installs]);
const queueBands = useMemo(() => ([
{ label: 'Transcriptions', count: sumRecordValues(queues?.transcription_jobs), tone: 'rgba(59,130,246,0.85)' },
{ label: 'Synthetic', count: sumRecordValues(queues?.synthetic_jobs), tone: 'rgba(20,184,166,0.85)' },
{ label: 'Inventory', count: sumRecordValues(queues?.inventory_batches), tone: 'rgba(245,158,11,0.85)' },
{ label: 'Admin actions', count: sumRecordValues(queues?.admin_actions), tone: 'rgba(244,63,94,0.85)' },
]), [queues]);
const maxQueue = useMemo(() => Math.max(1, ...queueBands.map((band) => band.count)), [queueBands]);
async function handleSubmitAction() {
if (!actionDraft.target_id.trim()) {
setActionMessage('Target ID is required before staging an admin action.');
return;
}
setSubmittingAction(true);
setActionMessage(null);
try {
const result = await submitAdminAction({
...actionDraft,
target_id: actionDraft.target_id.trim(),
});
setActionMessage(`Action staged successfully with status "${result.status}".`);
setActionDraft((current) => ({ ...current, target_id: '', payload: {} }));
await loadAdminSurface(false);
} catch (err) {
setActionMessage(err instanceof Error ? err.message : 'Failed to stage admin action.');
} finally {
setSubmittingAction(false);
}
}
return (
<section className="min-h-screen bg-[#05070b] text-zinc-100">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<header className="overflow-hidden rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(37,99,235,0.22),transparent_30%),radial-gradient(circle_at_85%_20%,rgba(34,197,94,0.14),transparent_24%),linear-gradient(180deg,rgba(12,16,24,0.96),rgba(7,9,14,0.96))] p-8 shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300">
<ShieldCheck className="h-3.5 w-3.5" />
Admin Surface
</div>
<h1 className="font-serif text-4xl tracking-tight text-white sm:text-5xl">
Control plane for installs, ingest, Oracle publication, and bounded admin actions.
</h1>
<p className="mt-4 max-w-xl text-sm leading-6 text-zinc-400">
This surface is intentionally narrow: health visibility, queue depth, template governance,
and auditable actions only. Anything destructive stays staged for review.
</p>
{error ? (
<p className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</p>
) : null}
</div>
<div className="grid gap-3 rounded-[28px] border border-white/10 bg-black/25 p-4 backdrop-blur xl:min-w-[320px]">
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">System health</span>
<span className="inline-flex items-center gap-2 font-medium text-emerald-300">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400 shadow-[0_0_14px_rgba(52,211,153,0.8)]" />
{health?.status ?? (loading ? 'Loading' : 'Unknown')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">DB latency</span>
<span className="text-white">
{health ? `${health.database.latency_ms} ms` : '...'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">Active sessions</span>
<span className="text-white">
{health ? `${health.active_sessions.total} live in the last 30m` : '...'}
</span>
</div>
</div>
</div>
</header>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) => {
const Icon = metric.icon;
return (
<article
key={metric.label}
className="rounded-[26px] border border-white/10 bg-[#0b1018] p-5 shadow-[0_18px_50px_rgba(0,0,0,0.28)]"
>
<div className="mb-4 flex items-center justify-between">
<div
className="flex h-11 w-11 items-center justify-center rounded-2xl"
style={{ backgroundColor: `${metric.accent}22`, color: metric.accent }}
>
<Icon className="h-5 w-5" />
</div>
<span className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">Live</span>
</div>
<p className="text-sm text-zinc-400">{metric.label}</p>
<p className="mt-1 text-3xl font-semibold text-white">{metric.value}</p>
<p className="mt-3 text-sm leading-6 text-zinc-500">{metric.detail}</p>
</article>
);
})}
</div>
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.9fr]">
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-white">Queue depth snapshot</h2>
<p className="mt-1 text-sm text-zinc-500">Operational pressure across the new surface families.</p>
</div>
<Activity className="h-5 w-5 text-sky-300" />
</div>
<div className="mt-6 grid gap-4">
{queueBands.map((band) => (
<div key={band.label} className="rounded-2xl border border-white/6 bg-white/[0.02] p-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-zinc-300">{band.label}</span>
<span className="text-sm text-white">{band.count}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full"
style={{
width: `${(band.count / maxQueue) * 100}%`,
background: band.tone,
}}
/>
</div>
</div>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-white">Bounded actions</h2>
<p className="mt-1 text-sm text-zinc-500">Stage auditable admin actions against the live backend.</p>
</div>
<AlertTriangle className="h-5 w-5 text-amber-300" />
</div>
<div className="mt-6 space-y-3">
{[
{
label: 'Approve inventory batch',
action_type: 'inventory_batch_approve',
target_type: 'inventory_batch',
},
{
label: 'Publish Oracle template',
action_type: 'template_publish',
target_type: 'oracle_template',
},
{
label: 'Cancel synthetic job',
action_type: 'synthetic_job_cancel',
target_type: 'synthetic_job',
},
{
label: 'Register surface install',
action_type: 'install_register',
target_type: 'surface_install',
},
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setActionDraft((current) => ({
...current,
action_type: preset.action_type,
target_type: preset.target_type,
}))}
className="flex w-full items-center justify-between rounded-2xl border border-white/8 bg-white/[0.02] px-4 py-3 text-left text-sm text-zinc-200 transition hover:border-sky-400/20 hover:bg-sky-400/[0.05]"
>
<span>{preset.label}</span>
<span className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Preset</span>
</button>
))}
</div>
<div className="mt-5 space-y-3 rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-300">
<span>Action type</span>
<select
value={actionDraft.action_type}
onChange={(event) =>
setActionDraft((current) => ({ ...current, action_type: event.target.value }))
}
className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white outline-none"
>
{[
'inventory_batch_approve',
'inventory_batch_reject',
'template_publish',
'template_archive',
'synthetic_job_cancel',
'install_register',
'install_deregister',
].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-zinc-300">
<span>Target type</span>
<input
value={actionDraft.target_type}
onChange={(event) =>
setActionDraft((current) => ({ ...current, target_type: event.target.value }))
}
className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white outline-none"
/>
</label>
</div>
<label className="space-y-2 text-sm text-zinc-300">
<span>Target ID</span>
<input
value={actionDraft.target_id}
onChange={(event) =>
setActionDraft((current) => ({ ...current, target_id: event.target.value }))
}
placeholder="Enter the entity identifier to stage"
className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white outline-none"
/>
</label>
<button
type="button"
onClick={() => void handleSubmitAction()}
disabled={submittingAction}
className="w-full rounded-xl bg-sky-500 px-4 py-3 text-sm font-semibold text-slate-950 transition disabled:opacity-60"
>
{submittingAction ? 'Staging action...' : 'Stage admin action'}
</button>
{actionMessage ? (
<p className="text-sm text-zinc-400">{actionMessage}</p>
) : null}
</div>
</section>
</div>
<div className="grid gap-6 xl:grid-cols-[1.15fr_1fr]">
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-white">Audit trail</h2>
<p className="mt-1 text-sm text-zinc-500">Recent admin activity across publication, install, and ingest.</p>
</div>
<FileClock className="h-5 w-5 text-zinc-300" />
</div>
<div className="mt-5 overflow-hidden rounded-3xl border border-white/8">
<div className="grid grid-cols-[90px_160px_1fr_120px] bg-white/[0.03] px-4 py-3 text-[11px] uppercase tracking-[0.18em] text-zinc-500">
<span>Time</span>
<span>Actor</span>
<span>Action</span>
<span>Status</span>
</div>
{actions.map((event) => (
<div
key={`${event.action_event_id}-${event.action_type}`}
className="grid grid-cols-[90px_160px_1fr_120px] border-t border-white/6 px-4 py-4 text-sm"
>
<span className="text-zinc-400">{formatShortTime(event.created_at)}</span>
<span className="text-zinc-300">{event.requested_by}</span>
<span className="text-white">{formatActionLabel(event)}</span>
<span className={statusTone(event.status)}>{event.status}</span>
</div>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<h2 className="text-xl font-semibold text-white">Cross-surface posture</h2>
<p className="mt-1 text-sm text-zinc-500">
A quick read on where the new scaffolds fit and what still remains bounded by MVP scope.
</p>
<div className="mt-5 space-y-4">
{[
{
title: 'Tablet parity',
body: `Observed active surfaces: ${Object.entries(health?.active_sessions.by_surface ?? {}).map(([surface, count]) => `${surface} (${count})`).join(', ') || 'none yet'}.`,
},
{
title: 'Phone edge companions',
body: `${installs?.installs.filter((item) => item.surface_type.includes('edge')).length ?? 0} edge install variants have reported into surface sessions.`,
},
{
title: 'Admin risk boundary',
body: `${actions.filter((action) => action.status === 'pending').length} admin actions are pending review and remain staged rather than auto-executed.`,
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-zinc-400">{item.body}</p>
</div>
))}
</div>
<div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-white">Latest inventory batches</p>
<div className="mt-3 space-y-3">
{batches.map((batch) => (
<div key={batch.batch_id} className="flex items-center justify-between gap-4 text-sm">
<div>
<p className="text-zinc-200">{batch.source_type}</p>
<p className="text-zinc-500">
Rows {batch.accepted_rows}/{batch.total_rows} accepted · submitted by {batch.submitted_by}
</p>
</div>
<span className={statusTone(batch.status)}>{batch.status}</span>
</div>
))}
</div>
</div>
</section>
</div>
</div>
</section>
);
}
function sumRecordValues(value: Record<string, number> | undefined): number {
if (!value) return 0;
return Object.values(value).reduce((sum, count) => sum + count, 0);
}
function formatShortTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatActionLabel(action: AdminActionRecord): string {
return `${action.action_type}${action.target_type} ${action.target_id}`;
}
function statusTone(status: string): string {
const normalized = status.toLowerCase();
if (normalized === 'completed' || normalized === 'published' || normalized === 'confirmed') {
return 'text-emerald-300';
}
if (normalized === 'failed' || normalized === 'rejected' || normalized === 'cancelled') {
return 'text-red-300';
}
return 'text-amber-300';
}

View File

@@ -1,45 +1,32 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Scan, User, Lock } from 'lucide-react';
import { motion } from 'framer-motion';
import { Scan, Mail, Lock } from 'lucide-react';
import { useStore } from '@/store/useStore';
import { clearVelocityToken, loginVelocity, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
export function LoginScreen() {
const { login } = useStore();
const [scanPhase, setScanPhase] = useState<'idle' | 'scanning' | 'success'>('idle');
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const isScanning = scanPhase !== 'idle';
const [isSubmitting, setIsSubmitting] = useState(false);
const handleFaceID = () => {
setScanPhase('scanning');
setError('');
// Keep total duration unchanged: 2.5s. Turn green before unlock.
setTimeout(() => {
setScanPhase('success');
}, 2100);
setTimeout(() => {
setScanPhase('idle');
login({
id: '1',
name: 'Ahmed Al-Farsi',
role: 'sales_director',
});
}, 2500);
};
const handlePasswordLogin = (e: React.FormEvent) => {
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (password === 'admin' || password === '') {
setIsSubmitting(true);
setError('');
try {
const me = await loginVelocity(email.trim(), password);
login({
id: '1',
name: 'Ahmed Al-Farsi',
role: 'sales_director',
id: me.user_id,
name: me.user_id,
role: normalizeVelocityRole(me.role),
});
} else {
setError('Invalid credentials');
} catch (err) {
clearVelocityToken();
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsSubmitting(false);
}
};
@@ -96,149 +83,76 @@ export function LoginScreen() {
transition={{ delay: 0.3 }}
>
<h1 className="text-xl font-bold text-white tracking-tight mb-1">Velocity WebOS</h1>
<p className="text-sm" style={{ color: 'hsl(var(--muted-fg))' }}>Real Estate Operating System</p>
<p className="text-sm" style={{ color: 'hsl(var(--muted-fg))' }}>Production operator login</p>
</motion.div>
<AnimatePresence mode="wait">
{!showPassword ? (
<motion.div
key="faceid"
className="flex flex-col items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
<motion.form
onSubmit={handlePasswordLogin}
className="space-y-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="relative">
<Mail
className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'hsl(var(--muted-fg))' }}
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Work email"
className="w-full rounded-xl py-3 pl-10 pr-4 text-sm text-white placeholder:text-zinc-500 focus:outline-none transition-all"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
autoFocus
autoComplete="username"
/>
</div>
<div className="relative">
<Lock
className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'hsl(var(--muted-fg))' }}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full rounded-xl py-3 pl-10 pr-4 text-sm text-white placeholder:text-zinc-500 focus:outline-none transition-all"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
autoComplete="current-password"
/>
</div>
{error && (
<motion.p
className="text-red-400 text-sm text-center"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.button
onClick={handleFaceID}
disabled={isScanning}
className="relative w-28 h-28 rounded-full flex items-center justify-center mb-5 transition-colors"
style={{ border: '2px solid hsl(var(--border))' }}
whileHover={{ scale: 1.03, borderColor: 'hsl(var(--accent) / 0.5)' }}
whileTap={{ scale: 0.97 }}
>
{isScanning && (
<>
<motion.div
className="absolute inset-0 rounded-full"
style={{
border: `2px solid ${scanPhase === 'success' ? 'hsl(var(--success))' : 'hsl(var(--accent))'}`,
boxShadow:
scanPhase === 'success'
? '0 0 26px rgba(34,197,94,0.8), 0 0 58px rgba(34,197,94,0.45), inset 0 0 16px rgba(34,197,94,0.3)'
: '0 0 32px rgba(59,130,246,0.95), 0 0 86px rgba(59,130,246,0.6), 0 0 140px rgba(59,130,246,0.35), inset 0 0 24px rgba(59,130,246,0.25)',
}}
animate={{ scale: [1, 1.08, 1], opacity: [0.86, 1, 0.86] }}
transition={{
duration: scanPhase === 'success' ? 0.35 : 1.05,
repeat: scanPhase === 'success' ? 0 : Infinity,
ease: 'easeInOut',
}}
/>
<motion.div
className="absolute -inset-2 rounded-full"
style={{
background:
scanPhase === 'success'
? 'radial-gradient(circle, rgba(34,197,94,0.22) 0%, rgba(34,197,94,0.08) 38%, transparent 72%)'
: 'radial-gradient(circle, rgba(59,130,246,0.3) 0%, rgba(59,130,246,0.12) 40%, transparent 72%)',
filter: 'blur(10px)',
}}
animate={{ opacity: [0.65, 1, 0.65] }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut' }}
/>
</>
)}
<User
className="w-10 h-10 transition-colors"
style={{
color:
scanPhase === 'success'
? 'hsl(var(--success))'
: isScanning
? 'hsl(var(--accent))'
: 'hsl(var(--muted-fg))',
}}
/>
</motion.button>
<p className="text-sm mb-5" style={{ color: 'hsl(var(--muted-fg))' }}>
{scanPhase === 'success'
? 'Face verified'
: isScanning
? 'Scanning...'
: 'Tap to authenticate with FaceID'}
</p>
<button
onClick={() => setShowPassword(true)}
className="text-sm transition-colors"
style={{ color: 'hsl(var(--subtle-fg))' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'hsl(var(--muted-fg))')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'hsl(var(--subtle-fg))')}
>
Use password instead
</button>
</motion.div>
) : (
<motion.form
key="password"
onSubmit={handlePasswordLogin}
className="space-y-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="relative">
<Lock
className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'hsl(var(--muted-fg))' }}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="w-full rounded-xl py-3 pl-10 pr-4 text-sm text-white placeholder:text-zinc-500 focus:outline-none transition-all"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
autoFocus
/>
</div>
{error && (
<motion.p
className="text-red-400 text-sm text-center"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
<button
type="submit"
className="w-full font-semibold py-3 rounded-xl transition-opacity hover:opacity-90 text-sm"
style={{
background: 'hsl(var(--accent))',
color: 'hsl(var(--accent-fg))',
}}
>
Sign In
</button>
<button
type="button"
onClick={() => { setShowPassword(false); setError(''); }}
className="w-full text-sm transition-colors py-1"
style={{ color: 'hsl(var(--subtle-fg))' }}
>
Back to FaceID
</button>
</motion.form>
{error}
</motion.p>
)}
</AnimatePresence>
<button
type="submit"
disabled={isSubmitting}
className="w-full font-semibold py-3 rounded-xl transition-opacity hover:opacity-90 text-sm disabled:opacity-60"
style={{
background: 'hsl(var(--accent))',
color: 'hsl(var(--accent-fg))',
}}
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</button>
</motion.form>
<motion.p
className="mt-7 text-center text-xs"
@@ -247,9 +161,9 @@ export function LoginScreen() {
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
Secured by On-Premise Python Backend
Secured by the live Velocity backend
</motion.p>
</motion.div>
</div>
);
}
}

View File

@@ -7,10 +7,12 @@ import {
Building2,
Sliders,
Megaphone,
Shield,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { MODULE_ROUTES } from '@/App';
import { isAdminRole } from '@/lib/velocityPlatformClient';
const NAV_ICONS: Record<string, LucideIcon> = {
'/dashboard': LayoutGrid,
@@ -19,10 +21,12 @@ const NAV_ICONS: Record<string, LucideIcon> = {
'/inventory': Building2,
'/catalyst': Megaphone,
'/settings': Sliders,
'/admin': Shield,
};
export function Sidebar() {
const { sidebarExpanded, setSidebarExpanded, status } = useStore();
const { sidebarExpanded, setSidebarExpanded, status, user } = useStore();
const visibleRoutes = MODULE_ROUTES.filter((route) => !route.adminOnly || isAdminRole(user?.role));
return (
<motion.aside
@@ -62,7 +66,7 @@ export function Sidebar() {
{/* Nav */}
<nav className="flex-1 px-3 space-y-1">
{MODULE_ROUTES.map((route) => {
{visibleRoutes.map((route) => {
const Icon = NAV_ICONS[route.path] ?? LayoutGrid;
return (

View File

@@ -758,34 +758,8 @@ function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
);
}
const MOCK_STREAM: Array<{ type: LiveEventType; message: string; campaign: string; value?: string }> = [
{ type: 'optimize', message: 'Expanded 3BHK audience targeting — added "Property Investment" interest layer.', campaign: '3BHK Prestige Launch', value: '+22k reach' },
{ type: 'rotate', message: 'Rotated in Arabic Poster (Qwen-2512) as new creative variant for A/B test.', campaign: 'Penthouse Whale Retarget' },
{ type: 'shift', message: 'Shifted AED 150 from underperforming Ad Set C to Ad Set A (CTR 3.2%).', campaign: '1BHK Investment', value: '+AED 150' },
{ type: 'pause', message: 'Paused Ad Set D — CPA crossed AED 480 threshold (target: AED 400).', campaign: 'Penthouse Whale Retarget', value: 'CPA: AED 481' },
{ type: 'create', message: 'Created new Custom Audience from 18 Closed/Won CRM leads (hashed emails).', campaign: '3BHK Prestige Launch' },
];
function LiveOptimizationFeed() {
const { liveEvents, pushLiveEvent } = useMarketingStore();
const streamIdx = useRef(0);
useEffect(() => {
const t = setInterval(() => {
const item = MOCK_STREAM[streamIdx.current % MOCK_STREAM.length];
streamIdx.current++;
pushLiveEvent({
id: `ev_${Date.now()}`,
type: item.type,
message: item.message,
campaignName: item.campaign,
timestamp: new Date(),
value: item.value,
});
}, 4000);
return () => clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { liveEvents } = useMarketingStore();
return (
<Widget delay={0.4} colSpan={1}>
@@ -796,11 +770,17 @@ function LiveOptimizationFeed() {
<LiveBadge />
</div>
<div className="space-y-2 max-h-72 overflow-y-auto custom-scrollbar pr-1">
<AnimatePresence mode="popLayout" initial={false}>
{liveEvents.slice(0, 8).map((ev) => (
<LiveEventItem key={ev.id} event={ev} />
))}
</AnimatePresence>
{liveEvents.length === 0 ? (
<div className="rounded-xl border border-white/8 bg-white/[0.02] p-4 text-sm text-zinc-400">
No live optimization events are available. Connect the production ad-platform integrations to populate this stream.
</div>
) : (
<AnimatePresence mode="popLayout" initial={false}>
{liveEvents.slice(0, 8).map((ev) => (
<LiveEventItem key={ev.id} event={ev} />
))}
</AnimatePresence>
)}
</div>
</Widget>
);

View File

@@ -5,7 +5,6 @@ import {
getCatalystCampaigns,
getLeadDemographics,
getSentimentScatter,
seedSyntheticLeads,
type LeadDemographics,
type MarketingCampaignSummary,
type ScatterDataPoint,
@@ -61,7 +60,6 @@ export function CatalystMarketingTab() {
const [demographics, setDemographics] = useState<LeadDemographics | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
useEffect(() => {
let active = true;
@@ -101,24 +99,6 @@ export function CatalystMarketingTab() {
return { totalBudget, totalSpent, totalLeads, whales, avgSentiment };
}, [campaigns, scatter]);
const handleSeed = async () => {
setSeeding(true);
try {
await seedSyntheticLeads(100);
const [scatterRows, demographicRows] = await Promise.all([
getSentimentScatter(),
getLeadDemographics(),
]);
setScatter(scatterRows);
setDemographics(demographicRows);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Synthetic seed failed');
} finally {
setSeeding(false);
}
};
return (
<div className="space-y-4">
<SectionCard
@@ -230,11 +210,11 @@ export function CatalystMarketingTab() {
subtitle="Production-readiness controls kept inside the same vertical marketing surface."
icon={DatabaseZap}
>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_auto]">
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-white/5 p-4 text-sm text-white/75">
<div className="mb-1 text-white font-medium">CRM Analytics</div>
<div>{totals.totalLeads > 0 ? 'Live data available' : 'No seeded verification data yet'}</div>
<div>{totals.totalLeads > 0 ? 'Live data available' : 'No live CRM analytics available yet'}</div>
</div>
<div className="rounded-xl border border-white/8 bg-white/5 p-4 text-sm text-white/75">
<div className="mb-1 text-white font-medium">Catalyst Contracts</div>
@@ -245,16 +225,6 @@ export function CatalystMarketingTab() {
<div>Total budget {formatMoney(totals.totalBudget)}</div>
</div>
</div>
<button
type="button"
onClick={() => void handleSeed()}
disabled={seeding}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-blue-400/25 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200 disabled:opacity-50"
>
{seeding ? <RefreshCw className="h-4 w-4 animate-spin" /> : <DatabaseZap className="h-4 w-4" />}
Seed 100 Synthetic Leads
</button>
</div>
{error && <p className="mt-4 text-sm text-red-300">{error}</p>}
</SectionCard>

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,7 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Film, Check, X } from 'lucide-react';
// ─── Recent designs mock data ─────────────────────────────────────────────────
const RECENT_DESIGNS = [
{ id: 'rd1', name: 'Penthouse Sea View', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a2a4a,#0f1928)', accent: '#60a5fa', date: '2h ago' },
{ id: 'rd2', name: 'Arabic 3BHK Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#2a1a3a,#180f28)', accent: '#a78bfa', date: '5h ago' },
{ id: 'rd3', name: 'Amenity Deck Reel', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a2a,#0f2818)', accent: '#4ade80', date: '8h ago' },
{ id: 'rd4', name: 'Penthouse En Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a2a1a,#281808)', accent: '#fbbf24', date: '1d ago' },
{ id: 'rd5', name: 'Dubai Marina Aerial', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a3a,#0f2828)', accent: '#22d3ee', date: '2d ago' },
{ id: 'rd6', name: 'Investment Lifestyle', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a1a1a,#280f0f)', accent: '#f87171', date: '3d ago' },
];
import { Plus, Film, X } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -98,7 +87,7 @@ function PickerPopup({ anchorRef, onSelect, onClose }: GroundTruthPickerProps) {
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-semibold uppercase tracking-widest"
style={{ color: 'rgba(148,163,184,0.5)' }}>
Recent Designs
Operator Assets
</p>
<motion.button
onClick={onClose}
@@ -116,47 +105,15 @@ function PickerPopup({ anchorRef, onSelect, onClose }: GroundTruthPickerProps) {
</motion.button>
</div>
{/* 3×2 recent designs grid */}
<div className="grid grid-cols-3 gap-2 mb-2">
{RECENT_DESIGNS.map((d, i) => (
<motion.button
key={d.id}
className="relative rounded-xl overflow-hidden flex flex-col items-start p-2 group text-left"
style={{
background: d.gradient,
border: '1px solid rgba(255,255,255,0.07)',
aspectRatio: '1',
}}
onClick={() => onSelect({ name: d.name, preview: '' })}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, delay: i * 0.04 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
<span
className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded-full mb-auto z-10"
style={{ background: `${d.accent}22`, color: d.accent }}
>
{d.type === 'video' ? '▶ Video' : '■ Image'}
</span>
{/* Glow */}
<div className="absolute bottom-0 right-0 w-12 h-12 pointer-events-none"
style={{ background: `radial-gradient(circle,${d.accent}44 0%,transparent 70%)`, filter: 'blur(8px)' }} />
<div className="w-full mt-1 z-10">
<p className="text-[10px] font-medium text-white leading-tight line-clamp-1">{d.name}</p>
<p className="text-[9px] mt-0.5" style={{ color: 'rgba(148,163,184,0.45)' }}>{d.date}</p>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-xl"
style={{ background: 'rgba(255,255,255,0.08)' }}>
<Check className="w-5 h-5 text-white" />
</div>
</motion.button>
))}
<div
className="mb-3 rounded-xl p-4 text-sm"
style={{
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
color: 'rgba(148,163,184,0.75)',
}}
>
No recent asset gallery is populated inside WebOS yet. Add a real image or video from the operator device instead of using built-in demo media.
</div>
{/* Bottom: gallery + camera */}

View File

@@ -14,11 +14,14 @@ import {
Copy,
Check,
ChevronDown,
LogOut,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
import { API_URL } from '@/lib/api';
import { VELOCITY_TOKEN_KEY, clearVelocityToken, getVelocityToken, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
const GLASS = {
@@ -223,6 +226,7 @@ function DarkInput({ type = 'text', defaultValue, placeholder }: { type?: string
// ── System Status ────────────────────────────────────────────────────────────
function SystemStatusCard() {
const { status, updateStatus } = useStore();
const lastSync = status.lastSync instanceof Date ? status.lastSync : new Date(status.lastSync);
return (
<GlassCard delay={0}>
@@ -238,7 +242,7 @@ function SystemStatusCard() {
<div>
<p className="text-white text-sm font-medium">Backend Connection</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
{status.isConnected ? 'Connected to local server' : 'Connection lost'}
{status.isConnected ? 'Connected to live Velocity backend' : 'Connection unavailable'}
</p>
</div>
</div>
@@ -257,7 +261,7 @@ function SystemStatusCard() {
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Version', value: status.version },
{ label: 'Last Sync', value: status.lastSync.toLocaleTimeString() },
{ label: 'Last Sync', value: Number.isNaN(lastSync.getTime()) ? 'Unavailable' : lastSync.toLocaleTimeString() },
].map(({ label, value }) => (
<div key={label} className="p-3 rounded-xl" style={INNER_SURFACE}>
<p className="text-xs mb-1" style={{ color: 'hsl(var(--muted-fg))' }}>{label}</p>
@@ -274,7 +278,10 @@ function SystemStatusCard() {
style={INNER_SURFACE}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
onClick={() => updateStatus({ serverStatus: 'syncing' })}
onClick={() => {
updateStatus({ serverStatus: 'syncing' });
window.location.reload();
}}
>
<RefreshCw className="w-4 h-4" style={{ color: 'hsl(var(--accent))' }} />
<span className="text-white">Sync Now</span>
@@ -285,6 +292,7 @@ function SystemStatusCard() {
style={INNER_SURFACE}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
onClick={() => window.location.reload()}
>
<Power className="w-4 h-4" style={{ color: 'hsl(var(--muted-fg))' }} />
<span className="text-white">Restart</span>
@@ -296,17 +304,10 @@ function SystemStatusCard() {
}
// ── iOS Device Connection ────────────────────────────────────────────────────
function IOSConnectionCard() {
const [paired, setPaired] = useState(false);
const [pairing, setPairing] = useState(false);
function CompanionSurfacesCard() {
const [copied, setCopied] = useState(false);
const pairingCode = 'VLC-7F3A-9B2D';
const deviceIp = '192.168.1.42:8765';
const handlePair = () => {
setPairing(true);
setTimeout(() => { setPairing(false); setPaired(true); }, 2000);
};
const token = getVelocityToken();
const maskedToken = token ? `${token.slice(0, 8)}...${token.slice(-6)}` : 'No active bearer token';
const handleCopy = (text: string) => {
void navigator.clipboard.writeText(text);
@@ -316,85 +317,58 @@ function IOSConnectionCard() {
return (
<GlassCard delay={0.05}>
<SectionHeader icon={Smartphone} title="iOS App / Device" accent="#a78bfa" />
<SectionHeader icon={Smartphone} title="Companion Surfaces" accent="#a78bfa" />
<div className="px-6 pb-6 space-y-3">
{/* Status */}
<div className="flex items-center justify-between p-4 rounded-xl" style={INNER_SURFACE}>
<div className="flex items-center gap-3">
<div className="relative w-3 h-3">
<div className={`w-3 h-3 rounded-full ${paired ? 'bg-green-500' : 'bg-zinc-500'}`} />
{paired && <div className="absolute inset-0 rounded-full bg-green-500 status-pulse" />}
<div className={`w-3 h-3 rounded-full ${token ? 'bg-green-500' : 'bg-zinc-500'}`} />
{token && <div className="absolute inset-0 rounded-full bg-green-500 status-pulse" />}
</div>
<div>
<p className="text-white text-sm font-medium">{paired ? 'iPhone Paired' : 'No Device Paired'}</p>
<p className="text-white text-sm font-medium">WebOS Session Token</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
{paired ? "Ahmeds iPhone 15 Pro" : 'Open Velocity iOS app to connect'}
{token ? 'Reusable by Oracle and protected WebOS routes.' : 'No authenticated backend session is currently present.'}
</p>
</div>
</div>
{paired
? <span className="px-2.5 py-1 rounded-full text-[11px] font-medium" style={{ background: 'rgba(34,197,94,0.15)', color: '#86efac' }}>CONNECTED</span>
{token
? <span className="px-2.5 py-1 rounded-full text-[11px] font-medium" style={{ background: 'rgba(34,197,94,0.15)', color: '#86efac' }}>ACTIVE</span>
: <Wifi className="w-4 h-4" style={{ color: 'hsl(var(--muted-fg))' }} />
}
</div>
{/* Pairing code */}
<div className="p-4 rounded-xl space-y-3" style={INNER_SURFACE}>
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'hsl(var(--muted-fg))' }}>Pairing Code</p>
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'hsl(var(--muted-fg))' }}>Runtime Access</p>
<div className="flex items-center justify-between">
<p className="text-2xl font-bold tracking-[0.2em] text-white font-mono">{pairingCode}</p>
<p className="text-sm font-bold tracking-[0.06em] text-white font-mono">{maskedToken}</p>
<button
type="button"
onClick={() => handleCopy(pairingCode)}
onClick={() => handleCopy(token ?? '')}
disabled={!token}
className="p-2 rounded-lg transition-colors"
style={{ background: 'rgba(255,255,255,0.06)' }}
>
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" style={{ color: 'hsl(var(--muted-fg))' }} />}
</button>
</div>
<div className="flex items-center gap-2">
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>Local IP: <span className="text-white font-mono">{deviceIp}</span></p>
<button type="button" onClick={() => handleCopy(deviceIp)} className="p-1 rounded transition-colors hover:opacity-70">
<Copy className="w-3 h-3" style={{ color: 'hsl(var(--muted-fg))' }} />
</button>
</div>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>
Mobile and tablet pairing is intentionally deferred until the next delivery phase. This WebOS pass does not simulate device pairing.
</p>
</div>
{/* Actions */}
<div className="flex gap-3">
<motion.button
type="button"
onClick={handlePair}
disabled={pairing || paired}
onClick={() => window.location.reload()}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all"
style={paired
? { background: 'rgba(34,197,94,0.15)', color: '#86efac', border: '1px solid rgba(34,197,94,0.2)' }
: { background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }
}
whileHover={!paired ? { scale: 1.02 } : {}}
whileTap={!paired ? { scale: 0.97 } : {}}
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
{pairing ? (
<><RefreshCw className="w-4 h-4 animate-spin" /> Pairing</>
) : paired ? (
<><Check className="w-4 h-4" /> Paired</>
) : (
<><Smartphone className="w-4 h-4" /> Pair Device</>
)}
<><RefreshCw className="w-4 h-4" /> Refresh WebOS</>
</motion.button>
{paired && (
<GhostButton onClick={() => setPaired(false)} danger>Unpair</GhostButton>
)}
</div>
{/* Push notifications toggle */}
<div className="flex items-center justify-between pt-1">
<div>
<p className="text-sm font-medium text-white">Push Notifications</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>Send alerts to paired iPhone</p>
</div>
<Toggle enabled={paired} onChange={() => { }} />
<GhostButton onClick={() => handleCopy(API_URL)}>Copy API URL</GhostButton>
</div>
</div>
</GlassCard>
@@ -404,7 +378,12 @@ function IOSConnectionCard() {
// ── Profile ──────────────────────────────────────────────────────────────────
function ProfileSettings() {
const { user } = useStore();
const initials = user?.name.split(' ').map((n) => n[0]).join('') ?? 'AA';
const initials = user?.name.split(' ').map((n) => n[0]).join('') ?? 'AU';
const roleLabel = normalizeVelocityRole(user?.role)
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ') || 'Authenticated User';
return (
<GlassCard delay={0.1}>
@@ -420,19 +399,19 @@ function ProfileSettings() {
<div>
<p className="text-white font-semibold">{user?.name}</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
{user?.role === 'sales_director' ? 'Sales Director' : 'Administrator'}
{roleLabel}
</p>
</div>
</div>
<div className="space-y-0 -mx-6">
<SettingsRow label="Full Name" description="Your display name">
<DarkInput defaultValue={user?.name} />
<SettingsRow label="Authenticated Name" description="Resolved from the active Velocity session">
<span className="text-sm text-white">{user?.name ?? 'Unavailable'}</span>
</SettingsRow>
<SettingsRow label="Email" description="Notification email">
<DarkInput type="email" defaultValue="ahmed@velocity.re" />
<SettingsRow label="User ID" description="Backend principal identifier">
<span className="text-sm font-mono text-white">{user?.id ?? 'Unavailable'}</span>
</SettingsRow>
<SettingsRow label="Phone" description="Contact number">
<DarkInput type="tel" defaultValue="+971 50 123 4567" />
<SettingsRow label="Role" description="Normalized access role from JWT claims">
<span className="text-sm text-white">{roleLabel}</span>
</SettingsRow>
</div>
</div>
@@ -467,25 +446,35 @@ function NotificationSettings() {
// ── Security ─────────────────────────────────────────────────────────────────
function SecuritySettings() {
const [twoFactor, setTwoFactor] = useState(true);
const [biometric, setBiometric] = useState(true);
const [timeout, setTimeout_] = useState('30');
const { logout } = useStore();
const token = getVelocityToken();
return (
<GlassCard delay={0.2}>
<SectionHeader icon={Shield} title="Security" accent="#f59e0b" />
<div>
<SettingsRow label="Two-Factor Authentication" description="Require OTP for login">
<Toggle enabled={twoFactor} onChange={setTwoFactor} />
<SettingsRow label="Bearer Token" description="Current authenticated WebOS session state">
<span className={`text-sm ${token ? 'text-emerald-300' : 'text-red-300'}`}>
{token ? 'Present' : 'Missing'}
</span>
</SettingsRow>
<SettingsRow label="Biometric Login" description="Use FaceID for authentication">
<Toggle enabled={biometric} onChange={setBiometric} />
<SettingsRow label="Password Management" description="Handled by the backend identity service">
<span className="text-sm text-zinc-400">Managed outside WebOS</span>
</SettingsRow>
<SettingsRow label="Change Password" description="Update your password">
<GhostButton>Change</GhostButton>
</SettingsRow>
<SettingsRow label="API Keys" description="Manage API access">
<GhostButton>Manage</GhostButton>
<SettingsRow label="API Session Reset" description="Clears the local bearer token and returns to login">
<GhostButton
danger
onClick={() => {
clearVelocityToken();
logout();
}}
>
<span className="inline-flex items-center gap-2">
<LogOut className="w-4 h-4" />
Sign Out
</span>
</GhostButton>
</SettingsRow>
<SettingsRow label="Session Timeout" description="Auto-logout after inactivity">
<DarkSelect
@@ -566,13 +555,45 @@ function DisplaySettings() {
// ── Data & Privacy ───────────────────────────────────────────────────────────
function DataSettings() {
const [retention, setRetention] = useState('90');
const { leads, messages, units, status } = useStore();
const exportSnapshot = () => {
const blob = new Blob([
JSON.stringify(
{
exported_at: new Date().toISOString(),
status,
lead_count: leads.length,
message_threads: Object.keys(messages).length,
inventory_count: units.length,
leads,
messages,
units,
},
null,
2,
),
], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `velocity-webos-export-${Date.now()}.json`;
anchor.click();
URL.revokeObjectURL(url);
};
const clearUiCache = () => {
localStorage.removeItem('velocity-webos-storage');
localStorage.removeItem('pv-currency');
window.location.reload();
};
return (
<GlassCard delay={0.3}>
<SectionHeader icon={Database} title="Data & Privacy" />
<div>
<SettingsRow label="Auto-Backup" description="Automatically backup data daily">
<Toggle enabled={true} onChange={() => { }} />
<SettingsRow label="Auto-Backup" description="Operational data is owned by backend systems, not browser local storage">
<span className="text-sm text-zinc-400">Backend managed</span>
</SettingsRow>
<SettingsRow label="Data Retention" description="How long to keep visitor data">
<DarkSelect
@@ -587,10 +608,10 @@ function DataSettings() {
/>
</SettingsRow>
<SettingsRow label="Export Data" description="Download all your data">
<GhostButton>Export</GhostButton>
<GhostButton onClick={exportSnapshot}>Export</GhostButton>
</SettingsRow>
<SettingsRow label="Clear Cache" description="Remove temporary files" danger>
<GhostButton danger>Clear</GhostButton>
<GhostButton danger onClick={clearUiCache}>Clear</GhostButton>
</SettingsRow>
</div>
</GlassCard>
@@ -599,6 +620,7 @@ function DataSettings() {
// ── About ────────────────────────────────────────────────────────────────────
function AboutSection() {
const token = getVelocityToken();
return (
<GlassCard delay={0.35}>
<SectionHeader icon={Wifi} title="About" />
@@ -614,14 +636,10 @@ function AboutSection() {
<div className="flex items-center justify-center gap-2 text-xs mb-6" style={{ color: 'hsl(var(--subtle-fg))' }}>
<span>Version 2.1.0</span>
<span></span>
<span>Build 2024.02.18</span>
<span>{token ? 'Authenticated session active' : 'No active session'}</span>
</div>
<div className="flex items-center justify-center gap-4">
{['Terms of Service', 'Privacy Policy', 'Documentation'].map((label) => (
<button key={label} type="button" className="text-sm transition-colors hover:opacity-80" style={{ color: 'hsl(var(--accent))' }}>
{label}
</button>
))}
<div className="text-xs text-zinc-500 mb-4">
Backend origin: <span className="font-mono text-zinc-300">{API_URL}</span>
</div>
</div>
</GlassCard>
@@ -635,7 +653,7 @@ export function Settings() {
{/* Row 1: System + iOS */}
<div className="grid grid-cols-2 gap-4 relative z-40">
<SystemStatusCard />
<IOSConnectionCard />
<CompanionSurfacesCard />
</div>
{/* Row 2: Profile + Notifications */}

View File

@@ -1,197 +0,0 @@
import type { Lead } from '@/types/crm';
export const mockLeads: Lead[] = [
{
id: 'lead-001',
name: 'Mr. Kapoor',
phone: '+91 9876543200',
stage: 'negotiation',
oracleScore: 92,
badge: 'whale',
tags: ['#Investor', '#CashBuyer'],
source: 'whatsapp',
budget: 'INR 12-15 Cr',
unitInterest: '4BHK Sky Villa - Unit 402',
profileImageUrl:
'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=180&q=80',
selfieImageUrl:
'https://images.unsplash.com/photo-1557862921-37829c790f19?auto=format&fit=crop&w=180&q=80',
visitedShowroom: true,
inShowroomNow: false,
messages: [
{
id: 'm-001',
sender: 'lead',
content: 'Can we discuss negotiation scope for Unit 402?',
createdAt: '2026-02-17T09:10:00.000Z',
},
{
id: 'm-002',
sender: 'oracle',
content:
'Absolutely. Based on your timeline and payment comfort, I can draft two pricing structures.',
createdAt: '2026-02-17T09:11:10.000Z',
},
{
id: 'm-003',
sender: 'system',
content: 'Visited Showroom (Duration: 45m)',
createdAt: '2026-02-17T09:38:00.000Z',
},
{
id: 'm-004',
sender: 'system',
content: 'Looked at 3BHK Unit 402',
createdAt: '2026-02-17T09:41:00.000Z',
},
],
sentimentLog: [
{ id: 's-001', at: '10:00', score: 52, note: 'Neutral on entry' },
{ id: 's-002', at: '10:12', score: 65, note: 'Interest peaked at kitchen' },
{ id: 's-003', at: '10:25', score: 78, note: 'Positive on balcony view' },
{ id: 's-004', at: '10:40', score: 74, note: 'Price sensitivity' },
],
},
{
id: 'lead-002',
name: 'Ananya Rao',
phone: '+91 9881122408',
stage: 'site_visit',
oracleScore: 84,
badge: 'hot',
tags: ['#EndUser'],
source: 'walkin',
budget: 'INR 3.8-4.5 Cr',
unitInterest: '3BHK - Tower B',
profileImageUrl:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=180&q=80',
visitedShowroom: true,
inShowroomNow: true,
messages: [
{
id: 'm-101',
sender: 'lead',
content: 'We are in the lounge now. Can you show the sun path?',
createdAt: '2026-02-17T10:03:00.000Z',
},
{
id: 'm-102',
sender: 'oracle',
content: 'Triggering live sun simulation for 5:30 PM in June.',
createdAt: '2026-02-17T10:03:15.000Z',
},
{
id: 'm-103',
sender: 'oracle',
content: 'Thinking...',
createdAt: '2026-02-17T10:03:18.000Z',
isThinking: true,
},
],
sentimentLog: [
{ id: 's-101', at: '10:00', score: 60, note: 'Curious at arrival' },
{ id: 's-102', at: '10:10', score: 73, note: 'Excited on kitchen finish' },
{ id: 's-103', at: '10:20', score: 80, note: 'High confidence on school access' },
],
},
{
id: 'lead-003',
name: 'Rizwan Shaikh',
phone: '+91 9812267804',
stage: 'qualified',
oracleScore: 69,
badge: 'hot',
tags: ['#Investor'],
source: 'whatsapp',
budget: 'INR 2.2-2.8 Cr',
unitInterest: '2BHK Corner Unit',
profileImageUrl:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=180&q=80',
visitedShowroom: false,
inShowroomNow: false,
messages: [
{
id: 'm-201',
sender: 'lead',
content: 'Need ROI sheet and expected rental yield.',
createdAt: '2026-02-17T07:20:00.000Z',
},
{
id: 'm-202',
sender: 'oracle',
content: 'AI verified budget. Sharing projected 7.8% rental yield details.',
createdAt: '2026-02-17T07:20:18.000Z',
},
],
sentimentLog: [
{ id: 's-201', at: '09:00', score: 49, note: 'Conservative start' },
{ id: 's-202', at: '09:20', score: 58, note: 'Positive on ROI data' },
{ id: 's-203', at: '09:40', score: 62, note: 'Needs tax clarity' },
],
},
{
id: 'lead-004',
name: 'Devika Sen',
phone: '+91 9900211206',
stage: 'new_inquiries',
oracleScore: 42,
badge: 'tire_kicker',
tags: ['#EndUser'],
source: 'whatsapp',
budget: 'Undisclosed',
unitInterest: 'General inquiry',
profileImageUrl:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=180&q=80',
visitedShowroom: false,
inShowroomNow: false,
messages: [
{
id: 'm-301',
sender: 'lead',
content: 'Do you have anything near metro? Just checking options.',
createdAt: '2026-02-17T11:45:00.000Z',
},
],
sentimentLog: [
{ id: 's-301', at: '11:45', score: 38, note: 'Exploratory intent' },
{ id: 's-302', at: '11:47', score: 41, note: 'Mild interest' },
],
},
{
id: 'lead-005',
name: 'Farah Nadeem',
phone: '+91 9820033344',
stage: 'closed',
oracleScore: 97,
badge: 'whale',
tags: ['#CashBuyer'],
source: 'website',
budget: 'INR 9 Cr',
unitInterest: 'Penthouse Unit PH-03',
profileImageUrl:
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&w=180&q=80',
selfieImageUrl:
'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=180&q=80',
visitedShowroom: true,
inShowroomNow: false,
messages: [
{
id: 'm-401',
sender: 'system',
content: 'Contract signed successfully.',
createdAt: '2026-02-16T17:15:00.000Z',
},
{
id: 'm-402',
sender: 'oracle',
content: 'Closing complete. Scheduling welcome concierge.',
createdAt: '2026-02-16T17:15:20.000Z',
},
],
sentimentLog: [
{ id: 's-401', at: '15:00', score: 76, note: 'Confident' },
{ id: 's-402', at: '16:00', score: 88, note: 'Ready to close' },
{ id: 's-403', at: '17:10', score: 94, note: 'Final commitment' },
],
},
];

View File

@@ -2,15 +2,23 @@ import { useEffect } from 'react';
import { getChatLogs, getLeads } from '@/lib/api';
import { mapLeadRecordToStoreLead } from '@/lib/crmMappers';
import { mapInventoryPropertySummaryToUnit } from '@/lib/platformMappers';
import { useStore } from '@/store/useStore';
import type { ChatMessage } from '@/types';
import type { LeadRecord, ChatLogRecord } from '@/lib/api';
import { listInventoryProperties } from '@/lib/velocityPlatformClient';
export function useCrmBootstrap() {
const { setLeads, replaceMessages } = useStore();
const { setLeads, replaceMessages, setUnits, updateMetrics, setVelocityData, updateStatus } = useStore();
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
updateStatus({
isConnected: false,
serverStatus: 'syncing',
});
try {
const leads = await getLeads();
if (cancelled) return;
@@ -33,8 +41,44 @@ export function useCrmBootstrap() {
if (!cancelled) {
replaceMessages(Object.fromEntries(messageEntries));
}
const inventoryResult = await listInventoryProperties(100).catch(() => null);
if (!cancelled) {
const units = inventoryResult?.properties.map(mapInventoryPropertySummaryToUnit) ?? [];
setUnits(units);
updateMetrics(buildDashboardMetrics(leads, messageEntries, units.length));
setVelocityData(buildVelocitySeries(leads));
updateStatus({
isConnected: true,
serverStatus: 'online',
lastSync: new Date(),
});
}
} catch {
// Keep the current in-app fallback state if the CRM backend is unreachable.
if (!cancelled) {
setLeads([]);
replaceMessages({});
setUnits([]);
updateMetrics({
activeVisitors: 0,
todayLeads: 0,
closedDeals: 0,
conversionRate: 0,
sentiment: 0,
systemHealth: {
cpu: 0,
gpu: 0,
memory: 0,
temperature: 0,
},
});
setVelocityData([]);
updateStatus({
isConnected: false,
serverStatus: 'offline',
lastSync: new Date(),
});
}
}
};
@@ -42,5 +86,61 @@ export function useCrmBootstrap() {
return () => {
cancelled = true;
};
}, [replaceMessages, setLeads]);
}, [replaceMessages, setLeads, setUnits, setVelocityData, updateMetrics, updateStatus]);
}
function buildDashboardMetrics(
leads: LeadRecord[],
messageEntries: ReadonlyArray<readonly [string, ChatMessage[]]>,
inventoryCount: number,
) {
const closedDeals = leads.filter((lead) => lead.stage === 'closed').length;
const engagedLeads = leads.filter((lead) => lead.score >= 75 || lead.stage === 'negotiation' || lead.stage === 'qualified').length;
const averageScore = leads.length > 0
? Math.round(leads.reduce((sum, lead) => sum + lead.score, 0) / leads.length)
: 0;
const totalMessages = messageEntries.reduce((sum, [, messages]) => sum + messages.length, 0);
return {
activeVisitors: Math.min(999, totalMessages),
todayLeads: leads.length,
closedDeals,
conversionRate: leads.length > 0 ? Number(((closedDeals / leads.length) * 100).toFixed(1)) : 0,
sentiment: averageScore,
systemHealth: {
cpu: Math.min(100, 10 + leads.length * 2),
gpu: Math.min(100, 5 + Math.round(inventoryCount * 1.5)),
memory: Math.min(100, 15 + totalMessages),
temperature: Math.min(100, 20 + engagedLeads * 4),
},
};
}
function buildVelocitySeries(leads: LeadRecord[]) {
const buckets = new Map<string, { generated: number; closed: number }>();
for (let dayOffset = 6; dayOffset >= 0; dayOffset -= 1) {
const day = new Date();
day.setHours(0, 0, 0, 0);
day.setDate(day.getDate() - dayOffset);
const key = day.toISOString().slice(0, 10);
buckets.set(key, { generated: 0, closed: 0 });
}
for (const lead of leads) {
const createdKey = (lead.created_at ?? '').slice(0, 10);
const updatedKey = (lead.updated_at ?? lead.created_at ?? '').slice(0, 10);
if (buckets.has(createdKey)) {
buckets.get(createdKey)!.generated += 1;
}
if (lead.stage === 'closed' && buckets.has(updatedKey)) {
buckets.get(updatedKey)!.closed += 1;
}
}
return Array.from(buckets.entries()).map(([key, value]) => ({
time: key.slice(5),
generated: value.generated,
closed: value.closed,
}));
}

View File

@@ -117,19 +117,3 @@ export async function getChatLogs(leadId?: string): Promise<ChatLogRecord[]> {
export async function getLeadDemographics(): Promise<LeadDemographics> {
return requestWrappedData<LeadDemographics>('/api/leads/demographics');
}
export async function seedSyntheticLeads(count = 100): Promise<{ seeded: number; chat_logs_seeded: number; batch: string }> {
const response = await fetch(`${API_URL}/api/leads/seed-synthetic`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ count }),
});
if (!response.ok) {
throw new Error(`Seed request failed: ${response.status}`);
}
const payload = await response.json() as { data: { seeded: number; chat_logs_seeded: number; batch: string } };
return payload.data;
}

View File

@@ -1,354 +0,0 @@
export type OracleCanvasView =
| 'pipeline'
| 'team_performance'
| 'account_timeline'
| 'lead_map'
| 'calendar_tasks';
export interface OracleQueryPayload {
prompt: string;
history: Array<{ role: 'user' | 'assistant'; content: string }>;
mode: 'cot-rag';
preferredView?: OracleCanvasView;
}
export interface PipelineCardData {
id: string;
name: string;
company: string;
value: string;
avatar: string;
}
export interface TeamMemberData {
id: string;
name: string;
dealsClosed: number;
revenueGenerated: string;
avatar: string;
}
export interface TimelineEvent {
id: string;
type: 'email' | 'meeting' | 'call';
title: string;
when: string;
summary: string;
}
export interface CalendarEventData {
id: string;
day: string;
time: string;
title: string;
suggested?: boolean;
}
export interface OracleQueryResult {
view: OracleCanvasView;
insight: string;
summary: string;
payload: {
pipeline?: Record<string, PipelineCardData[]>;
revenueSeries?: Array<{ month: string; revenue: number; goal: number }>;
quotaAttainment?: number;
team?: TeamMemberData[];
account?: {
name: string;
totalDealValue: string;
primaryContact: string;
industry: string;
contacts: Array<{ name: string; role: string; avatar: string }>;
timeline: TimelineEvent[];
};
map?: {
region: string;
pins: Array<{
id: string;
label: string;
x: number;
y: number;
temperature: 'cold' | 'warm' | 'hot';
count?: number;
}>;
};
calendar?: {
weekLabel: string;
events: CalendarEventData[];
tasks: Array<{ id: string; title: string; subtitle: string; due: string }>;
};
};
}
export const DEFAULT_ORACLE_RESULT: OracleQueryResult = {
view: 'pipeline',
insight: 'Pipeline Velocity: Average deal cycle is 21 days, 10% faster than Q3.',
summary: 'Pipeline view generated for Q4 by stage.',
payload: {
pipeline: {
'New Leads': [
{
id: 'n1',
name: 'Elena Rostova',
company: 'Rostova Ventures',
value: '$120k',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
},
{
id: 'n2',
name: 'Mary Iluskimon',
company: 'Nexloop',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80',
},
],
Qualified: [
{
id: 'q1',
name: 'Etlena Roya',
company: 'Mianaperson',
value: '$120k',
avatar:
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80',
},
{
id: 'q2',
name: 'Silver Rostova',
company: 'Silverline Co',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80',
},
],
'Proposal Sent': [
{
id: 'p1',
name: 'Magulanta Senneciton',
company: 'Senneciton',
value: '$140k',
avatar:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80',
},
{
id: 'p2',
name: 'Minatie Ganrison',
company: 'Ganrison Group',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80',
},
],
Negotiation: [
{
id: 'g1',
name: 'Jomath Bilotmberg',
company: 'Biotmberg',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80',
},
{
id: 'g2',
name: 'Josen Oateliars',
company: 'Oateliars',
value: '$100k',
avatar:
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
},
],
},
},
};
const VIEW_TO_PROMPT: Record<OracleCanvasView, string> = {
pipeline: 'Show me a pipeline view by stage for Q4.',
team_performance: "What's the performance of the sales team this month?",
account_timeline: "Find all contacts at 'Apex Innovations' and their recent activity.",
lead_map: 'Give me a map of all leads in California.',
calendar_tasks: 'Schedule a follow-up with the top 3 high-value leads.',
};
export function mockOracleResultForPrompt(prompt: string): OracleQueryResult {
const text = prompt.toLowerCase();
if (text.includes('performance') || text.includes('team')) {
return {
view: 'team_performance',
insight: 'Team is on track to exceed monthly quota by 15%.',
summary: 'Performance dashboard for current month.',
payload: {
revenueSeries: [
{ month: 'Jan', revenue: 10, goal: 20 },
{ month: 'Feb', revenue: 30, goal: 35 },
{ month: 'Mar', revenue: 28, goal: 40 },
{ month: 'Sep', revenue: 52, goal: 55 },
{ month: 'Oct', revenue: 56, goal: 60 },
{ month: 'Nov', revenue: 74, goal: 70 },
{ month: 'Dec', revenue: 88, goal: 80 },
],
quotaAttainment: 85,
team: [
{
id: 't1',
name: 'Elena Rostova',
dealsClosed: 12,
revenueGenerated: '$1.2M',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
},
{
id: 't2',
name: 'Etlena Roya',
dealsClosed: 12,
revenueGenerated: '$1.2M',
avatar:
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80',
},
{
id: 't3',
name: 'Minatie Ganrison',
dealsClosed: 13,
revenueGenerated: '$1.2M',
avatar:
'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80',
},
{
id: 't4',
name: 'Josen Oateliars',
dealsClosed: 18,
revenueGenerated: '$0.8M',
avatar:
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
},
],
},
};
}
if (text.includes('apex') || text.includes('activity') || text.includes('contacts')) {
return {
view: 'account_timeline',
insight: "Action: Schedule a check-in call with Apex's CEO regarding the proposal.",
summary: 'Account history and associated contacts for Apex Innovations.',
payload: {
account: {
name: 'Apex Innovations',
totalDealValue: '$4.5M',
primaryContact: 'Elena Rostova, CEO',
industry: 'Technology',
contacts: [
{
name: 'Elena Rostova',
role: 'CEO',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
},
{
name: 'Mary Iluskimon',
role: 'COO',
avatar:
'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80',
},
{
name: 'Entin Veenos',
role: 'VP Finance',
avatar:
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
},
],
timeline: [
{
id: 'a1',
type: 'email',
title: 'Email Sent',
when: 'Today, 10:30 AM',
summary: 'Proposal Follow-up',
},
{
id: 'a2',
type: 'meeting',
title: 'Meeting',
when: 'Yesterday, 2:00 PM',
summary: 'Q4 Strategy',
},
{
id: 'a3',
type: 'call',
title: 'Call Logged',
when: 'Yesterday, 6:20 PM',
summary: 'Discussed pricing',
},
],
},
},
};
}
if (text.includes('map') || text.includes('california') || text.includes('geographic')) {
return {
view: 'lead_map',
insight: 'Insight: 60% of high-value leads are concentrated in the Bay Area.',
summary: 'Geographic lead distribution in California.',
payload: {
map: {
region: 'California',
pins: [
{ id: 'm1', label: 'SF', x: 26, y: 32, temperature: 'warm', count: 24 },
{ id: 'm2', label: 'Oakland', x: 29, y: 35, temperature: 'cold', count: 19 },
{ id: 'm3', label: 'San Jose', x: 32, y: 42, temperature: 'hot' },
{ id: 'm4', label: 'LA', x: 44, y: 78, temperature: 'warm', count: 8 },
{ id: 'm5', label: 'San Diego', x: 46, y: 88, temperature: 'cold' },
{ id: 'm6', label: 'Sacramento', x: 36, y: 28, temperature: 'hot' },
],
},
},
};
}
if (text.includes('schedule') || text.includes('calendar') || text.includes('follow-up')) {
return {
view: 'calendar_tasks',
insight:
"Scheduling: Proposed times minimize conflicts and align with contact's preferred hours.",
summary: 'Weekly calendar and follow-up actions generated.',
payload: {
calendar: {
weekLabel: 'Week 21',
events: [
{ id: 'c1', day: 'Mon', time: '10:00', title: 'Elena Rostova' },
{ id: 'c2', day: 'Tue', time: '12:00', title: 'Appointments' },
{ id: 'c3', day: 'Wed', time: '13:00', title: 'Follow-up', suggested: true },
{ id: 'c4', day: 'Thu', time: '14:00', title: 'Meeting' },
{ id: 'c5', day: 'Fri', time: '12:00', title: 'Follow-up', suggested: true },
],
tasks: [
{ id: 'k1', title: 'Follow-up', subtitle: 'Elena Rostova', due: 'Due Today' },
{ id: 'k2', title: 'Prepare Proposal', subtitle: 'Apex Innovations', due: 'Due Tomorrow' },
{ id: 'k3', title: 'Confirm Slot', subtitle: 'Mr. Kapoor', due: 'Due Today' },
],
},
},
};
}
return DEFAULT_ORACLE_RESULT;
}
export async function queryOracle(payload: OracleQueryPayload): Promise<OracleQueryResult> {
const endpoint = import.meta.env.VITE_ORACLE_QUERY_URL;
if (!endpoint) {
if (payload.preferredView) {
return mockOracleResultForPrompt(VIEW_TO_PROMPT[payload.preferredView]);
}
return mockOracleResultForPrompt(payload.prompt);
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Oracle query failed with ${response.status}`);
}
return (await response.json()) as OracleQueryResult;
}

View File

@@ -0,0 +1,139 @@
import type { InventoryPropertySummary } from '@/lib/velocityPlatformClient';
import type { Unit } from '@/types';
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const cleaned = value.replace(/[^0-9.]/g, '');
const parsed = Number(cleaned);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
}
function pickFirstNumber(values: unknown[]): number | null {
for (const value of values) {
const parsed = asNumber(value);
if (parsed !== null) {
return parsed;
}
}
return null;
}
function mapInventoryStatus(status: string): Unit['status'] {
switch ((status ?? '').toLowerCase()) {
case 'active':
return 'available';
case 'under_review':
return 'reserved';
case 'archived':
return 'hold';
default:
return 'hold';
}
}
function inferArea(unitMix: unknown[]): number {
for (const item of unitMix) {
const record = asRecord(item);
const area = pickFirstNumber([
record.avg_area_sqm,
record.avg_area,
record.area_sqm,
record.area,
record.size_sqm,
record.size,
]);
if (area !== null) {
return Math.round(area);
}
}
return 0;
}
function inferPrice(priceBands: unknown[]): number {
for (const item of priceBands) {
const record = asRecord(item);
const price = pickFirstNumber([
record.from,
record.min,
record.price,
record.starting_price,
record.amount,
record.value,
]);
if (price !== null) {
return Math.round(price);
}
}
return 0;
}
function inferType(propertyType: string, unitMix: unknown[]): Unit['type'] {
const normalizedPropertyType = (propertyType ?? '').toLowerCase();
if (normalizedPropertyType.includes('penthouse')) return 'penthouse';
if (normalizedPropertyType.includes('studio')) return 'studio';
if (normalizedPropertyType.includes('1')) return '1br';
if (normalizedPropertyType.includes('2')) return '2br';
if (normalizedPropertyType.includes('3')) return '3br';
for (const item of unitMix) {
const record = asRecord(item);
const raw = String(
record.type ?? record.unit_type ?? record.label ?? record.configuration ?? ''
).toLowerCase();
if (raw.includes('penthouse')) return 'penthouse';
if (raw.includes('studio')) return 'studio';
if (raw.includes('1')) return '1br';
if (raw.includes('2')) return '2br';
if (raw.includes('3')) return '3br';
}
return '2br';
}
function inferFloor(unitMix: unknown[]): number {
for (const item of unitMix) {
const record = asRecord(item);
const floor = pickFirstNumber([record.floor, record.level, record.start_floor]);
if (floor !== null) {
return Math.round(floor);
}
}
return 0;
}
export function mapInventoryPropertySummaryToUnit(
property: InventoryPropertySummary,
index: number,
): Unit {
const location = asRecord(property.location);
const unitMix = Array.isArray(property.unit_mix) ? property.unit_mix : [];
const priceBands = Array.isArray(property.price_bands) ? property.price_bands : [];
const district = typeof location.district === 'string' ? location.district : '';
const city = typeof location.city === 'string' ? location.city : '';
const view = [district, city].filter(Boolean).join(', ') || property.developer_name || 'Location pending';
return {
id: property.property_id,
unitNumber: property.project_name || `Property ${index + 1}`,
type: inferType(property.property_type, unitMix),
floor: inferFloor(unitMix),
area: inferArea(unitMix),
price: inferPrice(priceBands),
status: mapInventoryStatus(property.status),
view,
lastUpdated: new Date(property.ingested_at ?? property.created_at ?? Date.now()),
};
}

View File

@@ -0,0 +1,269 @@
import { API_URL } from '@/lib/api';
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
export interface VelocityUserProfile {
user_id: string;
role: string;
}
export interface VelocityLoginResponse {
access_token: string;
token_type: string;
expires_in: number;
}
export interface AdminHealthSnapshot {
status: string;
timestamp: string;
database: {
connected: boolean;
latency_ms: number;
};
queues: {
pending_transcriptions: number;
pending_synthetic_jobs: number;
pending_admin_actions: number;
pending_inventory_batches: number;
};
active_sessions: {
total: number;
by_surface: Record<string, number>;
};
}
export interface AdminQueueSnapshot {
transcription_jobs: Record<string, number>;
synthetic_jobs: Record<string, number>;
inventory_batches: Record<string, number>;
admin_actions: Record<string, number>;
timestamp: string;
}
export interface AdminInstallSnapshot {
installs: Array<{
surface_type: string;
app_version: string;
session_count: number;
last_seen: string | null;
}>;
timestamp: string;
}
export interface AdminActionRecord {
action_event_id: string;
action_id: string;
action_type: string;
target_type: string;
target_id: string;
requested_by: string;
status: string;
result_message?: string | null;
executed_at?: string | null;
created_at: string;
}
export interface AdminActionRequest {
action_type: string;
target_type: string;
target_id: string;
payload?: Record<string, unknown>;
idempotency_key?: string;
}
export interface MobileEdgeAlertSnapshot {
pending_insights: number;
upcoming_calendar_events_24h: number;
pending_transcriptions: number;
generated_at: string;
}
export interface MobileCalendarEvent {
calendar_event_id: string;
lead_id?: string | null;
title: string;
description?: string | null;
start_at: string;
end_at: string;
all_day: boolean;
status: string;
reminder_minutes: number[];
created_by: string;
location?: string | null;
metadata: Record<string, unknown>;
created_at: string;
}
export interface MobileCommunicationEvent {
event_id: string;
lead_id: string;
channel: string;
direction: string;
provider?: string | null;
capture_mode: string;
consent_state: string;
timestamp: string;
duration_seconds?: number | null;
summary?: string | null;
raw_reference?: string | null;
recording_ref?: string | null;
provider_metadata: Record<string, unknown>;
created_at: string;
}
export interface InventoryImportBatchSummary {
batch_id: string;
source_type: string;
submitted_by: string;
status: string;
total_rows: number;
accepted_rows: number;
rejected_rows: number;
created_at: string;
completed_at?: string | null;
}
export interface InventoryPropertySummary {
property_id: string;
project_name: string;
developer_name: string;
property_type: string;
location: Record<string, unknown>;
price_bands: Array<Record<string, unknown>>;
unit_mix: Array<Record<string, unknown>>;
status: string;
ingested_at?: string | null;
created_at?: string | null;
}
function buildHeaders(init?: HeadersInit, includeJson = true): Headers {
const headers = new Headers(init);
if (includeJson && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
const token = getVelocityToken();
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
}
async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
...init,
headers: buildHeaders(init?.headers, init?.body !== undefined),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
typeof body?.detail === 'string'
? body.detail
: typeof body?.message === 'string'
? body.message
: `Request failed: ${response.status}`,
);
}
return response.json() as Promise<T>;
}
export function setVelocityToken(token: string) {
localStorage.setItem(VELOCITY_TOKEN_KEY, token);
}
export function getVelocityToken(): string | null {
return localStorage.getItem(VELOCITY_TOKEN_KEY);
}
export function clearVelocityToken() {
localStorage.removeItem(VELOCITY_TOKEN_KEY);
}
export function normalizeVelocityRole(role: string | null | undefined): string {
return (role ?? '').trim().toUpperCase();
}
export function isAdminRole(role: string | null | undefined): boolean {
const normalized = normalizeVelocityRole(role);
return normalized === 'ADMIN' || normalized === 'SUPERADMIN';
}
export async function loginVelocity(email: string, password: string): Promise<VelocityUserProfile> {
const auth = await platformFetch<VelocityLoginResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setVelocityToken(auth.access_token);
return getVelocityMe();
}
export async function getVelocityMe(): Promise<VelocityUserProfile> {
return platformFetch<VelocityUserProfile>('/api/auth/me', {
method: 'GET',
});
}
export async function getAdminHealth(): Promise<AdminHealthSnapshot> {
return platformFetch<AdminHealthSnapshot>('/api/admin-surface/health');
}
export async function getAdminQueues(): Promise<AdminQueueSnapshot> {
return platformFetch<AdminQueueSnapshot>('/api/admin-surface/queues');
}
export async function getAdminInstalls(): Promise<AdminInstallSnapshot> {
return platformFetch<AdminInstallSnapshot>('/api/admin-surface/installs');
}
export async function listAdminActions(limit = 20): Promise<{ actions: AdminActionRecord[] }> {
return platformFetch<{ actions: AdminActionRecord[] }>(
`/api/admin-surface/actions?limit=${encodeURIComponent(String(limit))}`,
);
}
export async function submitAdminAction(body: AdminActionRequest): Promise<{
action_event_id: string;
action_id: string;
status: string;
created_at: string;
}> {
return platformFetch('/api/admin-surface/actions', {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function getMobileAlerts(): Promise<MobileEdgeAlertSnapshot> {
return platformFetch<MobileEdgeAlertSnapshot>('/api/mobile-edge/alerts');
}
export async function getMobileCalendarEvents(): Promise<{ events: MobileCalendarEvent[] }> {
return platformFetch<{ events: MobileCalendarEvent[] }>('/api/mobile-edge/calendar');
}
export async function getMobileEventsByLead(
leadId: string,
limit = 20,
): Promise<{ events: MobileCommunicationEvent[] }> {
const params = new URLSearchParams({
lead_id: leadId,
limit: String(limit),
});
return platformFetch<{ events: MobileCommunicationEvent[] }>(`/api/mobile-edge/events?${params.toString()}`);
}
export async function listInventoryImportBatches(limit = 10): Promise<{ batches: InventoryImportBatchSummary[] }> {
return platformFetch<{ batches: InventoryImportBatchSummary[] }>(
`/api/inventory/import-batches?limit=${encodeURIComponent(String(limit))}`,
);
}
export async function listInventoryProperties(limit = 100): Promise<{ properties: InventoryPropertySummary[] }> {
return platformFetch<{ properties: InventoryPropertySummary[] }>(
`/api/inventory/properties?limit=${encodeURIComponent(String(limit))}`,
);
}

View File

@@ -17,6 +17,7 @@ import type {
OracleEnvelope,
CanvasPageRevision,
} from '../types/canvas';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
const BASE_URL = (import.meta.env.VITE_ORACLE_API_URL as string | undefined) ?? '';
const WS_URL = (import.meta.env.VITE_ORACLE_WS_URL as string | undefined) ?? '';
@@ -39,7 +40,7 @@ async function apiFetch<T>(
...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),
};
const token = localStorage.getItem('oracle_jwt');
const token = localStorage.getItem('oracle_jwt') ?? localStorage.getItem(VELOCITY_TOKEN_KEY);
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(apiUrl(path), { ...options, headers });

View File

@@ -1,455 +0,0 @@
/**
* Oracle Demo Data — In-memory seed canvas used when backend is not available.
* Preserves visual richness while the system is in development/demo mode.
* These objects conform exactly to the CanvasPage/CanvasComponent contract.
*/
import type { CanvasPage, UserProfile, CanvasComponent } from '../types/canvas';
// ── Demo user profile ─────────────────────────────────────────────────────────
export const IN_MEMORY_ME: UserProfile = {
userId: 'user_sales_director_001',
tenantId: 'tenant_binghatti_demo',
email: 'ahmed.alfarsi@binghatti.ae',
displayName: 'Ahmed Al-Farsi',
role: 'sales_director',
timezone: 'Asia/Dubai',
locale: 'en-AE',
defaultPageId: 'page_01_main_broker',
canvasPreferences: {
defaultDensity: 'comfortable',
defaultPlacementMode: 'append_after_last_visible_component',
showLineageBadges: true,
},
policyProfileId: 'policy_sales_director_standard_v4',
createdAt: '2026-01-15T09:00:00Z',
updatedAt: '2026-04-09T00:00:00Z',
};
// ── Default style signature ───────────────────────────────────────────────────
const VELOCITY_GLASS_STYLE = {
theme: 'velocity_glass',
paletteToken: 'ocean_signal',
motionProfile: 'calm_reveal',
density: 'comfortable' as const,
radiusScale: 'lg',
typographyScale: 'balanced',
};
// ── Demo components ───────────────────────────────────────────────────────────
const PIPELINE_BOARD: CanvasComponent = {
componentId: 'cmp_demo_pipeline_board',
type: 'pipelineBoard',
title: 'Active Pipeline by Stage',
description: 'Current deal distribution across funnel stages for Q2 2026.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_pipeline',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'deals',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT stage, COUNT(*) as count, SUM(value) as value FROM deals WHERE tenant_id = :tenant_id GROUP BY stage',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 100,
freshnessSlaSeconds: 120,
cachePolicy: { mode: 'ttl', ttlSeconds: 120 },
privacyTier: 'standard',
lineageRefs: [],
},
visualizationParameters: {
stages: ['New Leads', 'Qualified', 'Proposal Sent', 'Negotiation'],
showValue: true,
colorByStage: true,
},
dataBindings: {
dimensions: ['stage'],
measures: ['count', 'value'],
series: [],
filters: [],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_pipeline_board_v2',
promptExecutionId: 'pex_demo_seed_001',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T08:00:00Z',
},
renderingHints: {
estimatedHeightPx: 400,
skeletonVariant: 'pipeline',
virtualizationPriority: 9,
},
layout: {
orderIndex: 100,
sectionId: 'sec_pipeline',
widthMode: 'full',
minHeightPx: 380,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'none',
},
styleSignature: VELOCITY_GLASS_STYLE,
validationState: {
schema: 'pass',
policy: 'pass',
a11y: 'pass',
performance: 'pass',
status: 'validated',
},
auditLog: ['aud_demo_create_001'],
dataRows: [
{ stage: 'New Leads', count: 14, value: 18500000, leads: [
{ id: 'l1', name: 'Mohammed Al-Rashid', company: 'Rashid Group', value: 'AED 15M', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80' },
{ id: 'l2', name: 'Sarah Chen', company: 'Chen Capital', value: 'AED 8M', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80' },
{ id: 'l3', name: 'James Wilson', company: 'Wilson RE', value: 'AED 4.5M', avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Qualified', count: 9, value: 42000000, leads: [
{ id: 'l4', name: 'Fatima Hassan', company: 'Hassan Holdings', value: 'AED 22M', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80' },
{ id: 'l5', name: 'David Kumar', company: 'Kumar RE', value: 'AED 20M', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Proposal Sent', count: 5, value: 28000000, leads: [
{ id: 'l6', name: 'Elena Rostova', company: 'Rostova Ventures', value: 'AED 12M', avatar: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80' },
{ id: 'l7', name: 'Oliver Park', company: 'Park Investments', value: 'AED 16M', avatar: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Negotiation', count: 3, value: 65000000, leads: [
{ id: 'l8', name: 'Priya Sharma', company: 'Sharma Family Office', value: 'AED 32M', avatar: 'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80' },
{ id: 'l9', name: 'Carlos Mendez', company: 'Mendez Capital', value: 'AED 33M', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=80&q=80' },
]},
],
};
const WHALE_LEADS_BAR: CanvasComponent = {
componentId: 'cmp_demo_whale_bar',
type: 'barChart',
title: 'Whale Leads by Source This Week',
description: 'Compares QD-weighted whale lead volume across lead sources in the current week.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_whale_bar',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'lead_daily_snapshot',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: "SELECT source, SUM(qd_weighted_score) as qd_weighted_volume FROM lead_daily_snapshot WHERE tenant_id = :tenant_id AND lead_class = 'whale' GROUP BY source ORDER BY qd_weighted_volume DESC",
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 20,
freshnessSlaSeconds: 120,
cachePolicy: { mode: 'ttl', ttlSeconds: 120 },
privacyTier: 'standard',
lineageRefs: ['lin_demo_leadsnap'],
},
visualizationParameters: {
xAxis: 'source',
yAxis: 'qd_weighted_volume',
sort: 'desc',
showLabels: true,
colorScale: ['#0EA5E9', '#22D3EE', '#3B82F6'],
legend: false,
},
dataBindings: {
dimensions: ['source'],
measures: ['qd_weighted_volume'],
series: [],
filters: [{ field: 'lead_class', operator: '=', value: 'whale' }],
},
version: 1,
provenance: {
originType: 'prompt_generated',
templateId: 'tpl_bar_source_quality_v3',
promptExecutionId: 'pex_demo_seed_002',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T10:00:00Z',
},
renderingHints: {
estimatedHeightPx: 340,
skeletonVariant: 'chart',
virtualizationPriority: 8,
},
layout: {
orderIndex: 200,
sectionId: 'sec_leads',
widthMode: 'half',
minHeightPx: 320,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director', 'marketing_operator'],
redactionPolicy: 'aggregate_only',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'ocean_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_002'],
dataRows: [
{ source: 'WhatsApp', qd_weighted_volume: 182.4 },
{ source: 'Website', qd_weighted_volume: 149.2 },
{ source: 'Walk-in', qd_weighted_volume: 93.7 },
{ source: 'Referral', qd_weighted_volume: 87.1 },
{ source: 'Instagram', qd_weighted_volume: 54.3 },
],
};
const INVESTOR_GEO_MAP: CanvasComponent = {
componentId: 'cmp_demo_geo_investor',
type: 'geoMap',
title: 'Investor Interest Density by Dubai District',
description: 'Maps high-intent leads with at least one positive Sentinel spike in the last 30 days.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_geo',
sourceType: 'derived_dataset',
connectorId: 'velocity-core-postgres',
dataset: 'lead_geo_interest_rollup',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT district, lat, lng, lead_count, avg_qd_score FROM lead_geo_interest_rollup WHERE tenant_id = :tenant_id AND activity_window = :window',
queryParameters: { tenant_id: 'tenant_binghatti_demo', window: '30d' },
rowLimit: 100,
freshnessSlaSeconds: 300,
cachePolicy: { mode: 'ttl', ttlSeconds: 300 },
privacyTier: 'restricted',
lineageRefs: ['lin_demo_rollup', 'lin_demo_sentinel'],
},
visualizationParameters: {
mapStyle: 'dubai_district_heat',
intensityField: 'lead_count',
tooltipFields: ['district', 'lead_count', 'avg_qd_score'],
interactive: true,
},
dataBindings: {
dimensions: ['district'],
measures: ['lead_count', 'avg_qd_score'],
series: ['district'],
filters: [{ field: 'activity_window', operator: '=', value: '30d' }],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_geo_investor_heat_v2',
promptExecutionId: 'pex_demo_seed_002',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T10:00:01Z',
},
renderingHints: {
estimatedHeightPx: 420,
skeletonVariant: 'map',
virtualizationPriority: 9,
},
layout: {
orderIndex: 300,
sectionId: 'sec_leads',
widthMode: 'half',
minHeightPx: 400,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'district_level_only',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'aqua_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_003'],
dataRows: [
{ district: 'Downtown Dubai', lat: 25.1972, lng: 55.2744, lead_count: 38, avg_qd_score: 87.2, x: 52, y: 48 },
{ district: 'Dubai Marina', lat: 25.0777, lng: 55.1386, lead_count: 29, avg_qd_score: 82.1, x: 28, y: 68 },
{ district: 'Palm Jumeirah', lat: 25.1124, lng: 55.1390, lead_count: 24, avg_qd_score: 91.4, x: 22, y: 60 },
{ district: 'Business Bay', lat: 25.1850, lng: 55.2617, lead_count: 19, avg_qd_score: 74.8, x: 48, y: 44 },
{ district: 'Dubai Hills', lat: 25.1124, lng: 55.2454, lead_count: 15, avg_qd_score: 71.3, x: 44, y: 58 },
{ district: 'JBR', lat: 25.0794, lng: 55.1322, lead_count: 11, avg_qd_score: 68.9, x: 26, y: 70 },
{ district: 'DIFC', lat: 25.2048, lng: 55.2708, lead_count: 9, avg_qd_score: 79.5, x: 50, y: 38 },
],
};
const BROKER_PERFORMANCE: CanvasComponent = {
componentId: 'cmp_demo_broker_perf',
type: 'table',
title: 'Broker Performance Leaderboard',
description: 'Ranked by QD-adjusted deal value closed this month.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_brokers',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'broker_performance',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT broker_id, name, deals_closed, revenue_generated, avg_response_time_min FROM broker_performance WHERE tenant_id = :tenant_id ORDER BY revenue_generated DESC',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 20,
freshnessSlaSeconds: 300,
cachePolicy: { mode: 'ttl', ttlSeconds: 300 },
privacyTier: 'standard',
lineageRefs: [],
},
visualizationParameters: {
columns: ['name', 'deals_closed', 'revenue_generated', 'avg_response_time_min'],
rankBy: 'revenue_generated',
showTopBadge: true,
},
dataBindings: {
dimensions: ['name'],
measures: ['deals_closed', 'revenue_generated'],
series: [],
filters: [],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_broker_performance_v1',
promptExecutionId: 'pex_demo_seed_003',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T11:00:00Z',
},
renderingHints: {
estimatedHeightPx: 320,
skeletonVariant: 'table',
virtualizationPriority: 7,
},
layout: {
orderIndex: 400,
sectionId: 'sec_team',
widthMode: 'full',
minHeightPx: 300,
stickyHeader: true,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['sales_director'],
redactionPolicy: 'none',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'indigo_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_004'],
dataRows: [
{ name: 'Elena Rostova', deals_closed: 12, revenue_generated: 'AED 28.4M', avg_response_time_min: 8, rank: 1, avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80' },
{ name: 'Priya Sharma', deals_closed: 10, revenue_generated: 'AED 24.1M', avg_response_time_min: 11, rank: 2, avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80' },
{ name: 'Carlos Mendez', deals_closed: 9, revenue_generated: 'AED 19.7M', avg_response_time_min: 14, rank: 3, avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80' },
{ name: 'Ravi Kapoor', deals_closed: 7, revenue_generated: 'AED 15.2M', avg_response_time_min: 22, rank: 4, avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80' },
{ name: 'Minati Ganrison', deals_closed: 6, revenue_generated: 'AED 11.8M', avg_response_time_min: 19, rank: 5, avatar: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80' },
],
};
const FOLLOWUP_QUEUE: CanvasComponent = {
componentId: 'cmp_demo_followup_queue',
type: 'activityStream',
title: 'Follow-up Gap Queue',
description: 'High-scoring leads with no contact in the last 72 hours.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_queue',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'lead_follow_up_gaps',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT lead_id, name, last_contact_hours_ago, qd_score, assigned_broker FROM lead_follow_up_gaps WHERE tenant_id = :tenant_id AND last_contact_hours_ago > 72 ORDER BY qd_score DESC',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 10,
freshnessSlaSeconds: 60,
cachePolicy: { mode: 'ttl', ttlSeconds: 60 },
privacyTier: 'restricted',
lineageRefs: ['lin_demo_sentinel'],
},
visualizationParameters: {
showUrgencyIndicator: true,
enableQuickAction: true,
quickActions: ['call', 'whatsapp', 'email', 'assign'],
},
dataBindings: {
dimensions: ['name', 'assigned_broker'],
measures: ['qd_score', 'last_contact_hours_ago'],
series: [],
filters: [{ field: 'last_contact_hours_ago', operator: '>', value: 72 }],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_followup_queue_v1',
promptExecutionId: 'pex_demo_seed_004',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T12:00:00Z',
},
renderingHints: {
estimatedHeightPx: 380,
skeletonVariant: 'table',
virtualizationPriority: 10,
},
layout: {
orderIndex: 500,
sectionId: 'sec_actions',
widthMode: 'full',
minHeightPx: 360,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'team_scope',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'amber_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_005'],
dataRows: [
{ lead_id: 'l10', name: 'Alexander Petrov', last_contact_hours_ago: 96, qd_score: 88.4, assigned_broker: 'Elena Rostova', urgency: 'critical', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l11', name: 'Nadia Okafor', last_contact_hours_ago: 84, qd_score: 81.2, assigned_broker: 'Priya Sharma', urgency: 'high', avatar: 'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l12', name: 'Tariq Al-Mansoori', last_contact_hours_ago: 78, qd_score: 76.9, assigned_broker: 'Carlos Mendez', urgency: 'medium', avatar: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l13', name: 'Sophie Leclerc', last_contact_hours_ago: 73, qd_score: 72.1, assigned_broker: 'Ravi Kapoor', urgency: 'medium', avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80' },
],
};
// ── Demo Canvas Page ──────────────────────────────────────────────────────────
export const IN_MEMORY_DEMO_PAGE: CanvasPage = {
pageId: 'page_01_main_broker',
tenantId: 'tenant_binghatti_demo',
ownerId: 'user_sales_director_001',
branchId: 'branch_main',
branchName: 'main',
pageType: 'main',
title: 'Oracle — Pipeline & Investor Signals',
createdAt: '2026-04-09T08:00:00Z',
updatedAt: '2026-04-09T12:00:00Z',
isShared: false,
forks: [],
mainBranchPointer: {
pageId: 'page_01_main_broker',
branchId: 'branch_main',
revision: 5,
},
baseRevision: 0,
headRevision: 5,
sharingPolicy: {
shareMode: 'direct_fork_only',
allowReshare: false,
defaultForkVisibility: 'private',
},
presence: {
activeViewers: 1,
activeEditors: 1,
lastPresenceAt: new Date().toISOString(),
},
lineage: [
{
lineageRecordId: 'lin_demo_seed',
tenantId: 'tenant_binghatti_demo',
sourceKind: 'prompt',
sourceId: 'pex_demo_seed_001',
transformationType: 'prompt_to_component_bundle',
producedKind: 'page_revision',
producedId: 'page_01_main_broker:5',
createdAt: '2026-04-09T12:00:00Z',
},
],
audit: {
lastAuditEventId: 'aud_demo_rev5',
eventCount: 12,
},
components: [
PIPELINE_BOARD,
WHALE_LEADS_BAR,
INVESTOR_GEO_MAP,
BROKER_PERFORMANCE,
FOLLOWUP_QUEUE,
],
};

View File

@@ -49,7 +49,7 @@ export const useCurrencyStore = create<CurrencyState>()(
const { currency, option } = get();
const { locale } = option();
// Base assumption: Raw numbers in mock data are in AED.
// Base assumption: inventory and campaign amounts are stored in AED.
let convertedAmount = amount;
if (currency === 'USD') convertedAmount = amount * 0.272; // AED -> USD
if (currency === 'INR') convertedAmount = amount * 25.135112; // AED -> INR (0.272 * 92.4085)

View File

@@ -5,139 +5,8 @@ import type {
AdInsight,
LiveOptimizationEvent,
CatalystSettings,
LiveEventType,
} from '@/types';
// ── Mock Data ─────────────────────────────────────────────────────────────────
const mockCampaigns: Campaign[] = [
{
id: 'c1',
name: '3BHK Prestige Launch — Dubai Marina',
objective: 'OUTCOME_LEADS',
status: 'ACTIVE',
dailyBudget: 50000, // AED 500
lifetimeSpend: 2340000,
impressions: 487200,
clicks: 9744,
ctr: 2.0,
cpa: 240,
roi: 18.5,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
updatedAt: new Date(),
},
{
id: 'c2',
name: 'Penthouse Whale Retarget — Instagram',
objective: 'OUTCOME_SALES',
status: 'ACTIVE',
dailyBudget: 100000, // AED 1000
lifetimeSpend: 5800000,
impressions: 92400,
clicks: 2772,
ctr: 3.0,
cpa: 2094,
roi: 42.1,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14),
updatedAt: new Date(),
},
{
id: 'c3',
name: '1BHK Investment — Lookalike Audience',
objective: 'OUTCOME_TRAFFIC',
status: 'PAUSED',
dailyBudget: 25000, // AED 250
lifetimeSpend: 980000,
impressions: 213000,
clicks: 4260,
ctr: 2.0,
cpa: 230,
roi: 8.2,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
updatedAt: new Date(),
},
];
const mockAssets: MarketingAsset[] = [
{
id: 'a1',
name: 'Penthouse Cinematic — Sea View',
type: 'video',
status: 'ready',
localUrl: '/assets/renders/penthouse_wan22_001.mp4',
metaAssetId: 'meta_vid_83920',
language: 'en',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
},
{
id: 'a2',
name: 'Arabic Poster — 3BHK (Qwen-2512)',
type: 'image',
status: 'uploaded',
localUrl: '/assets/renders/3bhk_qwen_ar.png',
metaAssetId: 'meta_img_74811',
language: 'ar',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5),
},
{
id: 'a3',
name: 'Amenity Deck Reel — Wan 2.2 14B',
type: 'video',
status: 'rendering',
renderMessage: 'Wan 2.2 is compositing the infinity pool reflection...',
language: 'en',
createdAt: new Date(),
},
{
id: 'a4',
name: 'English Poster — Penthouse Launch',
type: 'image',
status: 'queued',
renderMessage: 'Qwen-Image 2512 queued for cinematic poster render...',
language: 'en',
createdAt: new Date(),
},
];
const mockInsights: AdInsight[] = Array.from({ length: 14 }, (_, i) => {
const d = new Date(Date.now() - 1000 * 60 * 60 * 24 * (13 - i));
return {
adSetId: `as_${i}`,
adSetName: i % 2 === 0 ? '3BHK — Dubai Marina' : 'Penthouse Retarget',
spend: 800 + Math.floor(Math.random() * 400),
impressions: 18000 + Math.floor(Math.random() * 10000),
clicks: 360 + Math.floor(Math.random() * 200),
ctr: 1.8 + Math.random() * 1.5,
cpa: 190 + Math.floor(Math.random() * 120),
roi: 14 + Math.random() * 20,
date: d.toISOString().split('T')[0],
};
});
function makeLiveEvent(
type: LiveEventType,
message: string,
campaignName?: string,
value?: string
): LiveOptimizationEvent {
return {
id: `ev_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
type,
message,
campaignName,
timestamp: new Date(),
value,
};
}
const mockLiveEvents: LiveOptimizationEvent[] = [
makeLiveEvent('pause', 'Paused Ad Set B due to CPA exceeding AED 500 threshold.', '3BHK Prestige Launch', 'CPA: AED 512'),
makeLiveEvent('shift', 'Shifted AED 200/day budget from Ad Set B to Ad Set A (lower CPA).', '3BHK Prestige Launch', '+AED 200'),
makeLiveEvent('rotate', 'Rotated in Penthouse Cinematic (sea view) as new creative for A/B test.', 'Penthouse Whale Retarget'),
makeLiveEvent('optimize', 'Expanded Lookalike Audience to 2% similarity — 48k new reach.', '1BHK Investment', '+48k reach'),
makeLiveEvent('create', 'Created new Ad Set targeting High-Net-Worth Lookalike from 23 new CRM Closed/Won leads.', 'Penthouse Whale Retarget'),
];
// ── Store Types ────────────────────────────────────────────────────────────────
interface MarketingState {
@@ -162,10 +31,10 @@ interface MarketingState {
// ── Store ─────────────────────────────────────────────────────────────────────
export const useMarketingStore = create<MarketingState>()((set) => ({
campaigns: mockCampaigns,
activeAssets: mockAssets,
adInsights: mockInsights,
liveEvents: mockLiveEvents,
campaigns: [],
activeAssets: [],
adInsights: [],
liveEvents: [],
activeTab: 'studio',
settings: {

View File

@@ -61,6 +61,7 @@ interface DashboardState {
metrics: DashboardMetrics;
velocityData: LeadVelocityData[];
updateMetrics: (metrics: Partial<DashboardMetrics>) => void;
setVelocityData: (data: LeadVelocityData[]) => void;
addVelocityDataPoint: (data: LeadVelocityData) => void;
}
@@ -69,6 +70,7 @@ interface InventoryState {
units: Unit[];
selectedUnitId: string | null;
filterStatus: Unit['status'] | 'all';
setUnits: (units: Unit[]) => void;
setSelectedUnit: (unitId: string | null) => void;
setFilterStatus: (status: Unit['status'] | 'all') => void;
}
@@ -101,160 +103,6 @@ interface StoreState extends
SystemState,
NotificationState { }
// Mock Data
const mockLeads: Lead[] = [
{
id: '1',
name: 'Mohammed Al-Rashid',
phone: '+971 55 123 4567',
source: 'whatsapp',
status: 'hot',
lastMessage: 'Can we schedule a viewing for the penthouse tomorrow?',
lastActive: new Date(Date.now() - 1000 * 60 * 5),
unreadCount: 2,
qualification: 'whale',
budget: 'AED 15M+',
interest: 'Penthouse Suite',
},
{
id: '2',
name: 'Sarah Chen',
phone: '+971 50 987 6543',
source: 'walkin',
status: 'engaged',
lastMessage: 'Thank you for the brochure. I will review with my partner.',
lastActive: new Date(Date.now() - 1000 * 60 * 30),
unreadCount: 0,
qualification: 'potential',
budget: 'AED 5-8M',
interest: '2 Bedroom Sea View',
},
{
id: '3',
name: 'James Wilson',
phone: '+971 52 456 7890',
source: 'website',
status: 'new',
lastMessage: 'Interested in investment opportunities.',
lastActive: new Date(Date.now() - 1000 * 60 * 60 * 2),
unreadCount: 1,
qualification: 'potential',
budget: 'AED 3-5M',
interest: '1 Bedroom Investment',
},
{
id: '4',
name: 'Fatima Hassan',
phone: '+971 54 321 0987',
source: 'whatsapp',
status: 'qualified',
lastMessage: 'What are the payment plan options?',
lastActive: new Date(Date.now() - 1000 * 60 * 60 * 4),
unreadCount: 0,
qualification: 'whale',
budget: 'AED 12M+',
interest: '3 Bedroom + Maid',
},
{
id: '5',
name: 'David Kumar',
phone: '+971 56 789 0123',
source: 'walkin',
status: 'closed',
lastMessage: 'Contract signed. Thank you!',
lastActive: new Date(Date.now() - 1000 * 60 * 60 * 24),
unreadCount: 0,
qualification: 'whale',
budget: 'AED 20M',
interest: 'Full Floor',
},
];
const mockMessages: Record<string, ChatMessage[]> = {
'1': [
{
id: 'm1',
sender: 'user',
content: 'Hi, I am interested in the penthouse units.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2),
},
{
id: 'm2',
sender: 'oracle',
content: 'Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M. Would you like to know more about specific floor plans?',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2 + 1000 * 30),
},
{
id: 'm3',
sender: 'user',
content: 'Can we schedule a viewing for the penthouse tomorrow?',
timestamp: new Date(Date.now() - 1000 * 60 * 5),
},
],
'2': [
{
id: 'm4',
sender: 'oracle',
content: 'Hello Sarah! Thank you for visiting our Experience Center today. Here is the digital brochure for the 2-bedroom units we discussed.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 4),
},
{
id: 'm5',
sender: 'user',
content: 'Thank you for the brochure. I will review with my partner.',
timestamp: new Date(Date.now() - 1000 * 60 * 30),
},
],
};
const mockVisitors: Visitor[] = [
{
id: 'v1',
faceId: 'face_001',
sentiment: 'excited',
confidence: 0.92,
dwellTime: 450,
zone: 'Penthouse Showroom',
timestamp: new Date(),
},
{
id: 'v2',
faceId: 'face_002',
sentiment: 'interested',
confidence: 0.87,
dwellTime: 320,
zone: 'Amenity Deck VR',
timestamp: new Date(),
},
{
id: 'v3',
faceId: 'face_003',
sentiment: 'neutral',
confidence: 0.78,
dwellTime: 180,
zone: 'Reception',
timestamp: new Date(),
},
];
const mockVelocityData: LeadVelocityData[] = Array.from({ length: 12 }, (_, i) => ({
time: `${9 + Math.floor(i / 2)}:${i % 2 === 0 ? '00' : '30'}`,
generated: Math.floor(Math.random() * 8) + 2,
closed: Math.floor(Math.random() * 3),
}));
const mockUnits: Unit[] = [
{ id: 'u1', unitNumber: 'PH-01', type: 'penthouse', floor: 45, area: 520, price: 25000000, status: 'available', view: 'Panoramic Sea', lastUpdated: new Date() },
{ id: 'u2', unitNumber: 'PH-02', type: 'penthouse', floor: 45, area: 480, price: 22000000, status: 'reserved', view: 'Sea & Marina', lastUpdated: new Date() },
{ id: 'u3', unitNumber: '4501', type: '3br', floor: 45, area: 280, price: 12000000, status: 'available', view: 'Sea View', lastUpdated: new Date() },
{ id: 'u4', unitNumber: '4502', type: '3br', floor: 45, area: 265, price: 11500000, status: 'sold', view: 'Marina View', lastUpdated: new Date() },
{ id: 'u5', unitNumber: '4401', type: '2br', floor: 44, area: 180, price: 7500000, status: 'available', view: 'Sea View', lastUpdated: new Date() },
{ id: 'u6', unitNumber: '4402', type: '2br', floor: 44, area: 175, price: 7200000, status: 'hold', view: 'City View', lastUpdated: new Date() },
{ id: 'u7', unitNumber: '4301', type: '1br', floor: 43, area: 95, price: 4200000, status: 'available', view: 'Sea View', lastUpdated: new Date() },
{ id: 'u8', unitNumber: '4302', type: '1br', floor: 43, area: 92, price: 4000000, status: 'available', view: 'City View', lastUpdated: new Date() },
];
export const useStore = create<StoreState>()(
persist(
(set) => ({
@@ -272,9 +120,9 @@ export const useStore = create<StoreState>()(
setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
// Oracle State
leads: mockLeads,
leads: [],
activeLeadId: null,
messages: mockMessages,
messages: {},
isOracleThinking: false,
setLeads: (leads) => set({ leads }),
replaceMessages: (messages) => set({ messages }),
@@ -293,7 +141,7 @@ export const useStore = create<StoreState>()(
})),
// Sentinel State
visitors: mockVisitors,
visitors: [],
isAlertActive: false,
alertMessage: '',
addVisitor: (visitor) => set((state) => ({
@@ -307,37 +155,39 @@ export const useStore = create<StoreState>()(
// Dashboard State
metrics: {
activeVisitors: 12,
todayLeads: 24,
closedDeals: 3,
conversionRate: 12.5,
sentiment: 78,
activeVisitors: 0,
todayLeads: 0,
closedDeals: 0,
conversionRate: 0,
sentiment: 0,
systemHealth: {
cpu: 34,
gpu: 28,
memory: 42,
temperature: 58,
cpu: 0,
gpu: 0,
memory: 0,
temperature: 0,
},
},
velocityData: mockVelocityData,
velocityData: [],
updateMetrics: (metrics) => set((state) => ({
metrics: { ...state.metrics, ...metrics },
})),
setVelocityData: (data) => set({ velocityData: data }),
addVelocityDataPoint: (data) => set((state) => ({
velocityData: [...state.velocityData.slice(1), data],
})),
// Inventory State
units: mockUnits,
units: [],
selectedUnitId: null,
filterStatus: 'all',
setUnits: (units) => set({ units }),
setSelectedUnit: (unitId) => set({ selectedUnitId: unitId }),
setFilterStatus: (status) => set({ filterStatus: status }),
// System State
status: {
isConnected: true,
serverStatus: 'online',
isConnected: false,
serverStatus: 'syncing',
lastSync: new Date(),
version: '2.1.0',
},

View File

@@ -1,5 +1,5 @@
// Navigation Module Types
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst';
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin';
export type SentinelSubTab = 'overview' | 'live-session';
@@ -15,7 +15,7 @@ export interface User {
id: string;
name: string;
avatar?: string;
role: 'sales_director' | 'admin';
role: string;
}
// Chat Types for Oracle
@@ -301,4 +301,3 @@ export interface MarketingVideo {
video_url: string;
thumbnail_color: string;
}

View File

@@ -0,0 +1,529 @@
"""
routes_admin_surface.py
───────────────────────
Admin Control Plane API
Roles: Only 'admin' or 'superadmin' may access these endpoints.
Endpoints:
GET /admin-surface/health — system health overview
GET /admin-surface/queues — queue depth snapshot
GET /admin-surface/installs — surface session / install overview
POST /admin-surface/actions — submit an admin action
GET /admin-surface/actions — list admin action history
GET /admin-surface/actions/{id} — get a specific action
GET /admin-surface/logs — recent audit event log
GET /admin-surface/templates — template catalog summary (admin view)
POST /admin-surface/templates/{id}/publish — publish a template
POST /admin-surface/templates/{id}/archive — archive a template
GET /admin-surface/synthetic-jobs — list synthetic generation jobs
POST /admin-surface/synthetic-jobs/{id}/cancel — cancel a synthetic job
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.admin_surface")
router = APIRouter()
# ── RBAC guard ────────────────────────────────────────────────────────────────
ADMIN_ROLES = {"admin", "superadmin", "ADMIN", "SUPERADMIN"}
def require_admin(user=Depends(get_current_user)):
normalized_role = user.role.upper()
if normalized_role not in {"ADMIN", "SUPERADMIN"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required.",
)
return user
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
# ── Pydantic Models ───────────────────────────────────────────────────────────
VALID_ACTION_TYPES = {
"user_create", "user_deactivate", "user_role_change",
"tenant_config_update", "inventory_batch_approve", "inventory_batch_reject",
"template_publish", "template_archive",
"synthetic_job_trigger", "synthetic_job_cancel",
"system_health_check", "queue_drain", "debug_event_export",
"install_register", "install_deregister",
}
class AdminActionRequest(BaseModel):
action_type: str
target_type: str
target_id: str
payload: dict = Field(default_factory=dict)
idempotency_key: Optional[str] = None
# ── System Health ─────────────────────────────────────────────────────────────
@router.get("/health", summary="System health overview")
async def get_health(
request: Request,
admin=Depends(require_admin),
):
"""
Returns an aggregated health snapshot covering DB pool, queue depths,
and basic surface session counts.
"""
pool = _pool(request)
async with pool.acquire() as conn:
# DB round-trip latency
import time
t0 = time.monotonic()
await conn.fetchval("SELECT 1")
db_latency_ms = round((time.monotonic() - t0) * 1000, 2)
# Pending jobs
pending_transcriptions = await conn.fetchval(
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE status='pending'"
)
pending_synthetic_jobs = await conn.fetchval(
"SELECT COUNT(*) FROM oracle_synthetic_generation_jobs WHERE status IN ('pending','running')"
)
pending_admin_actions = await conn.fetchval(
"SELECT COUNT(*) FROM admin_action_events WHERE status='pending'"
)
pending_inventory_batches = await conn.fetchval(
"SELECT COUNT(*) FROM inventory_import_batches WHERE status IN ('pending','validating','processing')"
)
# Active surface sessions (last 30 min)
active_sessions = await conn.fetchval(
"SELECT COUNT(*) FROM surface_sessions WHERE last_active_at > NOW() - INTERVAL '30 minutes'"
)
# Surface breakdown
surface_breakdown = await conn.fetch(
"""
SELECT surface_type, COUNT(*) as count
FROM surface_sessions
WHERE last_active_at > NOW() - INTERVAL '30 minutes'
GROUP BY surface_type
"""
)
return {
"status": "ok",
"timestamp": datetime.now(UTC).isoformat(),
"database": {
"connected": True,
"latency_ms": db_latency_ms,
},
"queues": {
"pending_transcriptions": pending_transcriptions,
"pending_synthetic_jobs": pending_synthetic_jobs,
"pending_admin_actions": pending_admin_actions,
"pending_inventory_batches": pending_inventory_batches,
},
"active_sessions": {
"total": active_sessions,
"by_surface": {r["surface_type"]: r["count"] for r in surface_breakdown},
},
}
# ── Queue Visibility ──────────────────────────────────────────────────────────
@router.get("/queues", summary="Queue depth snapshot")
async def get_queues(
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
transcription_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM edge_transcription_jobs
GROUP BY status ORDER BY status
"""
)
synthetic_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM oracle_synthetic_generation_jobs
GROUP BY status ORDER BY status
"""
)
inventory_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM inventory_import_batches
GROUP BY status ORDER BY status
"""
)
admin_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM admin_action_events
GROUP BY status ORDER BY status
"""
)
return {
"transcription_jobs": {r["status"]: r["count"] for r in transcription_queue},
"synthetic_jobs": {r["status"]: r["count"] for r in synthetic_queue},
"inventory_batches": {r["status"]: r["count"] for r in inventory_queue},
"admin_actions": {r["status"]: r["count"] for r in admin_queue},
"timestamp": datetime.now(UTC).isoformat(),
}
# ── Install / Surface Overview ────────────────────────────────────────────────
@router.get("/installs", summary="Surface session and install overview")
async def get_installs(
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT surface_type, app_version, COUNT(*) as session_count,
MAX(last_active_at) as last_seen
FROM surface_sessions
GROUP BY surface_type, app_version
ORDER BY surface_type, app_version
"""
)
return {
"installs": [dict(r) for r in rows],
"timestamp": datetime.now(UTC).isoformat(),
}
# ── Admin Actions ─────────────────────────────────────────────────────────────
@router.post("/actions", status_code=status.HTTP_201_CREATED, summary="Submit an admin action")
async def submit_action(
request: Request,
body: AdminActionRequest,
admin=Depends(require_admin),
):
"""
Submit a bounded admin action. All actions are persisted with full audit trail.
Supported action_types are enumerated in VALID_ACTION_TYPES.
Actions are not auto-executed — they transition to 'pending' and must be
processed by the appropriate backend job or confirmed by a second admin.
(This prevents destructive mass-actions from running unreviewed.)
"""
if body.action_type not in VALID_ACTION_TYPES:
raise HTTPException(400, f"Invalid action_type. Valid: {sorted(VALID_ACTION_TYPES)}")
action_id = body.idempotency_key or str(uuid.uuid4())
pool = _pool(request)
async with pool.acquire() as conn:
try:
row = await conn.fetchrow(
"""
INSERT INTO admin_action_events (
tenant_id, action_id, action_type, target_type, target_id,
requested_by, payload
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)
RETURNING action_event_id, status, created_at
""",
admin.role, action_id, body.action_type, body.target_type,
body.target_id, admin.user_id, json.dumps(body.payload),
)
except Exception as exc:
if "unique" in str(exc).lower():
raise HTTPException(409, "Action with this idempotency key already exists")
raise
logger.info(
"Admin action submitted: %s by %s%s/%s",
body.action_type, admin.user_id, body.target_type, body.target_id,
)
return {
"action_event_id": str(row["action_event_id"]),
"action_id": action_id,
"status": row["status"],
"created_at": str(row["created_at"]),
}
@router.get("/actions", summary="List admin action history")
async def list_actions(
request: Request,
action_type: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
where = "WHERE tenant_id = $1"
params: list[Any] = [admin.role]
idx = 2
if action_type:
where += f" AND action_type = ${idx}"; params.append(action_type); idx += 1
if status_filter:
where += f" AND status = ${idx}"; params.append(status_filter); idx += 1
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT action_event_id, action_id, action_type, target_type, target_id,
requested_by, status, result_message, executed_at, created_at
FROM admin_action_events
{where}
ORDER BY created_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM admin_action_events {where}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "actions": [dict(r) for r in rows]}
@router.get("/actions/{action_event_id}", summary="Get a specific admin action")
async def get_action(
action_event_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM admin_action_events WHERE action_event_id=$1 AND tenant_id=$2",
action_event_id, admin.role,
)
if not row:
raise HTTPException(404, "Admin action not found")
return dict(row)
# ── Audit Log ─────────────────────────────────────────────────────────────────
@router.get("/logs", summary="Recent Oracle audit events")
async def get_audit_logs(
request: Request,
entity_type: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
if entity_type:
rows = await conn.fetch(
"""
SELECT audit_event_id, entity_type, entity_id, action, actor_id,
actor_type, correlation_id, details, created_at
FROM oracle_audit_events
WHERE tenant_id=$1 AND entity_type=$2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
""",
admin.role, entity_type, limit, offset,
)
else:
rows = await conn.fetch(
"""
SELECT audit_event_id, entity_type, entity_id, action, actor_id,
actor_type, correlation_id, details, created_at
FROM oracle_audit_events
WHERE tenant_id=$1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
admin.role, limit, offset,
)
return {"logs": [dict(r) for r in rows]}
# ── Template Administration ───────────────────────────────────────────────────
@router.get("/templates", summary="Template catalog admin view")
async def get_templates_admin(
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
where = "WHERE tenant_id = $1"
params: list[Any] = [admin.role]
idx = 2
if status_filter:
where += f" AND status = ${idx}"; params.append(status_filter); idx += 1
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT t.template_id, t.name, t.category, t.status, t.origin,
t.version, t.use_count, t.chapter_id, t.subchapter_id,
ch.name as chapter_name, sub.name as subchapter_name,
t.created_at, t.updated_at
FROM oracle_component_templates t
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
{where}
ORDER BY t.updated_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM oracle_component_templates {where}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "templates": [dict(r) for r in rows]}
@router.post("/templates/{template_id}/publish", summary="Publish a template")
async def publish_template(
template_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE oracle_component_templates
SET status='catalog_active', updated_at=NOW()
WHERE template_id=$1 AND tenant_id=$2
""",
template_id, admin.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Template not found")
logger.info("Template %s published by admin %s", template_id, admin.user_id)
return {"status": "published"}
@router.post("/templates/{template_id}/archive", summary="Archive a template")
async def archive_template(
template_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE oracle_component_templates
SET status='archived', updated_at=NOW()
WHERE template_id=$1 AND tenant_id=$2
""",
template_id, admin.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Template not found")
logger.info("Template %s archived by admin %s", template_id, admin.user_id)
return {"status": "archived"}
# ── Template Chapter Admin ────────────────────────────────────────────────────
@router.get("/template-chapters", summary="List template chapters (admin view)")
async def list_chapters_admin(
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT ch.chapter_id, ch.name, ch.description, ch.sort_order, ch.is_active,
COUNT(sub.subchapter_id) as subchapter_count
FROM oracle_template_chapters ch
LEFT JOIN oracle_template_subchapters sub ON sub.chapter_id = ch.chapter_id
WHERE ch.tenant_id=$1
GROUP BY ch.chapter_id
ORDER BY ch.sort_order ASC
""",
admin.role,
)
return {"chapters": [dict(r) for r in rows]}
# ── Synthetic Jobs Admin ──────────────────────────────────────────────────────
@router.get("/synthetic-jobs", summary="List synthetic generation jobs")
async def list_synthetic_jobs(
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
if status_filter:
rows = await conn.fetch(
"""
SELECT job_id, template_id, model, status, requested_count,
accepted_count, created_by, started_at, completed_at, created_at
FROM oracle_synthetic_generation_jobs
WHERE tenant_id=$1 AND status=$2
ORDER BY created_at DESC LIMIT $3 OFFSET $4
""",
admin.role, status_filter, limit, offset,
)
else:
rows = await conn.fetch(
"""
SELECT job_id, template_id, model, status, requested_count,
accepted_count, created_by, started_at, completed_at, created_at
FROM oracle_synthetic_generation_jobs
WHERE tenant_id=$1
ORDER BY created_at DESC LIMIT $2 OFFSET $3
""",
admin.role, limit, offset,
)
return {"jobs": [dict(r) for r in rows]}
@router.post("/synthetic-jobs/{job_id}/cancel", summary="Cancel a synthetic generation job")
async def cancel_synthetic_job(
job_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE oracle_synthetic_generation_jobs
SET status='cancelled', updated_at=NOW()
WHERE job_id=$1 AND tenant_id=$2 AND status IN ('pending','running')
""",
job_id, admin.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Job not found or already in terminal state")
return {"status": "cancelled"}

View File

@@ -0,0 +1,399 @@
"""
routes_inventory.py
───────────────────
Inventory Pipeline API
Endpoints:
POST /inventory/import-batches — create a new import batch
GET /inventory/import-batches — list import batches
GET /inventory/import-batches/{batch_id} — get batch status
POST /inventory/properties — create a single property
GET /inventory/properties — list properties
GET /inventory/properties/{property_id} — get a property
PATCH /inventory/properties/{property_id} — update a property
DELETE /inventory/properties/{property_id} — archive a property
POST /inventory/properties/{property_id}/media — attach media to a property
GET /inventory/properties/{property_id}/media — list media for a property
DELETE /inventory/media/{media_asset_id} — remove a media asset
"""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.inventory")
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
# ── Pydantic Models ───────────────────────────────────────────────────────────
VALID_SOURCE_TYPES = {"csv", "json", "api_push", "manual"}
VALID_PROPERTY_STATUSES = {"active", "archived", "draft", "under_review"}
VALID_MEDIA_TYPES = {"image", "video", "floorplan", "brochure", "360", "vr"}
class ImportBatchCreate(BaseModel):
source_type: str
source_file_ref: Optional[str] = None
total_rows: int = 0
class PropertyCreate(BaseModel):
batch_id: Optional[str] = None
source_id: Optional[str] = None
project_name: str
developer_name: str
location: dict = Field(default_factory=dict) # {city, district, lat, lng}
property_type: str
price_bands: list[dict] = Field(default_factory=list)
unit_mix: list[dict] = Field(default_factory=list)
amenities: list[str] = Field(default_factory=list)
status: str = "draft"
validation_state: dict = Field(default_factory=dict)
class PropertyUpdate(BaseModel):
project_name: Optional[str] = None
developer_name: Optional[str] = None
location: Optional[dict] = None
property_type: Optional[str] = None
price_bands: Optional[list[dict]] = None
unit_mix: Optional[list[dict]] = None
amenities: Optional[list[str]] = None
status: Optional[str] = None
validation_state: Optional[dict] = None
class MediaAssetCreate(BaseModel):
media_type: str
url: str
thumbnail_url: Optional[str] = None
sort_order: int = 0
metadata: dict = Field(default_factory=dict)
# ── Import Batches ────────────────────────────────────────────────────────────
@router.post("/import-batches", status_code=status.HTTP_201_CREATED,
summary="Create an inventory import batch")
async def create_import_batch(
request: Request,
body: ImportBatchCreate,
user=Depends(get_current_user),
):
if body.source_type not in VALID_SOURCE_TYPES:
raise HTTPException(400, f"Invalid source_type. Valid: {sorted(VALID_SOURCE_TYPES)}")
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO inventory_import_batches
(tenant_id, source_type, submitted_by, total_rows, source_file_ref)
VALUES ($1, $2, $3, $4, $5)
RETURNING batch_id, status, created_at
""",
user.role, body.source_type, user.user_id, body.total_rows, body.source_file_ref,
)
return dict(row)
@router.get("/import-batches", summary="List import batches")
async def list_import_batches(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT batch_id, source_type, submitted_by, status, total_rows,
accepted_rows, rejected_rows, created_at, completed_at
FROM inventory_import_batches
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
user.role, limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", user.role,
)
return {"total": total, "limit": limit, "offset": offset, "batches": [dict(r) for r in rows]}
@router.get("/import-batches/{batch_id}", summary="Get import batch status")
async def get_import_batch(
batch_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2
""",
batch_id, user.role,
)
if not row:
raise HTTPException(404, "Batch not found")
return dict(row)
# ── Properties ────────────────────────────────────────────────────────────────
@router.post("/properties", status_code=status.HTTP_201_CREATED, summary="Create a property")
async def create_property(
request: Request,
body: PropertyCreate,
user=Depends(get_current_user),
):
if body.status not in VALID_PROPERTY_STATUSES:
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}")
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO inventory_properties (
tenant_id, batch_id, source_id, project_name, developer_name,
location, property_type, price_bands, unit_mix, amenities,
status, validation_state
) VALUES (
$1, $2, $3, $4, $5,
$6::jsonb, $7, $8::jsonb, $9::jsonb, $10,
$11, $12::jsonb
)
RETURNING property_id, created_at
""",
user.role, body.batch_id, body.source_id, body.project_name, body.developer_name,
json.dumps(body.location), body.property_type, json.dumps(body.price_bands),
json.dumps(body.unit_mix), body.amenities,
body.status, json.dumps(body.validation_state),
)
return {"property_id": str(row["property_id"]), "created_at": str(row["created_at"])}
@router.get("/properties", summary="List inventory properties")
async def list_properties(
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
property_type: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
where_clause = "WHERE tenant_id = $1"
params: list[Any] = [user.role]
idx = 2
if status_filter:
where_clause += f" AND status = ${idx}"
params.append(status_filter)
idx += 1
if property_type:
where_clause += f" AND property_type = ${idx}"
params.append(property_type)
idx += 1
rows = await conn.fetch(
f"""
SELECT property_id, project_name, developer_name, property_type,
location, price_bands, unit_mix, status, ingested_at, created_at
FROM inventory_properties
{where_clause}
ORDER BY created_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM inventory_properties {where_clause}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "properties": [dict(r) for r in rows]}
@router.get("/properties/{property_id}", summary="Get a property")
async def get_property(
property_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
property_id, user.role,
)
if not row:
raise HTTPException(404, "Property not found")
return dict(row)
@router.patch("/properties/{property_id}", summary="Update a property")
async def update_property(
property_id: str,
request: Request,
body: PropertyUpdate,
user=Depends(get_current_user),
):
updates: list[str] = []
values: list[Any] = []
idx = 1
def _add(col: str, val: Any, cast: str = ""):
nonlocal idx
updates.append(f"{col} = ${idx}{cast}")
values.append(val)
idx += 1
if body.project_name is not None: _add("project_name", body.project_name)
if body.developer_name is not None: _add("developer_name", body.developer_name)
if body.location is not None: _add("location", json.dumps(body.location), "::jsonb")
if body.property_type is not None: _add("property_type", body.property_type)
if body.price_bands is not None: _add("price_bands", json.dumps(body.price_bands), "::jsonb")
if body.unit_mix is not None: _add("unit_mix", json.dumps(body.unit_mix), "::jsonb")
if body.amenities is not None: _add("amenities", body.amenities)
if body.status is not None:
if body.status not in VALID_PROPERTY_STATUSES:
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}")
_add("status", body.status)
if body.validation_state is not None:
_add("validation_state", json.dumps(body.validation_state), "::jsonb")
if not updates:
raise HTTPException(400, "No fields to update")
_add("updated_at", datetime.now(UTC))
values.extend([property_id, user.role])
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
f"""
UPDATE inventory_properties
SET {', '.join(updates)}
WHERE property_id=${idx} AND tenant_id=${idx+1}
""",
*values,
)
if result == "UPDATE 0":
raise HTTPException(404, "Property not found")
return {"status": "updated"}
@router.delete("/properties/{property_id}", summary="Archive a property")
async def archive_property(
property_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE inventory_properties
SET status='archived', updated_at=NOW()
WHERE property_id=$1 AND tenant_id=$2
""",
property_id, user.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Property not found")
return {"status": "archived"}
# ── Media Assets ──────────────────────────────────────────────────────────────
@router.post("/properties/{property_id}/media", status_code=status.HTTP_201_CREATED,
summary="Attach media to a property")
async def add_media(
property_id: str,
request: Request,
body: MediaAssetCreate,
user=Depends(get_current_user),
):
if body.media_type not in VALID_MEDIA_TYPES:
raise HTTPException(400, f"Invalid media_type. Valid: {sorted(VALID_MEDIA_TYPES)}")
pool = _pool(request)
async with pool.acquire() as conn:
# Verify property belongs to tenant
exists = await conn.fetchval(
"SELECT 1 FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
property_id, user.role,
)
if not exists:
raise HTTPException(404, "Property not found")
row = await conn.fetchrow(
"""
INSERT INTO inventory_media_assets
(property_id, tenant_id, media_type, url, thumbnail_url, sort_order, metadata, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8)
RETURNING media_asset_id, created_at
""",
property_id, user.role, body.media_type, body.url, body.thumbnail_url,
body.sort_order, json.dumps(body.metadata), user.user_id,
)
return {"media_asset_id": str(row["media_asset_id"]), "created_at": str(row["created_at"])}
@router.get("/properties/{property_id}/media", summary="List media for a property")
async def list_media(
property_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT media_asset_id, media_type, url, thumbnail_url, sort_order, metadata, created_at
FROM inventory_media_assets
WHERE property_id=$1 AND tenant_id=$2
ORDER BY sort_order ASC, created_at ASC
""",
property_id, user.role,
)
return {"media": [dict(r) for r in rows]}
@router.delete("/media/{media_asset_id}", summary="Remove a media asset")
async def delete_media(
media_asset_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM inventory_media_assets WHERE media_asset_id=$1 AND tenant_id=$2",
media_asset_id, user.role,
)
if result == "DELETE 0":
raise HTTPException(404, "Media asset not found")
return {"status": "deleted"}

View File

@@ -0,0 +1,635 @@
"""
routes_mobile_edge.py
─────────────────────
Mobile Edge API — serves iPhone Edge and Android Phone Edge apps.
Surfaces:
GET /mobile-edge/events — communication events for a lead
POST /mobile-edge/events — log a new communication event
GET /mobile-edge/memory — memory facts for a lead
POST /mobile-edge/imports — operator-assisted import of a recording/note
POST /mobile-edge/notes — quick note attached to a lead
GET /mobile-edge/calendar — calendar events for the authed user
POST /mobile-edge/calendar — create a calendar event
PATCH /mobile-edge/calendar/{id} — update a calendar event
DELETE /mobile-edge/calendar/{id} — cancel a calendar event
GET /mobile-edge/transcripts/{id} — transcript segments for an event
GET /mobile-edge/insights/{lead_id}— insight recommendations for a lead
POST /mobile-edge/insights/{id}/act — act on or dismiss an insight
GET /mobile-edge/alerts — active alerts for the authed user
POST /mobile-edge/session — register a surface session heartbeat
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.mobile_edge")
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
def _now() -> str:
return datetime.now(UTC).isoformat()
# ── Pydantic models ───────────────────────────────────────────────────────────
VALID_CHANNELS = {
"pstn", "whatsapp_message", "whatsapp_voice", "whatsapp_video",
"email", "facebook_message", "instagram_message", "in_app_voip", "manual_note",
}
VALID_CAPTURE_MODES = {"direct_api", "provider_routed", "operator_import", "operator_note"}
VALID_DIRECTIONS = {"inbound", "outbound"}
VALID_CONSENT = {"unknown", "granted", "denied", "not_required"}
class CommunicationEventCreate(BaseModel):
lead_id: str
channel: str
direction: str = "inbound"
provider: Optional[str] = None
capture_mode: str
consent_state: str = "unknown"
duration_seconds: Optional[int] = None
summary: Optional[str] = None
raw_reference: Optional[str] = None
recording_ref: Optional[str] = None
provider_metadata: dict = Field(default_factory=dict)
class ImportCreate(BaseModel):
lead_id: str
channel: str
capture_mode: str = "operator_import"
recording_ref: Optional[str] = None
summary: Optional[str] = None
consent_state: str = "granted"
class NoteCreate(BaseModel):
lead_id: str
note_text: str
fact_type: str = "custom"
effective_date: Optional[str] = None
class CalendarEventCreate(BaseModel):
lead_id: Optional[str] = None
source_event_id: Optional[str] = None
title: str
description: Optional[str] = None
start_at: str # ISO8601
end_at: str # ISO8601
all_day: bool = False
reminder_minutes: list[int] = Field(default_factory=lambda: [15])
location: Optional[str] = None
metadata: dict = Field(default_factory=dict)
class CalendarEventUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
start_at: Optional[str] = None
end_at: Optional[str] = None
status: Optional[str] = None
reminder_minutes: Optional[list[int]] = None
location: Optional[str] = None
class InsightActionRequest(BaseModel):
action: str = Field(..., pattern="^(accepted|dismissed|acted_upon)$")
class SessionHeartbeat(BaseModel):
surface_type: str
app_version: str
screen: Optional[str] = None
metadata: dict = Field(default_factory=dict)
# ── Communication Events ───────────────────────────────────────────────────────
@router.get("/events", summary="List communication events for a lead")
async def list_events(
request: Request,
lead_id: str = Query(..., description="Lead ID to fetch events for"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
"""Return paginated communication events for a given lead, newest first."""
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT event_id, lead_id, channel, direction, provider, capture_mode,
consent_state, timestamp, duration_seconds, summary, raw_reference,
recording_ref, provider_metadata, created_at
FROM edge_communication_events
WHERE tenant_id = $1 AND lead_id = $2
ORDER BY timestamp DESC
LIMIT $3 OFFSET $4
""",
user.role, # tenant_id derived from role scope; production uses dedicated tenant field
lead_id, limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2",
user.role, lead_id,
)
return {
"total": total,
"limit": limit,
"offset": offset,
"events": [dict(r) for r in rows],
}
@router.post("/events", status_code=status.HTTP_201_CREATED, summary="Log a communication event")
async def create_event(
request: Request,
body: CommunicationEventCreate,
user=Depends(get_current_user),
):
"""
Create a new communication event record.
Supports all three capture modes: direct_api, provider_routed, operator_import.
"""
if body.channel not in VALID_CHANNELS:
raise HTTPException(400, f"Invalid channel. Valid: {sorted(VALID_CHANNELS)}")
if body.capture_mode not in VALID_CAPTURE_MODES:
raise HTTPException(400, f"Invalid capture_mode. Valid: {sorted(VALID_CAPTURE_MODES)}")
if body.direction not in VALID_DIRECTIONS:
raise HTTPException(400, "direction must be 'inbound' or 'outbound'")
if body.consent_state not in VALID_CONSENT:
raise HTTPException(400, f"Invalid consent_state. Valid: {sorted(VALID_CONSENT)}")
pool = _pool(request)
import json
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO edge_communication_events (
tenant_id, lead_id, channel, direction, provider, capture_mode,
consent_state, duration_seconds, summary, raw_reference,
recording_ref, provider_metadata
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)
RETURNING event_id, created_at
""",
user.role, body.lead_id, body.channel, body.direction, body.provider,
body.capture_mode, body.consent_state, body.duration_seconds,
body.summary, body.raw_reference, body.recording_ref,
json.dumps(body.provider_metadata),
)
logger.info("Created communication event %s for lead %s", row["event_id"], body.lead_id)
return {"event_id": str(row["event_id"]), "created_at": str(row["created_at"])}
# ── Communication Memory Facts ────────────────────────────────────────────────
@router.get("/memory", summary="List memory facts for a lead")
async def list_memory_facts(
request: Request,
lead_id: str = Query(...),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT fact_id, lead_id, event_id, fact_type, fact_text,
effective_date, confidence, extracted_from, is_confirmed,
confirmed_by, confirmed_at, created_at
FROM edge_communication_memory_facts
WHERE tenant_id = $1 AND lead_id = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
""",
user.role, lead_id, limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM edge_communication_memory_facts WHERE tenant_id=$1 AND lead_id=$2",
user.role, lead_id,
)
return {"total": total, "limit": limit, "offset": offset, "facts": [dict(r) for r in rows]}
# ── Operator-Assisted Import ──────────────────────────────────────────────────
@router.post("/imports", status_code=status.HTTP_201_CREATED, summary="Operator-assisted import")
async def create_import(
request: Request,
body: ImportCreate,
user=Depends(get_current_user),
):
"""
Mode C import: user uploads recording ref or confirms a note manually.
Creates an event with capture_mode = 'operator_import' and triggers a
transcription job if a recording_ref is supplied.
"""
if body.channel not in VALID_CHANNELS:
raise HTTPException(400, f"Invalid channel. Valid: {sorted(VALID_CHANNELS)}")
pool = _pool(request)
import json
async with pool.acquire() as conn:
async with conn.transaction():
event_row = await conn.fetchrow(
"""
INSERT INTO edge_communication_events (
tenant_id, lead_id, channel, direction, capture_mode,
consent_state, recording_ref, summary
) VALUES ($1,$2,$3,'inbound',$4,$5,$6,$7)
RETURNING event_id, created_at
""",
user.role, body.lead_id, body.channel, body.capture_mode,
body.consent_state, body.recording_ref, body.summary,
)
event_id = event_row["event_id"]
job_id = None
if body.recording_ref:
job_row = await conn.fetchrow(
"""
INSERT INTO edge_transcription_jobs (
tenant_id, event_id, media_type, consent_state
) VALUES ($1,$2,'audio',$3)
RETURNING transcription_job_id
""",
user.role, event_id, body.consent_state,
)
job_id = str(job_row["transcription_job_id"])
return {
"event_id": str(event_id),
"transcription_job_id": job_id,
"created_at": str(event_row["created_at"]),
}
# ── Quick Notes ───────────────────────────────────────────────────────────────
@router.post("/notes", status_code=status.HTTP_201_CREATED, summary="Create a quick note for a lead")
async def create_note(
request: Request,
body: NoteCreate,
user=Depends(get_current_user),
):
"""
Create a manual memory fact from an operator note.
No event is created — this is a direct fact insertion.
"""
pool = _pool(request)
from datetime import date
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO edge_communication_memory_facts (
tenant_id, lead_id, fact_type, fact_text, effective_date,
extracted_from, confidence, is_confirmed
) VALUES ($1,$2,$3,$4,$5,'operator_note',1.0, TRUE)
RETURNING fact_id, created_at
""",
user.role, body.lead_id, body.fact_type, body.note_text,
body.effective_date,
)
return {"fact_id": str(row["fact_id"]), "created_at": str(row["created_at"])}
# ── Calendar ──────────────────────────────────────────────────────────────────
@router.get("/calendar", summary="Get calendar events for the authed user")
async def list_calendar_events(
request: Request,
from_date: Optional[str] = Query(None),
to_date: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
if from_date and to_date:
rows = await conn.fetch(
"""
SELECT calendar_event_id, lead_id, title, description, start_at, end_at,
all_day, status, reminder_minutes, created_by, location, metadata, created_at
FROM user_calendar_events
WHERE tenant_id=$1 AND owner_user_id=$2
AND start_at >= $3::timestamptz AND end_at <= $4::timestamptz
ORDER BY start_at ASC LIMIT $5
""",
user.role, user.user_id, from_date, to_date, limit,
)
else:
rows = await conn.fetch(
"""
SELECT calendar_event_id, lead_id, title, description, start_at, end_at,
all_day, status, reminder_minutes, created_by, location, metadata, created_at
FROM user_calendar_events
WHERE tenant_id=$1 AND owner_user_id=$2
ORDER BY start_at ASC LIMIT $3
""",
user.role, user.user_id, limit,
)
return {"events": [dict(r) for r in rows]}
@router.post("/calendar", status_code=status.HTTP_201_CREATED, summary="Create a calendar event")
async def create_calendar_event(
request: Request,
body: CalendarEventCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
import json
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO user_calendar_events (
tenant_id, owner_user_id, lead_id, source_event_id, title, description,
start_at, end_at, all_day, reminder_minutes, created_by, location, metadata
) VALUES ($1,$2,$3,$4,$5,$6,$7::timestamptz,$8::timestamptz,$9,$10,$11,$12,$13::jsonb)
RETURNING calendar_event_id, created_at
""",
user.role, user.user_id, body.lead_id, body.source_event_id,
body.title, body.description, body.start_at, body.end_at,
body.all_day, body.reminder_minutes, "user",
body.location, json.dumps(body.metadata),
)
return {"calendar_event_id": str(row["calendar_event_id"]), "created_at": str(row["created_at"])}
@router.patch("/calendar/{calendar_event_id}", summary="Update a calendar event")
async def update_calendar_event(
calendar_event_id: str,
request: Request,
body: CalendarEventUpdate,
user=Depends(get_current_user),
):
pool = _pool(request)
# Build partial update
updates: list[str] = []
values: list[Any] = []
idx = 1
def _add(col: str, val: Any):
nonlocal idx
updates.append(f"{col} = ${idx}")
values.append(val)
idx += 1
if body.title is not None: _add("title", body.title)
if body.description is not None: _add("description", body.description)
if body.start_at is not None: _add("start_at", body.start_at)
if body.end_at is not None: _add("end_at", body.end_at)
if body.status is not None: _add("status", body.status)
if body.reminder_minutes is not None: _add("reminder_minutes", body.reminder_minutes)
if body.location is not None: _add("location", body.location)
if not updates:
raise HTTPException(400, "No fields to update")
_add("updated_at", datetime.now(UTC))
_add("tenant_id", user.role)
_add("owner_user_id", user.user_id)
values.append(calendar_event_id)
async with pool.acquire() as conn:
result = await conn.execute(
f"""
UPDATE user_calendar_events
SET {', '.join(updates)}
WHERE tenant_id=${idx} AND owner_user_id=${idx+1} AND calendar_event_id=${idx+2}
""",
*values,
)
if result == "UPDATE 0":
raise HTTPException(404, "Calendar event not found or not owned by you")
return {"status": "updated"}
@router.delete("/calendar/{calendar_event_id}", summary="Cancel a calendar event")
async def delete_calendar_event(
calendar_event_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE user_calendar_events
SET status='cancelled', updated_at=NOW()
WHERE tenant_id=$1 AND owner_user_id=$2 AND calendar_event_id=$3
""",
user.role, user.user_id, calendar_event_id,
)
if result == "UPDATE 0":
raise HTTPException(404, "Calendar event not found or not owned by you")
return {"status": "cancelled"}
# ── Transcripts ───────────────────────────────────────────────────────────────
@router.get("/transcripts/{event_id}", summary="Get transcript segments for an event")
async def get_transcript(
event_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
job = await conn.fetchrow(
"""
SELECT j.transcription_job_id, j.status, j.provider, j.speaker_count,
j.word_count, j.language, j.completed_at
FROM edge_transcription_jobs j
JOIN edge_communication_events e ON e.event_id = j.event_id
WHERE j.event_id = $1 AND e.tenant_id = $2
ORDER BY j.created_at DESC LIMIT 1
""",
event_id, user.role,
)
if not job:
raise HTTPException(404, "No transcription job found for this event")
segments = await conn.fetch(
"""
SELECT segment_id, speaker_label, start_ms, end_ms, text, confidence, is_agent_turn
FROM edge_transcript_segments
WHERE transcription_job_id = $1
ORDER BY start_ms ASC
""",
job["transcription_job_id"],
)
return {
"job": dict(job),
"segments": [dict(s) for s in segments],
}
# ── Insights ──────────────────────────────────────────────────────────────────
@router.get("/insights/{lead_id}", summary="Get insight recommendations for a lead")
async def get_insights(
lead_id: str,
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(20, ge=1, le=100),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
if status_filter:
rows = await conn.fetch(
"""
SELECT recommendation_id, lead_id, source_event_id, recommendation_type,
summary, suggested_action, target_system, status, confidence, created_at
FROM insight_recommendations
WHERE tenant_id=$1 AND lead_id=$2 AND status=$3
ORDER BY created_at DESC LIMIT $4
""",
user.role, lead_id, status_filter, limit,
)
else:
rows = await conn.fetch(
"""
SELECT recommendation_id, lead_id, source_event_id, recommendation_type,
summary, suggested_action, target_system, status, confidence, created_at
FROM insight_recommendations
WHERE tenant_id=$1 AND lead_id=$2
ORDER BY created_at DESC LIMIT $3
""",
user.role, lead_id, limit,
)
return {"insights": [dict(r) for r in rows]}
@router.post("/insights/{recommendation_id}/act", summary="Act on or dismiss an insight")
async def act_on_insight(
recommendation_id: str,
request: Request,
body: InsightActionRequest,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE insight_recommendations
SET status=$1, acted_by=$2, acted_at=NOW(), updated_at=NOW()
WHERE recommendation_id=$3 AND tenant_id=$4
""",
body.action, user.user_id, recommendation_id, user.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Insight not found")
return {"status": body.action}
# ── Alerts ────────────────────────────────────────────────────────────────────
@router.get("/alerts", summary="Get active alerts for the authed user")
async def get_alerts(
request: Request,
user=Depends(get_current_user),
):
"""
Returns a combined, prioritized view of:
- Pending insights needing action
- Calendar events due within 24 hours
- Pending transcription jobs
"""
pool = _pool(request)
async with pool.acquire() as conn:
pending_insights = await conn.fetchval(
"SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id=$1 AND status='pending'",
user.role,
)
upcoming_events = await conn.fetchval(
"""
SELECT COUNT(*) FROM user_calendar_events
WHERE tenant_id=$1 AND owner_user_id=$2
AND status='confirmed'
AND start_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
""",
user.role, user.user_id,
)
pending_transcriptions = await conn.fetchval(
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id=$1 AND status='pending'",
user.role,
)
return {
"pending_insights": pending_insights,
"upcoming_calendar_events_24h": upcoming_events,
"pending_transcriptions": pending_transcriptions,
"generated_at": _now(),
}
# ── Session Heartbeat ─────────────────────────────────────────────────────────
@router.post("/session", status_code=status.HTTP_200_OK, summary="Register surface session heartbeat")
async def session_heartbeat(
request: Request,
body: SessionHeartbeat,
user=Depends(get_current_user),
):
"""Upsert a surface session to track cross-surface activity."""
valid_surfaces = {
"webos", "ipad", "android_tablet", "iphone_edge", "android_phone_edge",
}
if body.surface_type not in valid_surfaces:
raise HTTPException(400, f"Invalid surface_type. Valid: {sorted(valid_surfaces)}")
pool = _pool(request)
import json
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO surface_sessions (tenant_id, user_id, surface_type, app_version, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT DO NOTHING
RETURNING session_id
""",
user.role, user.user_id, body.surface_type, body.app_version,
json.dumps(body.metadata),
)
# Update last_active + screen_sequence for existing session (within 30 min)
if body.screen and row is None:
await conn.execute(
"""
UPDATE surface_sessions
SET last_active_at=NOW(),
screen_sequence = array_append(screen_sequence, $1)
WHERE tenant_id=$2 AND user_id=$3 AND surface_type=$4
AND last_active_at > NOW() - INTERVAL '30 minutes'
""",
body.screen, user.role, user.user_id, body.surface_type,
)
return {"status": "ok", "timestamp": _now()}

View File

@@ -0,0 +1,398 @@
"""
routes_oracle_templates.py
──────────────────────────
Oracle Template Catalog API
Extends the existing Oracle route surface with template taxonomy and seeding.
Endpoints:
GET /oracle/template-chapters — list chapters
POST /oracle/template-chapters — create a chapter
GET /oracle/template-subchapters — list subchapters (optionally filtered)
POST /oracle/template-subchapters — create a subchapter
GET /oracle/component-templates — list templates (filterable)
POST /oracle/component-templates — create a template
GET /oracle/component-templates/{id} — get a template
POST /oracle/component-templates/{id}/seed — add a seed example
GET /oracle/component-templates/{id}/seed — list seed examples for a template
POST /oracle/component-templates/synthetic-jobs — trigger a Kimi synthetic job
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.oracle_templates")
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(503, "Database unavailable.")
return pool
# ── Models ────────────────────────────────────────────────────────────────────
class ChapterCreate(BaseModel):
name: str
description: Optional[str] = None
sort_order: int = 0
class SubchapterCreate(BaseModel):
chapter_id: str
name: str
description: Optional[str] = None
sort_order: int = 0
class TemplateCreate(BaseModel):
name: str
category: str
chapter_id: Optional[str] = None
subchapter_id: Optional[str] = None
component_type: Optional[str] = None
accepted_shapes: list[str] = Field(default_factory=list)
json_template: Optional[dict] = None
description: Optional[str] = None
origin: str = "premade"
version: str = "1.0.0"
class SeedExampleCreate(BaseModel):
title: str
example_json: dict
quality_notes: Optional[str] = None
chapter_id: Optional[str] = None
subchapter_id: Optional[str] = None
is_canonical: bool = False
class SyntheticJobCreate(BaseModel):
template_id: str
chapter_id: Optional[str] = None
subchapter_id: Optional[str] = None
model: str = "kimi"
requested_count: int = Field(10, ge=1, le=500)
# ── Template Chapters ─────────────────────────────────────────────────────────
@router.get("/template-chapters", summary="List Oracle template chapters")
async def list_template_chapters(
request: Request,
include_inactive: bool = Query(False),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
where = "WHERE ch.tenant_id=$1" + ("" if include_inactive else " AND ch.is_active=TRUE")
rows = await conn.fetch(
f"""
SELECT ch.chapter_id, ch.name, ch.description, ch.sort_order, ch.is_active,
COUNT(sub.subchapter_id) FILTER (WHERE sub.is_active=TRUE) as subchapter_count,
COUNT(t.template_id) as template_count
FROM oracle_template_chapters ch
LEFT JOIN oracle_template_subchapters sub ON sub.chapter_id = ch.chapter_id
LEFT JOIN oracle_component_templates t ON t.chapter_id = ch.chapter_id
AND t.status != 'archived'
{where}
GROUP BY ch.chapter_id
ORDER BY ch.sort_order ASC
""",
user.role,
)
return {"chapters": [dict(r) for r in rows]}
@router.post("/template-chapters", status_code=status.HTTP_201_CREATED,
summary="Create a template chapter")
async def create_template_chapter(
request: Request,
body: ChapterCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO oracle_template_chapters (tenant_id, name, description, sort_order)
VALUES ($1,$2,$3,$4)
RETURNING chapter_id, created_at
""",
user.role, body.name, body.description, body.sort_order,
)
return {"chapter_id": str(row["chapter_id"]), "created_at": str(row["created_at"])}
# ── Template Subchapters ──────────────────────────────────────────────────────
@router.get("/template-subchapters", summary="List Oracle template subchapters")
async def list_template_subchapters(
request: Request,
chapter_id: Optional[str] = Query(None),
include_inactive: bool = Query(False),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
where = "WHERE sub.tenant_id=$1"
params: list[Any] = [user.role]
idx = 2
if not include_inactive:
where += " AND sub.is_active=TRUE"
if chapter_id:
where += f" AND sub.chapter_id=${idx}"; params.append(chapter_id); idx += 1
rows = await conn.fetch(
f"""
SELECT sub.subchapter_id, sub.chapter_id, ch.name as chapter_name,
sub.name, sub.description, sub.sort_order, sub.is_active,
COUNT(t.template_id) as template_count
FROM oracle_template_subchapters sub
JOIN oracle_template_chapters ch ON ch.chapter_id = sub.chapter_id
LEFT JOIN oracle_component_templates t ON t.subchapter_id = sub.subchapter_id
AND t.status != 'archived'
{where}
GROUP BY sub.subchapter_id, ch.name
ORDER BY sub.chapter_id, sub.sort_order ASC
""",
*params,
)
return {"subchapters": [dict(r) for r in rows]}
@router.post("/template-subchapters", status_code=status.HTTP_201_CREATED,
summary="Create a template subchapter")
async def create_template_subchapter(
request: Request,
body: SubchapterCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
# Verify chapter exists and belongs to tenant
ch_exists = await conn.fetchval(
"SELECT 1 FROM oracle_template_chapters WHERE chapter_id=$1 AND tenant_id=$2",
body.chapter_id, user.role,
)
if not ch_exists:
raise HTTPException(404, "Chapter not found")
row = await conn.fetchrow(
"""
INSERT INTO oracle_template_subchapters
(chapter_id, tenant_id, name, description, sort_order)
VALUES ($1,$2,$3,$4,$5)
RETURNING subchapter_id, created_at
""",
body.chapter_id, user.role, body.name, body.description, body.sort_order,
)
return {"subchapter_id": str(row["subchapter_id"]), "created_at": str(row["created_at"])}
# ── Component Templates ───────────────────────────────────────────────────────
@router.get("/component-templates", summary="List Oracle component templates")
async def list_component_templates(
request: Request,
chapter_id: Optional[str] = Query(None),
subchapter_id: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
search: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
where = "WHERE t.tenant_id=$1"
params: list[Any] = [user.role]
idx = 2
if chapter_id:
where += f" AND t.chapter_id=${idx}"; params.append(chapter_id); idx += 1
if subchapter_id:
where += f" AND t.subchapter_id=${idx}"; params.append(subchapter_id); idx += 1
if status_filter:
where += f" AND t.status=${idx}"; params.append(status_filter); idx += 1
if search:
where += f" AND (t.name ILIKE ${idx} OR t.description ILIKE ${idx})"
params.append(f"%{search}%"); idx += 1
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT t.template_id, t.name, t.category, t.status, t.origin, t.version,
t.accepted_shapes, t.use_count, t.chapter_id, t.subchapter_id,
t.description, ch.name as chapter_name, sub.name as subchapter_name,
t.created_at, t.updated_at
FROM oracle_component_templates t
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
{where}
ORDER BY t.updated_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM oracle_component_templates t {where}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "templates": [dict(r) for r in rows]}
@router.post("/component-templates", status_code=status.HTTP_201_CREATED,
summary="Create a component template")
async def create_component_template(
request: Request,
body: TemplateCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO oracle_component_templates (
tenant_id, name, category, chapter_id, subchapter_id,
accepted_shapes, json_template, description, origin, version, status
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,'draft')
RETURNING template_id, created_at
""",
user.role, body.name, body.category, body.chapter_id, body.subchapter_id,
body.accepted_shapes,
json.dumps(body.json_template) if body.json_template else None,
body.description, body.origin, body.version,
)
return {"template_id": str(row["template_id"]), "created_at": str(row["created_at"])}
@router.get("/component-templates/{template_id}", summary="Get a component template")
async def get_component_template(
template_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT t.*, ch.name as chapter_name, sub.name as subchapter_name
FROM oracle_component_templates t
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
WHERE t.template_id=$1 AND t.tenant_id=$2
""",
template_id, user.role,
)
if not row:
raise HTTPException(404, "Template not found")
return dict(row)
# ── Seed Examples ─────────────────────────────────────────────────────────────
@router.post("/component-templates/{template_id}/seed", status_code=status.HTTP_201_CREATED,
summary="Add a seed example to a template")
async def add_seed_example(
template_id: str,
request: Request,
body: SeedExampleCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT 1 FROM oracle_component_templates WHERE template_id=$1 AND tenant_id=$2",
template_id, user.role,
)
if not exists:
raise HTTPException(404, "Template not found")
row = await conn.fetchrow(
"""
INSERT INTO oracle_template_seed_examples (
template_id, chapter_id, subchapter_id, title, example_json,
quality_notes, is_canonical
) VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7)
RETURNING example_id, created_at
""",
template_id, body.chapter_id, body.subchapter_id, body.title,
json.dumps(body.example_json), body.quality_notes, body.is_canonical,
)
return {"example_id": str(row["example_id"]), "created_at": str(row["created_at"])}
@router.get("/component-templates/{template_id}/seed", summary="List seed examples for a template")
async def list_seed_examples(
template_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT example_id, title, example_json, quality_notes, is_canonical, created_at
FROM oracle_template_seed_examples
WHERE template_id=$1
ORDER BY is_canonical DESC, created_at ASC
""",
template_id,
)
return {"examples": [dict(r) for r in rows]}
# ── Synthetic Jobs ────────────────────────────────────────────────────────────
@router.post("/component-templates/synthetic-jobs", status_code=status.HTTP_201_CREATED,
summary="Trigger a Kimi synthetic data generation job")
async def trigger_synthetic_job(
request: Request,
body: SyntheticJobCreate,
user=Depends(get_current_user),
):
"""
Queues a Kimi synthetic data expansion job for a template.
The job will be picked up by the background synthetic generation worker.
"""
pool = _pool(request)
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT 1 FROM oracle_component_templates WHERE template_id=$1 AND tenant_id=$2",
body.template_id, user.role,
)
if not exists:
raise HTTPException(404, "Template not found")
row = await conn.fetchrow(
"""
INSERT INTO oracle_synthetic_generation_jobs (
tenant_id, template_id, chapter_id, subchapter_id,
model, requested_count, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING job_id, status, created_at
""",
user.role, body.template_id, body.chapter_id, body.subchapter_id,
body.model, body.requested_count, user.user_id,
)
logger.info(
"Synthetic job queued: %s for template %s (%d examples)",
row["job_id"], body.template_id, body.requested_count,
)
return {
"job_id": str(row["job_id"]),
"status": row["status"],
"created_at": str(row["created_at"]),
}

View File

@@ -52,9 +52,10 @@ JWT_EXPIRE_HOURS = 8
def create_access_token(user_id: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
normalized_role = role.strip().upper()
payload = {
"sub": user_id,
"role": role,
"role": normalized_role,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
@@ -70,7 +71,7 @@ class UserPrincipal:
@property
def role_level(self) -> int:
return ROLE_HIERARCHY.get(self.role, -1)
return ROLE_HIERARCHY.get(self.role.upper(), -1)
# ── Dependency: parse bearer token ────────────────────────────────────────────
@@ -104,7 +105,7 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
) from exc
return UserPrincipal(user_id=payload["sub"], role=payload["role"])
return UserPrincipal(user_id=payload["sub"], role=str(payload["role"]).strip().upper())
# ── Dependency factory: role gate ─────────────────────────────────────────────

View File

@@ -23,6 +23,10 @@ from dotenv import load_dotenv
from backend.api.routes_catalyst import router as catalyst_router
from backend.api.routes_crm import crm_router, analytics_router
from backend.api.routes_oracle import router as oracle_helper_router
from backend.api.routes_mobile_edge import router as mobile_edge_router
from backend.api.routes_inventory import router as inventory_router
from backend.api.routes_admin_surface import router as admin_surface_router
from backend.api.routes_oracle_templates import router as oracle_templates_router
from backend.auth.dependencies import (
create_access_token, verify_password, get_current_user
)
@@ -93,11 +97,15 @@ app.include_router(crm_router, prefix="/api", tags=["CRM"])
app.include_router(analytics_router, prefix="/api/analytics", tags=["Analytics"])
app.include_router(oracle_helper_router, prefix="/api/oracle", tags=["Oracle"])
app.include_router(oracle_v1_router, prefix="/api/oracle/v1", tags=["Oracle V1"])
app.include_router(oracle_templates_router, prefix="/api/oracle", tags=["Oracle Templates"])
app.include_router(sentinel_router, prefix="/api/sentinel", tags=["Sentinel"])
app.include_router(cctv_router, prefix="/api/cctv", tags=["CCTV"])
app.include_router(scenes_router, prefix="/api/scenes", tags=["Scenes"])
app.include_router(videos_router, prefix="/api/videos", tags=["Videos"])
app.include_router(vault_router, prefix="/api/vault", tags=["Vault"])
app.include_router(mobile_edge_router, prefix="/api/mobile-edge", tags=["Mobile Edge"])
app.include_router(inventory_router, prefix="/api/inventory", tags=["Inventory"])
app.include_router(admin_surface_router, prefix="/api/admin-surface", tags=["Admin Surface"])
# Public vault link (no /api prefix — shared externally with prospects)
from backend.routers.vault import router as public_vault_router

Some files were not shown because too many files have changed in this diff Show More