Quick add task from the header (owner): modal, g t shortcut, auto_handle + Daisy webhook
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
- + button next to Dashboard (desktop and mobile sheet), lazy-loaded native <dialog>
- POST /trackers/tasks/quick inserts source 'quick-add', notes, due date, priority
- tasks.tasks.auto_handle (default false) with a Daisy badge in the tracker
- DAISY_TASK_WEBHOOK_URL/KEY/HEADER: fire-and-forget POST {task_id, title}, 3 s timeout
This commit is contained in:
37
src/lib/server/daisy-hook.test.ts
Normal file
37
src/lib/server/daisy-hook.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { daisyHookConfig, notifyDaisy } from './daisy-hook';
|
||||
|
||||
describe('daisy task webhook', () => {
|
||||
it('is off until both URL and key are set', () => {
|
||||
expect(daisyHookConfig({})).toBeNull();
|
||||
expect(
|
||||
daisyHookConfig({ DAISY_TASK_WEBHOOK_URL: 'https://x', DAISY_TASK_WEBHOOK_KEY: '' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('sends a bearer token by default and the bare key in a custom header', () => {
|
||||
const base = { DAISY_TASK_WEBHOOK_URL: 'https://x/hook', DAISY_TASK_WEBHOOK_KEY: 'k1' };
|
||||
expect(daisyHookConfig(base)?.headers.Authorization).toBe('Bearer k1');
|
||||
const custom = daisyHookConfig({ ...base, DAISY_TASK_WEBHOOK_HEADER: 'X-Api-Key' });
|
||||
expect(custom?.headers['X-Api-Key']).toBe('k1');
|
||||
expect(custom?.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('posts {task_id, title} without waiting, and skips quietly when unconfigured', async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const config = daisyHookConfig({
|
||||
DAISY_TASK_WEBHOOK_URL: 'https://x/hook',
|
||||
DAISY_TASK_WEBHOOK_KEY: 'k'
|
||||
});
|
||||
expect(
|
||||
notifyDaisy({ id: 7, title: 'Buy milk' }, config, fetcher as unknown as typeof fetch)
|
||||
).toBe('sent');
|
||||
const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe('https://x/hook');
|
||||
expect(JSON.parse(String(init.body))).toEqual({ task_id: 7, title: 'Buy milk' });
|
||||
expect(notifyDaisy({ id: 8, title: 'x' }, null, fetcher as unknown as typeof fetch)).toBe(
|
||||
'not-configured'
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
38
src/lib/server/daisy-hook.ts
Normal file
38
src/lib/server/daisy-hook.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// "Let Daisy handle it" on quick add: tell Daisy's routine about the new task.
|
||||
// Configured in /etc/personal-website/prod.env:
|
||||
// DAISY_TASK_WEBHOOK_URL where to POST {task_id, title}
|
||||
// DAISY_TASK_WEBHOOK_KEY secret sent with it
|
||||
// DAISY_TASK_WEBHOOK_HEADER header name (default Authorization, sent as "Bearer <key>";
|
||||
// any other header gets the bare key)
|
||||
// Unset URL or key: nothing is sent and the task is still saved.
|
||||
|
||||
export type DaisyHookConfig = { url: string; headers: Record<string, string> };
|
||||
|
||||
export function daisyHookConfig(env: Record<string, string | undefined> = process.env) {
|
||||
const url = env.DAISY_TASK_WEBHOOK_URL?.trim();
|
||||
const key = env.DAISY_TASK_WEBHOOK_KEY?.trim();
|
||||
if (!url || !key) return null;
|
||||
const header = env.DAISY_TASK_WEBHOOK_HEADER?.trim() || 'Authorization';
|
||||
const value = header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key;
|
||||
return { url, headers: { 'content-type': 'application/json', [header]: value } };
|
||||
}
|
||||
|
||||
/** Fire and forget: never awaited by the request, 3 s timeout, failures only logged. */
|
||||
export function notifyDaisy(
|
||||
task: { id: number; title: string },
|
||||
config: DaisyHookConfig | null = daisyHookConfig(),
|
||||
fetcher: typeof fetch = fetch
|
||||
): 'sent' | 'not-configured' {
|
||||
if (!config) return 'not-configured';
|
||||
fetcher(config.url, {
|
||||
method: 'POST',
|
||||
headers: config.headers,
|
||||
body: JSON.stringify({ task_id: task.id, title: task.title }),
|
||||
signal: AbortSignal.timeout(3000)
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error(`[daisy-hook] task ${task.id}: HTTP ${res.status}`);
|
||||
})
|
||||
.catch((err) => console.error(`[daisy-hook] task ${task.id}: ${String(err)}`));
|
||||
return 'sent';
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export const todoTasks = tasksSchema.table(
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
||||
externalId: text('external_id'), // id in the source system, for future syncs
|
||||
// Quick add "Let Daisy handle it": Daisy's routine picks these up (webhook on create).
|
||||
autoHandle: boolean('auto_handle').notNull().default(false),
|
||||
startAt: timestamptz('start_at'), // planned start (TickTick startDate)
|
||||
timeZone: text('time_zone'), // zone the task was planned in (TickTick); due_time stays Amsterdam
|
||||
startedAt: timestamptz('started_at'),
|
||||
|
||||
Reference in New Issue
Block a user