import { type Application, type Request, type Response } from 'express'; import { type Repository } from '../db/repository.js'; import type { BrowserSessionRepo } from '../db/browser-session-repo.js'; import { convertToCron, calcNextRun, toSqliteDatetime } from '../scheduler.js'; import { type Scheduler } from '../scheduler.js'; export interface ScheduledTasksApiOptions { /** * Optional. When set, accepting browserSessionProfileId on create / update * verifies the profile belongs to the requesting user. Without it, the * field is silently dropped (legacy / no-auth deployments). */ sessRepo?: BrowserSessionRepo; /** * Whether the auth subsystem is wired. When `false` (no-auth single-user * deployment) requests carry no `req.user`, so new scheduled tasks (and the * jobs the scheduler spawns from them) are owned by the stable `local` user * instead of NULL — keeping per-task reflection / visibility consistent with * the rest of the no-auth path. Defaults to `true`. */ authActive?: boolean; } export function mountScheduledTasksApi( app: Application, repo: Repository, scheduler: Scheduler, apiOpts: ScheduledTasksApiOptions = {}, ): void { const { sessRepo } = apiOpts; const noAuthOwner: string | null = (apiOpts.authActive ?? true) ? null : 'local'; /** * Validate and resolve a browserSessionProfileId from a request body. * Returns: * - { ok: true, value: number | null } when accepted (null = unset / clear). * - { ok: false, error } when validation fails (caller sends 400). * Pass an undefined raw to skip validation entirely (PATCH "field absent" case). */ function resolveBrowserSessionProfileId( raw: unknown, user: Express.User | undefined, ): { ok: true; value: number | null } | { ok: false; error: string } { if (raw === undefined) return { ok: true, value: null }; if (raw === null || raw === '') return { ok: true, value: null }; const n = Number(raw); if (!Number.isInteger(n) || n <= 0) { return { ok: false, error: 'browserSessionProfileId must be a positive integer' }; } if (sessRepo) { if (!user?.id) { return { ok: false, error: 'browserSessionProfileId requires an authenticated user' }; } const owned = sessRepo.getProfileById(n, user.id); if (!owned) { return { ok: false, error: 'browser session profile not found or not owned by you' }; } } return { ok: true, value: n }; } // 一覧取得 app.get('/api/scheduled-tasks', async (req: Request, res: Response) => { try { const viewer = req.user as Express.User | undefined; const tasks = await repo.listScheduledTasks(viewer ? { viewer } : undefined); res.json({ tasks }); } catch (err) { res.status(500).json({ error: `Failed to list scheduled tasks: ${err}` }); } }); // 詳細取得 app.get('/api/scheduled-tasks/:id', async (req: Request, res: Response) => { try { const id = Number(req.params.id); const viewer = req.user as Express.User | undefined; const task = await repo.getScheduledTask(id, { viewer }); if (!task) { res.status(404).json({ error: 'Not found' }); return; } res.json({ task }); } catch (err) { res.status(500).json({ error: `Failed to get scheduled task: ${err}` }); } }); // 新規作成 app.post('/api/scheduled-tasks', async (req: Request, res: Response) => { try { const { title, body, piece, profile, outputFormat, scheduleType, hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron, scheduledAt } = req.body; // task_kind: 'agent' (default) or 'script' const rawTaskKind = req.body?.taskKind; const taskKind: 'agent' | 'script' = rawTaskKind === 'script' ? 'script' : 'agent'; let scriptName: string | null = null; let scriptParams: string | null = null; if (taskKind === 'script') { const rawScriptName = req.body?.scriptName; if (typeof rawScriptName !== 'string' || !rawScriptName.trim()) { res.status(400).json({ error: 'scriptName is required when taskKind=script' }); return; } scriptName = rawScriptName.trim(); const rawScriptParams = req.body?.scriptParams; if (rawScriptParams !== undefined && rawScriptParams !== null) { if (typeof rawScriptParams === 'string') { try { const parsed = JSON.parse(rawScriptParams); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('scriptParams must be a JSON object'); } scriptParams = JSON.stringify(parsed); } catch (err) { res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` }); return; } } else if (typeof rawScriptParams === 'object' && !Array.isArray(rawScriptParams)) { scriptParams = JSON.stringify(rawScriptParams); } else { res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' }); return; } } } if (taskKind === 'agent' && !body) { res.status(400).json({ error: 'body is required' }); return; } if (!scheduleType) { res.status(400).json({ error: 'scheduleType is required' }); return; } // Visibility extraction + validation (mirrors POST /api/local/tasks) const rawVisibility = req.body?.visibility ?? 'private'; if (!['private', 'org', 'public'].includes(rawVisibility)) { res.status(400).json({ error: 'invalid visibility' }); return; } const visibility = rawVisibility as 'private' | 'org' | 'public'; const rawScopeOrgId = req.body?.visibilityScopeOrgId; const visibilityScopeOrgId: string | null = typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null; if (visibility === 'org') { const orgIds = (req.user as Express.User | undefined)?.orgIds ?? []; if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) { res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return; } } const ownerId = (req.user as Express.User | undefined)?.id ?? noAuthOwner; const profileBinding = resolveBrowserSessionProfileId( req.body?.browserSessionProfileId, req.user as Express.User | undefined, ); if (!profileBinding.ok) { res.status(400).json({ error: profileBinding.error }); return; } const cronExpr = convertToCron(scheduleType, { hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron }); let nextRunAt: string; if (scheduleType === 'once') { if (!scheduledAt) { res.status(400).json({ error: 'scheduledAt is required for once type' }); return; } nextRunAt = toSqliteDatetime(new Date(scheduledAt)); } else { const next = calcNextRun(cronExpr); if (!next) { res.status(400).json({ error: 'Failed to calculate next run time' }); return; } nextRunAt = next; } const task = await repo.createScheduledTask({ title: title || null, body: taskKind === 'script' ? (body ?? '') : body, pieceName: piece ?? 'auto', profile: profile ?? 'auto', outputFormat: outputFormat ?? 'markdown', cronExpression: cronExpr, nextRunAt, ownerId, visibility, visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null, browserSessionProfileId: profileBinding.value, taskKind, scriptName, scriptParams, }); res.status(201).json({ task }); } catch (err) { res.status(400).json({ error: `Failed to create scheduled task: ${err}` }); } }); // 編集 app.patch('/api/scheduled-tasks/:id', async (req: Request, res: Response) => { try { const id = Number(req.params.id); const viewer = req.user as Express.User | undefined; const existing = await repo.getScheduledTask(id, { viewer }); if (!existing) { res.status(404).json({ error: 'Not found' }); return; } if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) { res.status(404).json({ error: 'Not found' }); return; } const updates: Record = {}; if (req.body.title !== undefined) updates.title = req.body.title; if (req.body.body !== undefined) updates.body = req.body.body; if (req.body.piece !== undefined) updates.pieceName = req.body.piece; if (req.body.profile !== undefined) updates.profile = req.body.profile; if (req.body.outputFormat !== undefined) updates.outputFormat = req.body.outputFormat; // スケジュール変更 if (req.body.scheduleType) { const cronExpr = convertToCron(req.body.scheduleType, { hour: req.body.hour, minute: req.body.minute, dayOfWeek: req.body.dayOfWeek, dayOfMonth: req.body.dayOfMonth, cronExpression: req.body.cronExpression, }); updates.cronExpression = cronExpr; if (req.body.scheduleType === 'once' && req.body.scheduledAt) { updates.nextRunAt = toSqliteDatetime(new Date(req.body.scheduledAt)); } else { const next = calcNextRun(cronExpr); if (next) updates.nextRunAt = next; } } // 一時停止/再開 if (req.body.isActive !== undefined) { updates.isActive = req.body.isActive; // 再開時は next_run_at を再計算 if (req.body.isActive && !updates.cronExpression) { const next = calcNextRun(existing.cronExpression); if (next) updates.nextRunAt = next; } } // Visibility 変更 (POST と同じバリデーション) if (req.body.visibility !== undefined) { const rawVisibility = req.body.visibility; if (!['private', 'org', 'public'].includes(rawVisibility)) { res.status(400).json({ error: 'invalid visibility' }); return; } const rawScopeOrgId = req.body.visibilityScopeOrgId; const visibilityScopeOrgId: string | null = typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null; if (rawVisibility === 'org') { const orgIds = viewer?.orgIds ?? []; if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) { res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return; } } updates.visibility = rawVisibility; updates.visibilityScopeOrgId = rawVisibility === 'org' ? visibilityScopeOrgId : null; } // browserSessionProfileId 変更 (owner check) if (req.body.browserSessionProfileId !== undefined) { const binding = resolveBrowserSessionProfileId(req.body.browserSessionProfileId, viewer); if (!binding.ok) { res.status(400).json({ error: binding.error }); return; } updates.browserSessionProfileId = binding.value; } // taskKind / scriptName / scriptParams (PATCH 用) if (req.body.taskKind !== undefined) { if (req.body.taskKind !== 'agent' && req.body.taskKind !== 'script') { res.status(400).json({ error: "taskKind must be 'agent' or 'script'" }); return; } updates.taskKind = req.body.taskKind; } if (req.body.scriptName !== undefined) { if (req.body.scriptName === null || req.body.scriptName === '') { updates.scriptName = null; } else if (typeof req.body.scriptName === 'string') { updates.scriptName = req.body.scriptName.trim(); } else { res.status(400).json({ error: 'scriptName must be a string' }); return; } } if (req.body.scriptParams !== undefined) { if (req.body.scriptParams === null) { updates.scriptParams = null; } else if (typeof req.body.scriptParams === 'string') { try { const parsed = JSON.parse(req.body.scriptParams); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('scriptParams must be a JSON object'); } updates.scriptParams = JSON.stringify(parsed); } catch (err) { res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` }); return; } } else if (typeof req.body.scriptParams === 'object' && !Array.isArray(req.body.scriptParams)) { updates.scriptParams = JSON.stringify(req.body.scriptParams); } else { res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' }); return; } } const updated = await repo.updateScheduledTask(id, updates); res.json({ task: updated }); } catch (err) { res.status(400).json({ error: `Failed to update scheduled task: ${err}` }); } }); // 削除 app.delete('/api/scheduled-tasks/:id', async (req: Request, res: Response) => { try { const id = Number(req.params.id); const viewer = req.user as Express.User | undefined; const existing = await repo.getScheduledTask(id, { viewer }); if (!existing) { res.status(404).json({ error: 'Not found' }); return; } if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) { res.status(404).json({ error: 'Not found' }); return; } const deleted = await repo.deleteScheduledTask(id); if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } res.json({ ok: true }); } catch (err) { res.status(500).json({ error: `Failed to delete scheduled task: ${err}` }); } }); // 手動即時実行 app.post('/api/scheduled-tasks/:id/trigger', async (req: Request, res: Response) => { try { const id = Number(req.params.id); const viewer = req.user as Express.User | undefined; const existing = await repo.getScheduledTask(id, { viewer }); if (!existing) { res.status(404).json({ error: 'Not found' }); return; } if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) { res.status(404).json({ error: 'Not found' }); return; } await scheduler.executeById(id); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: `Failed to trigger scheduled task: ${err}` }); } }); }