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