355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
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<string, any> = {};
|
|
|
|
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}` });
|
|
}
|
|
});
|
|
}
|