add API routes, admin UI, and SPA bootstrap
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) <noreply@anthropic.com>
This commit is contained in:
parent
24acbe6674
commit
48a63c0338
5 changed files with 631 additions and 12 deletions
192
static/js/index.js
Normal file
192
static/js/index.js
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
198
static/js/index.vue
Normal file
198
static/js/index.vue
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<template id="page-tasks">
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<q-card v-if="isAdmin">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center justify-between">
|
||||||
|
<div class="col">
|
||||||
|
<span class="text-subtitle1">Settings</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-toggle
|
||||||
|
v-model="settings.public_listing"
|
||||||
|
label="Public task listing"
|
||||||
|
@update:model-value="saveSettings"
|
||||||
|
></q-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn unelevated color="primary" @click="openTaskDialog"
|
||||||
|
>New Task</q-btn
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Tasks</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:rows="tasks"
|
||||||
|
row-key="id"
|
||||||
|
:columns="tasksTable.columns"
|
||||||
|
v-model:pagination="tasksTable.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="editTask(props.row)"
|
||||||
|
icon="edit"
|
||||||
|
color="light-blue"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteTask(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
class="q-ml-xs"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<q-badge
|
||||||
|
v-if="col.name === 'recurrence' && col.value"
|
||||||
|
color="purple"
|
||||||
|
:label="col.value"
|
||||||
|
></q-badge>
|
||||||
|
<span v-else v-text="col.value"></span>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
|
{{SITE_TITLE}} Tasks extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list>
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="info"
|
||||||
|
label="About"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card-section class="text-caption">
|
||||||
|
Recurring tasks and chore tracking, federated over Nostr
|
||||||
|
NIP-52 calendar events (kind 31922) and a task-status RSVP
|
||||||
|
variant (kind 31925).
|
||||||
|
</q-card-section>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="taskDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="submitTask" class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="taskDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
></q-select>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="taskDialog.data.title"
|
||||||
|
label="Title *"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="taskDialog.data.start_date"
|
||||||
|
label="Start date (YYYY-MM-DD) *"
|
||||||
|
placeholder="2026-05-13"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="taskDialog.data.end_date"
|
||||||
|
label="End date (optional)"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="textarea"
|
||||||
|
v-model.trim="taskDialog.data.description"
|
||||||
|
label="Description"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="taskDialog.data.location"
|
||||||
|
label="Location"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="taskDialog.data.recurrence_frequency"
|
||||||
|
:options="['', 'daily', 'weekly']"
|
||||||
|
label="Recurrence"
|
||||||
|
emit-value
|
||||||
|
></q-select>
|
||||||
|
<q-input
|
||||||
|
v-if="taskDialog.data.recurrence_frequency === 'weekly'"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="taskDialog.data.recurrence_day_of_week"
|
||||||
|
label="Weekday (e.g. monday)"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-if="taskDialog.data.recurrence_frequency"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="taskDialog.data.recurrence_end_date"
|
||||||
|
label="Recurrence end date (optional)"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="taskDialog.data.id"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
:disable="!canSubmit"
|
||||||
|
>Update Task</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
:disable="!canSubmit"
|
||||||
|
>Create Task</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -4,11 +4,5 @@
|
||||||
"name": "PageTasks",
|
"name": "PageTasks",
|
||||||
"template": "/tasks/static/js/index.vue",
|
"template": "/tasks/static/js/index.vue",
|
||||||
"component": "/tasks/static/js/index.js"
|
"component": "/tasks/static/js/index.js"
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/tasks/:id",
|
|
||||||
"name": "PageTaskDisplay",
|
|
||||||
"template": "/tasks/static/js/display.vue",
|
|
||||||
"component": "/tasks/static/js/display.js"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
5
views.py
5
views.py
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter, Depends
|
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
|
from lnbits.decorators import check_account_id_exists
|
||||||
|
|
||||||
tasks_generic_router = APIRouter()
|
tasks_generic_router = APIRouter()
|
||||||
|
|
@ -11,6 +11,3 @@ tasks_generic_router.add_api_route(
|
||||||
dependencies=[Depends(check_account_id_exists)],
|
dependencies=[Depends(check_account_id_exists)],
|
||||||
)
|
)
|
||||||
|
|
||||||
tasks_generic_router.add_api_route(
|
|
||||||
"/{task_id}", methods=["GET"], endpoint=index_public
|
|
||||||
)
|
|
||||||
|
|
|
||||||
242
views_api.py
242
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")
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue