feat(scheduler): Immutable schedules and HTTP-only pipeline
## Changes - **Migration 089**: Add is_immutable and method columns to task_schedules - Per-state product_discovery schedules (4h default) - Store discovery weekly (168h) - All schedules use HTTP transport (Puppeteer/browser) - **Task Scheduler**: HTTP-only product discovery with per-state scheduling - Each state has its own immutable schedule - Schedules can be edited (interval/priority) but not deleted - **TasksDashboard UI**: Full immutability support - Lock icon for immutable schedules - State and Method columns in schedules table - Disabled delete for immutable, restricted edit fields - **Store Discovery HTTP**: Auto-queue product_discovery for new stores - **Migration 088**: Discovery payloads storage schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -157,6 +157,9 @@ router.get('/capacity/:role', async (req: Request, res: Response) => {
|
||||
/**
|
||||
* GET /api/tasks/schedules
|
||||
* List all task schedules
|
||||
*
|
||||
* Returns schedules with is_immutable flag - immutable schedules can only
|
||||
* have their interval_hours, priority, and enabled fields updated (not deleted).
|
||||
*/
|
||||
router.get('/schedules', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -164,7 +167,9 @@ router.get('/schedules', async (req: Request, res: Response) => {
|
||||
|
||||
let query = `
|
||||
SELECT id, name, role, description, enabled, interval_hours,
|
||||
priority, state_code, platform, last_run_at, next_run_at,
|
||||
priority, state_code, platform, method,
|
||||
COALESCE(is_immutable, false) as is_immutable,
|
||||
last_run_at, next_run_at,
|
||||
last_task_count, last_error, created_at, updated_at
|
||||
FROM task_schedules
|
||||
`;
|
||||
@@ -173,7 +178,15 @@ router.get('/schedules', async (req: Request, res: Response) => {
|
||||
query += ` WHERE enabled = true`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY name`;
|
||||
query += ` ORDER BY
|
||||
CASE role
|
||||
WHEN 'store_discovery' THEN 1
|
||||
WHEN 'product_discovery' THEN 2
|
||||
WHEN 'analytics_refresh' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
state_code NULLS FIRST,
|
||||
name`;
|
||||
|
||||
const result = await pool.query(query);
|
||||
res.json({ schedules: result.rows });
|
||||
@@ -187,25 +200,45 @@ router.get('/schedules', async (req: Request, res: Response) => {
|
||||
* DELETE /api/tasks/schedules
|
||||
* Bulk delete schedules
|
||||
*
|
||||
* Immutable schedules are automatically skipped (not deleted).
|
||||
*
|
||||
* Body:
|
||||
* - ids: number[] (required) - array of schedule IDs to delete
|
||||
* - all: boolean (optional) - if true, delete all schedules (ids ignored)
|
||||
* - all: boolean (optional) - if true, delete all non-immutable schedules (ids ignored)
|
||||
*/
|
||||
router.delete('/schedules', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { ids, all } = req.body;
|
||||
|
||||
let result;
|
||||
let skippedImmutable: { id: number; name: string }[] = [];
|
||||
|
||||
if (all === true) {
|
||||
// Delete all schedules
|
||||
// First, find immutable schedules that will be skipped
|
||||
const immutableResult = await pool.query(`
|
||||
SELECT id, name FROM task_schedules WHERE is_immutable = true
|
||||
`);
|
||||
skippedImmutable = immutableResult.rows;
|
||||
|
||||
// Delete all non-immutable schedules
|
||||
result = await pool.query(`
|
||||
DELETE FROM task_schedules RETURNING id, name
|
||||
DELETE FROM task_schedules
|
||||
WHERE COALESCE(is_immutable, false) = false
|
||||
RETURNING id, name
|
||||
`);
|
||||
} else if (Array.isArray(ids) && ids.length > 0) {
|
||||
// Delete specific schedules by IDs
|
||||
// First, find which of the requested IDs are immutable
|
||||
const immutableResult = await pool.query(`
|
||||
SELECT id, name FROM task_schedules
|
||||
WHERE id = ANY($1) AND is_immutable = true
|
||||
`, [ids]);
|
||||
skippedImmutable = immutableResult.rows;
|
||||
|
||||
// Delete only non-immutable schedules from the requested IDs
|
||||
result = await pool.query(`
|
||||
DELETE FROM task_schedules WHERE id = ANY($1) RETURNING id, name
|
||||
DELETE FROM task_schedules
|
||||
WHERE id = ANY($1) AND COALESCE(is_immutable, false) = false
|
||||
RETURNING id, name
|
||||
`, [ids]);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
@@ -217,7 +250,11 @@ router.delete('/schedules', async (req: Request, res: Response) => {
|
||||
success: true,
|
||||
deleted_count: result.rowCount,
|
||||
deleted: result.rows,
|
||||
message: `Deleted ${result.rowCount} schedule(s)`,
|
||||
skipped_immutable_count: skippedImmutable.length,
|
||||
skipped_immutable: skippedImmutable,
|
||||
message: skippedImmutable.length > 0
|
||||
? `Deleted ${result.rowCount} schedule(s), skipped ${skippedImmutable.length} immutable schedule(s)`
|
||||
: `Deleted ${result.rowCount} schedule(s)`,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Error bulk deleting schedules:', error);
|
||||
@@ -311,6 +348,13 @@ router.get('/schedules/:id', async (req: Request, res: Response) => {
|
||||
/**
|
||||
* PUT /api/tasks/schedules/:id
|
||||
* Update an existing schedule
|
||||
*
|
||||
* For IMMUTABLE schedules, only these fields can be updated:
|
||||
* - enabled (turn on/off)
|
||||
* - interval_hours (change frequency)
|
||||
* - priority (change priority)
|
||||
*
|
||||
* For regular schedules, all fields can be updated.
|
||||
*/
|
||||
router.put('/schedules/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -326,23 +370,68 @@ router.put('/schedules/:id', async (req: Request, res: Response) => {
|
||||
platform,
|
||||
} = req.body;
|
||||
|
||||
// First check if schedule exists and if it's immutable
|
||||
const checkResult = await pool.query(`
|
||||
SELECT id, name, COALESCE(is_immutable, false) as is_immutable
|
||||
FROM task_schedules WHERE id = $1
|
||||
`, [scheduleId]);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
const schedule = checkResult.rows[0];
|
||||
const isImmutable = schedule.is_immutable;
|
||||
|
||||
// For immutable schedules, reject attempts to change protected fields
|
||||
if (isImmutable) {
|
||||
const protectedFields: string[] = [];
|
||||
if (name !== undefined) protectedFields.push('name');
|
||||
if (role !== undefined) protectedFields.push('role');
|
||||
if (description !== undefined) protectedFields.push('description');
|
||||
if (state_code !== undefined) protectedFields.push('state_code');
|
||||
if (platform !== undefined) protectedFields.push('platform');
|
||||
|
||||
if (protectedFields.length > 0) {
|
||||
return res.status(403).json({
|
||||
error: 'Cannot modify protected fields on immutable schedule',
|
||||
message: `Schedule "${schedule.name}" is immutable. Only enabled, interval_hours, and priority can be changed.`,
|
||||
protected_fields: protectedFields,
|
||||
allowed_fields: ['enabled', 'interval_hours', 'priority'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build dynamic update query
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(name);
|
||||
}
|
||||
if (role !== undefined) {
|
||||
updates.push(`role = $${paramIndex++}`);
|
||||
values.push(role);
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updates.push(`description = $${paramIndex++}`);
|
||||
values.push(description);
|
||||
// These fields can only be updated on non-immutable schedules
|
||||
if (!isImmutable) {
|
||||
if (name !== undefined) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(name);
|
||||
}
|
||||
if (role !== undefined) {
|
||||
updates.push(`role = $${paramIndex++}`);
|
||||
values.push(role);
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updates.push(`description = $${paramIndex++}`);
|
||||
values.push(description);
|
||||
}
|
||||
if (state_code !== undefined) {
|
||||
updates.push(`state_code = $${paramIndex++}`);
|
||||
values.push(state_code || null);
|
||||
}
|
||||
if (platform !== undefined) {
|
||||
updates.push(`platform = $${paramIndex++}`);
|
||||
values.push(platform || null);
|
||||
}
|
||||
}
|
||||
|
||||
// These fields can be updated on ALL schedules (including immutable)
|
||||
if (enabled !== undefined) {
|
||||
updates.push(`enabled = $${paramIndex++}`);
|
||||
values.push(enabled);
|
||||
@@ -360,14 +449,6 @@ router.put('/schedules/:id', async (req: Request, res: Response) => {
|
||||
updates.push(`priority = $${paramIndex++}`);
|
||||
values.push(priority);
|
||||
}
|
||||
if (state_code !== undefined) {
|
||||
updates.push(`state_code = $${paramIndex++}`);
|
||||
values.push(state_code || null);
|
||||
}
|
||||
if (platform !== undefined) {
|
||||
updates.push(`platform = $${paramIndex++}`);
|
||||
values.push(platform || null);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
@@ -381,14 +462,12 @@ router.put('/schedules/:id', async (req: Request, res: Response) => {
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, name, role, description, enabled, interval_hours,
|
||||
priority, state_code, platform, last_run_at, next_run_at,
|
||||
priority, state_code, platform, method,
|
||||
COALESCE(is_immutable, false) as is_immutable,
|
||||
last_run_at, next_run_at,
|
||||
last_task_count, last_error, created_at, updated_at
|
||||
`, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
if (error.code === '23505') {
|
||||
@@ -402,22 +481,41 @@ router.put('/schedules/:id', async (req: Request, res: Response) => {
|
||||
/**
|
||||
* DELETE /api/tasks/schedules/:id
|
||||
* Delete a schedule
|
||||
*
|
||||
* Immutable schedules cannot be deleted - they can only be disabled.
|
||||
*/
|
||||
router.delete('/schedules/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const scheduleId = parseInt(req.params.id, 10);
|
||||
|
||||
const result = await pool.query(`
|
||||
DELETE FROM task_schedules WHERE id = $1 RETURNING id, name
|
||||
// First check if schedule exists and is immutable
|
||||
const checkResult = await pool.query(`
|
||||
SELECT id, name, COALESCE(is_immutable, false) as is_immutable
|
||||
FROM task_schedules WHERE id = $1
|
||||
`, [scheduleId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
if (checkResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
const schedule = checkResult.rows[0];
|
||||
|
||||
// Prevent deletion of immutable schedules
|
||||
if (schedule.is_immutable) {
|
||||
return res.status(403).json({
|
||||
error: 'Cannot delete immutable schedule',
|
||||
message: `Schedule "${schedule.name}" is immutable and cannot be deleted. You can disable it instead.`,
|
||||
schedule_id: scheduleId,
|
||||
is_immutable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the schedule
|
||||
await pool.query(`DELETE FROM task_schedules WHERE id = $1`, [scheduleId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Schedule "${result.rows[0].name}" deleted`,
|
||||
message: `Schedule "${schedule.name}" deleted`,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Error deleting schedule:', error);
|
||||
|
||||
Reference in New Issue
Block a user