From 48a63c0338fefa97b46ece4460064d989ceb0dd2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 11:43:59 +0200 Subject: [PATCH] add API routes, admin UI, and SPA bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit views_api wires the full task lifecycle: create / update / delete / list (per-wallet, public, admin-all) and the completion flow (claim / start / complete via POST, unclaim via DELETE, plus a "mine" lookup for the current user's claim on a task or specific occurrence). Auth model: tasks are owned by an LNbits wallet but signed with the wallet owner's account.pubkey — _wallet_pubkey resolves that pubkey at create time and refuses to create tasks for accounts that haven't generated a keypair yet, so we never publish a task we can't sign. Completions optimistically insert with a local hash, publish to Nostr, then re-insert under the actual event id so a later delete can find it. Static SPA: Quasar-UMD index.vue / index.js mirroring the events extension layout — wallet picker, task table, create/edit dialog with optional daily/weekly recurrence, plus an admin-only public_listing toggle. /tasks/:id and display.vue intentionally left out until the public task page lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/js/index.js | 192 +++++++++++++++++++++++++++++++++++ static/js/index.vue | 198 ++++++++++++++++++++++++++++++++++++ static/routes.json | 6 -- views.py | 5 +- views_api.py | 242 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 631 insertions(+), 12 deletions(-) create mode 100644 static/js/index.js create mode 100644 static/js/index.vue diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..a1840a3 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,192 @@ +window.PageTasks = { + template: '#page-tasks', + data() { + return { + tasks: [], + isAdmin: false, + settings: { + public_listing: false + }, + tasksTable: { + columns: [ + {name: 'title', align: 'left', label: 'Title', field: 'title'}, + { + name: 'start_date', + align: 'left', + label: 'Start', + field: 'start_date' + }, + { + name: 'end_date', + align: 'left', + label: 'End', + field: 'end_date' + }, + { + name: 'recurrence', + align: 'left', + label: 'Recurrence', + field: row => (row.recurrence ? row.recurrence.frequency : '') + }, + {name: 'status', align: 'left', label: 'Status', field: 'status'}, + {name: 'location', align: 'left', label: 'Location', field: 'location'} + ], + pagination: { + rowsPerPage: 10 + } + }, + taskDialog: { + show: false, + data: {} + } + } + }, + computed: { + canSubmit() { + return ( + this.taskDialog.data.title && + this.taskDialog.data.start_date && + this.taskDialog.data.wallet + ) + } + }, + methods: { + openTaskDialog() { + this.taskDialog.data = { + title: '', + start_date: '', + end_date: '', + description: '', + location: '', + wallet: this.g.user.wallets[0]?.id, + recurrence_frequency: '', + recurrence_day_of_week: '', + recurrence_end_date: '' + } + this.taskDialog.show = true + }, + editTask(row) { + this.taskDialog.data = { + id: row.id, + wallet: row.wallet, + title: row.title, + start_date: row.start_date, + end_date: row.end_date || '', + description: row.description || '', + location: row.location || '', + recurrence_frequency: row.recurrence?.frequency || '', + recurrence_day_of_week: row.recurrence?.day_of_week || '', + recurrence_end_date: row.recurrence?.end_date || '' + } + this.taskDialog.show = true + }, + _buildPayload(d) { + const payload = { + wallet: d.wallet, + title: d.title, + start_date: d.start_date, + end_date: d.end_date || null, + description: d.description || '', + location: d.location || null + } + if (d.recurrence_frequency) { + payload.recurrence = { + frequency: d.recurrence_frequency, + day_of_week: d.recurrence_day_of_week || null, + end_date: d.recurrence_end_date || null + } + } + return payload + }, + async submitTask() { + const d = this.taskDialog.data + const wallet = _.findWhere(this.g.user.wallets, {id: d.wallet}) + if (!wallet) return + const payload = this._buildPayload(d) + try { + if (d.id) { + await LNbits.api.request( + 'PUT', + '/tasks/api/v1/tasks/' + d.id, + wallet.adminkey, + payload + ) + } else { + await LNbits.api.request( + 'POST', + '/tasks/api/v1/tasks', + wallet.adminkey, + payload + ) + } + this.taskDialog.show = false + this.getTasks() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async deleteTask(taskId) { + LNbits.utils + .confirmDialog('Delete this task?') + .onOk(async () => { + const task = _.findWhere(this.tasks, {id: taskId}) + const wallet = _.findWhere(this.g.user.wallets, {id: task.wallet}) + if (!wallet) return + try { + await LNbits.api.request( + 'DELETE', + '/tasks/api/v1/tasks/' + taskId, + wallet.adminkey + ) + this.getTasks() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, + async getTasks() { + try { + const wallet = this.g.user.wallets[0] + if (!wallet) return + const {data} = await LNbits.api.request( + 'GET', + '/tasks/api/v1/tasks?all_wallets=true', + wallet.inkey + ) + this.tasks = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async getSettings() { + if (!this.isAdmin) return + try { + const {data} = await LNbits.api.request( + 'GET', + '/tasks/api/v1/tasks/settings', + this.g.user.wallets[0]?.adminkey + ) + this.settings = data + } catch (_) { + // Non-admin or settings not seeded — silent. + } + }, + async saveSettings() { + try { + await LNbits.api.request( + 'PUT', + '/tasks/api/v1/tasks/settings', + this.g.user.wallets[0]?.adminkey, + this.settings + ) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + }, + created() { + this.isAdmin = !!(window.user && window.user.admin) + this.getTasks() + this.getSettings() + } +} diff --git a/static/js/index.vue b/static/js/index.vue new file mode 100644 index 0000000..c1731d6 --- /dev/null +++ b/static/js/index.vue @@ -0,0 +1,198 @@ + diff --git a/static/routes.json b/static/routes.json index c1ff471..4ebd9bb 100644 --- a/static/routes.json +++ b/static/routes.json @@ -4,11 +4,5 @@ "name": "PageTasks", "template": "/tasks/static/js/index.vue", "component": "/tasks/static/js/index.js" - }, - { - "path": "/tasks/:id", - "name": "PageTaskDisplay", - "template": "/tasks/static/js/display.vue", - "component": "/tasks/static/js/display.js" } ] diff --git a/views.py b/views.py index 860922e..21cec6a 100644 --- a/views.py +++ b/views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends -from lnbits.core.views.generic import index, index_public +from lnbits.core.views.generic import index from lnbits.decorators import check_account_id_exists tasks_generic_router = APIRouter() @@ -11,6 +11,3 @@ tasks_generic_router.add_api_route( dependencies=[Depends(check_account_id_exists)], ) -tasks_generic_router.add_api_route( - "/{task_id}", methods=["GET"], endpoint=index_public -) diff --git a/views_api.py b/views_api.py index bda3896..8f01fbc 100644 --- a/views_api.py +++ b/views_api.py @@ -1,5 +1,243 @@ -from fastapi import APIRouter +from http import HTTPStatus + +from fastapi import APIRouter, Depends, HTTPException, Query +from lnbits.core.crud import get_user +from lnbits.core.crud.users import get_account +from lnbits.core.models import Account, WalletTypeInfo +from lnbits.decorators import check_admin, require_admin_key, require_invoice_key + +from .crud import ( + create_completion, + create_task, + delete_completion, + delete_task, + delete_task_completions, + get_all_tasks, + get_completion, + get_completions_for_task, + get_public_tasks, + get_settings, + get_task, + get_tasks, + update_settings, + update_task, +) +from .models import ( + CreateTask, + CreateTaskCompletion, + PublicTask, + Task, + TaskCompletion, + TasksSettings, +) +from .nostr_hooks import ( + publish_completion_delete, + publish_or_delete_task_event, + publish_task_completion, +) tasks_api_router = APIRouter(prefix="/api/v1/tasks") -# API routes are filled in by the next commit. + +async def _wallet_pubkey(wallet_id: str) -> str: + """Resolve the Nostr pubkey for the wallet's owning account. Tasks are + published as that pubkey, so we refuse to create tasks for accounts + that haven't generated a keypair yet.""" + from lnbits.core.crud.wallets import get_wallet + + wallet_obj = await get_wallet(wallet_id) + if not wallet_obj: + raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.") + account = await get_account(wallet_obj.user) + if not account or not getattr(account, "pubkey", None): + raise HTTPException( + HTTPStatus.PRECONDITION_FAILED, + "Account has no Nostr pubkey; generate one before creating tasks.", + ) + return account.pubkey # type: ignore[attr-defined] + + +# Literal-prefix routes (/public, /all, /settings, /completions) MUST come +# before any "/{task_id}" route so FastAPI doesn't match them as path params. + + +@tasks_api_router.get("") +async def api_tasks( + all_wallets: bool = Query(False), + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> list[Task]: + wallet_ids = [wallet.wallet.id] + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + return await get_tasks(wallet_ids) + + +@tasks_api_router.get("/public") +async def api_tasks_public() -> list[PublicTask]: + """Public listing. Gated by the public_listing settings flag — when it + is off we return an empty list rather than 404 so SPA callers can render + a clean empty state.""" + settings = await get_settings() + if not settings.public_listing: + return [] + tasks = await get_public_tasks() + return [PublicTask(**t.dict()) for t in tasks] + + +@tasks_api_router.get("/all") +async def api_tasks_all(admin: Account = Depends(check_admin)) -> list[Task]: + return await get_all_tasks() + + +@tasks_api_router.get("/settings") +async def api_get_settings( + admin: Account = Depends(check_admin), +) -> TasksSettings: + return await get_settings() + + +@tasks_api_router.put("/settings") +async def api_update_settings( + data: TasksSettings, + admin: Account = Depends(check_admin), +) -> TasksSettings: + return await update_settings(data) + + +@tasks_api_router.post("") +async def api_task_create( + data: CreateTask, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Task: + if not data.wallet: + data.wallet = wallet.wallet.id + pubkey = await _wallet_pubkey(data.wallet) + task = await create_task(pubkey, data) + await publish_or_delete_task_event(task) + return task + + +@tasks_api_router.get("/{task_id}", response_model=Task) +async def api_get_task(task_id: str) -> Task: + task = await get_task(task_id) + if not task: + raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.") + return task + + +@tasks_api_router.put("/{task_id}") +async def api_task_update( + task_id: str, + data: CreateTask, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Task: + task = await get_task(task_id) + if not task: + raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.") + if task.wallet != wallet.wallet.id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your task.") + + # Only mutate fields the caller is allowed to change; pubkey and d_tag + # are part of the replaceable Nostr address and must stay stable. + for k, v in data.dict(exclude={"wallet"}).items(): + setattr(task, k, v) + task = await update_task(task) + + if task.nostr_event_id: + await publish_or_delete_task_event(task) + return task + + +@tasks_api_router.delete("/{task_id}") +async def api_task_delete( + task_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> None: + task = await get_task(task_id) + if not task: + raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.") + if task.wallet != wallet.wallet.id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your task.") + + if task.nostr_event_id: + await publish_or_delete_task_event(task, delete=True) + await delete_task(task_id) + await delete_task_completions(task.address) + + +@tasks_api_router.get("/{task_id}/completions") +async def api_task_completions(task_id: str) -> list[TaskCompletion]: + task = await get_task(task_id) + if not task: + raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.") + return await get_completions_for_task(task.address) + + +@tasks_api_router.post("/{task_id}/completions") +async def api_task_complete( + task_id: str, + data: CreateTaskCompletion, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> TaskCompletion: + """Claim / start / complete / block / cancel a task (or specific recurring + occurrence). The completer uses their own account pubkey, NOT the task + author's, since anyone in the audience can act on a task.""" + task = await get_task(task_id) + if not task: + raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.") + + completer_pubkey = await _wallet_pubkey(wallet.wallet.id) + + # The route parameter is the local task id, but the Nostr address is + # always (pubkey, d_tag) — overwrite whatever the client sent in the + # body so we can't be tricked into pointing at a different task. + data.task_address = task.address + + completion = await create_completion(completer_pubkey, data) + + nostr_id = await publish_task_completion(task, completion) + if nostr_id and nostr_id != completion.id: + # Replace the locally-generated hash with the actual Nostr event id + # so a later delete can find it. + await delete_completion(completion.id) + completion = await create_completion( + completer_pubkey, data, nostr_event_id=nostr_id + ) + return completion + + +@tasks_api_router.delete("/completions/{completion_id}") +async def api_completion_delete( + completion_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> None: + """Unclaim/withdraw a completion. Only the completer may delete it.""" + from .crud import get_completion_by_id + + completion = await get_completion_by_id(completion_id) + if not completion: + raise HTTPException(HTTPStatus.NOT_FOUND, "Completion not found.") + + completer_pubkey = await _wallet_pubkey(wallet.wallet.id) + if completion.pubkey != completer_pubkey: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your completion.") + + await publish_completion_delete(wallet.wallet.id, completion_id) + await delete_completion(completion_id) + + +# Convenience: look up a completion for the current user on a given task, +# optionally for a specific occurrence date. Returns 204 when absent so a +# client can poll without alarmist 404s in the console. +@tasks_api_router.get("/{task_id}/completions/mine") +async def api_my_completion( + task_id: str, + occurrence: str | None = Query(None), + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> TaskCompletion | None: + task = await get_task(task_id) + if not task: + raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.") + completer_pubkey = await _wallet_pubkey(wallet.wallet.id) + return await get_completion(task.address, completer_pubkey, occurrence)