import { z } from "zod"; import type { ScanScheduleSummary } from "@/types/scans"; import { SCAN_SCHEDULE_CAPABILITY, type ScanScheduleCapability, SCHEDULE_FREQUENCY, SCHEDULE_WEEKDAY_LABELS, type ScheduleAttributes, type ScheduleFormValues, type ScheduleProps, type ScheduleUpdatePayload, } from "@/types/schedules"; const DEFAULT_DAY_OF_WEEK = 1; const DEFAULT_DAY_OF_MONTH = 1; // The backend (prowler-cloud) enforces SCAN_INTERVAL_HOURS_MIN = 24. 48 is well // above that floor. const SCAN_INTERVAL_HOURS_MIN = 24; const DEFAULT_INTERVAL_HOURS = 48; const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" }); const DAY_OF_MONTH_SUFFIXES: Record = { zero: "th", one: "st", two: "nd", few: "rd", many: "th", other: "th", }; export const scheduleFormSchema = z.object({ frequency: z.enum(SCHEDULE_FREQUENCY), hour: z.number().int().min(0).max(23), dayOfWeek: z.number().int().min(0).max(6), dayOfMonth: z.number().int().min(1).max(28), intervalHours: z.number().int().min(SCAN_INTERVAL_HOURS_MIN), launchInitialScan: z.boolean(), }); export const scheduleUpdatePayloadSchema = z.object({ scan_enabled: z.boolean(), scan_frequency: z.enum(SCHEDULE_FREQUENCY), scan_hour: z.number().int().min(0).max(23), scan_timezone: z.string().min(1), scan_interval_hours: z.number().int().min(SCAN_INTERVAL_HOURS_MIN).nullable(), scan_day_of_week: z.number().int().min(0).max(6).nullable(), scan_day_of_month: z.number().int().min(1).max(28).nullable(), }); /** * Default scan-schedule capability for the current environment. * * Pure function (no side effects) so it is trivial to unit-test. Prowler OSS has * no billing, so the only distinction it can make is Cloud vs non-Cloud: * non-Cloud → legacy daily-only, Cloud → full scheduling. The prowler-cloud * overlay computes its own (billing-aware) capability and passes it down via the * optional `capability` prop, overriding this default — no billing concept ever * leaks into OSS. */ export function getScanScheduleCapability( isCloud: boolean, ): ScanScheduleCapability { return isCloud ? SCAN_SCHEDULE_CAPABILITY.ADVANCED : SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; } export function formatScheduleHour(hour: number): string { const normalizedHour = ((hour % 24) + 24) % 24; const period = normalizedHour >= 12 ? "pm" : "am"; const displayHour = normalizedHour % 12 === 0 ? 12 : normalizedHour % 12; return `${displayHour}:00${period}`; } export function formatDayOfMonth(day: number): string { return `${day}${DAY_OF_MONTH_SUFFIXES[ordinalRules.select(day)]}`; } export function getBrowserTimezone(): string { if (typeof window === "undefined") { return "UTC"; } return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } function getDefaultScheduleHour(now: Date): number { const isOnTheHour = now.getMinutes() === 0 && now.getSeconds() === 0 && now.getMilliseconds() === 0; return (now.getHours() + (isOnTheHour ? 0 : 1)) % 24; } export function getScheduleFormDefaults(now = new Date()): ScheduleFormValues { return { frequency: SCHEDULE_FREQUENCY.DAILY, hour: getDefaultScheduleHour(now), dayOfWeek: DEFAULT_DAY_OF_WEEK, dayOfMonth: DEFAULT_DAY_OF_MONTH, intervalHours: DEFAULT_INTERVAL_HOURS, launchInitialScan: false, }; } export function getScheduleFormValues( schedule?: ScheduleAttributes | null, now = new Date(), ): ScheduleFormValues { const defaults = getScheduleFormDefaults(now); if (!schedule || schedule.scan_hour === null) { return defaults; } return { frequency: schedule.scan_frequency, hour: schedule.scan_hour, dayOfWeek: schedule.scan_day_of_week ?? defaults.dayOfWeek, dayOfMonth: schedule.scan_day_of_month ?? defaults.dayOfMonth, intervalHours: schedule.scan_interval_hours ?? defaults.intervalHours, launchInitialScan: false, }; } export function buildScheduleUpdatePayload( values: ScheduleFormValues, ): ScheduleUpdatePayload { return { scan_enabled: true, scan_frequency: values.frequency, scan_hour: values.hour, scan_timezone: getBrowserTimezone(), scan_interval_hours: values.frequency === SCHEDULE_FREQUENCY.INTERVAL ? values.intervalHours : null, scan_day_of_week: values.frequency === SCHEDULE_FREQUENCY.WEEKLY ? values.dayOfWeek : null, scan_day_of_month: values.frequency === SCHEDULE_FREQUENCY.MONTHLY ? values.dayOfMonth : null, }; } interface SchedulesActionResult { data?: ScheduleProps[] | null; error?: unknown; } /** * Indexes a `getSchedules()` result by provider id — the schedule resource's own * `id` IS the provider id. Returns an empty map on error or missing data, so * callers can treat schedules as best-effort (e.g. OSS, where the `/schedules` * list endpoint may not exist). Shared by the scans and providers views. */ export function buildSchedulesByProviderId( result: SchedulesActionResult | null | undefined, ): Record { const byProviderId: Record = {}; if (!result || result.error) return byProviderId; for (const schedule of result.data ?? []) { byProviderId[schedule.id] = schedule.attributes; } return byProviderId; } interface ProviderScheduleAttributeSource { scan_enabled?: boolean | null; scan_frequency?: ScheduleAttributes["scan_frequency"] | null; scan_hour?: number | null; scan_timezone?: string | null; scan_interval_hours?: number | null; scan_day_of_week?: number | null; scan_day_of_month?: number | null; next_scan_at?: string | null; last_scan_at?: string | null; } export function buildScheduleAttributesFromProvider( attributes: ProviderScheduleAttributeSource, ): ScheduleAttributes | undefined { if (!Object.prototype.hasOwnProperty.call(attributes, "scan_hour")) { return undefined; } return { scan_enabled: attributes.scan_enabled ?? true, scan_frequency: attributes.scan_frequency ?? SCHEDULE_FREQUENCY.DAILY, scan_hour: attributes.scan_hour ?? null, scan_timezone: attributes.scan_timezone ?? "UTC", scan_interval_hours: attributes.scan_interval_hours ?? null, scan_day_of_week: attributes.scan_day_of_week ?? null, scan_day_of_month: attributes.scan_day_of_month ?? null, next_scan_at: attributes.next_scan_at, last_scan_at: attributes.last_scan_at, }; } /** * Whether a provider has an explicitly configured scan schedule. * * The schedule resource is backed by the Provider row itself: an unconfigured * provider — or one whose schedule was removed (DELETE resets the schedule to * defaults: `scan_hour=null`, but leaves `scan_enabled=true`) — comes back with * `scan_hour === null`. So `scan_hour` is the canonical "is configured" signal; * `scan_enabled` is NOT, because a freshly created provider already reports * `scan_enabled=true`. */ export function isScheduleConfigured( attributes: Pick, ): boolean { return attributes.scan_hour !== null; } /** * Computes the next time a schedule would run, as a local `Date`. Pure: `now` is * injected so it is deterministic to test. Computation is done in the browser's * local time, which matches the timezone shown next to it (`getBrowserTimezone`), * so no timezone-conversion library is needed. This is an estimate for display; * the backend is the source of truth for the actual fire time. * * INTERVAL shares the DAILY computation: the backend anchors its first run at * the next occurrence of `scan_hour`. */ export function getNextScheduledRun( values: ScheduleFormValues, now: Date, ): Date { const next = new Date(now); next.setHours(values.hour, 0, 0, 0); switch (values.frequency) { case SCHEDULE_FREQUENCY.WEEKLY: { let delta = (values.dayOfWeek - next.getDay() + 7) % 7; if (delta === 0 && next <= now) delta = 7; next.setDate(next.getDate() + delta); return next; } case SCHEDULE_FREQUENCY.MONTHLY: { next.setDate(values.dayOfMonth); if (next <= now) { next.setMonth(next.getMonth() + 1, values.dayOfMonth); } return next; } default: { // DAILY and INTERVAL (the interval anchor is the next occurrence of the hour) if (next <= now) next.setDate(next.getDate() + 1); return next; } } } export interface ScheduleCadenceParts { /** e.g. "Weekly on Monday" */ cadence: string; /** e.g. "9:00am (Europe/Madrid)" */ time: string; } export function getScheduleCadenceParts( attributes: ScheduleAttributes, ): ScheduleCadenceParts { const time = `${formatScheduleHour(attributes.scan_hour ?? 0)} (${attributes.scan_timezone})`; switch (attributes.scan_frequency) { case SCHEDULE_FREQUENCY.WEEKLY: { const weekday = SCHEDULE_WEEKDAY_LABELS[attributes.scan_day_of_week ?? 0] ?? SCHEDULE_WEEKDAY_LABELS[0]; return { cadence: `Weekly on ${weekday}`, time }; } case SCHEDULE_FREQUENCY.MONTHLY: return { cadence: `Monthly on the ${formatDayOfMonth( attributes.scan_day_of_month ?? 1, )}`, time, }; case SCHEDULE_FREQUENCY.INTERVAL: return { cadence: `Every ${attributes.scan_interval_hours ?? 0} hours`, time, }; default: return { cadence: "Daily", time }; } } /** Human-readable cadence, e.g. "Weekly on Monday @ 9:00am (Europe/Madrid)". */ export function describeScheduleCadence( attributes: ScheduleAttributes, ): string { const { cadence, time } = getScheduleCadenceParts(attributes); return `${cadence} @ ${time}`; } /** * Next-run estimate honoring the schedule's own timezone: `toLocaleString` * converts `now` to that timezone's wall-clock and the offset is applied back. */ export function getNextScheduledRunInTimezone( attributes: ScheduleAttributes, now: Date, ): Date | null { if (attributes.scan_hour === null) return null; let timezoneNow: Date; try { timezoneNow = new Date( now.toLocaleString("en-US", { timeZone: attributes.scan_timezone }), ); } catch { timezoneNow = new Date(now); } if (Number.isNaN(timezoneNow.getTime())) timezoneNow = new Date(now); const target = getNextScheduledRun( getScheduleFormValues(attributes), timezoneNow, ); return new Date(now.getTime() + (target.getTime() - timezoneNow.getTime())); } /** * Builds the display summary (cadence + next/last run) for a provider's * schedule, the way the scans and providers tables render it. Shared so both * views show identical cadence labels. * * `next_scan_at` semantics: an absent field (older API) with an enabled * schedule falls back to a client estimate; an explicit null (paused) means no * next run. */ export function buildProviderScheduleSummary( attributes: ScheduleAttributes, now: Date, ): ScanScheduleSummary { const nextScanAt = attributes.next_scan_at === undefined && attributes.scan_enabled ? (getNextScheduledRunInTimezone(attributes, now)?.toISOString() ?? null) : (attributes.next_scan_at ?? null); return { summary: describeScheduleCadence(attributes), cadence: getScheduleCadenceParts(attributes).cadence, nextScanAt, lastScanAt: attributes.last_scan_at ?? null, }; }