Files
Odoo-Modules/fusion_schedule/CLAUDE.md
gsinghpal ba7c028c30 CHANGES
2026-06-04 09:49:51 -04:00

800 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# fusion_schedule — Claude Code Instructions
> Module-level guide. The repo-wide Odoo 19 rules in `K:\Github\Odoo-Modules\CLAUDE.md`
> (and the global `K:\Github\CLAUDE.md`) **still apply** — this file only adds what is
> specific to `fusion_schedule`. Read both.
>
> **Companion docs:** [`CODE_MAP.md`](CODE_MAP.md) is the precise symbol-level
> "where-is-what" index (every field/method/route/JS fn/template with line numbers) — use it
> to locate code; use this file for guidance. Open audit findings are tracked in Supabase
> `fusionapps.issues` under project **Fusion Schedule**
> (`576de219-57e6-4596-8c8c-0c093e4cb54a`) and summarised in §16 below.
>
> **Provenance:** this module was originally designed & coded with **Cursor using Claude 4.5
> Opus** (AI-generated), then audited by Claude Code. That shows in the failure profile: the
> Odoo-19 *syntax/idioms* are clean (no deprecated APIs), but the bugs cluster in semantic areas
> that need domain reasoning or a running install to catch — unscoped ORM queries (cross-user
> event merging), timezone handling, copy-paste-drifted duplicates (authenticated vs public
> booking), swallowed exceptions, and untested public/render paths. When extending it, **assume
> plausible-but-unverified until tested on Enterprise.**
---
## 1. What this module is
**Fusion Schedule** (`fusion_schedule`, `__manifest__.py` version **19.0.2.1.0**, author
"Fusion Claims", LGPL-3) is a **multi-account calendar synchronisation hub + portal
booking system** for staff (authorizers / sales reps / technicians) in the Fusion Claims
product family.
Three product surfaces, one engine:
1. **Multi-calendar sync** — a staff user connects any number of **Google** and **Microsoft
Outlook** calendars. A 5-minute cron pulls external events into Odoo `calendar.event`
and pushes Odoo-native events out, so the user has one merged calendar and is "busy on
one → blocked on all".
2. **Portal "My Schedule"** (`/my/schedule`) — a portal dashboard: today's + upcoming
appointments, connected-account management, schedule preferences (work hours / break /
travel buffer / base address), a booking form with a week-calendar preview, **AI slot
suggestions** and **AI day-route optimization**, and travel-time blocking.
3. **Public booking links** (`/schedule/<slug>`) — each user gets a shareable slug; external
visitors (no login) can self-book into the user's free slots and later
cancel/reschedule via a per-event **manage token** (`/schedule/manage/<token>`).
> ⚠️ This is the active **Outlook ↔ Odoo sync** for this deployment — **not** Odoo's native
> `microsoft_calendar`/`google_calendar` sync. The backend calendar UI patch (see §11)
> deliberately **hides** the native sync buttons and substitutes Fusion Schedule's own.
It was originally built in **Cursor** (note the leftover `graphify-out/` artifact — a Cursor
code-graph dump; safe to ignore/delete, not loaded by Odoo). Development now happens in
Claude Code.
---
## 2. Enterprise-only — you cannot install this on local Community
The manifest depends on **`appointment`** (Odoo **Enterprise**), plus `google_account` and
`microsoft_account`. Therefore — like `fusion_portal` and `fusion_repairs` — **it cannot be
installed or tested on local `odoo-modsdev` (Community).** The old
`-d fusion-dev -u <module>` recipe does **not** work here.
Test on an Enterprise environment (a Westin clone is the natural choice since
`fusion_portal` already runs there — see the *Westin Prod* section of the repo `CLAUDE.md`).
There are currently **no automated tests** in this module (`tests/` does not exist).
---
## 3. Dependency map
### 3.1 Hard dependencies (`__manifest__.py` → `depends`)
```
base · portal · website · calendar · appointment · google_account · microsoft_account · fusion_portal
```
- `appointment` — Enterprise. Uses `appointment.type`, `appointment.invite`, and
`appointment_type._prepare_calendar_event_values(...)` to build booking events.
- `calendar` — the core model everything revolves around (`calendar.event` is inherited).
- `google_account` / `microsoft_account` — base OAuth plumbing. **Note:** the module rolls
its *own* OAuth flow (it does not reuse `google_calendar`/`microsoft_calendar` sync). It
only borrows their stored client-id ICP params as a *fallback* (see §10).
- `fusion_portal` — the **only `fusion_*` hard dependency**. This is what transitively pulls
in the whole claims stack: `fusion_portal → fusion_claims` (+ `fusion_tasks`,
`fusion_loaners_management`, `knowledge`). So **`fusion_claims` is a transitive
dependency**, always present at runtime.
### 3.2 Soft dependencies (used via `try/except`, NOT in `depends`)
- **`fusion_api`** (`fusion.api.service`) — preferred broker for the Google Maps key and
OpenAI calls. Not declared in `depends`; every call is wrapped in `try/except` and falls
back to `fusion_claims.*` ICP params, then degrades gracefully. The module still runs if
`fusion_api` is absent.
### 3.3 Reverse dependencies
- **Nothing depends on `fusion_schedule`.** It is a leaf/top module. The only mention
elsewhere is `fusion_repairs/__manifest__.py` which lists "fusion_schedule slots" as a
*deferred / future* integration — not a real dependency today.
```
┌─────────────────┐
│ fusion_schedule │ (leaf — nothing depends on it)
└────────┬────────┘
depends │ soft (try/except, NOT in manifest)
┌─────────────────┼──────────────────────────┐
▼ ▼ ▼
fusion_portal appointment (EE) fusion_api ── fusion.api.service
│ google_account / microsoft_account (Maps key + OpenAI broker)
fusion_claims ── owns the `fusion_claims.*` ICP params reused as fallbacks
│ (+ fusion_tasks, fusion_loaners_management, knowledge)
```
---
## 4. ⭐ Relationship with `fusion_claims` (read this — it's the whole point of the coupling)
`fusion_schedule` **does not modify any `fusion_claims` model or view.** The coupling is
indirect and entirely through shared infrastructure. Five concrete links:
### 4.1 Transitive dependency (stack position)
`fusion_schedule` sits **on top of** the claims stack via `fusion_portal → fusion_claims`.
It assumes the claims/portal data model and the authorizer/sales-rep portal already exist.
### 4.2 Config-parameter namespace reuse (the main runtime link)
The portal pages **borrow `fusion_claims`-owned `ir.config_parameter` values** so the
schedule UI matches the claims portal branding and shares the same API keys. These params
are **defined in `fusion_claims/models/res_config_settings.py`**, *not* here:
| ICP key (owned by fusion_claims) | Used in fusion_schedule for | Where |
|---|---|---|
| `fusion_claims.portal_gradient_start` / `_mid` / `_end` | portal header gradient (brand colour) | `PortalSchedule._get_schedule_values()` |
| `fusion_claims.google_maps_api_key` | Maps/Places/Distance-Matrix key **fallback** | `_get_maps_api_key()` |
| `fusion_claims.ai_api_key` | OpenAI key **fallback** (direct HTTP) | `_call_ai()` |
> If you rename/remove these in `fusion_claims`, the schedule portal silently loses its
> gradient / maps / AI. They are read with defaults, so it won't crash — it just degrades.
### 4.3 The `fusion.api.service` broker (preferred path, fusion_claims-family convention)
`_get_maps_api_key()` and `_call_ai()` first try `request.env['fusion.api.service']`
(from **`fusion_api`**) — the same metered, budget-/rate-limited broker the rest of the
Fusion family uses — with `consumer='fusion_schedule'`. Only if that raises do they fall
back to the `fusion_claims.*` ICP params above. So the order is:
**`fusion_api` broker → `fusion_claims` ICP param → graceful no-op.**
> Two non-obvious facts (detail in [`CODE_MAP.md`](CODE_MAP.md) §9): (1) `get_api_key` returns
> the `group_admin`-gated `key.api_key` on a **non-sudo** recordset, so from a portal/public
> request it likely raises `AccessError` and the **ICP fallback fires every time** — for portal
> callers `fusion_claims.google_maps_api_key` is effectively the real source, not the broker.
> (2) That maps-key param is actually **owned by `fusion_tasks`** (`res_config_settings.py:12`),
> not `fusion_claims`, despite the `fusion_claims.*` prefix — grepping `fusion_claims/` for it
> finds nothing.
### 4.4 Portal tile injection (into fusion_portal, which is built on fusion_claims)
`views/portal_schedule_tile.xml` (`portal_my_home_schedule`, priority 45) inherits
**`fusion_portal.portal_my_home_authorizer`** (which itself inherits `portal.portal_my_home`,
priority 40) and `xpath`s a "My Schedule" card into the authorizer/sales-rep portal home
grid. It reuses fusion_portal's `fc_gradient` template var.
- **`fc_gradient` origin:** set in `fusion_portal/views/portal_templates.xml` as
`portal_gradient or <default green/blue>`, where `portal_gradient` is computed by
fusion_portal's home controller from the same `fusion_claims.portal_gradient_*` params
(§4.2). The tile falls back to the literal default if `fc_gradient` is unset.
- **⚠ Fragile xpath:** the tile anchors on
`//a[@href='/my/funding-claims']/ancestor::div[hasclass('row') and hasclass('g-3') and hasclass('mb-4')]`.
If fusion_portal renames the funding-claims route, removes that card, or restructures the
home grid's classes, the tile **silently disappears** (or the view fails to load on `-u`).
Re-check this xpath whenever fusion_portal's home template changes.
### 4.5 The `tz` cookie is populated by fusion_portal
Fusion Schedule's timezone resolution (`_resolve_timezone`) reads a browser **`tz`** cookie
(IANA name). That cookie is set by **`fusion_portal/static/src/js/timezone_detect.js`**
(`tz=<IANA>;path=/;max-age=1yr;SameSite=Lax`) — and, redundantly, by Fusion Schedule's own
booking JS (`setTzCookie` IIFE). So on portal pages the correct timezone flows in **from
fusion_portal**; without that cookie (or a `user.tz`), times fall back to the company
calendar tz, then UTC.
### 4.5 Parallel/overlapping scheduling — they share the `calendar.event` table
`fusion_claims` already has its **own, simpler** scheduling:
- `fusion_claims.schedule.assessment.wizard` (`wizard/schedule_assessment_wizard.py`) — a
*backend* wizard that creates a plain `calendar.event` for an ADP assessment from a
`sale.order` (optional 1-day email alarm). No sync, no portal, no travel logic.
- `technician_task` routing — push notifications + travel time using the **same**
`fusion_claims.google_maps_api_key`.
`fusion_schedule` is the **newer, richer, portal-facing + multi-calendar layer**. Both
write to `calendar.event`, so they **interplay**: an assessment event created by the
fusion_claims wizard for a user who has connected calendars will be picked up by Fusion
Schedule's **cross-calendar push** (it's an unlinked `calendar.event` on the user's partner)
and mirrored to that user's external calendar, and it appears in `/my/schedule`. They are
**complementary, not isolated** — keep that shared table in mind when changing either side.
---
## 5. Data model
All custom fields use the `x_fc_*` prefix (repo convention). Models load in this order
(`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link →
calendar_event → res_users → res_config_settings`.
### 5.1 `fusion.calendar.account` — the OAuth account + sync engine *(god object, ~35 edges)*
`models/fusion_calendar_account.py`. One row per connected external calendar.
| Field | Notes |
|---|---|
| `x_fc_user_id` (m2o res.users, required, cascade) | owner |
| `x_fc_provider` (sel: google/microsoft, required) | |
| `x_fc_email` / `x_fc_name` (compute, stored) | label = "Google — a@b.com" |
| `x_fc_active` (bool) | |
| `x_fc_rtoken` / `x_fc_token` / `x_fc_token_validity` | **`groups='base.group_system'`** — OAuth secrets, admin-only |
| `x_fc_sync_token` | provider delta/sync token (`group_system`). Clear it to force a fresh full sync |
| `x_fc_calendar_id` (default `'primary'`) | |
| `x_fc_last_sync`, `x_fc_sync_status` (active/error/paused), `x_fc_error_message` | |
| `x_fc_link_ids` (o2m → event link) | |
This file is the engine. Key method groups (all on the account record):
- **Credential resolution** `_get_google_client_id/_secret`, `_get_microsoft_*` — dedicated
`fusion_schedule_*` ICP param → native `google_calendar_client_id` / `microsoft_calendar_client_id`.
- **Token mgmt** `_get_valid_token` (1-min skew buffer), `_refresh_token`
`_refresh_google_token` / `_refresh_microsoft_token` (MS may rotate the refresh token —
it's re-saved). On HTTP 400/401 the account is marked `error` and tokens cleared.
- **Code exchange** `_exchange_google_code` / `_exchange_microsoft_code` (called from the
controller callback). `_fetch_google_email` / `_fetch_microsoft_email`.
- **Pull (external → Odoo)** `_sync_pull``_sync_pull_google` / `_sync_pull_microsoft`,
with `_google_request_with_retry` / `_microsoft_request_with_retry` (429/503 + connection
retry, capped). Google initial window **now-14d … now+30d**; subsequent syncs use the
sync token (HTTP 410 → drop token, full resync). MS uses Graph `calendarView/delta`;
delta token expiry (`fullSyncRequired`/`SyncStateNotFound`) → full resync. MS page cap:
2000 events initial / 5000 incremental.
- **Event mapping** `_google_event_to_odoo_vals` / `_microsoft_event_to_odoo_vals` and the
reverse `_odoo_event_to_google` / `_odoo_event_to_microsoft`.
- **Upsert/dedup** `_process_google_event` / `_process_microsoft_event`,
`_find_existing_event` (matches name+start+stop, **includes archived** to reuse), and
`_upsert_event_link`.
- **Push (Odoo → external)** `_sync_push_event` (+ insert/patch/delete per provider).
- **Cross-calendar busy block** `_cross_calendar_push` (see §6.3).
- **Backend RPC** `get_user_accounts_status()`, `sync_current_user()` (called from the
calendar UI patch).
- **Cron** `_cron_sync_all_accounts()`.
- **Teardown** `action_disconnect()` — deletes pushed external events, unlinks rows, pauses.
### 5.2 `fusion.calendar.event.link` — Odoo-event ↔ external-event join
`models/fusion_calendar_event_link.py`. One row per (Odoo event, account).
- `x_fc_event_id` (m2o calendar.event, cascade), `x_fc_account_id` (m2o account, cascade),
`x_fc_external_id` (required), `x_fc_universal_id` (iCalUID — used for cross-provider
dedup), `x_fc_last_synced`, `x_fc_sync_direction` (pull/push/both).
- **Constraint:** `models.Constraint('UNIQUE(x_fc_account_id, x_fc_external_id)')` — an
external event links once per account. (Odoo-19 declarative constraint, per repo rule #9.)
### 5.3 `calendar.event` (inherited)
`models/calendar_event.py`. Adds:
- `x_fc_source_account_id` (m2o account) — set when an event was *pulled* from external;
used for colour-coding the source in the portal.
- `x_fc_is_external` (compute, **stored** from source account).
- `x_fc_link_ids` (o2m → link).
- `x_fc_manage_token` (indexed, `copy=False`) — 32-hex public manage token.
- `x_fc_client_email` / `x_fc_client_phone`.
- `x_fc_address_lat` / `x_fc_address_lng` (Float, digits 10,7) — for travel-time calc.
- `x_fc_travel_minutes_before` (int) and `x_fc_is_travel_block` (bool) — travel placeholder
events generated after booking.
- **`write()` / `unlink()` overrides** push updates/deletions to all linked external
calendars — **unless** `_skip_fc_sync()` is true (context has `no_calendar_sync` or
`dont_notify`). `write()` only pushes when a sync-relevant field changed.
### 5.4 `res.users` (inherited)
`models/res_users.py`. Adds per-staff scheduling config:
- `x_fc_calendar_account_ids` (o2m), `x_fc_schedule_slug` (**`UNIQUE` constraint**),
`x_fc_booking_enabled` (default False).
- Work prefs: `x_fc_work_start` (9.0), `x_fc_work_end` (17.0), `x_fc_break_start` (12.0),
`x_fc_break_duration` (0.5h), `x_fc_travel_buffer` (30 min), `x_fc_home_address` +
`x_fc_home_lat`/`x_fc_home_lng`.
- **`create()` override** auto-generates a slug from the name + 4-hex suffix
(`_generate_schedule_slug`). Every user (including pre-existing ones created elsewhere)
gets a unique public slug.
### 5.5 `res.config.settings` (inherited)
`models/res_config_settings.py`. See §12.
---
## 6. The sync engine — how events flow
### 6.1 Pull (external → Odoo), per account
1. `_get_valid_token()` (refresh if needed).
2. Fetch pages (sync-token delta when available, else the ±window).
3. For each event: cancelled/removed → archive local + unlink the link row; otherwise
**upsert** with a 3-tier dedup ladder:
- existing link for `(account, external_id)` → update in place;
- else existing link by **iCalUID** (cross-provider/same-event) → relink;
- else `_find_existing_event` by name+start+stop (incl. archived) → reuse + relink;
- else **create** a new `calendar.event` (owner partner attached) + new link.
4. Persist `x_fc_sync_token`, `x_fc_last_sync`, status.
### 6.2 Push (Odoo → external), per event
`calendar.event.write()` triggers `_sync_push_event` on each linked active account
(insert if no link, patch if linked). New links are tagged `direction='push'`.
### 6.3 Cross-calendar busy-blocking (`_cross_calendar_push`)
Runs in the cron **only for users with ≥2 active accounts**. It finds the user's
**Odoo-native** events (those with **no** existing link) in the window now-1d … now+90d and
pushes them to the **first active account only** (lowest id). Pushing to a single calendar +
only un-linked events together prevent the **pull → push → pull feedback loop** and
cross-calendar duplicates. *This is the "busy on one, blocked on all" mechanism.*
### 6.4 Cron
`data/ir_cron_data.xml``ir_cron_fusion_calendar_sync`, every **5 minutes**, runs as
`base.user_root`, code `model._cron_sync_all_accounts()`. Never-synced accounts are
processed first. Per-account isolation uses `self.env.cr.commit()` / `rollback()` so one bad
account doesn't poison the batch (see §13 footgun about tests).
---
## 7. OAuth connect/callback flow
`/my/schedule/connect/google` and `/connect/microsoft` build the auth URL (scopes:
Google `calendar` + `userinfo.email`, offline + consent; Microsoft `offline_access openid
Calendars.ReadWrite User.Read`), stash a CSRF token in `request.session['fc_oauth_csrf']`,
and encode `{provider, csrf}` into `state`. Redirect URI is always
`<web.base.url>/my/schedule/oauth/callback`.
`/my/schedule/oauth/callback` validates `state` + CSRF, exchanges the code, fetches the
account email, then **find-or-creates** a `fusion.calendar.account` (re-activating a matching
existing one). Requires a **refresh token** — if the provider didn't return one, it errors
asking the user to grant offline access. There's a resilience fallback:
`_find_recently_connected_account` (created in the last 10 min) so a refreshed/timed-out
callback still reports success instead of erroring.
---
## 8. Travel time + AI scheduling
- **Travel time** `_get_travel_time(lat,lng→lat,lng)` — Google **Distance Matrix** (driving,
avoid tolls, depart now), returns minutes or 0 on any failure. `_geocode_address` uses the
Geocoding API (region `ca`).
- **Travel blocks** `_create_travel_blocks(event, staff_user)` — after a booking, looks at
the prev/next located appointments that day and inserts `Travel to …` placeholder events
(`x_fc_is_travel_block=True`, `show_as=busy`) sized to `max(distance-matrix, travel_buffer)`.
- **AI slot suggest** `/my/schedule/ai/suggest` — builds a schedule context, asks OpenAI
(`gpt-4o-mini`) to pick **exactly 3** times **from the provided free-slot list only**
(strict prompt + post-filter against the real slots; never invents times). Used by the
booking form.
- **AI day optimize** `/my/schedule/ai/optimize` — needs ≥2 located appointments; builds a
travel matrix and asks OpenAI for an optimal visiting order + suggested times + savings.
- Both AI calls route through `_call_ai()` (`fusion.api.service.call_openai`
`fusion_claims.ai_api_key` direct-HTTP fallback). Failures degrade to "AI unavailable".
---
## 9. Routes (controllers/portal_schedule.py — `PortalSchedule(CustomerPortal)`)
| Method | Route | Auth | Renders / returns |
|---|---|---|---|
| http | `/my/schedule` | user | `portal_schedule_page` |
| jsonrpc | `/my/schedule/preferences` | user | save work/break/travel/home prefs (geocodes address) |
| http | `/my/schedule/book` | user | `portal_schedule_book` |
| jsonrpc | `/my/schedule/available-slots` | user | free slots for a date |
| jsonrpc | `/my/schedule/week-events` | user | MonSun events for the week strip |
| http POST | `/my/schedule/book/submit` | user | create booking (+ confirmation email + travel blocks) |
| jsonrpc | `/my/schedule/event/cancel` | user | delete own event |
| jsonrpc | `/my/schedule/event/reschedule` | user | move own event |
| jsonrpc | `/my/schedule/ai/suggest` | user | 3 AI slot picks |
| jsonrpc | `/my/schedule/ai/optimize` | user | AI day route |
| http | `/my/schedule/connect/google` · `/connect/microsoft` | user | start OAuth |
| http | `/my/schedule/oauth/callback` | user | finish OAuth |
| jsonrpc | `/my/schedule/disconnect` | user | `action_disconnect` |
| jsonrpc | `/my/schedule/sync-now` | user | `_sync_pull` one account |
| jsonrpc | `/my/schedule/toggle-booking` | user | enable/disable public page |
| http | `/schedule/<slug>` | **public** | `public_booking_page` |
| jsonrpc | `/schedule/<slug>/available-slots` | **public** (csrf=False) | slots |
| http POST | `/schedule/<slug>/book` | **public** (csrf) | public booking |
| http | `/schedule/manage/<token>` | **public** | `public_manage_page` |
| http POST | `/schedule/manage/<token>/cancel` · `/reschedule` | **public** (csrf) | self-service |
| jsonrpc | `/schedule/manage/<token>/available-slots` | **public** (csrf=False) | slots |
Backend (ORM, not HTTP), called from the calendar UI patch:
`fusion.calendar.account.get_user_accounts_status()` and `.sync_current_user()`.
**Slot generation** (`_generate_available_slots`) is the shared core for *all* slot
endpoints: honours the staff user's work hours / break / travel-buffer, intersects with
appointment-type recurring slots, removes past times, and rejects slots that overlap any
existing event **plus the travel buffer** after it.
**Timezone resolution** (`_resolve_timezone`): `user.tz``tz` cookie (set by the frontend
JS / fusion_portal, §4.5) → `company.resource_calendar_id.tz` → UTC.
### 9.1 Authenticated portal vs public booking are TWO separate implementations
This is the single most important structural fact the templates reveal — the two booking
flows do **not** share code and behave differently:
| | Authenticated `/my/schedule/book` | Public `/schedule/<slug>` |
|---|---|---|
| Layout | `portal.portal_layout` (portal chrome + breadcrumbs) | `website.layout` (public site chrome) |
| Slot/booking JS | the **registered asset files** (`portal_schedule_booking.js`, `portal_schedule_accounts.js`) | **inline `<script>`** embedded in `public_booking.xml` (a *second copy* of the slot-render + Places-autocomplete logic) |
| Brand gradient | `portal_gradient` from `fusion_claims.*` params | **hardcoded** `linear-gradient(135deg,#5ba848,#3a8fb7)` — ignores the brand params |
| Event creation | `appointment_type._prepare_calendar_event_values(...)` → a real **appointment** with booking lines/capacity | a **raw `calendar.event`** dict (no appointment lines, no capacity) |
| Slot re-validation on submit | **yes** — re-runs `_generate_available_slots` and rejects stale slots | **no** — trusts the posted `slot_datetime` (double-book risk) |
| Week-calendar preview + AI suggest/optimize | yes | no |
So "fix the booking form" almost always means **edit two places**. Changing slot logic in
the Python `_generate_available_slots` covers both (it's shared server-side), but any
client-side change to slot rendering, autocomplete, or validation must be mirrored between
`portal_schedule_booking.js` and the inline script in `public_booking.xml`.
### 9.2 Two share links, one of them dead
- `schedule_page` computes `share_url = appointment.invite.book_url` (native appointment
share, looked up by `staff_user_ids`) **and** `public_booking_url = <base>/schedule/<slug>`.
Only **`public_booking_url`** is actually rendered (the "Share Booking Link" card/button).
`share_url` is passed to the template but **never used** — and the only seeded
`appointment.invite` (`default_appointment_invite`) has empty `appointment_type_ids`/no
staff, so it would be blank anyway. The slug link is the real share mechanism.
- There is **no `_prepare_home_portal_values` override**, so `/my/schedule` has **no portal
home counter** and no portal breadcrumb registration — the injected tile (§4.4) is the
only discoverable entry point besides the calendar-view cog button (§11).
---
## 10. ICP parameters (full list)
**Owned by this module:**
- OAuth creds: `fusion_schedule_google_client_id`, `fusion_schedule_google_client_secret`,
`fusion_schedule_microsoft_client_id`, `fusion_schedule_microsoft_client_secret`
- Sync: `fusion_schedule_sync_interval` (minutes; **note:** the cron interval is set in XML,
this param is currently informational — changing it does not re-write the cron)
- Defaults: `fusion_schedule.default_work_start` / `_work_end` / `_break_start` /
`_break_duration` / `_travel_buffer`
**Fallbacks read from elsewhere (not owned here):**
- Native Odoo: `google_calendar_client_id`, `google_calendar_client_secret`,
`microsoft_calendar_client_id`, `microsoft_calendar_client_secret`, `web.base.url`
- **fusion_claims namespace:** `fusion_claims.portal_gradient_start/_mid/_end`,
`fusion_claims.google_maps_api_key`, `fusion_claims.ai_api_key` (see §4.2)
---
## 11. Frontend / assets
Registered in `__manifest__.py` `assets`:
**`web.assets_backend`** — patches the native calendar:
- `static/src/views/fusion_calendar_controller.js``patch(AttendeeCalendarController…)`:
loads connected accounts (`get_user_accounts_status`) and adds a "Sync now"
(`sync_current_user`) action.
- `static/src/views/fusion_calendar_controller.xml` — t-inherits
`calendar.AttendeeCalendarController`, **hides** `#header_synchronization_settings` (the
native Google/Outlook sync UI, kept in DOM so other xpaths survive) and injects Fusion's
account chips + sync button + a cog link to `/my/schedule`.
**`web.assets_frontend`** — portal pages:
- `static/src/css/portal_schedule.css`
- `static/src/js/portal_schedule_booking.js` — booking form: sets the `tz` cookie, week
calendar strip, slot fetch + morning/afternoon grouping, AI suggestions, **Google Places
address autocomplete** (`country: 'ca'`, writes hidden lat/lng), submit guards.
- `static/src/js/portal_schedule_accounts.js` — the `/my/schedule` dashboard: reusable
`fusionConfirm` modal + `fusionToast`, disconnect/sync-now, share-link (Web Share /
clipboard), save-preferences, cancel/reschedule modals, AI "optimize my day" modal.
These are **plain IIFE scripts** (not Odoo `Interaction` classes) that bind to **DOM element
IDs** in the QWeb templates. If you rename an element id in the templates you must update the
JS, and vice-versa. Key ids the JS expects: `bookingDate`, `appointmentTypeSelect`,
`slotsContainer/slotsGrid/slotsLoading/noSlots`, `slotDatetime`, `slotDuration`,
`weekCalendar*`, `aiSuggest*`, `clientStreet/clientCity/clientProvince/clientPostal/clientLat/clientLng`,
`rescheduleModal` (+ children), `optimizeModal` (+ children), `schedulePrefsForm`,
`fusionConfirmModal`.
**Templates** (QWeb):
- `views/portal_schedule.xml``portal_schedule_page`, `portal_schedule_book`
(both `portal.portal_layout`).
- `views/public_booking.xml``public_booking_page`, `public_manage_page`
(both `website.layout`; **carry their own inline `<script>`** — see §9.1).
- `views/portal_schedule_tile.xml``portal_my_home_schedule` (the fusion_portal tile).
Frontend wiring notes:
- **Google Maps loader handshake.** The booking templates inject the Maps Places script with
`&callback=initScheduleAddressAutocomplete` (public: `initPublicAddressAutocomplete`). Because
the async script can land before *or* after the IIFE in `portal_schedule_booking.js`, they
coordinate via `window._googleMapsReady` / `window._scheduleAutocompleteInit`. Maps only
loads when a `google_maps_api_key` resolved (§4.2/§4.3) — no key ⇒ no autocomplete, fields
still work manually.
- **Dead toast markup.** `portal_schedule.xml` ships a Bootstrap `#fusionToast` /
`#fusionToastMessage` element, but `portal_schedule_accounts.js` defines its own
`fusionToast()` that builds a fresh `#fusionToastLive` node and **ignores** the template
one. Don't wire new code to `#fusionToast`; call the JS `fusionToast(msg, type)` helper.
- **CSS** (`portal_schedule.css`) is tiny: collapse-chevron rotation, a `.min-width-0`
truncation helper, and mobile sizing for slot buttons / tables / modals. No theming —
colours come from the inline `portal_gradient` styles and Bootstrap utility classes.
---
## 12. Settings UI
`views/res_config_settings_views.xml` adds a **"Fusion Schedule"** app block to
Settings (`base.res_config_settings_view_form`, priority 90) with: Sync Interval, Google
OAuth creds (+ "using Odoo default" hint via `x_fc_google_has_fallback`), Microsoft OAuth
creds (+ fallback hint), and Schedule Defaults (work hours / break / travel buffer, all
`float_time` widgets). The compute fields `x_fc_*_has_fallback` light up when no dedicated
key is set but a native `*_calendar_client_id` exists.
Backend list/form for accounts: `views/fusion_calendar_account_views.xml`
action + menu **Settings → Technical → Calendar Accounts** (`base.menu_custom`).
---
## 13. Security
`security/security.xml` — two record rules (both additive on `base.group_user`):
- users see only their own `fusion.calendar.account` (`x_fc_user_id = user.id`);
- users see only event links for their own accounts.
`security/ir.model.access.csv` — account: full CRUD for `group_user`, none for
`group_public`; event link: CRU for `group_user`, full for `group_system`.
OAuth secrets (`x_fc_rtoken/x_fc_token/x_fc_token_validity/x_fc_sync_token`) are
`groups='base.group_system'` so non-admin users can't read them even on their own rows;
sync code uses `.sudo()` to access them.
---
## 14. Footguns & gotchas (read before editing)
1. **The silent-context flags are load-bearing.** Any time you create/write/unlink a
`calendar.event` *during sync or travel-block creation*, pass `_silent_ctx()` (or at
least `no_calendar_sync=True, dont_notify=True`). Otherwise the `calendar.event`
`write/unlink` overrides will try to **push back to external calendars** → pull → push
feedback loop and/or attendee emails. The whole sync path already does this; mirror it.
2. **MS delta `@removed` reason matters.** `@removed` with reason `'deleted'` (or
`isCancelled`) → archive + unlink. `@removed` with any other reason (typically
`'changed'`) → **return `'skipped'`, do NOT archive** — the event merely drifted out of
the delta window and still exists upstream. This exact distinction was the
`f1cea2fb` bug fix ("stop archiving valid events on @removed=changed"). Don't regress it.
3. **`cr.commit()` / `cr.rollback()` in the cron will raise inside `TransactionCase`.**
Per repo rule #14, Odoo 19 test cursors refuse commit/rollback. There are no tests today,
but if you add any that exercise `_cron_sync_all_accounts` / `sync_current_user`, refactor
to `with self.env.cr.savepoint():` per iteration instead of commit/rollback, or the test
cursor will break.
4. **Declarative SQL objects only** (rule #9): this module already uses
`models.Constraint(...)` for the unique constraints — keep that style, never
`_sql_constraints` or `init()`.
5. **`google_account`/`microsoft_account` ≠ native calendar sync.** Don't "simplify" by
reusing `google_calendar`/`microsoft_calendar` sync — this module intentionally owns its
OAuth + sync and hides the native UI. The native client-id params are only a credential
fallback.
6. **Public endpoints.** `/schedule/<slug>` and `/schedule/manage/<token>` are
`auth='public'`. The manage token is `secrets.token_hex(16)` (32 chars) and
`_get_event_by_token` enforces `len == 32`. Public booking requires both
`x_fc_booking_enabled=True` **and** the user having an `appointment.type` with them as
staff. Keep CSRF on the POST forms; the slot JSON-RPC endpoints are `csrf=False` by design.
7. **`data/appointment_invite_data.xml` is `noupdate=1`** and ships
`default_appointment_invite` with **empty** `appointment_type_ids` — the generic
`/book/book-appointment` share link won't resolve to a real type until configured. The
`/my/schedule` page separately resolves an `appointment.invite` by `staff_user_ids`.
8. **`data/mail_template_data.xml` is NOT `noupdate`** — the booking confirmation template
(`fusion_schedule_booking_confirmation`, on `calendar.event`) reloads on every `-u`.
It renders the manage link from `company.website or get_base_url()`.
9. **`graphify-out/` is a Cursor artifact**, not part of the module. It's not in the
manifest and Odoo never loads it. Safe to ignore or delete; don't treat its
`GRAPH_REPORT.md` as authoritative (it's a heuristic code-graph, ~87% extracted).
10. **Soft-dependency discipline.** Never assume `fusion_api` is installed — keep the
`try/except` + ICP fallback pattern in `_get_maps_api_key` / `_call_ai`. Adding
`fusion_api`/`fusion_claims` to `depends` would change the install graph; only do it
deliberately.
11. **Public booking does NOT re-validate the slot.** `schedule_book_submit` (authenticated)
re-runs `_generate_available_slots` and rejects a slot that's no longer free;
`public_book_submit` does **not** — it trusts the posted `slot_datetime`. Two visitors
hitting the same public slot can double-book. If you tighten this, add the same
re-validation to the public path.
12. **The two booking flows diverge** (§9.1): authenticated bookings are real `appointment`
events (`_prepare_calendar_event_values`); public bookings are raw `calendar.event`
rows. Reporting/automation that assumes every booking is an `appointment.type` booking
will miss public ones. Client-side changes must be made twice (asset file **and** the
inline script in `public_booking.xml`).
13. **`public_booking_page` references `today` but the controller never passes it.** The
template has `t-att-min="today"` on the date picker, yet
`PortalSchedule.public_booking_page()`'s values dict omits `today`. Either the website
render context happens to supply it or the `min` is silently empty (no past-date guard on
the public picker). **Verify / fix** by passing `today` from the controller if you touch
this page. (The authenticated book page correctly uses `now.strftime('%Y-%m-%d')`.)
14. **Public pages ignore the brand gradient.** They hardcode the default green/blue; only
the authenticated portal pages pick up `fusion_claims.portal_gradient_*`. If branding
must reach the public booking page, thread `portal_gradient` through
`public_booking_page` / `public_manage_page` values.
---
## 15. Deployment & history
- Built in **Cursor**; now maintained in Claude Code.
- Lives wherever **`fusion_portal`** lives (the authorizer/sales-rep portal — the **Westin**
Enterprise environment per the repo `CLAUDE.md` *Westin Prod* section). **Verify the
current target before shipping** — there's no in-module deploy note and nothing else
depends on it.
- Notable recent commits touching it:
- `f1cea2fb` — fix: stop archiving valid events on MS `@removed=changed` (the §14.2 bug).
- `747c8142``fusion_portal` renamed from `fusion_authorizer_portal` (this module's
`depends`/tile `inherit_id` already reference the **new** name `fusion_portal`).
- **Renaming the technical name** would require the full DB-rename procedure in repo rule #16
(it's a `fusion_*` module with external IDs, view keys, and a cron baked into the DB).
---
## 16. Audit findings — confirmed bugs, gaps & risks (2026-06-03 deep dive)
These were found by reading the code, not by running it. None are fixed yet — they're
recorded so the next change can address (or consciously accept) them. **The slot `datetime`
emitted by `_generate_available_slots` is UTC** (line 520: `slot_start_utc.strftime(...)`);
hold that fact while reading #1.
### 🔴 Bugs
1. **Timezone double-conversion on 3 of the 4 booking write-paths.** The slot's hidden
`datetime` is **UTC**, but only the authenticated *booking* path consumes it as UTC:
-`schedule_book_submit` (`portal_schedule.py:661`) — `datetime.strptime(...)` used
directly as UTC. **Correct.**
-`schedule_event_reschedule` (`:801803`)
-`public_book_submit` (`:15051507`)
-`public_manage_reschedule` (`:889891`)
The three ❌ paths do `tz.localize(naive).astimezone(utc)` — i.e. they treat an
already-UTC string as *local* and convert **again**, shifting the appointment by the
user's UTC offset. It is **silent when the resolved tz is UTC** (UTC server, no `tz`
cookie / `user.tz`), which is why it can pass casual testing — but with the
`tz`-cookie set by fusion_portal (e.g. `America/Toronto`, §4.5) a reschedule or **any**
public booking lands 45 h off. **Fix:** in those three paths, treat the slot string as
UTC exactly like `schedule_book_submit` (drop the `localize`/`astimezone`).
2. **Google pull is coupled to the server's OS timezone.** In
`_google_event_to_odoo_vals` (`fusion_calendar_account.py:530`):
`start_dt.astimezone(tz=None).replace(tzinfo=None)``astimezone(None)` converts an
aware datetime to the **system local** zone, not UTC. Odoo stores naive **UTC**, so
pulled Google events are correct **only if the container runs UTC**. The Microsoft path
parses as naive-UTC and is fine. **Fix:** `.astimezone(pytz.utc).replace(tzinfo=None)`.
3. **Public booking does not re-validate the slot** (`public_book_submit`) — see §14.11.
Combined with #1 it means the public path can both mis-time *and* double-book.
### 🟠 Gaps between documented intent and implementation
4. **"Busy on one, blocked on all" is enforced at *portal-booking time*, not by syncing
events between external calendars.** `_cross_calendar_push` **skips any event that
already has a link** (`if existing_links: continue`), and every *pulled* event has a
link — so a Google event is **never** pushed into the user's Outlook (and vice-versa).
What actually delivers "blocked on all" is `_generate_available_slots`, which searches
**all** of the user's `calendar.event` rows (everything pulled from every calendar) when
computing free slots — so booking **through `/my/schedule`** respects every connected
calendar. Booking *directly* in Google will not block Outlook. `_cross_calendar_push`
only mirrors **Odoo-native** events to the **first** active account. The manifest's
"busy on one, blocked on all" oversells the cross-external behaviour — state it as
*portal-booking-time* blocking.
### 🟡 Risks / abuse vectors
5. **Slug generation can block user creation.** `res.users.create` sets
`x_fc_schedule_slug` for **every** new user, guarded by `UNIQUE(x_fc_schedule_slug)`. The
4-hex suffix gives 1/65536 collision odds per name-base; a collision raises the
constraint and **fails the whole user-creation transaction** (no retry). Low probability,
high blast radius — consider a retry/uniqueness loop if user-creation volume grows.
6. **Unthrottled public booking.** `/schedule/<slug>/book` creates a `res.partner`, a
`calendar.event`, and force-sends an email for any visitor with **no captcha / rate
limit**. A scripted abuser can spam partners + events + outbound mail. Consider a
throttle / honeypot if the slug links are widely shared.
7. **Synchronous external HTTP inside `calendar.event.write()/unlink()`.** Because
fusion_schedule is the **sole** `calendar.event` extender (verified — see below), its
overrides fire for **every** event in the system. For a *linked* event, a write that
touches a sync field makes a **blocking** Google/Microsoft API call inside the caller's
transaction; a bulk write/delete over many linked events ⇒ N serial HTTP round-trips,
potentially stalling that request/transaction. Keep this in mind before bulk-editing
calendar events in any module.
### 🔬 Deep-dive #5 additions — sync-dedup cluster + public-endpoint security
Found by an adversarial re-read (all verified against code). Full detail + fixes in Supabase
`fusionapps.issues` (project Fusion Schedule). The **dedup cluster (810) is the most serious
— it corrupts data across users**:
8. **🔴 `_find_existing_event` merges events across users + resurrects archived ones.**
`fusion_calendar_account.py:401-417` dedups by **name+start+stop only**, on `.sudo()`
(record rules bypassed), **unscoped** by user/partner/company. Two staff with a same-titled
same-time event (Standup, Lunch, an org-wide invite) → user B's sync **reuses user A's
`calendar.event`** and links B's account onto it; also **reactivates a deliberately-archived
event**. Runs as root in cron → crosses companies. Fix: scope to
`partner_ids in [self.x_fc_user_id.partner_id]` + `x_fc_source_account_id in [self.id, False]`;
never auto-reactivate an event with no surviving link to this account.
9. **🔴 iCalUID cross-link is unscoped.** `fusion_calendar_account.py:482-489` (Google) /
`715-724` (MS) match `x_fc_universal_id` across **all** accounts/users. A real invite sent to
two staff shares one iCalUID → user B's account links onto user A's event; B never gets their
own row. Fix: scope the lookup to `x_fc_account_id.x_fc_user_id = self.x_fc_user_id.id`.
10. **🔴 No per-row isolation in the sync loop.** `_sync_pull_google/_microsoft` loop
`_process_*_event` with no savepoint and write `sync_token` **after** the loop. One row
exception (e.g. an IntegrityError — `_upsert_event_link` branches on `(account,event_id)` at
`:419-445` but the UNIQUE is `(account,external_id)` at `fusion_calendar_event_link.py:32`)
rolls back the whole page and **never advances `sync_token`** → deterministic errors wedge
the account forever. Fix: `with self.env.cr.savepoint():` per row; branch the upsert on
`(account, external_id)`.
11. **🔴 MS delta page-cap stalls large calendars.** `_sync_pull_microsoft` caps at 2000/5000
and `break`s without the `@odata.deltaLink` (`:601-606`), writing back the old token → a
>2000-event window re-fetches the same 2000 forever and never delivers the rest. The
410/`fullSyncRequired` recursion (`:318-321`, `:588-591`) has **no depth guard**.
12. **🟡 Public booking mutates/attaches an existing partner by email.**
`public_book_submit` (`portal_schedule.py:1516-1525`) does
`Partner.search([('email','=ilike', visitor_email)])` then writes `phone` onto the match and
attaches it as an attendee. An anonymous visitor can pollute an arbitrary contact (incl.
staff), pull internal partners into an event, and mail arbitrary addresses. Fix: on the
public path, never mutate/attach a partner matched only by attacker-supplied email.
13. **🟡 Manage-token leaks via redirect URL + no re-validation + no throttle.** The success
redirect puts the 32-char bearer token in an in-page URL query string
(`portal_schedule.py:1590-1594`) → leaks via history + `Referer` to Google Maps assets.
`public_manage_reschedule` (`:876-903`) also skips slot re-validation; public routes are
unthrottled. (Token entropy itself is fine.) Fix: keep the token in the emailed link only,
add `Referrer-Policy: no-referrer`, re-validate, throttle.
14. **🟡 `sync_current_user` commits mid-loop** (`:1097`) — non-atomic inside an interactive
RPC; reports `{success: False}` after already persisting earlier accounts.
15. **🟡 Dead imports** trip pyflakes: `import secrets` (`calendar_event.py:4`) and
`import hashlib` (`controllers/portal_schedule.py:4`) are unused. (`res_users.py` is fine —
it uses `uuid`.)
> Refinement to #4: `_cross_calendar_push` is **also** gated by `len(user_accounts) > 1`
> (`:1149`), so **single-account users never get their Odoo-native events pushed out at all**,
> and the `start >= now-1d` filter excludes all-day events. So even the portal-side mirroring is
> partial.
### 🧱 Deep-dive #6 — install / render / Odoo-19-API correctness (the AI-codegen layer)
**Clean meta-result:** a grep for every repo-documented Odoo-19 anti-pattern came back empty —
no `type="json"`, `groups_id`, `_sql_constraints`, `numbercall`, `useService('rpc')`,
`category_id`, `fields.Date` in settings, or SCSS `@import`; `models.Constraint`/`models.Index`,
`@api.model_create_multi`, the OWL import path, and route types are all **correct** Odoo 19. So
the AI (Cursor + Claude 4.5 Opus) got the *syntax/idioms* right; the defects are semantic
(logic/integration/tz), plus these render/version items:
16. **🟡 `today` undefined on the public booking page.** `public_booking.xml:79`
(`t-att-min="today"`) but `public_booking_page` (`:1418-1426`) never passes `today`
(the authenticated page correctly passes `now`). At minimum the public date picker loses its
min-date guard (visitor can pick a past date → server returns 0 slots). **Confirm on Odoo 19
whether QWeb omits the attr or 500s** — the public page looks untested. Copy-paste drift.
17. **🟡 Confirmation email renders UTC times + wrong language.** `mail_template_data.xml`
`t-out object.start/stop` with the `datetime` widget renders in the **renderer's tz** (UTC on
`force_send` from a portal request) → email shows UTC, not the client's local time. And
`lang = {{ object.partner_ids[:1].lang }}` picks the **first** partner = the **staff** user,
not the client. (Mail body is otherwise rule-17-safe — no `url_encode`/undefined names;
`res.company.website` + `get_base_url()` resolve.)
18. **🟡 Address-autocomplete drift.** The asset JS stores province as full name
(`portal_schedule_booking.js:546`, `long_name` → "Ontario"); the public inline JS stores the
2-letter code (`public_booking.xml:318`, `short_name` → "ON"). Same field, two formats. The
asset version also omits the Places `fields:[...]` filter → Google all-fields billing tier.
19. **🟠 `_prepare_calendar_event_values` signature unverified.** `portal_schedule.py:717-730`
calls this **private Enterprise** method (signature shifted across 16→19). A mismatched kwarg
raises `TypeError`, swallowed by the `except` at `:766` → **authenticated bookings silently
never get created**. The public path builds vals by hand (a tell). **Needs a booking
smoke-test on Enterprise** — couldn't byte-verify (Docker/Odoo source unreachable).
**Version-fragility notes (work now, but verify on Odoo point-upgrades — not logged as bugs):**
- The backend patch xpaths `//div[@id='header_synchronization_settings']`
(`fusion_calendar_controller.xml:10,15`) against `calendar.AttendeeCalendarController`. It
resolves on the deployed version (else the *entire* `web.assets_backend` bundle would be dead),
but a future Odoo restructure of that template would brick the bundle. Prefer a stabler
selector when next touched.
- The `appointment.invite` seed (`appointment_invite_data.xml:8`) has empty
`appointment_type_ids` **and** no `staff_user_ids`, so `schedule_page`'s `share_url`
(`invite.book_url`) never resolves for anyone — the seed is inert (the `/schedule/<slug>` flow
is the real share). Reconcile or drop it.
### ✅ Audit results that came back clean (good to know)
- **No `x_fc_*` field-name collisions.** None of `x_fc_schedule_slug / _booking_enabled /
_work_start / _work_end / _break_start / _travel_buffer / _home_address / _home_lat`
appears in any other module.
- **`calendar.event` is inherited by `fusion_schedule` alone** (whole repo). Its
`write/unlink` overrides are the only custom hooks on that model — but they run for every
calendar event once installed (see risk #7).
- **No conflicting `res.users.create()` override in the dependency chain.** `fusion_portal`
only overrides `_generate_tutorial_articles` / `portal.wizard.user`; `fusion_tasks` adds
`x_fc_is_field_staff / x_fc_start_address / x_fc_tech_sync_id` (no `create`, no overlap).
So the `@api.model_create_multi create()` slug hook chains cleanly via `super()`.
---
## 17. File index
```
fusion_schedule/
├── __manifest__.py # deps, data load order, assets (v19.0.2.1.0)
├── controllers/portal_schedule.py # ALL routes + slot gen + travel + AI + OAuth (~1600 lines)
├── models/
│ ├── fusion_calendar_account.py # OAuth + sync engine (the core)
│ ├── fusion_calendar_event_link.py # Odoo↔external join (unique per account)
│ ├── calendar_event.py # inherit: source/links/manage-token/travel + write/unlink push
│ ├── res_users.py # inherit: slug, booking flag, work prefs, auto-slug
│ └── res_config_settings.py # OAuth creds + sync interval + schedule defaults
├── data/
│ ├── ir_cron_data.xml # 5-min sync cron
│ ├── mail_template_data.xml # booking confirmation email (NOT noupdate)
│ └── appointment_invite_data.xml # default share invite (noupdate, empty types)
├── security/{security.xml, ir.model.access.csv}
├── views/
│ ├── fusion_calendar_account_views.xml # backend list/form + Technical menu
│ ├── res_config_settings_views.xml # Settings app block
│ ├── portal_schedule_tile.xml # tile into fusion_portal.portal_my_home_authorizer
│ ├── portal_schedule.xml # portal_schedule_page + portal_schedule_book
│ └── public_booking.xml # public_booking_page + public_manage_page
├── static/src/
│ ├── css/portal_schedule.css
│ ├── js/portal_schedule_booking.js # booking form + Places autocomplete + AI suggest
│ ├── js/portal_schedule_accounts.js # dashboard modals/toasts + optimize
│ └── views/fusion_calendar_controller.{js,xml} # backend calendar patch
├── utils/__init__.py # empty placeholder
└── graphify-out/ # Cursor code-graph artifact — NOT loaded by Odoo
```