[ADD] contracts: add Odoo sync API contract and note mapping

Define the integration contract between erplibre_mobile and ERPLibre
platform: JSON-RPC 2.0 call specs, Note→project.task field mapping,
GeoMultiPoint format for geolocation entries, conflict resolution
strategy, re-auth flow, and version compatibility matrix.
Establishes the shared source of truth before implementation begins.

--- FR ---

Définit le contrat d'intégration entre erplibre_mobile et la plateforme
ERPLibre : spécifications des appels JSON-RPC 2.0, correspondance des champs
Note→project.task, format GeoMultiPoint pour les entrées de géolocalisation,
stratégie de résolution des conflits, flux de réauthentification et matrice
de compatibilité des versions. Établit la source de vérité commune avant que
l'implémentation ne commence.

Assisted-by: Claude Sonnet 4.6
This commit is contained in:
Mathieu Benoit 2026-03-29 00:10:22 -04:00
parent fd3506351d
commit 394be35324
3 changed files with 385 additions and 0 deletions

View file

@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Version compatibility matrix — ERPLibre platform ↔ erplibre_mobile app",
"updated": "2026-03-29",
"matrix": [
{
"mobile": "2026.03.29.01",
"erplibre": "1.6.0",
"odoo": "18.0",
"sync_api_version": "1.0",
"modules_required": [
"project",
"project_todo",
"base_geoengine",
"erplibre_mobile_todo"
],
"breaking": false,
"notes": "Initial sync support — push/pull project.task (todos), media attachments, geolocation"
}
],
"modules": {
"project_todo": {
"description": "Odoo built-in To-Do — project.task with project_id=False",
"auto_installed": true,
"source": "odoo/addons/project_todo"
},
"base_geoengine": {
"description": "OCA geospatial fields — GeoMultiPoint for geolocation entries",
"source": "odoo18.0/addons/OCA_geospatial/base_geoengine",
"version": "18.0.1.2.0",
"python_deps": ["shapely", "geojson"]
},
"erplibre_mobile_todo": {
"description": "ERPLibre companion module — adds geo_task_point (GeoMultiPoint) to project.task",
"source": "odoo18.0/addons/ERPLibre_erplibre_addons/erplibre_mobile_todo",
"depends": ["project_todo", "base_geoengine"],
"custom_fields_on_project_task": ["geo_task_point"]
}
}
}

85
contracts/note-mapping.md Normal file
View file

@ -0,0 +1,85 @@
# Note mobile → project.task mapping
## Field mapping
| Note mobile field | project.task field | Type | Notes |
|------------------------|-----------------------|------------------|-------|
| *(no mobile id sent)* | `id` | Integer (server) | Odoo ID stored in mobile SQLite as `odoo_id` after first push |
| `title` | `name` | Char | Required |
| `done` | `state` | Selection | `'done'` ↔ done=true ; `'01_in_progress'` ↔ done=false |
| `archived` | `active` | Boolean | false = archived |
| `pinned` | `priority` | Selection | `'1'` = pinned, `'0'` = normal |
| `tags` | `tag_ids` | Many2many | Matched by tag name; created if missing |
| entry `text` | `description` | Html | Each entry → `<p>text content</p>` |
| entry `date` | `date_deadline` | Datetime | First date entry only |
| entry `audio` | `attachment_ids` | ir.attachment | + `<p>🎙️ Enregistrement audio — ISO_DATE</p>` in description |
| entry `photo` | `attachment_ids` | ir.attachment | + `<p>📷 Photo — ISO_DATE</p>` in description |
| entry `video` | `attachment_ids` | ir.attachment | + `<p>🎥 Vidéo — ISO_DATE</p>` in description |
| entry `geolocation` | `geo_task_point` | GeoMultiPoint | All lat/lon → MultiPoint GeoJSON ; `<p>📍 text — lat,lon — ISO_DATE</p>` in description |
## Description HTML structure
Each note's `description` field is built by concatenating all entries in order:
```html
<!-- entry type=text -->
<p>Content of the text entry.</p>
<!-- entry type=text (another one) -->
<p>Another paragraph of notes.</p>
<!-- entry type=date -->
<p>📅 Date : 2026-03-29T14:30:00</p>
<!-- entry type=geolocation -->
<p>📍 Géolocalisation : 45.5017, -73.5673 — Bureau principal — 2026-03-29T14:35:00</p>
<!-- entry type=audio -->
<p>🎙️ Enregistrement audio — 2026-03-29T14:40:00</p>
<!-- entry type=photo -->
<p>📷 Photo — 2026-03-29T14:45:00</p>
<!-- entry type=video -->
<p>🎥 Vidéo — 2026-03-29T14:50:00</p>
```
## GeoMultiPoint format (geo_task_point)
All geolocation entries of a note are stored as a single GeoJSON MultiPoint:
```json
{
"type": "MultiPoint",
"coordinates": [
[-73.5673, 45.5017],
[-73.5789, 45.4972]
]
}
```
Order matches the order of geolocation entries. Coordinates are `[longitude, latitude]` per GeoJSON spec.
Timestamps and text descriptions are preserved in the HTML description `<p>` lines.
## Sync ID strategy
- Mobile does **not** send its internal UUID to Odoo.
- On first push: `project.task.create()` returns the Odoo `id` (integer).
- Mobile stores it as `odoo_id` in its local SQLite `notes` table.
- Subsequent pushes use `project.task.write([[odoo_id], {...}])`.
- Multiple mobile clients: each independently stores the same `odoo_id` — fully supported.
## Conflict resolution
| Condition | Resolution |
|-----------|------------|
| `write_date` (Odoo) > `last_synced_at` (mobile) | Odoo wins — pull overwrites mobile |
| Mobile modified since `last_synced_at`, Odoo not changed | Mobile wins — push |
| Both modified since last sync | Odoo wins (last-write-wins, v1) |
## Tag resolution
1. Mobile sends tag names as strings.
2. SyncService calls `project.tags` `search_read` to find existing tags by name.
3. Missing tags: `project.tags.create()` before task create/write.
4. `tag_ids` in task payload uses integer IDs from step 2-3.

260
contracts/odoo-api.json Normal file
View file

@ -0,0 +1,260 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "API contract — erplibre_mobile ↔ Odoo JSON-RPC 2.0",
"version": "1.0",
"updated": "2026-03-29",
"base_url": "{odoo_url}",
"protocol": "JSON-RPC 2.0",
"content_type": "application/json",
"authentication": {
"method": "session_cookie",
"notes": "Standard Odoo session — no custom auth module required"
},
"endpoints": {
"auth.login": {
"description": "Authenticate and open a session",
"method": "POST",
"path": "/web/session/authenticate",
"request": {
"jsonrpc": "2.0",
"method": "call",
"params": {
"db": "{database_name}",
"login": "{username}",
"password": "{password}"
}
},
"response_fields": {
"uid": "integer — user ID, null if auth failed",
"session_id": "string — session cookie value"
},
"errors": {
"uid=false": "Wrong credentials"
},
"side_effects": "Sets session cookie — store in SecureStorage"
},
"auth.check": {
"description": "Verify session is still valid (ping)",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": "res.lang",
"method": "search_read",
"args": [],
"kwargs": { "domain": [], "fields": ["name"], "limit": 1 }
}
},
"errors": {
"session_expired": "HTTP 200 with error code 100 — trigger re-auth"
}
},
"tags.resolve": {
"description": "Find or prepare project tags by name",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"params": {
"model": "project.tags",
"method": "search_read",
"args": [],
"kwargs": {
"domain": [["name", "in", ["tag1", "tag2"]]],
"fields": ["id", "name"]
}
}
},
"notes": "Compare returned names against mobile tags — create missing ones via tags.create"
},
"tags.create": {
"description": "Create a missing tag",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"params": {
"model": "project.tags",
"method": "create",
"args": [{ "name": "{tag_name}" }],
"kwargs": {}
}
},
"response": "integer — new tag ID"
},
"notes.pull": {
"description": "Pull todos modified since last sync",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"params": {
"model": "project.task",
"method": "search_read",
"args": [],
"kwargs": {
"domain": [
["project_id", "=", false],
["write_date", ">", "{last_synced_at_iso}"]
],
"fields": [
"id", "name", "description", "priority", "active",
"state", "tag_ids", "date_deadline",
"geo_task_point", "write_date", "attachment_ids"
],
"limit": 100,
"offset": 0,
"order": "write_date asc"
}
}
},
"notes": "project_id=False = project_todo personal tasks. geo_task_point requires erplibre_mobile_todo installed."
},
"notes.poll": {
"description": "Lightweight poll — fetch only IDs and write_date to detect changes",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"params": {
"model": "project.task",
"method": "search_read",
"args": [],
"kwargs": {
"domain": [
["project_id", "=", false],
["write_date", ">", "{last_synced_at_iso}"]
],
"fields": ["id", "write_date"],
"limit": 200
}
}
}
},
"notes.create": {
"description": "Push a new note to Odoo — returns the Odoo ID to store locally",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"params": {
"model": "project.task",
"method": "create",
"args": [
{
"name": "{note.title}",
"description": "{note.html_description}",
"project_id": false,
"priority": "1 if pinned else 0",
"active": "not note.archived",
"state": "done if note.done else 01_in_progress",
"tag_ids": [[6, 0, ["{tag_id_1}", "{tag_id_2}"]]],
"date_deadline": "{first_date_entry_iso or null}",
"geo_task_point": "{geojson_multipoint or null}"
}
],
"kwargs": {}
}
},
"response": "integer — Odoo task ID → store as odoo_id in mobile SQLite",
"notes": "tag_ids uses Odoo ORM command 6 (replace all). geo_task_point is GeoJSON string."
},
"notes.update": {
"description": "Push changes to an existing note",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"params": {
"model": "project.task",
"method": "write",
"args": [
["{note.odoo_id}"],
{
"name": "{note.title}",
"description": "{note.html_description}",
"priority": "1 if pinned else 0",
"active": "not note.archived",
"state": "done if note.done else 01_in_progress",
"tag_ids": [[6, 0, ["{tag_id_1}"]]],
"date_deadline": "{first_date_entry_iso or null}",
"geo_task_point": "{geojson_multipoint or null}"
}
],
"kwargs": {}
}
},
"response": "true on success"
},
"attachments.list": {
"description": "List attachments for a set of tasks",
"method": "POST",
"path": "/web/dataset/call_kw",
"request": {
"params": {
"model": "ir.attachment",
"method": "search_read",
"args": [],
"kwargs": {
"domain": [
["res_model", "=", "project.task"],
["res_id", "in", ["{odoo_id_1}", "{odoo_id_2}"]]
],
"fields": ["id", "name", "mimetype", "file_size", "create_date", "res_id"]
}
}
}
},
"attachments.upload": {
"description": "Upload a media file (audio, photo, video) as task attachment",
"method": "POST",
"path": "/web/binary/upload_attachment",
"content_type": "multipart/form-data",
"request_fields": {
"model": "project.task",
"id": "{note.odoo_id}",
"ufile": "<binary file content>"
},
"response_fields": {
"id": "integer — attachment ID",
"name": "string — filename"
},
"notes": "Upload only on WiFi option recommended for video files. Requires valid session cookie."
}
},
"re_auth_strategy": {
"trigger": "JSON-RPC error code 100 (session expired) or HTTP 401",
"steps": [
"1. Retrieve credentials from SecureStorage key 'odoo_sync_credentials_{app_url}'",
"2. Call auth.login with retrieved credentials",
"3. Store new session cookie in SecureStorage key 'odoo_sync_session_{app_url}'",
"4. Retry original request once",
"5. If still failing — set sync_status='error', notify user"
]
},
"sync_status_values": {
"local": "Note exists only on device — never pushed",
"pending": "Modified since last sync — push needed",
"synced": "In sync with Odoo — write_date matches",
"conflict": "Both mobile and Odoo modified since last sync (v1: Odoo wins)",
"error": "Last sync attempt failed — show error to user"
},
"sqlite_columns_added": {
"table": "notes",
"migration": "2026032901",
"columns": {
"odoo_id": "INTEGER — Odoo project.task id, null if never pushed",
"odoo_url": "TEXT — Base URL of the Odoo instance used for sync",
"sync_status": "TEXT DEFAULT 'local' — see sync_status_values",
"last_synced_at": "TEXT — ISO 8601 datetime of last successful sync"
}
}
}