Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
264 lines
8.6 KiB
Plaintext
264 lines
8.6 KiB
Plaintext
# Tasks — Starlark Backend (v0.1.0)
|
|
#
|
|
# Task management extension using ext_data + notifications + triggers.
|
|
#
|
|
# Entry points:
|
|
# on_request(req) → surface API routes
|
|
# on_task_event(event) → event trigger (task.*)
|
|
# on_webhook(req) → webhook trigger for external task creation
|
|
#
|
|
# Modules: db, json, notifications, settings
|
|
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# Helpers
|
|
# ═══════════════════════════════════════════════
|
|
|
|
def _resp(status, data):
|
|
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
|
|
|
def _str(v):
|
|
if v == None:
|
|
return ""
|
|
return str(v)
|
|
|
|
def _now():
|
|
"""ISO-ish timestamp from Starlark (no time module — use db auto-created_at or pass from caller)."""
|
|
return ""
|
|
|
|
def _statuses():
|
|
raw = settings.get("statuses")
|
|
if not raw:
|
|
return ["todo", "in_progress", "done", "cancelled"]
|
|
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
|
|
def _priorities():
|
|
raw = settings.get("priorities")
|
|
if not raw:
|
|
return ["low", "medium", "high", "urgent"]
|
|
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# Surface API routes (/s/tasks/api/*)
|
|
# ═══════════════════════════════════════════════
|
|
|
|
def on_request(req):
|
|
path = req["path"]
|
|
method = req["method"]
|
|
|
|
# GET /items — list tasks
|
|
if method == "GET" and path == "/items":
|
|
return _list_items(req)
|
|
|
|
# POST /items — create task
|
|
if method == "POST" and path == "/items":
|
|
return _create_item(req)
|
|
|
|
# GET /stats — aggregate counts
|
|
if method == "GET" and path == "/stats":
|
|
return _get_stats()
|
|
|
|
# GET /items/:id
|
|
if method == "GET" and path.startswith("/items/"):
|
|
return _get_item(path[len("/items/"):])
|
|
|
|
# PUT /items/:id
|
|
if method == "PUT" and path.startswith("/items/"):
|
|
return _update_item(path[len("/items/"):], req)
|
|
|
|
# DELETE /items/:id
|
|
if method == "DELETE" and path.startswith("/items/"):
|
|
return _delete_item(path[len("/items/"):])
|
|
|
|
return _resp(404, {"error": "not found"})
|
|
|
|
|
|
def _list_items(req):
|
|
q = req.get("query", {})
|
|
filters = {}
|
|
|
|
status = _str(q.get("status", ""))
|
|
if status:
|
|
filters["status"] = status
|
|
|
|
assignee = _str(q.get("assignee_id", ""))
|
|
if assignee:
|
|
filters["assignee_id"] = assignee
|
|
|
|
priority = _str(q.get("priority", ""))
|
|
if priority:
|
|
filters["priority"] = priority
|
|
|
|
creator = _str(q.get("creator_id", ""))
|
|
if creator:
|
|
filters["creator_id"] = creator
|
|
|
|
order = _str(q.get("order", "")) or "created_at"
|
|
limit_str = _str(q.get("limit", ""))
|
|
limit = int(limit_str) if limit_str else 100
|
|
|
|
rows = db.query("items", filters=filters, order=order, limit=limit)
|
|
return _resp(200, {"data": rows or []})
|
|
|
|
|
|
def _create_item(req):
|
|
body = json.decode(req.get("body", "{}"))
|
|
user_id = req.get("user_id", "")
|
|
|
|
title = _str(body.get("title", ""))
|
|
if not title:
|
|
return _resp(400, {"error": "title is required"})
|
|
|
|
statuses = _statuses()
|
|
status = _str(body.get("status", ""))
|
|
if not status:
|
|
status = statuses[0] if statuses else "todo"
|
|
|
|
priorities = _priorities()
|
|
priority = _str(body.get("priority", ""))
|
|
if not priority:
|
|
priority = priorities[0] if priorities else "medium"
|
|
|
|
row = db.insert("items", {
|
|
"title": title,
|
|
"description": _str(body.get("description", "")),
|
|
"status": status,
|
|
"priority": priority,
|
|
"assignee_id": _str(body.get("assignee_id", "")) or user_id,
|
|
"creator_id": user_id,
|
|
"due_date": _str(body.get("due_date", "")),
|
|
"tags": _str(body.get("tags", "")),
|
|
"updated_at": "",
|
|
"completed_at":"",
|
|
})
|
|
return _resp(201, row)
|
|
|
|
|
|
def _get_item(item_id):
|
|
rows = db.query("items", filters={"id": item_id}, limit=1)
|
|
if not rows:
|
|
return _resp(404, {"error": "task not found"})
|
|
return _resp(200, rows[0])
|
|
|
|
|
|
def _update_item(item_id, req):
|
|
body = json.decode(req.get("body", "{}"))
|
|
user_id = req.get("user_id", "")
|
|
|
|
# Fetch existing to detect transitions
|
|
existing = db.query("items", filters={"id": item_id}, limit=1)
|
|
if not existing:
|
|
return _resp(404, {"error": "task not found"})
|
|
old = existing[0]
|
|
|
|
updates = {}
|
|
for key in ["title", "description", "status", "priority", "assignee_id", "due_date", "tags"]:
|
|
if key in body:
|
|
updates[key] = _str(body[key])
|
|
|
|
# Detect completion transition
|
|
new_status = updates.get("status", "")
|
|
old_status = _str(old.get("status", ""))
|
|
if new_status == "done" and old_status != "done":
|
|
updates["completed_at"] = "now" # sentinel — db module handles timestamp
|
|
|
|
ok = db.update("items", item_id, updates)
|
|
if not ok:
|
|
return _resp(500, {"error": "update failed"})
|
|
|
|
# Notify creator on completion if assignee is different
|
|
if new_status == "done" and old_status != "done":
|
|
creator = _str(old.get("creator_id", ""))
|
|
assignee = _str(old.get("assignee_id", ""))
|
|
if creator and assignee and creator != assignee:
|
|
notifications.send(
|
|
creator,
|
|
"Task completed: " + _str(old.get("title", "")),
|
|
body=_str(old.get("title", "")) + " was marked done by assignee.",
|
|
)
|
|
|
|
# Re-fetch updated row
|
|
rows = db.query("items", filters={"id": item_id}, limit=1)
|
|
return _resp(200, rows[0] if rows else {})
|
|
|
|
|
|
def _delete_item(item_id):
|
|
ok = db.delete("items", item_id)
|
|
if not ok:
|
|
return _resp(404, {"error": "task not found"})
|
|
return _resp(200, {"deleted": True})
|
|
|
|
|
|
def _get_stats():
|
|
all_items = db.query("items", limit=10000)
|
|
items = all_items or []
|
|
counts = {}
|
|
for s in _statuses():
|
|
counts[s] = 0
|
|
for item in items:
|
|
st = _str(item.get("status", ""))
|
|
if st in counts:
|
|
counts[st] = counts[st] + 1
|
|
else:
|
|
counts[st] = 1
|
|
return _resp(200, {"counts": counts, "total": len(items)})
|
|
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# Event trigger handler
|
|
# ═══════════════════════════════════════════════
|
|
|
|
def on_task_event(event):
|
|
"""Handle task lifecycle events emitted by the platform."""
|
|
label = event.get("event_label", "")
|
|
payload = event.get("event_payload", {})
|
|
|
|
if type(payload) == "string":
|
|
payload = json.decode(payload) if payload else {}
|
|
|
|
# Could be used for audit logging, metrics, etc.
|
|
# For now just a placeholder — actual notifications happen inline in _update_item.
|
|
pass
|
|
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# Webhook trigger handler
|
|
# ═══════════════════════════════════════════════
|
|
|
|
def on_webhook(req):
|
|
"""
|
|
External task creation via webhook.
|
|
POST /api/v1/hooks/tasks/create with JSON body:
|
|
{ "title": "...", "description": "...", "priority": "...", "assignee_id": "..." }
|
|
"""
|
|
raw_body = req.get("body", "{}")
|
|
body = json.decode(raw_body) if raw_body else {}
|
|
|
|
title = _str(body.get("title", ""))
|
|
if not title:
|
|
return {"status": 400, "body": json.encode({"error": "title is required"})}
|
|
|
|
priorities = _priorities()
|
|
priority = _str(body.get("priority", ""))
|
|
if not priority:
|
|
priority = priorities[0] if priorities else "medium"
|
|
|
|
statuses = _statuses()
|
|
status = statuses[0] if statuses else "todo"
|
|
|
|
row = db.insert("items", {
|
|
"title": title,
|
|
"description": _str(body.get("description", "")),
|
|
"status": status,
|
|
"priority": priority,
|
|
"assignee_id": _str(body.get("assignee_id", "")),
|
|
"creator_id": "",
|
|
"due_date": _str(body.get("due_date", "")),
|
|
"tags": _str(body.get("tags", "")),
|
|
"updated_at": "",
|
|
"completed_at":"",
|
|
})
|
|
|
|
return {"status": 201, "body": json.encode(row), "headers": {"Content-Type": "application/json"}}
|