schedules.ts178 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { ValidationError } from '@briven/shared';
5
6import { requireAuth } from '../middleware/session.js';
7import { assertProjectRole } from '../services/access.js';
8import { audit, hashIp } from '../services/audit.js';
9import {
10 createSchedule,
11 deleteSchedule,
12 getSchedule,
13 listSchedules,
14 nextRunAfter,
15 parseCron,
16 updateSchedule,
17} from '../services/schedules.js';
18import type { AppEnv } from '../types/app-env.js';
19
20export const schedulesRouter = new Hono<AppEnv>();
21
22schedulesRouter.use('/v1/projects/:id/schedules', requireAuth());
23schedulesRouter.use('/v1/projects/:id/schedules/*', requireAuth());
24
25// jsonb args column: any JSON-shaped value. We disallow non-object roots
26// so the dashboard form has a clear contract (rows of key/value pairs).
27const ARGS_SCHEMA = z.record(z.string(), z.unknown()).optional();
28const NAME_SCHEMA = z.string().min(1).max(64);
29const FN_SCHEMA = z.string().min(1).max(128);
30const CRON_SCHEMA = z.string().min(1).max(64);
31
32const createSchema = z.object({
33 name: NAME_SCHEMA,
34 functionName: FN_SCHEMA,
35 cronExpression: CRON_SCHEMA,
36 args: ARGS_SCHEMA,
37 enabled: z.boolean().optional(),
38});
39
40const updateSchema = z.object({
41 name: NAME_SCHEMA.optional(),
42 functionName: FN_SCHEMA.optional(),
43 cronExpression: CRON_SCHEMA.optional(),
44 args: ARGS_SCHEMA,
45 enabled: z.boolean().optional(),
46});
47
48function validationResponse(issues: unknown) {
49 return {
50 code: 'validation_failed' as const,
51 message: 'invalid request body',
52 issues,
53 };
54}
55
56schedulesRouter.get('/v1/projects/:id/schedules', async (c) => {
57 const user = c.get('user')!;
58 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer');
59 const rows = await listSchedules(project.id);
60 return c.json({ schedules: rows });
61});
62
63schedulesRouter.post('/v1/projects/:id/schedules', async (c) => {
64 const user = c.get('user')!;
65 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'developer');
66 const body = await c.req.json().catch(() => null);
67 const parsed = createSchema.safeParse(body);
68 if (!parsed.success) return c.json(validationResponse(parsed.error.issues), 400);
69
70 try {
71 const schedule = await createSchedule({
72 projectId: project.id,
73 name: parsed.data.name,
74 functionName: parsed.data.functionName,
75 cronExpression: parsed.data.cronExpression,
76 args: parsed.data.args ?? {},
77 enabled: parsed.data.enabled,
78 createdBy: user.id,
79 });
80 await audit({
81 actorId: user.id,
82 projectId: project.id,
83 action: 'schedule.create',
84 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
85 userAgent: c.req.header('user-agent') ?? null,
86 metadata: {
87 scheduleId: schedule.id,
88 name: schedule.name,
89 functionName: schedule.functionName,
90 cronExpression: schedule.cronExpression,
91 },
92 });
93 return c.json({ schedule }, 201);
94 } catch (err) {
95 if (err instanceof ValidationError) {
96 return c.json({ code: 'validation_failed', message: err.message }, 400);
97 }
98 throw err;
99 }
100});
101
102schedulesRouter.patch('/v1/projects/:id/schedules/:scheduleId', async (c) => {
103 const user = c.get('user')!;
104 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'developer');
105 const scheduleId = c.req.param('scheduleId');
106
107 const body = await c.req.json().catch(() => null);
108 const parsed = updateSchema.safeParse(body);
109 if (!parsed.success) return c.json(validationResponse(parsed.error.issues), 400);
110
111 try {
112 const schedule = await updateSchedule(scheduleId, project.id, parsed.data);
113 await audit({
114 actorId: user.id,
115 projectId: project.id,
116 action: 'schedule.update',
117 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
118 userAgent: c.req.header('user-agent') ?? null,
119 metadata: {
120 scheduleId,
121 // Audit log records the keys that changed, not the full row — keeps
122 // the audit-log table out of the data-export firing line.
123 fields: Object.keys(parsed.data),
124 },
125 });
126 return c.json({ schedule });
127 } catch (err) {
128 if (err instanceof ValidationError) {
129 return c.json({ code: 'validation_failed', message: err.message }, 400);
130 }
131 throw err;
132 }
133});
134
135schedulesRouter.delete('/v1/projects/:id/schedules/:scheduleId', async (c) => {
136 const user = c.get('user')!;
137 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'developer');
138 const scheduleId = c.req.param('scheduleId');
139 // Confirm the row exists in this project before we record audit.
140 await getSchedule(scheduleId, project.id);
141 await deleteSchedule(scheduleId, project.id);
142 await audit({
143 actorId: user.id,
144 projectId: project.id,
145 action: 'schedule.delete',
146 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
147 userAgent: c.req.header('user-agent') ?? null,
148 metadata: { scheduleId },
149 });
150 return c.json({ ok: true });
151});
152
153// Validates a candidate cron expression without writing anything. The
154// dashboard form uses this for live "next run preview" UX.
155schedulesRouter.post('/v1/projects/:id/schedules/preview', async (c) => {
156 const user = c.get('user')!;
157 await assertProjectRole(c.req.param('id'), user.id, 'viewer');
158 const body = (await c.req.json().catch(() => null)) as { cronExpression?: unknown } | null;
159 if (!body || typeof body.cronExpression !== 'string') {
160 return c.json({ code: 'validation_failed', message: 'cronExpression required' }, 400);
161 }
162 try {
163 const sets = parseCron(body.cronExpression);
164 const previews: string[] = [];
165 let cursor = new Date();
166 for (let i = 0; i < 5; i += 1) {
167 const next = nextRunAfter(sets, cursor);
168 previews.push(next.toISOString());
169 cursor = next;
170 }
171 return c.json({ ok: true, nextRuns: previews });
172 } catch (err) {
173 if (err instanceof ValidationError) {
174 return c.json({ ok: false, code: 'validation_failed', message: err.message }, 400);
175 }
176 throw err;
177 }
178});