diff --git a/core/actions/_lib/runActionInstance.ts b/core/actions/_lib/runActionInstance.ts index 60011e814..dd0355572 100644 --- a/core/actions/_lib/runActionInstance.ts +++ b/core/actions/_lib/runActionInstance.ts @@ -56,7 +56,7 @@ const _runActionInstance = async ({ } if (actionInstanceResult.status === "rejected") { - logger.debug({ msg: actionInstanceResult.reason }); + logger.error({ msg: actionInstanceResult.reason }); return { error: "Action instance not found", cause: actionInstanceResult.reason, @@ -105,6 +105,7 @@ const _runActionInstance = async ({ }); if (values.error) { + logger.error(values.error); return { error: values.error, }; @@ -121,7 +122,7 @@ const _runActionInstance = async ({ stageId: actionInstance.stageId, }); - revalidateTag(`community-stages_${pub.communityId}`); + // revalidateTag(`community-stages_${pub.communityId}`); return result; } catch (error) { diff --git a/core/actions/_lib/scheduleActionInstance.ts b/core/actions/_lib/scheduleActionInstance.ts new file mode 100644 index 000000000..46a317e95 --- /dev/null +++ b/core/actions/_lib/scheduleActionInstance.ts @@ -0,0 +1,96 @@ +// "use server"; + +import { jsonArrayFrom } from "kysely/helpers/postgres"; + +import { logger } from "logger"; + +import type { PubsId } from "~/kysely/types/public/Pubs"; +import type { Rules } from "~/kysely/types/public/Rules"; +import type { StagesId } from "~/kysely/types/public/Stages"; +import { db } from "~/kysely/database"; +import Event from "~/kysely/types/public/Event"; +import { addDuration } from "~/lib/dates"; +import { getJobsClient } from "~/lib/server/jobs"; + +export const scheduleActionInstances = async ({ + pubId, + stageId, +}: { + pubId: PubsId; + stageId: StagesId; +}) => { + if (!pubId || !stageId) { + throw new Error("pubId and stageId are required"); + } + + const instances = await db + .selectFrom("action_instances") + .where("action_instances.stage_id", "=", stageId) + // .innerJoin("rules", "rules.action_instance_id", "action_instances.id") + // .where("rules.event", "=", Event.pubInStageForDuration) + .select((eb) => [ + "action_instances.id as id", + "action_instances.name as name", + jsonArrayFrom( + eb + .selectFrom("rules") + .select([ + "rules.id as id", + "rules.event as event", + "rules.config as config", + "rules.action_instance_id as action_instance_id", + ]) + .where("rules.action_instance_id", "=", eb.ref("action_instances.id")) + .where("rules.event", "=", Event.pubInStageForDuration) + ).as("rules"), + ]) + .execute(); + + if (!instances.length) { + logger.warn({ + msg: `No action instances found for stage ${stageId}`, + pubId, + stageId, + instances, + }); + return; + } + + const validRules = instances.flatMap((instance) => + instance.rules + .filter((rule): rule is Rules & { config: { duration: number } } => + Boolean(rule.config?.duration && rule.config.interval) + ) + .map((rule) => ({ ...rule, actionName: instance.name })) + ); + + if (!validRules.length) { + logger.warn({ msg: "No action instances found for pub", pubId, stageId, instances }); + return; + } + + const jobsClient = await getJobsClient(); + + const results = await Promise.all( + validRules.flatMap(async (rule) => { + const job = await jobsClient.scheduleAction({ + actionInstanceId: rule.action_instance_id, + duration: rule.config.duration, + interval: rule.config.interval, + stageId: stageId, + pubId, + }); + return { + result: job, + actionInstanceId: rule.action_instance_id, + actionInstanceName: rule.actionName, + runAt: addDuration({ + duration: rule.config.duration, + interval: rule.config.interval, + }).toISOString(), + }; + }) + ); + + return results; +}; diff --git a/core/actions/api/index.ts b/core/actions/api/index.ts index 8ac964a36..675e1c8a0 100644 --- a/core/actions/api/index.ts +++ b/core/actions/api/index.ts @@ -1,6 +1,7 @@ // shared actions between server and client import type Event from "~/kysely/types/public/Event"; +import { pubEnteredStage, pubInStageForDuration, pubLeftStage } from "../_lib/rules"; import * as email from "../email/action"; import * as log from "../log/action"; import * as move from "../move/action"; @@ -23,6 +24,16 @@ export const getActionNames = () => { return Object.keys(actions) as (keyof typeof actions)[]; }; +export const rules = { + [pubInStageForDuration.event]: pubInStageForDuration, + [pubEnteredStage.event]: pubEnteredStage, + [pubLeftStage.event]: pubLeftStage, +} as const; + +export const getRuleByName = (name: keyof typeof rules) => { + return rules[name]; +}; + const humanReadableEvents: Record = { pubEnteredStage: "a pub enters this stage", pubLeftStage: "a pub leaves this stage", diff --git a/core/actions/api/server.ts b/core/actions/api/server.ts index 2cdfd98fc..cc32db72a 100644 --- a/core/actions/api/server.ts +++ b/core/actions/api/server.ts @@ -1 +1 @@ -export { runActionInstance, runInstancesForEvent } from "../_lib/runActionInstance"; +export { scheduleActionInstances } from "../_lib/scheduleActionInstance"; diff --git a/core/actions/api/serverActions.ts b/core/actions/api/serverActions.ts new file mode 100644 index 000000000..24101ebfb --- /dev/null +++ b/core/actions/api/serverActions.ts @@ -0,0 +1,6 @@ +// everything exported from here should use the "use server" directive +// in order to allow importing these function in client components +// +// BUT, this file should NOT have the "use server" directive + +export { runActionInstance, runInstancesForEvent } from "../_lib/runActionInstance"; diff --git a/core/app/c/[communitySlug]/stages/manage/actions.ts b/core/app/c/[communitySlug]/stages/manage/actions.ts index b88d7d1cb..eaf25c381 100644 --- a/core/app/c/[communitySlug]/stages/manage/actions.ts +++ b/core/app/c/[communitySlug]/stages/manage/actions.ts @@ -12,8 +12,10 @@ import type { RulesId } from "~/kysely/types/public/Rules"; import { humanReadableEvent } from "~/actions/api"; import { db } from "~/kysely/database"; import { type ActionInstancesId } from "~/kysely/types/public/ActionInstances"; +import { CommunitiesId } from "~/kysely/types/public/Communities"; import { defineServerAction } from "~/lib/server/defineServerAction"; import prisma from "~/prisma/db"; +import { CreateRuleSchema } from "./components/panel/StagePanelRuleCreator"; async function deleteStages(stageIds: string[]) { await prisma.stage.deleteMany({ @@ -236,24 +238,31 @@ export const deleteAction = defineServerAction(async function deleteAction( } }); -export const addRule = defineServerAction(async function addRule( - event: Event, - actionInstanceId: ActionInstancesId, - communityId: string -) { +export const addRule = defineServerAction(async function addRule({ + data, + communityId, +}: { + data: CreateRuleSchema; + communityId: CommunitiesId; +}) { try { await db .insertInto("rules") - .values({ action_instance_id: actionInstanceId, event }) + .values({ + action_instance_id: data.actionInstanceId as ActionInstancesId, + event: data.event, + config: "additionalConfiguration" in data ? data.additionalConfiguration : null, + }) + .executeTakeFirstOrThrow(); } catch (error) { logger.error(error); if (error.message?.includes("unique constraint")) { return { title: "Rule already exists", - error: `A rule for '${humanReadableEvent(event)}' and this action already exists. Please add another action + error: `A rule for '${humanReadableEvent(data.event)}' and this action already exists. Please add another action of the same type to this stage in order to have the same action trigger - multiple times for '${humanReadableEvent(event)}'.`, + multiple times for '${humanReadableEvent(data.event)}'.`, cause: error, }; } diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelPubsRunActionButton.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelPubsRunActionButton.tsx index 1d9653ffb..a71bbf9eb 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelPubsRunActionButton.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelPubsRunActionButton.tsx @@ -14,7 +14,7 @@ import { toast } from "ui/use-toast"; import type { ActionInstances, ActionInstancesId } from "~/kysely/types/public/ActionInstances"; import type { PubsId } from "~/kysely/types/public/Pubs"; import { getActionByName } from "~/actions/api"; -import { runActionInstance } from "~/actions/api/server"; +import { runActionInstance } from "~/actions/api/serverActions"; import { useServerAction } from "~/lib/serverActions"; export const StagePanelPubsRunActionButton = ({ diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelRuleCreator.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelRuleCreator.tsx index 25b812da8..2685fb202 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelRuleCreator.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelRuleCreator.tsx @@ -16,20 +16,22 @@ import { DialogTrigger, } from "ui/dialog"; import { Form, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; -import { Input } from "ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"; +import type { Rules } from "~/actions/_lib/rules"; import type Action from "~/kysely/types/public/Action"; import type { ActionInstances, ActionInstancesId } from "~/kysely/types/public/ActionInstances"; -import { actions, humanReadableEvent } from "~/actions/api"; +import type { CommunitiesId } from "~/kysely/types/public/Communities"; +import { actions, getRuleByName, humanReadableEvent, rules } from "~/actions/api"; import Event from "~/kysely/types/public/Event"; import { useServerAction } from "~/lib/serverActions"; +import AutoFormObject from "../../../../../../../../packages/ui/src/auto-form/fields/object"; import { addRule } from "../../actions"; type Props = { // onAdd: (event: Event, actionInstanceId: ActionInstancesId) => Promise; actionInstances: ActionInstances[]; - communityId: string; + communityId: CommunitiesId; rules: { id: string; event: Event; @@ -39,25 +41,41 @@ type Props = { }[]; }; -const schema = z.object({ - event: z.nativeEnum(Event), - actionInstanceId: z.string(), - additionalConfiguration: z - .object({ - duration: z.number(), - interval: z.enum(["hour", "day", "week", "month"]), - }) - .optional(), -}); +const schema = z.discriminatedUnion("event", [ + z.object({ + event: z.literal(Event.pubEnteredStage), + actionInstanceId: z.string().uuid(), + }), + z.object({ + event: z.literal(Event.pubLeftStage), + actionInstanceId: z.string().uuid(), + }), + ...Object.values(rules) + .filter( + (rule): rule is Exclude => + rule.event !== Event.pubEnteredStage && rule.event !== Event.pubLeftStage + ) + .map((rule) => + z.object({ + event: z.literal(rule.event), + actionInstanceId: z.string().uuid(), + additionalConfiguration: rule.additionalConfig + ? rule.additionalConfig + : z.null().optional(), + }) + ), +]); + +export type CreateRuleSchema = z.infer; export const StagePanelRuleCreator = (props: Props) => { // const runOnAdd = useServerAction(props.onAdd); const runAddRule = useServerAction(addRule); const [isOpen, setIsOpen] = useState(false); const onSubmit = useCallback( - async ({ event, actionInstanceId }: z.infer) => { + async (data: CreateRuleSchema) => { setIsOpen(false); - runAddRule(event, actionInstanceId as ActionInstancesId, props.communityId); + runAddRule({ data, communityId: props.communityId }); // runOnAdd(event, actionInstanceId as ActionInstancesId); }, [props.communityId] // [props.onAdd, runOnAdd] @@ -80,6 +98,8 @@ export const StagePanelRuleCreator = (props: Props) => { .map((rule) => rule.event); const allowedEvents = Object.values(Event).filter((event) => !disallowedEvents.includes(event)); + const rule = getRuleByName(event); + return (
@@ -174,55 +194,17 @@ export const StagePanelRuleCreator = (props: Props) => { )} /> - {event === Event.pubInStageForDuration && ( - ( - - Additional Config -
- - field.onChange({ - ...field.value, - duration: val.target.valueAsNumber, - }) - } - defaultValue={5} - /> - -
- -
- )} + schema={rule.additionalConfig} /> )}
diff --git a/core/app/components/PubCRUD/actions.ts b/core/app/components/PubCRUD/actions.ts index b057a0a04..d4b5a3162 100644 --- a/core/app/components/PubCRUD/actions.ts +++ b/core/app/components/PubCRUD/actions.ts @@ -79,7 +79,7 @@ export const createPub = defineServerAction(async function createPub({ report: `Successfully created a new Pub`, }; } catch (error) { - logger.debug(error); + logger.error(error); return { error: "Failed to create pub", cause: error, @@ -131,24 +131,33 @@ export const updatePub = defineServerAction(async function updatePub({ .values({ pubId, stageId }) .execute(); - const queries: Promise[] = [ - pubValues.map(async (pubValue) => - db + const queries = [ + pubValues.map(async (pubValue) => { + const field = fields[pubValue.field_id]; + if (!field) { + logger.debug({ + msg: `Field ${pubValue.field_id} not found in fields`, + fields, + pubValue, + }); + return; + } + const { value } = field; + + return db .updateTable("pub_values") .set({ - value: JSON.stringify(fields[pubValue.field_id].value), + value: JSON.stringify(value), }) .where("pub_values.id", "=", pubValue.id) .returningAll() - .execute() - ), - ].flat(); + .execute(); + }), + ] + .filter((x) => x != null) + .flat(); - if (stageMoveQuery) { - queries.push(stageMoveQuery); - } - - const updatePub = await Promise.allSettled(queries); + const updatePub = await Promise.allSettled([...queries, ...([stageMoveQuery] || [])]); const errors = updatePub.filter( (pubValue): pubValue is typeof pubValue & { status: "rejected" } => @@ -172,7 +181,7 @@ export const updatePub = defineServerAction(async function updatePub({ report: `Successfully updated the Pub`, }; } catch (error) { - logger.debug(error); + logger.error(error); return { error: "Failed to update pub", cause: error, diff --git a/core/lib/dates.ts b/core/lib/dates.ts index 7de47d807..702c833de 100644 --- a/core/lib/dates.ts +++ b/core/lib/dates.ts @@ -1,3 +1,30 @@ +import { addDays, addHours, addMinutes, addMonths, addWeeks, addYears } from "date-fns"; + +import type { PubInStageForDuration, RuleConfig } from "~/actions/_lib/rules"; + +export const addDuration = (duration: RuleConfig, date = new Date()) => { + const now = new Date(date); + + switch (duration.interval) { + case "minute": + return addMinutes(now, duration.duration); + case "hour": + return addHours(now, duration.duration); + case "day": + return addDays(now, duration.duration); + case "week": + return addWeeks(now, duration.duration); + case "month": + return addMonths(now, duration.duration); + case "year": + return addYears(now, duration.duration); + case "hour": + return addHours(now, duration.duration); + default: + throw new Error("Invalid interval"); + } +}; + export const getMonthAndDateString = () => { const date = new Date(); return date.toLocaleString("default", { month: "short", day: "numeric" }); diff --git a/core/lib/server/jobs.ts b/core/lib/server/jobs.ts index 29bedfb7a..1916433dd 100644 --- a/core/lib/server/jobs.ts +++ b/core/lib/server/jobs.ts @@ -1,9 +1,19 @@ -import { Job, makeWorkerUtils } from "graphile-worker"; +import type { Job } from "graphile-worker"; -import { JobOptions, SendEmailRequestBody } from "contracts"; +import { makeWorkerUtils } from "graphile-worker"; + +import type { JobOptions, SendEmailRequestBody } from "contracts"; import { logger } from "logger"; +import type { ClientExceptionOptions } from "../serverActions"; import { env } from "../env/env.mjs"; +import { ClientException } from "../serverActions"; + +import "date-fns"; + +import type { Interval } from "~/actions/_lib/rules"; +import Event from "~/kysely/types/public/Event"; +import { addDuration } from "../dates"; export type JobsClient = { scheduleEmail( @@ -11,7 +21,14 @@ export type JobsClient = { email: SendEmailRequestBody, jobOptions: JobOptions ): Promise; - unscheduleEmail(jobKey: string): Promise; + unscheduleJob(jobKey: string): Promise; + scheduleAction(options: { + actionInstanceId: string; + stageId: string; + duration: number; + interval: Interval; + pubId: string; + }): Promise; }; export const makeJobsClient = async (): Promise => { @@ -39,12 +56,65 @@ export const makeJobsClient = async (): Promise => { }); return job; }, - async unscheduleEmail(jobKey: string) { + async unscheduleJob(jobKey: string) { logger.info({ msg: `Unscheduling email with key: ${jobKey}`, job: { key: jobKey } }); await workerUtils.withPgClient(async (pg) => { await pg.query(`SELECT graphile_worker.remove_job($1);`, [jobKey]); }); }, + async scheduleAction({ actionInstanceId, stageId, duration, interval, pubId }) { + const runAt = addDuration({ duration, interval }); + + logger.info({ + msg: `Scheduling action with key: ${actionInstanceId} to run at ${runAt}`, + actionInstanceId, + stageId, + duration, + interval, + runAt, + pubId, + }); + try { + const job = await workerUtils.addJob( + "emitEvent", + { + event: Event.pubInStageForDuration, + duration, + interval, + runAt, + actionInstanceId, + stageId, + pubId, + }, + { + runAt, + } + ); + + logger.info({ + msg: `Successfully scheduled action with key: ${actionInstanceId} to run at ${runAt}`, + actionInstanceId, + stageId, + duration, + interval, + pubId, + }); + return job; + } catch (err) { + logger.error({ + msg: `Error scheduling action with key: ${actionInstanceId} to run at ${runAt}`, + actionInstanceId, + stageId, + duration, + interval, + pubId, + err, + }); + return { + error: err, + }; + } + }, }; }; diff --git a/core/package.json b/core/package.json index f742d5d12..0ca144bfb 100644 --- a/core/package.json +++ b/core/package.json @@ -54,6 +54,7 @@ "ajv-formats": "^2.1.1", "clsx": "^2.0.0", "contracts": "workspace:*", + "date-fns": "^3.6.0", "debounce": "^2.0.0", "diacritics": "^1.3.0", "eta": "^3.1.1", @@ -62,7 +63,7 @@ "kysely": "^0.27.2", "logger": "workspace:*", "lucide-react": "^0.356.0", - "next": "14.2.1", + "next": "14.2.3", "next-connect": "^1.0.0", "next-runtime-env": "^3.2.0", "nodemailer": "^6.9.5", diff --git a/core/pages/api/v0/[...ts-rest].ts b/core/pages/api/v0/[...ts-rest].ts index 75f2e8ce4..24a6c8a95 100644 --- a/core/pages/api/v0/[...ts-rest].ts +++ b/core/pages/api/v0/[...ts-rest].ts @@ -3,10 +3,13 @@ import { createNextRoute, createNextRouter } from "@ts-rest/next"; import { api } from "contracts"; import { logger } from "logger"; -import { runInstancesForEvent } from "~/actions/api/server"; -import Event from "~/kysely/types/public/Event"; -import { PubsId } from "~/kysely/types/public/Pubs"; -import { StagesId } from "~/kysely/types/public/Stages"; +import type Event from "~/kysely/types/public/Event"; +import type { PubsId } from "~/kysely/types/public/Pubs"; +import type { StagesId } from "~/kysely/types/public/Stages"; +import { scheduleActionInstances } from "~/actions/_lib/scheduleActionInstance"; +import { + runInstancesForEvent, // scheduleActionInstances +} from "~/actions/api/serverActions"; import { compareAPIKeys, getBearerToken } from "~/lib/auth/api"; import { env } from "~/lib/env/env.mjs"; import { @@ -103,7 +106,7 @@ const integrationsRouter = createNextRoute(api.integrations, { unscheduleEmail: async ({ headers, params }) => { checkAuthentication(headers.authorization); const jobs = await getJobsClient(); - await jobs.unscheduleEmail(params.key); + await jobs.unscheduleJob(params.key); return { status: 200, body: { @@ -177,6 +180,21 @@ const internalRouter = createNextRoute(api.internal, { body: actionRunResults, }; }, + scheduleAction: async ({ headers, params, body }) => { + checkAuthentication(headers.authorization); + const { pubId } = body; + const { stageId } = params; + + const actionScheduleResults = await scheduleActionInstances({ + pubId: pubId as PubsId, + stageId: stageId as StagesId, + }); + + return { + status: 200, + body: actionScheduleResults, + }; + }, }); const router = { diff --git a/jobs/index.ts b/jobs/index.ts index fd6dc01c6..b71490024 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -25,6 +25,54 @@ type DBTriggerEventPayload = { old: T; }; +type ScheduledEventPayload = { + event: Event.pubInStageForDuration; + duration: number; + interval: "minute" | "hour" | "day" | "week" | "month" | "year"; + runAt: Date; + stageId: string; + pubId: string; + actionInstanceId: string; +}; + +enum Event { + pubEnteredStage = "pubEnteredStage", + pubLeftStage = "pubLeftStage", + pubInStageForDuration = "pubInStageForDuration", +} + +const apiClient = initClient(api.internal, { + baseUrl: `${process.env.PUBPUB_URL}/api/v0`, + baseHeaders: { authorization: `Bearer ${process.env.API_KEY}` }, + jsonQuery: true, +}); + +const normalizeEventPayload = (payload: DBTriggerEventPayload | ScheduledEventPayload) => { + // pubInStageForDuration event, triggered after being scheduled for a while + if ("event" in payload) { + return payload; + } + + // pubInStageForDuration event, triggered after being scheduled for a while + if (payload.operation === "INSERT") { + return { + event: Event.pubEnteredStage, + ...payload.new, + }; + } + + // pubInStageForDuration event, triggered after being scheduled for a while + if (payload.operation === "DELETE") { + return { + event: Event.pubLeftStage, + ...payload.old, + }; + } + + // strange null case, should not really happen + return null; +}; + const makeTaskList = (client: Client<{}>) => { const sendEmail = (async ( payload: InstanceJobPayload, @@ -36,36 +84,93 @@ const makeTaskList = (client: Client<{}>) => { logger.info({ msg: `Sent email`, info, job: helpers.job }); }) as Task; - const emitEvent = (async (payload: DBTriggerEventPayload) => { + const emitEvent = (async ( + payload: DBTriggerEventPayload | ScheduledEventPayload + ) => { const eventLogger = logger.child({ payload }); - eventLogger.debug({ msg: "Starting emitEvent", payload }); - const client = initClient(api.internal, { - baseUrl: `${process.env.PUBPUB_URL}/api/v0`, - baseHeaders: { authorization: `Bearer ${process.env.API_KEY}` }, - jsonQuery: true, - }); - let event: "pubLeftStage" | "pubEnteredStage" | "" = ""; - let stageId: string = ""; - let pubId: string = ""; - if (payload.operation === "INSERT") { - event = "pubEnteredStage"; - stageId = payload.new.stageId; - pubId = payload.new.pubId; - } else if (payload.operation === "DELETE") { - event = "pubLeftStage"; - stageId = payload.old.stageId; - pubId = payload.old.pubId; + eventLogger.info({ msg: "Starting emitEvent", payload }); + + let schedulePromise: ReturnType | null = null; + + // try to schedule actions when a pub enters a stage + if ("operation" in payload && payload.operation === "INSERT") { + const { stageId, pubId } = payload.new; + + schedulePromise = apiClient.scheduleAction({ + params: { stageId }, + body: { pubId }, + }); } - if (!event || !pubId || !stageId) { - eventLogger.debug({ msg: "No event emitted" }); + const normalizedEventPayload = normalizeEventPayload(payload); + + if (!normalizedEventPayload) { + eventLogger.info({ msg: "No event emitted" }); return; } - eventLogger.debug({ msg: "Emitting event", event }); - const results = await client.triggerAction({ params: { stageId }, body: { event, pubId } }); + const { event, stageId, pubId, ...extra } = normalizedEventPayload; + + eventLogger.info({ msg: `Emitting event ${event}`, extra }); - eventLogger.debug({ msg: "Action run results", results, event }); + const resultsPromise = apiClient.triggerAction({ + params: { stageId }, + body: { event, pubId }, + }); + + const [scheduleResult, resultsResult] = await Promise.allSettled([ + schedulePromise, + resultsPromise, + ]); + + if (scheduleResult.status === "rejected") { + eventLogger.error({ + msg: "Error scheduling action", + error: scheduleResult.reason, + scheduleResult, + stageId, + pubId, + event, + ...extra, + }); + } else if (scheduleResult.value && scheduleResult.value?.status > 400) { + eventLogger.error({ + msg: `API error scheduling action`, + error: scheduleResult.value?.body, + scheduleResult, + stageId, + pubId, + event, + ...extra, + }); + } else if (scheduleResult.value !== null) { + eventLogger.info({ + msg: "Action scheduled", + results: scheduleResult.value, + stageId, + pubId, + event, + ...extra, + }); + } + + if (resultsResult.status === "rejected") { + eventLogger.error({ + msg: "Error running action", + error: resultsResult.reason, + stageId, + pubId, + event, + }); + } else { + eventLogger.info({ + msg: "Action run results", + results: resultsResult.value, + stageId, + pubId, + event, + }); + } }) as Task; return { sendEmail, emitEvent }; diff --git a/packages/contracts/src/resources/internal.ts b/packages/contracts/src/resources/internal.ts index 0cd0fce48..3c4b6fa27 100644 --- a/packages/contracts/src/resources/internal.ts +++ b/packages/contracts/src/resources/internal.ts @@ -5,6 +5,30 @@ const contract = initContract(); export const internalApi = contract.router( { + scheduleAction: { + method: "POST", + path: "/actions/:stageId/schedule", + summary: "Schedule an action to run", + description: "Schedule an action to run on a Pub in a stage to run at a later time.", + pathParams: z.object({ + stageId: z.string(), + }), + body: z.object({ + pubId: z.string(), + }), + responses: { + 200: z + .array( + z.object({ + actionInstanceName: z.string(), + actionInstanceId: z.string(), + runAt: z.string(), + result: z.any(), + }) + ) + .optional(), + }, + }, triggerAction: { method: "POST", path: "/actions/:stageId/trigger", @@ -15,7 +39,7 @@ export const internalApi = contract.router( stageId: z.string(), }), body: z.object({ - event: z.enum(["pubLeftStage", "pubEnteredStage"]), + event: z.enum(["pubLeftStage", "pubEnteredStage", "pubInStageForDuration"]), pubId: z.string(), }), responses: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c67f2230b..53eadcc5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,7 +152,7 @@ importers: version: link:../packages/sdk '@sentry/nextjs': specifier: ^7.106.1 - version: 7.109.0(next@14.2.1)(react@18.2.0) + version: 7.109.0(next@14.2.3)(react@18.2.0) '@stoplight/elements': specifier: ^7.12.2 version: 7.12.2(@babel/core@7.24.3)(react-dom@18.2.0)(react@18.2.0) @@ -170,7 +170,7 @@ importers: version: 3.28.0(zod@3.22.4) '@ts-rest/next': specifier: ^3.33.1 - version: 3.33.1(next@14.2.1)(zod@3.22.4) + version: 3.33.1(next@14.2.3)(zod@3.22.4) '@ts-rest/open-api': specifier: ^3.28.0 version: 3.28.0(@ts-rest/core@3.28.0)(zod@3.22.4) @@ -186,6 +186,9 @@ importers: contracts: specifier: workspace:* version: link:../packages/contracts + date-fns: + specifier: ^3.6.0 + version: 3.6.0 debounce: specifier: ^2.0.0 version: 2.0.0 @@ -211,14 +214,14 @@ importers: specifier: ^0.356.0 version: 0.356.0(react@18.2.0) next: - specifier: 14.2.1 - version: 14.2.1(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) + specifier: 14.2.3 + version: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) next-connect: specifier: ^1.0.0 version: 1.0.0 next-runtime-env: specifier: ^3.2.0 - version: 3.2.0(next@14.2.1)(react@18.2.0) + version: 3.2.0(next@14.2.3)(react@18.2.0) nodemailer: specifier: ^6.9.5 version: 6.9.5 @@ -3895,6 +3898,10 @@ packages: resolution: {integrity: sha512-qsHJle3GU3CmVx7pUoXcghX4sRN+vINkbLdH611T8ZlsP//grzqVW87BSUgOZeSAD4q7ZdZicdwNe/20U2janA==} dev: false + /@next/env@14.2.3: + resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==} + dev: false + /@next/eslint-plugin-next@14.1.4: resolution: {integrity: sha512-n4zYNLSyCo0Ln5b7qxqQeQ34OZKXwgbdcx6kmkQbywr+0k6M3Vinft0T72R6CDAcDrne2IAgSud4uWCzFgc5HA==} dependencies: @@ -3910,6 +3917,15 @@ packages: dev: false optional: true + /@next/swc-darwin-arm64@14.2.3: + resolution: {integrity: sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-darwin-x64@14.2.1: resolution: {integrity: sha512-dAdWndgdQi7BK2WSXrx4lae7mYcOYjbHJUhvOUnJjMNYrmYhxbbvJ2xElZpxNxdfA6zkqagIB9He2tQk+l16ew==} engines: {node: '>= 10'} @@ -3919,6 +3935,15 @@ packages: dev: false optional: true + /@next/swc-darwin-x64@14.2.3: + resolution: {integrity: sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-gnu@14.2.1: resolution: {integrity: sha512-2ZctfnyFOGvTkoD6L+DtQtO3BfFz4CapoHnyLTXkOxbZkVRgg3TQBUjTD/xKrO1QWeydeo8AWfZRg8539qNKrg==} engines: {node: '>= 10'} @@ -3928,6 +3953,15 @@ packages: dev: false optional: true + /@next/swc-linux-arm64-gnu@14.2.3: + resolution: {integrity: sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-musl@14.2.1: resolution: {integrity: sha512-jazZXctiaanemy4r+TPIpFP36t1mMwWCKMsmrTRVChRqE6putyAxZA4PDujx0SnfvZHosjdkx9xIq9BzBB5tWg==} engines: {node: '>= 10'} @@ -3937,6 +3971,15 @@ packages: dev: false optional: true + /@next/swc-linux-arm64-musl@14.2.3: + resolution: {integrity: sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-gnu@14.2.1: resolution: {integrity: sha512-VjCHWCjsAzQAAo8lkBOLEIkBZFdfW+Z18qcQ056kL4KpUYc8o59JhLDCBlhg+hINQRgzQ2UPGma2AURGOH0+Qg==} engines: {node: '>= 10'} @@ -3946,6 +3989,15 @@ packages: dev: false optional: true + /@next/swc-linux-x64-gnu@14.2.3: + resolution: {integrity: sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-musl@14.2.1: resolution: {integrity: sha512-7HZKYKvAp4nAHiHIbY04finRqjeYvkITOGOurP1aLMexIFG/1+oCnqhGogBdc4lao/lkMW1c+AkwWSzSlLasqw==} engines: {node: '>= 10'} @@ -3955,6 +4007,15 @@ packages: dev: false optional: true + /@next/swc-linux-x64-musl@14.2.3: + resolution: {integrity: sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-arm64-msvc@14.2.1: resolution: {integrity: sha512-YGHklaJ/Cj/F0Xd8jxgj2p8po4JTCi6H7Z3Yics3xJhm9CPIqtl8erlpK1CLv+HInDqEWfXilqatF8YsLxxA2Q==} engines: {node: '>= 10'} @@ -3964,6 +4025,15 @@ packages: dev: false optional: true + /@next/swc-win32-arm64-msvc@14.2.3: + resolution: {integrity: sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-ia32-msvc@14.2.1: resolution: {integrity: sha512-o+ISKOlvU/L43ZhtAAfCjwIfcwuZstiHVXq/BDsZwGqQE0h/81td95MPHliWCnFoikzWcYqh+hz54ZB2FIT8RA==} engines: {node: '>= 10'} @@ -3973,6 +4043,15 @@ packages: dev: false optional: true + /@next/swc-win32-ia32-msvc@14.2.3: + resolution: {integrity: sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-x64-msvc@14.2.1: resolution: {integrity: sha512-GmRoTiLcvCLifujlisknv4zu9/C4i9r0ktsA8E51EMqJL4bD4CpO7lDYr7SrUxCR0tS4RVcrqKmCak24T0ohaw==} engines: {node: '>= 10'} @@ -3982,6 +4061,15 @@ packages: dev: false optional: true + /@next/swc-win32-x64-msvc@14.2.3: + resolution: {integrity: sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7382,7 +7470,38 @@ packages: '@sentry/vercel-edge': 7.109.0 '@sentry/webpack-plugin': 1.21.0 chalk: 3.0.0 - next: 14.2.1(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) + next: 14.2.1(@babel/core@7.22.17)(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + resolve: 1.22.8 + rollup: 2.78.0 + stacktrace-parser: 0.1.10 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + + /@sentry/nextjs@7.109.0(next@14.2.3)(react@18.2.0): + resolution: {integrity: sha512-AT0jhMDj7N57z8+XfgEyTJBogpU64z4mQpfOsSF5uuequzo3IlVVoJcu88jdqUkaVFxBJp3aF2T4nz65OHLoeA==} + engines: {node: '>=8'} + peerDependencies: + next: ^10.0.8 || ^11.0 || ^12.0 || ^13.0 || ^14.0 + react: 16.x || 17.x || 18.x + webpack: '>= 4.0.0' + peerDependenciesMeta: + webpack: + optional: true + dependencies: + '@rollup/plugin-commonjs': 24.0.0(rollup@2.78.0) + '@sentry/core': 7.109.0 + '@sentry/integrations': 7.109.0 + '@sentry/node': 7.109.0 + '@sentry/react': 7.109.0(react@18.2.0) + '@sentry/types': 7.109.0 + '@sentry/utils': 7.109.0 + '@sentry/vercel-edge': 7.109.0 + '@sentry/webpack-plugin': 1.21.0 + chalk: 3.0.0 + next: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 resolve: 1.22.8 rollup: 2.78.0 @@ -8577,7 +8696,7 @@ packages: dependencies: zod: 3.22.4 - /@ts-rest/next@3.33.1(next@14.2.1)(zod@3.22.4): + /@ts-rest/next@3.33.1(next@14.2.3)(zod@3.22.4): resolution: {integrity: sha512-/QhxcCbwxthxDBsKUaYc209S0j/fIP6m8RrFGVDTPXkKtCCZMLeo+nlDPfSwYdUZSiI+tw807S4st1Nn4t8Cwg==} peerDependencies: next: ^12.0.0 || ^13.0.0 || ^14.0.0 @@ -8586,7 +8705,7 @@ packages: zod: optional: true dependencies: - next: 14.2.1(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) + next: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) zod: 3.22.4 dev: false @@ -14715,13 +14834,13 @@ packages: regexparam: 2.0.1 dev: false - /next-runtime-env@3.2.0(next@14.2.1)(react@18.2.0): + /next-runtime-env@3.2.0(next@14.2.3)(react@18.2.0): resolution: {integrity: sha512-rwe3flUgSRm51hzRN4Vt5MMSYMS4aDMEPJa0r+CMONA3UyUZl8Y5O8zjHSIlaNb3yquTCttZ0ahObPyPprBj9g==} peerDependencies: next: ^14 react: ^18 dependencies: - next: 14.2.1(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) + next: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 dev: false @@ -14772,8 +14891,8 @@ packages: - babel-plugin-macros dev: false - /next@14.2.1(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-SF3TJnKdH43PMkCcErLPv+x/DY1YCklslk3ZmwaVoyUfDgHKexuKlf9sEfBQ69w+ue8jQ3msLb+hSj1T19hGag==} + /next@14.2.3(@babel/core@7.24.3)(@opentelemetry/api@1.7.0)(@playwright/test@1.43.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -14790,7 +14909,7 @@ packages: sass: optional: true dependencies: - '@next/env': 14.2.1 + '@next/env': 14.2.3 '@opentelemetry/api': 1.7.0 '@playwright/test': 1.43.1 '@swc/helpers': 0.5.5 @@ -14802,15 +14921,15 @@ packages: react-dom: 18.2.0(react@18.2.0) styled-jsx: 5.1.1(@babel/core@7.24.3)(react@18.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.1 - '@next/swc-darwin-x64': 14.2.1 - '@next/swc-linux-arm64-gnu': 14.2.1 - '@next/swc-linux-arm64-musl': 14.2.1 - '@next/swc-linux-x64-gnu': 14.2.1 - '@next/swc-linux-x64-musl': 14.2.1 - '@next/swc-win32-arm64-msvc': 14.2.1 - '@next/swc-win32-ia32-msvc': 14.2.1 - '@next/swc-win32-x64-msvc': 14.2.1 + '@next/swc-darwin-arm64': 14.2.3 + '@next/swc-darwin-x64': 14.2.3 + '@next/swc-linux-arm64-gnu': 14.2.3 + '@next/swc-linux-arm64-musl': 14.2.3 + '@next/swc-linux-x64-gnu': 14.2.3 + '@next/swc-linux-x64-musl': 14.2.3 + '@next/swc-win32-arm64-msvc': 14.2.3 + '@next/swc-win32-ia32-msvc': 14.2.3 + '@next/swc-win32-x64-msvc': 14.2.3 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros