apply_polled_quote_template_jobmaterials.py added to the repo and the docs
This commit is contained in:
Executable
+388
@@ -0,0 +1,388 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create ServiceM8 jobMaterials for one selected polled Quote Template response.
|
||||||
|
|
||||||
|
Safe-by-default:
|
||||||
|
- Requires an explicit form response UUID.
|
||||||
|
- Dry-run unless --apply is provided.
|
||||||
|
- Refuses to apply twice for the same form response unless --force is used.
|
||||||
|
- Records every dry-run/apply attempt in the poll DB for inspector visibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from contextlib import closing
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from servicem8_quote_template_parser import STATE_DB_PATH, init_state_db, record_generated_job_material
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
POLL_DB_PATH = Path(os.getenv("WEBHOOK_POLL_DB_PATH", SCRIPT_DIR / "servicem8_formresponse_poll.db"))
|
||||||
|
BASE_URL = os.getenv("SERVICEM8_BASE_URL", "https://api.servicem8.com/api_1.0")
|
||||||
|
REQUEST_TIMEOUT = int(os.getenv("SERVICEM8_TIMEOUT", "30"))
|
||||||
|
DEV_QUOTE_MATERIAL_UUID = "f78b1d23-b9fa-40fe-a806-2425fe09cc0b"
|
||||||
|
DEV_QUOTE_TAX_RATE_UUID = "84e4dd28-06b3-452b-a796-1f58a20ac49b"
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def build_payload(job_uuid: str, row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"job_uuid": job_uuid,
|
||||||
|
"material_uuid": DEV_QUOTE_MATERIAL_UUID,
|
||||||
|
"tax_rate_uuid": DEV_QUOTE_TAX_RATE_UUID,
|
||||||
|
"name": row["name"],
|
||||||
|
"quantity": "0",
|
||||||
|
"price": "",
|
||||||
|
"displayed_amount": "",
|
||||||
|
"displayed_amount_is_tax_inclusive": "",
|
||||||
|
"sort_order": row["sort_order"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_job_material(session: requests.Session, payload: dict) -> str:
|
||||||
|
response = session.post(f"{BASE_URL}/jobmaterial.json", json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
if not response.ok:
|
||||||
|
raise RuntimeError(f"Create failed: HTTP {response.status_code} :: {response.text}")
|
||||||
|
record_uuid = response.headers.get("x-record-uuid", "")
|
||||||
|
if not record_uuid:
|
||||||
|
raise RuntimeError("Create succeeded but x-record-uuid header was missing")
|
||||||
|
return record_uuid
|
||||||
|
|
||||||
|
|
||||||
|
def load_api_key() -> str:
|
||||||
|
for name in ("SERVICEM8_ACCESS_TOKEN", "SERVICEM8_API_KEY"):
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
fallback = SCRIPT_DIR / "servicem8-list-webhook-subscriptions-table.py"
|
||||||
|
if fallback.exists():
|
||||||
|
spec = importlib.util.spec_from_file_location("servicem8_subs", fallback)
|
||||||
|
if spec and spec.loader:
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module) # type: ignore[union-attr]
|
||||||
|
value = getattr(module, "ACCESS_TOKEN", None)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
raise RuntimeError("Missing SERVICEM8_ACCESS_TOKEN or SERVICEM8_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn(db_path: Path = POLL_DB_PATH) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_apply_tables(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS quote_template_apply_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
form_response_uuid TEXT NOT NULL,
|
||||||
|
job_uuid TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
desired_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS quote_template_apply_run_rows (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
form_response_uuid TEXT NOT NULL,
|
||||||
|
job_uuid TEXT NOT NULL,
|
||||||
|
row_index INTEGER NOT NULL,
|
||||||
|
kind TEXT,
|
||||||
|
source_question TEXT,
|
||||||
|
name TEXT,
|
||||||
|
api_payload_json TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
job_material_uuid TEXT,
|
||||||
|
error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(run_id) REFERENCES quote_template_apply_runs(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_apply_runs_form_response ON quote_template_apply_runs(form_response_uuid)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_apply_rows_run ON quote_template_apply_run_rows(run_id)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def load_quote(conn: sqlite3.Connection, form_response_uuid: str) -> sqlite3.Row:
|
||||||
|
row = conn.execute(
|
||||||
|
"select * from quote_template_form_responses where form_response_uuid = ?",
|
||||||
|
(form_response_uuid,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise SystemExit(f"No parsed polled Quote Template response found for UUID: {form_response_uuid}")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def existing_created_for_form(form_response_uuid: str) -> int:
|
||||||
|
if not STATE_DB_PATH.exists():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(STATE_DB_PATH)) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"select count(*) from generated_job_materials where form_response_uuid = ?",
|
||||||
|
(form_response_uuid,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0] if row else 0)
|
||||||
|
except sqlite3.Error:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_apply_run(conn: sqlite3.Connection, *, form_response_uuid: str, job_uuid: str, mode: str, desired_count: int) -> int:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO quote_template_apply_runs (
|
||||||
|
form_response_uuid, job_uuid, mode, started_at, desired_count, status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(form_response_uuid, job_uuid, mode, utc_now(), desired_count, "running"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_apply_run(conn: sqlite3.Connection, run_id: int, *, status: str, created_count: int, error: Optional[str] = None) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE quote_template_apply_runs
|
||||||
|
SET finished_at = ?, status = ?, created_count = ?, error = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(utc_now(), status, created_count, error, run_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_apply_row(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
run_id: int,
|
||||||
|
form_response_uuid: str,
|
||||||
|
job_uuid: str,
|
||||||
|
row_index: int,
|
||||||
|
row: Dict[str, Any],
|
||||||
|
api_payload: Dict[str, Any],
|
||||||
|
action: str,
|
||||||
|
job_material_uuid: str = "",
|
||||||
|
error: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO quote_template_apply_run_rows (
|
||||||
|
run_id, form_response_uuid, job_uuid, row_index, kind, source_question,
|
||||||
|
name, api_payload_json, action, job_material_uuid, error, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
form_response_uuid,
|
||||||
|
job_uuid,
|
||||||
|
row_index,
|
||||||
|
row.get("kind", ""),
|
||||||
|
row.get("source_question", ""),
|
||||||
|
row.get("name", ""),
|
||||||
|
json.dumps(api_payload, ensure_ascii=False, sort_keys=True),
|
||||||
|
action,
|
||||||
|
job_material_uuid,
|
||||||
|
error,
|
||||||
|
utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
select form_response_uuid, discovered_at, job_uuid, description, queued_at,
|
||||||
|
processed_at, process_status, desired_job_materials_json
|
||||||
|
from quote_template_form_responses
|
||||||
|
order by discovered_at desc
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
existing = existing_created_for_form(row["form_response_uuid"])
|
||||||
|
try:
|
||||||
|
desired_count = len(json.loads(row["desired_job_materials_json"] or "[]"))
|
||||||
|
except Exception:
|
||||||
|
desired_count = None
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"form_response_uuid": row["form_response_uuid"],
|
||||||
|
"discovered_at": row["discovered_at"],
|
||||||
|
"job_uuid": row["job_uuid"],
|
||||||
|
"description": row["description"],
|
||||||
|
"desired_count": desired_count,
|
||||||
|
"created_in_state_db": existing,
|
||||||
|
"processed_at": row["processed_at"],
|
||||||
|
"process_status": row["process_status"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Dry-run or apply one selected polled Quote Template response to ServiceM8 jobMaterials")
|
||||||
|
parser.add_argument("--uuid", help="Polled Quote Template form_response_uuid to process")
|
||||||
|
parser.add_argument("--db", default=str(POLL_DB_PATH), help="Poll DB path")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Actually create ServiceM8 jobMaterial records. Default is dry-run.")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Allow apply even if generated_job_materials already contains rows for this form response")
|
||||||
|
parser.add_argument("--list", action="store_true", help="List parsed polled Quote Template responses and exit")
|
||||||
|
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db)
|
||||||
|
if not db_path.exists():
|
||||||
|
raise SystemExit(f"Poll DB not found: {db_path}")
|
||||||
|
|
||||||
|
with closing(get_conn(db_path)) as conn:
|
||||||
|
init_apply_tables(conn)
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
print(json.dumps({"ok": True, "rows": list_pending(conn)}, indent=2 if args.pretty else None, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.uuid:
|
||||||
|
raise SystemExit("--uuid is required unless using --list")
|
||||||
|
|
||||||
|
quote = load_quote(conn, args.uuid)
|
||||||
|
form_response_uuid = quote["form_response_uuid"]
|
||||||
|
job_uuid = quote["job_uuid"]
|
||||||
|
desired_rows = json.loads(quote["desired_job_materials_json"] or "[]")
|
||||||
|
mode = "apply" if args.apply else "dry-run"
|
||||||
|
|
||||||
|
existing_created = existing_created_for_form(form_response_uuid)
|
||||||
|
if args.apply and existing_created and not args.force:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Refusing duplicate apply: {existing_created} generated_job_materials already recorded for {form_response_uuid}. Use --force only if intentional."
|
||||||
|
)
|
||||||
|
|
||||||
|
run_id = create_apply_run(
|
||||||
|
conn,
|
||||||
|
form_response_uuid=form_response_uuid,
|
||||||
|
job_uuid=job_uuid,
|
||||||
|
mode=mode,
|
||||||
|
desired_count=len(desired_rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"mode": mode,
|
||||||
|
"run_id": run_id,
|
||||||
|
"form_response_uuid": form_response_uuid,
|
||||||
|
"job_uuid": job_uuid,
|
||||||
|
"description": quote["description"],
|
||||||
|
"desired_count": len(desired_rows),
|
||||||
|
"created_count": 0,
|
||||||
|
"state_db_path": str(STATE_DB_PATH),
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not args.apply:
|
||||||
|
for idx, row in enumerate(desired_rows, start=1):
|
||||||
|
api_payload = build_payload(job_uuid, row)
|
||||||
|
record_apply_row(
|
||||||
|
conn,
|
||||||
|
run_id=run_id,
|
||||||
|
form_response_uuid=form_response_uuid,
|
||||||
|
job_uuid=job_uuid,
|
||||||
|
row_index=idx,
|
||||||
|
row=row,
|
||||||
|
api_payload=api_payload,
|
||||||
|
action="would_create",
|
||||||
|
)
|
||||||
|
result["rows"].append({"action": "would_create", "kind": row.get("kind"), "payload": api_payload})
|
||||||
|
finish_apply_run(conn, run_id, status="dry-run", created_count=0)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE quote_template_form_responses SET process_status = ? WHERE form_response_uuid = ?",
|
||||||
|
("dry-run", form_response_uuid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print(json.dumps(result, indent=2 if args.pretty else None, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
api_key = load_api_key()
|
||||||
|
init_state_db(STATE_DB_PATH)
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update({"X-Api-Key": api_key, "Accept": "application/json", "Content-Type": "application/json"})
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for idx, row in enumerate(desired_rows, start=1):
|
||||||
|
api_payload = build_payload(job_uuid, row)
|
||||||
|
created_uuid = create_job_material(session, api_payload)
|
||||||
|
created_count += 1
|
||||||
|
record_generated_job_material(
|
||||||
|
job_uuid=job_uuid,
|
||||||
|
form_response_uuid=form_response_uuid,
|
||||||
|
job_material_uuid=created_uuid,
|
||||||
|
kind=row.get("kind", ""),
|
||||||
|
source_field_uuid=row.get("source_field_uuid", ""),
|
||||||
|
source_question=row.get("source_question", ""),
|
||||||
|
source_text=row.get("name", ""),
|
||||||
|
db_path=STATE_DB_PATH,
|
||||||
|
)
|
||||||
|
record_apply_row(
|
||||||
|
conn,
|
||||||
|
run_id=run_id,
|
||||||
|
form_response_uuid=form_response_uuid,
|
||||||
|
job_uuid=job_uuid,
|
||||||
|
row_index=idx,
|
||||||
|
row=row,
|
||||||
|
api_payload=api_payload,
|
||||||
|
action="created",
|
||||||
|
job_material_uuid=created_uuid,
|
||||||
|
)
|
||||||
|
result["rows"].append({"action": "created", "kind": row.get("kind"), "job_material_uuid": created_uuid, "payload": api_payload})
|
||||||
|
|
||||||
|
result["created_count"] = created_count
|
||||||
|
finish_apply_run(conn, run_id, status="applied", created_count=created_count)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE quote_template_form_responses SET processed_at = ?, process_status = ?, process_error = NULL WHERE form_response_uuid = ?",
|
||||||
|
(utc_now(), "applied", form_response_uuid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print(json.dumps(result, indent=2 if args.pretty else None, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
finish_apply_run(conn, run_id, status="error", created_count=result.get("created_count", 0), error=str(exc))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE quote_template_form_responses SET process_status = ?, process_error = ? WHERE form_response_uuid = ?",
|
||||||
|
("error", str(exc), form_response_uuid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except Exception as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
# List all Form Responses
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### Filtering
|
||||||
|
This endpoint supports result filtering. For more information on how to filter this request, [go here](/docs/filtering).
|
||||||
|
|
||||||
|
|
||||||
|
#### OAuth Scope
|
||||||
|
This endpoint requires the following OAuth scope **read_forms**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# OpenAPI definition
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {
|
||||||
|
"title": "ServiceM8 API",
|
||||||
|
"description": "Move your app forward with the ServiceM8 API\n\n\n\n## Limits and Throttling\nTo ensure continuous quality of service, API usage can be subject to throttling. The throttle will be applied once an API consumer reaches a certain \nthreshold in terms of a maximum number of requests per minute. Most clients will never hit this threshold, but those that do, will get met by a \nHTTP 429 Too Many Requests response code. \n \nThere is a limit of 180 requests per minute, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per minute exceeded\".\nThere is a limit of 20000 requests per day, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per day exceeded\".\n\nWe encourage all API developers to anticipate this error, and take appropriate measures like e.g. using a cached value from a previous call, or passing on a message to the end user that gets subjected to this behaviour (if any).\n\nLimits are per Addon per account.\n",
|
||||||
|
"termsOfService": "https://www.servicem8.com/terms-of-service",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "https://api.servicem8.com/api_1.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"apiKey": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oauth2": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/formresponse.json": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Form Responses"
|
||||||
|
],
|
||||||
|
"operationId": "listFormResponses",
|
||||||
|
"summary": "List all Form Responses",
|
||||||
|
"description": "\n\t\t\t\n#### Filtering\nThis endpoint supports result filtering. For more information on how to filter this request, [go here](/docs/filtering).\n\t\t\t\n\t\t\t\n#### OAuth Scope\nThis endpoint requires the following OAuth scope **read_forms**.\n\n\t\t\t",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"apiKey": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oauth2": [
|
||||||
|
"read_forms"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "An array of Form Responses",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/FormResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"success": {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"uuid": "123e4567-af16-4618-80c7-23f940cc449b",
|
||||||
|
"active": 1,
|
||||||
|
"edit_date": "2026-03-01 12:00:00",
|
||||||
|
"form_uuid": "123e4567-f8b2-4802-a8db-23f94cf50e1b",
|
||||||
|
"staff_uuid": "123e4567-5c89-4cb3-8dc0-23f94d75521b",
|
||||||
|
"regarding_object": "string",
|
||||||
|
"regarding_object_uuid": "123e4567-016e-482c-a3f7-23f943f6d72b",
|
||||||
|
"field_data": "string",
|
||||||
|
"timestamp": "2026-03-01 12:00:00",
|
||||||
|
"form_by_staff_uuid": "123e4567-44e8-448d-925c-23f9435b205b",
|
||||||
|
"document_attachment_uuid": "123e4567-2f15-479f-8791-23f94c0fbbbb",
|
||||||
|
"asset_uuid": "123e4567-89f4-4183-ae1e-23f94832e59b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request - The request is malformed or contains invalid parameters",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"badRequest": {
|
||||||
|
"value": {
|
||||||
|
"errorCode": "1000",
|
||||||
|
"message": "An error occurred completing your request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized - Authentication credentials are missing or invalid",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AuthenticationError"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"unauthorized": {
|
||||||
|
"value": {
|
||||||
|
"errorCode": "401",
|
||||||
|
"message": "Authentication failed. Please check your API key or OAuth token."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden - You don't have permission to access this resource",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ForbiddenError"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"forbidden": {
|
||||||
|
"value": {
|
||||||
|
"errorCode": "403",
|
||||||
|
"message": "Access forbidden. You don't have permission to access this resource."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests - You have exceeded the rate limit",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RateLimitError"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"rateLimitMinute": {
|
||||||
|
"value": {
|
||||||
|
"errorCode": 429,
|
||||||
|
"message": "Number of allowed API requests per minute exceeded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rateLimitDay": {
|
||||||
|
"value": {
|
||||||
|
"errorCode": 429,
|
||||||
|
"message": "Number of allowed API requests per day exceeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error - An unexpected error occurred on the server",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"serverError": {
|
||||||
|
"value": {
|
||||||
|
"errorCode": 500,
|
||||||
|
"message": "An unexpected error occurred. Please try again later."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"apiKey": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "X-Api-Key",
|
||||||
|
"in": "header"
|
||||||
|
},
|
||||||
|
"oauth2": {
|
||||||
|
"type": "oauth2",
|
||||||
|
"flows": {
|
||||||
|
"authorizationCode": {
|
||||||
|
"authorizationUrl": "https://api.servicem8.com/oauth/authorize",
|
||||||
|
"tokenUrl": "https://api.servicem8.com/oauth/access_token",
|
||||||
|
"scopes": {
|
||||||
|
"staff_locations": "Access to real-time GPS information about staff",
|
||||||
|
"staff_activity": "Access to clock on, lunch break and clock off information about staff",
|
||||||
|
"publish_sms": "Access to send SMS messages to customers and/or staff on your behalf. Note sending SMS messages will incur account charges.",
|
||||||
|
"publish_email": "Access to send Email messages to customers and/or staff on your behalf",
|
||||||
|
"vendor": "Access to basic account information",
|
||||||
|
"vendor_logo": "Access to account logo",
|
||||||
|
"vendor_email": "Access to account holder email address",
|
||||||
|
"read_locations": "Read-only access to Location Endpoint",
|
||||||
|
"manage_locations": "Full access to Location Endpoint",
|
||||||
|
"read_staff": "Read-only access to Staff Endpoint",
|
||||||
|
"manage_staff": "Full access to Staff Endpoint",
|
||||||
|
"read_customers": "Read-only access to Company Endpoint",
|
||||||
|
"manage_customers": "Full access to Company Endpoint",
|
||||||
|
"read_customer_contacts": "Read-only access to CompanyContact Endpoint",
|
||||||
|
"manage_customer_contacts": "Full access to CompanyContact Endpoint",
|
||||||
|
"read_jobs": "Read-only access to Job Endpoint",
|
||||||
|
"manage_jobs": "Full access to Job Endpoint",
|
||||||
|
"create_jobs": "Ability to create jobs on behalf of account. Note creating jobs may incur account charges.",
|
||||||
|
"read_job_contacts": "Read-only access to JobContact Endpoint",
|
||||||
|
"manage_job_contacts": "Full access to JobContact Endpoint",
|
||||||
|
"read_job_materials": "Read-only access to JobMaterials Endpoint",
|
||||||
|
"manage_job_materials": "Full access to JobMaterials Endpoint",
|
||||||
|
"read_job_categories": "Read-only access to Categories Endpoint",
|
||||||
|
"manage_job_categories": "Full access to Categories Endpoint",
|
||||||
|
"read_job_queues": "Read-only access to Job Queues Endpoint",
|
||||||
|
"manage_job_queues": "Full access to Job Queues Endpoint",
|
||||||
|
"read_tasks": "Read-only access to Tasks Endpoint",
|
||||||
|
"manage_tasks": "Full access to Tasks Endpoint",
|
||||||
|
"read_schedule": "Read-only access to JobActivity Endpoint",
|
||||||
|
"manage_schedule": "Full access to JobActivity Endpoint",
|
||||||
|
"read_inventory": "Read-only access to Materials Endpoint",
|
||||||
|
"manage_inventory": "Full access to Materials Endpoint",
|
||||||
|
"read_job_notes": "Read-only access to job notes",
|
||||||
|
"publish_job_notes": "Ability to add new job notes",
|
||||||
|
"read_job_photos": "Read-only access to job photos",
|
||||||
|
"publish_job_photos": "Ability to add new job photos",
|
||||||
|
"read_attachments": "Read-only access to Attachments Endpoint",
|
||||||
|
"manage_attachments": "Full access to Attachments Endpoint",
|
||||||
|
"read_inbox": "Read-only access to inbox messages",
|
||||||
|
"read_messages": "Read-only access to staff messages",
|
||||||
|
"manage_notifications": "Ability to read notifications and mark as read",
|
||||||
|
"manage_templates": "Full-access to email, sms and document templates",
|
||||||
|
"manage_badges": "Full-access to create/modify job badges",
|
||||||
|
"read_assets": "Read-only access to Assets Endpoint",
|
||||||
|
"manage_assets": "Full access to Assets Endpoint",
|
||||||
|
"read_knowledge_base": "Read-only access to Knowledge Base Endpoint",
|
||||||
|
"manage_knowledge_base": "Full access to Knowledge Base Endpoint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"Error": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"errorCode": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "int32",
|
||||||
|
"example": "1000"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "An error occurred completing your request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RateLimitError": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"errorCode": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "int32",
|
||||||
|
"example": "429"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Number of allowed API requests per minute exceeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AuthenticationError": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"errorCode": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "int32",
|
||||||
|
"example": "401"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Authentication failed. Please check your API key or OAuth token."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ForbiddenError": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"errorCode": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "int32",
|
||||||
|
"example": "403"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Access forbidden. You don't have permission to access this resource."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FormResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"form_uuid": {
|
||||||
|
"description": "UUID of the form used to generate this form response. Links to a specific form in the system that defines the fields to be gathered.",
|
||||||
|
"format": "uuid",
|
||||||
|
"example": "123e4567-8f59-4273-9bb2-23f94e3071eb",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"staff_uuid": {
|
||||||
|
"description": "UUID of the staff member who completed this FormResponse.",
|
||||||
|
"format": "uuid",
|
||||||
|
"example": "123e4567-068f-433c-a2e6-23f94e815bdb",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"regarding_object": {
|
||||||
|
"description": "The object type that this form response is associated with. Common values include 'job', 'asset', or 'company'. Works in conjunction with regarding_object_uuid to link this form response to a specific record in the system.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"regarding_object_uuid": {
|
||||||
|
"description": "UUID of the specific record this form response is linked to. For example, if regarding_object is 'job', this will be the UUID of the specific job. This creates a relationship between the form response and the object it refers to.",
|
||||||
|
"format": "uuid",
|
||||||
|
"example": "123e4567-41ee-4ea3-8ca2-23f94fe918eb",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"field_data": {
|
||||||
|
"description": "JSON array of form answers captured at submission time.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"description": "Date and time when the form was submitted/completed. Used for sorting and displaying form responses chronologically. Format is YYYY-MM-DD HH:MM:SS in UTC timezone.",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-03-01 12:00:00"
|
||||||
|
},
|
||||||
|
"form_by_staff_uuid": {
|
||||||
|
"description": "UUID of the staff member who completed or submitted this form. Identifies which user filled out the form. Used for tracking form submission history and staff accountability.",
|
||||||
|
"format": "uuid",
|
||||||
|
"example": "123e4567-0230-4fe1-83c9-23f94429362b",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"document_attachment_uuid": {
|
||||||
|
"description": "UUID of the document attachment generated from this form response. When a form is completed, it can generate a PDF document which is stored as an attachment. This field links to that generated document attachment.",
|
||||||
|
"format": "uuid",
|
||||||
|
"example": "123e4567-ff79-499a-8ef4-23f94a981a6b",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"asset_uuid": {
|
||||||
|
"description": "UUID of the Asset this form response is related to. Used when the FormResponsepertains to a specific asset, such as equipment inspections, maintenance checklists, or asset condition reports.",
|
||||||
|
"format": "uuid",
|
||||||
|
"example": "123e4567-fc2c-4a3d-a278-23f942d7f62b",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Unique identifier for this record",
|
||||||
|
"example": "123e4567-faa3-4649-8ea5-23f94011885b",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"enum": [
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"type": "integer",
|
||||||
|
"default": 1,
|
||||||
|
"description": "Record active/deleted flag. Valid values are [0,1]"
|
||||||
|
},
|
||||||
|
"edit_date": {
|
||||||
|
"example": "2026-03-01 12:00:00",
|
||||||
|
"readOnly": true,
|
||||||
|
"description": "Timestamp at which record was last modified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-speakeasy-retries": {
|
||||||
|
"strategy": "backoff",
|
||||||
|
"backoff": {
|
||||||
|
"initialInterval": 500,
|
||||||
|
"maxInterval": 60000,
|
||||||
|
"maxElapsedTime": 3600000,
|
||||||
|
"exponent": 1.5
|
||||||
|
},
|
||||||
|
"statusCodes": [
|
||||||
|
"5XX",
|
||||||
|
"429"
|
||||||
|
],
|
||||||
|
"retryConnectionErrors": true
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Form Responses",
|
||||||
|
"description": "Operations related to Form Responses"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user