diff --git a/.github/workflows/deploy-workflows.yml b/.github/workflows/deploy-workflows.yml
new file mode 100644
index 00000000000..d154016e691
--- /dev/null
+++ b/.github/workflows/deploy-workflows.yml
@@ -0,0 +1,24 @@
+name: Fly Deploy Workflows
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "apps/workflows/**"
+ - "packages/db/**"
+ - "packages/emails/**"
+ - "packages/utils/**"
+ - "packages/tsconfig/**"
+jobs:
+ deploy-workflows:
+ name: Deploy Workflows
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: superfly/flyctl-actions/setup-flyctl@master
+ - working-directory: apps/workflows
+ name: Deploy Workflows
+ run: |
+ flyctl deploy --remote-only --wait-timeout=500
+ env:
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/apps/server/src/v1/pageSubscribers/post.ts b/apps/server/src/v1/pageSubscribers/post.ts
index dc7bdf95069..1c355a8ad19 100644
--- a/apps/server/src/v1/pageSubscribers/post.ts
+++ b/apps/server/src/v1/pageSubscribers/post.ts
@@ -4,7 +4,7 @@ import { and, eq } from "@openstatus/db";
import { db } from "@openstatus/db/src/db";
import { page, pageSubscriber } from "@openstatus/db/src/schema";
import { SubscribeEmail } from "@openstatus/emails";
-import { sendEmail } from "@openstatus/emails/emails/send";
+import { sendEmail } from "@openstatus/emails/src/send";
import { HTTPException } from "hono/http-exception";
import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses";
import type { pageSubscribersApi } from "./index";
@@ -42,9 +42,16 @@ const postRouteSubscriber = createRoute({
export function registerPostPageSubscriber(api: typeof pageSubscribersApi) {
return api.openapi(postRouteSubscriber, async (c) => {
const workspaceId = c.get("workspaceId");
+ const limits = c.get("limits");
const input = c.req.valid("json");
const { id } = c.req.valid("param");
+ if (!limits["status-subscribers"]) {
+ throw new HTTPException(403, {
+ message: "Upgrade for status page subscribers",
+ });
+ }
+
const _page = await db
.select()
.from(page)
diff --git a/apps/server/src/v1/statusReportUpdates/post.ts b/apps/server/src/v1/statusReportUpdates/post.ts
index 5a5a863d4bd..d33d075db34 100644
--- a/apps/server/src/v1/statusReportUpdates/post.ts
+++ b/apps/server/src/v1/statusReportUpdates/post.ts
@@ -47,8 +47,8 @@ export function registerPostStatusReportUpdate(
) {
return api.openapi(createStatusUpdate, async (c) => {
const workspaceId = c.get("workspaceId");
- const workspacePlan = c.get("workspacePlan");
const input = c.req.valid("json");
+ const limits = c.get("limits");
const _statusReport = await db
.select()
@@ -80,7 +80,7 @@ export function registerPostStatusReportUpdate(
// send email
- if (workspacePlan.limits.notifications && _statusReport.pageId) {
+ if (limits["status-subscribers"] && _statusReport.pageId) {
const subscribers = await db
.select()
.from(pageSubscriber)
@@ -98,18 +98,15 @@ export function registerPostStatusReportUpdate(
.where(eq(page.id, _statusReport.pageId))
.get();
if (pageInfo) {
- const subscribersEmails = subscribers.map(
- (subscriber) => subscriber.email,
- );
-
- // TODO: verify if we leak any email data here
- await sendEmailHtml({
- to: subscribersEmails,
+ const subscribersEmails = subscribers.map((subscriber) => ({
+ to: subscriber.email,
subject: `New status update for ${pageInfo.title}`,
html: `
Hi,
${pageInfo.title} just posted an update on their status page:
New Status : ${statusReportUpdate.status}
${statusReportUpdate.message}
Powered by OpenStatus
- `,
+ `,
from: "Notification OpenStatus ",
- });
+ }));
+
+ await sendEmailHtml(subscribersEmails);
}
}
diff --git a/apps/server/src/v1/statusReports/post.ts b/apps/server/src/v1/statusReports/post.ts
index 5469b6a2857..1346ed13360 100644
--- a/apps/server/src/v1/statusReports/post.ts
+++ b/apps/server/src/v1/statusReports/post.ts
@@ -11,7 +11,7 @@ import {
} from "@openstatus/db/src/schema";
import { getLimit } from "@openstatus/db/src/schema/plan/utils";
-import { sendBatchEmailHtml } from "@openstatus/emails/emails/send";
+import { sendBatchEmailHtml } from "@openstatus/emails/src/send";
import { HTTPException } from "hono/http-exception";
import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses";
import { isoDate } from "../utils";
diff --git a/apps/server/src/v1/statusReports/update/post.ts b/apps/server/src/v1/statusReports/update/post.ts
index 7528e7c7b08..7f4710b46fd 100644
--- a/apps/server/src/v1/statusReports/update/post.ts
+++ b/apps/server/src/v1/statusReports/update/post.ts
@@ -7,7 +7,7 @@ import {
statusReportUpdate,
} from "@openstatus/db/src/schema";
import { getLimit } from "@openstatus/db/src/schema/plan/utils";
-import { sendBatchEmailHtml } from "@openstatus/emails/emails/send";
+import { sendBatchEmailHtml } from "@openstatus/emails/src/send";
import { HTTPException } from "hono/http-exception";
import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses";
import { StatusReportUpdateSchema } from "../../statusReportUpdates/schema";
diff --git a/apps/web/src/app/(content)/blog/[slug]/page.tsx b/apps/web/src/app/(content)/blog/[slug]/page.tsx
index 080644f63ff..81c7797022b 100644
--- a/apps/web/src/app/(content)/blog/[slug]/page.tsx
+++ b/apps/web/src/app/(content)/blog/[slug]/page.tsx
@@ -61,9 +61,7 @@ export async function generateMetadata({
export default function PostPage({ params }: { params: { slug: string } }) {
const post = allPosts.find((post) => post.slug === params.slug);
- if (!post) {
- notFound();
- }
+ if (!post) notFound();
return (
<>
diff --git a/apps/web/src/lib/auth/index.ts b/apps/web/src/lib/auth/index.ts
index c0ce5061cde..6d0e15aedd1 100644
--- a/apps/web/src/lib/auth/index.ts
+++ b/apps/web/src/lib/auth/index.ts
@@ -4,7 +4,7 @@ import NextAuth from "next-auth";
import { analytics, trackAnalytics } from "@openstatus/analytics";
import { db, eq } from "@openstatus/db";
import { user } from "@openstatus/db/src/schema";
-import { sendEmail } from "@openstatus/emails/emails/send";
+import { sendEmail } from "@openstatus/emails/src/send";
import { identifyUser } from "@/providers/posthog";
import { WelcomeEmail } from "@openstatus/emails/emails/welcome";
diff --git a/apps/workflows/.dockerignore b/apps/workflows/.dockerignore
new file mode 100644
index 00000000000..2431ccfa775
--- /dev/null
+++ b/apps/workflows/.dockerignore
@@ -0,0 +1,15 @@
+# This file is generated by Dofigen v2.1.0
+# See https://github.com/lenra-io/dofigen
+
+node_modules
+/apps/docs
+/apps/screenshot-service
+/apps/server
+/apps/web
+/packages/analytics
+/packages/api
+/packages/error
+/packages/notifications
+/packages/tinybird
+/packages/tracker
+/packages/upstash
diff --git a/apps/workflows/.gitignore b/apps/workflows/.gitignore
new file mode 100644
index 00000000000..506e4c37e78
--- /dev/null
+++ b/apps/workflows/.gitignore
@@ -0,0 +1,2 @@
+# deps
+node_modules/
diff --git a/apps/workflows/Dockerfile b/apps/workflows/Dockerfile
new file mode 100644
index 00000000000..e62808cc671
--- /dev/null
+++ b/apps/workflows/Dockerfile
@@ -0,0 +1,42 @@
+# syntax=docker/dockerfile:1.7
+# This file is generated by Dofigen v2.1.0
+# See https://github.com/lenra-io/dofigen
+
+# install
+FROM oven/bun@sha256:e2c0b11e277f0285e089ffb77ad831faeec2833b9c4b04d6d317f054e587ef4e AS install
+WORKDIR /app/
+RUN \
+ --mount=type=bind,target=package.json,source=package.json \
+ --mount=type=bind,target=apps/workflows/package.json,source=apps/workflows/package.json \
+ --mount=type=bind,target=packages/assertions/package.json,source=packages/assertions/package.json \
+ --mount=type=bind,target=packages/db/package.json,source=packages/db/package.json \
+ --mount=type=bind,target=packages/emails/package.json,source=packages/emails/package.json \
+ --mount=type=bind,target=packages/utils/package.json,source=packages/utils/package.json \
+ --mount=type=bind,target=packages/tsconfig/package.json,source=packages/tsconfig/package.json \
+ --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
+ bun install --production --ignore-scripts --frozen-lockfile --verbose
+
+# build
+FROM oven/bun@sha256:e2c0b11e277f0285e089ffb77ad831faeec2833b9c4b04d6d317f054e587ef4e AS build
+ENV NODE_ENV="production"
+WORKDIR /app/apps/workflows
+COPY \
+ --link \
+ "." "/app/"
+COPY \
+ --from=install \
+ --link \
+ "/app/node_modules" "/app/node_modules"
+RUN bun build --compile --sourcemap src/index.ts --outfile=app
+
+# runtime
+FROM debian@sha256:610b4c7ad241e66f6e2f9791e3abdf0cc107a69238ab21bf9b4695d51fd6366a AS runtime
+COPY \
+ --from=build \
+ --chown=1000:1000 \
+ --chmod=555 \
+ --link \
+ "/app/apps/workflows/app" "/bin/"
+USER 1000:1000
+EXPOSE 3000
+ENTRYPOINT ["/bin/app"]
diff --git a/apps/workflows/README.md b/apps/workflows/README.md
new file mode 100644
index 00000000000..5b08cc1d454
--- /dev/null
+++ b/apps/workflows/README.md
@@ -0,0 +1,33 @@
+## Development
+
+To install dependencies:
+```sh
+bun install
+```
+
+To run:
+```sh
+bun run dev
+```
+
+open http://localhost:3000
+
+
+## Deploy
+
+From root
+
+```bash
+flyctl deploy --config apps/workflows/fly.toml --dockerfile apps/workflows/Dockerfile
+```
+
+## Docker
+
+The Dockerfile is generated thanks to [Dofigen](https://github.com/lenra-io/dofigen). To generate the Dockerfile, run the following command from the `apps/workflows` directory:
+
+```bash
+# Update the dependent image versions
+dofigen update
+# Generate the Dockerfile
+dofigen gen
+```
diff --git a/apps/workflows/dofigen.lock b/apps/workflows/dofigen.lock
new file mode 100644
index 00000000000..567b5902c7b
--- /dev/null
+++ b/apps/workflows/dofigen.lock
@@ -0,0 +1,134 @@
+effective: |
+ ignore:
+ - node_modules
+ - /apps/docs
+ - /apps/screenshot-service
+ - /apps/server
+ - /apps/web
+ - /packages/analytics
+ - /packages/api
+ - /packages/error
+ - /packages/notifications
+ - /packages/tinybird
+ - /packages/tracker
+ - /packages/upstash
+ builders:
+ install:
+ fromImage:
+ path: oven/bun
+ digest: sha256:e2c0b11e277f0285e089ffb77ad831faeec2833b9c4b04d6d317f054e587ef4e
+ workdir: /app/
+ run:
+ - bun install --production --ignore-scripts --frozen-lockfile --verbose
+ cache:
+ - target: /root/.bun/install/cache
+ bind:
+ - target: package.json
+ source: package.json
+ - target: apps/workflows/package.json
+ source: apps/workflows/package.json
+ - target: packages/assertions/package.json
+ source: packages/assertions/package.json
+ - target: packages/db/package.json
+ source: packages/db/package.json
+ - target: packages/emails/package.json
+ source: packages/emails/package.json
+ - target: packages/utils/package.json
+ source: packages/utils/package.json
+ - target: packages/tsconfig/package.json
+ source: packages/tsconfig/package.json
+ build:
+ fromImage:
+ path: oven/bun
+ digest: sha256:e2c0b11e277f0285e089ffb77ad831faeec2833b9c4b04d6d317f054e587ef4e
+ workdir: /app/apps/workflows
+ env:
+ NODE_ENV: production
+ copy:
+ - paths:
+ - .
+ target: /app/
+ - fromBuilder: install
+ paths:
+ - /app/node_modules
+ target: /app/node_modules
+ run:
+ - bun build --compile --sourcemap src/index.ts --outfile=app
+ fromImage:
+ path: debian
+ digest: sha256:610b4c7ad241e66f6e2f9791e3abdf0cc107a69238ab21bf9b4695d51fd6366a
+ copy:
+ - fromBuilder: build
+ paths:
+ - /app/apps/workflows/app
+ target: /bin/
+ chmod: '555'
+ entrypoint:
+ - /bin/app
+ expose:
+ - port: 3000
+images:
+ registry.hub.docker.com:443:
+ library:
+ debian:
+ bullseye-slim:
+ digest: sha256:610b4c7ad241e66f6e2f9791e3abdf0cc107a69238ab21bf9b4695d51fd6366a
+ oven:
+ bun:
+ latest:
+ digest: sha256:e2c0b11e277f0285e089ffb77ad831faeec2833b9c4b04d6d317f054e587ef4e
+resources:
+ dofigen.yml:
+ hash: d232b15ff842b392611e64b97bf65d642ca573052072490c2f54ea2f4dc4481e
+ content: |
+ ignore:
+ - node_modules
+ - /apps/docs
+ - /apps/screenshot-service
+ - /apps/server
+ - /apps/web
+ - /packages/analytics
+ - /packages/api
+ - /packages/error
+ - /packages/notifications
+ - /packages/tinybird
+ - /packages/tracker
+ - /packages/upstash
+ builders:
+ install:
+ fromImage: oven/bun
+ workdir: /app/
+ # Copy project
+ bind:
+ - package.json
+ - apps/workflows/package.json
+ - packages/assertions/package.json
+ - packages/db/package.json
+ - packages/emails/package.json
+ - packages/utils/package.json
+ - packages/tsconfig/package.json
+ # Install dependencies
+ run: bun install --production --ignore-scripts --frozen-lockfile --verbose
+ cache:
+ - /root/.bun/install/cache
+ build:
+ fromImage: oven/bun
+ workdir: /app/apps/workflows
+ copy:
+ - . /app/
+ - fromBuilder: install
+ source: /app/node_modules
+ target: /app/node_modules
+ # Should set env to production here
+ # Compile the TypeScript application
+ env:
+ NODE_ENV: production
+ run: bun build --compile --sourcemap src/index.ts --outfile=app
+ fromImage: debian:bullseye-slim
+ copy:
+ - fromBuilder: build
+ source: /app/apps/workflows/app
+ target: /bin/
+ chmod: "555"
+ expose: 3000
+ entrypoint: /bin/app
diff --git a/apps/workflows/dofigen.yml b/apps/workflows/dofigen.yml
new file mode 100644
index 00000000000..9d8f170b50e
--- /dev/null
+++ b/apps/workflows/dofigen.yml
@@ -0,0 +1,51 @@
+ignore:
+ - node_modules
+ - /apps/docs
+ - /apps/screenshot-service
+ - /apps/server
+ - /apps/web
+ - /packages/analytics
+ - /packages/api
+ - /packages/error
+ - /packages/notifications
+ - /packages/tinybird
+ - /packages/tracker
+ - /packages/upstash
+builders:
+ install:
+ fromImage: oven/bun
+ workdir: /app/
+ # Copy project
+ bind:
+ - package.json
+ - apps/workflows/package.json
+ - packages/assertions/package.json
+ - packages/db/package.json
+ - packages/emails/package.json
+ - packages/utils/package.json
+ - packages/tsconfig/package.json
+ # Install dependencies
+ run: bun install --production --ignore-scripts --frozen-lockfile --verbose
+ cache:
+ - /root/.bun/install/cache
+ build:
+ fromImage: oven/bun
+ workdir: /app/apps/workflows
+ copy:
+ - . /app/
+ - fromBuilder: install
+ source: /app/node_modules
+ target: /app/node_modules
+ # Should set env to production here
+ # Compile the TypeScript application
+ env:
+ NODE_ENV: production
+ run: bun build --compile --sourcemap src/index.ts --outfile=app
+fromImage: debian:bullseye-slim
+copy:
+ - fromBuilder: build
+ source: /app/apps/workflows/app
+ target: /bin/
+ chmod: "555"
+expose: 3000
+entrypoint: /bin/app
diff --git a/apps/workflows/fly.toml b/apps/workflows/fly.toml
new file mode 100644
index 00000000000..d436cba1640
--- /dev/null
+++ b/apps/workflows/fly.toml
@@ -0,0 +1,42 @@
+# fly.toml app configuration file generated for openstatus-workflows on 2024-11-09T11:20:33+01:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = 'openstatus-workflows'
+primary_region = 'ams'
+
+[build]
+ dockerfile = "./Dockerfile"
+
+[[vm]]
+ cpu_kind = "shared"
+ cpus = 1
+ memory_mb = 256
+
+[http_service]
+ internal_port = 3000
+ force_https = true
+ auto_stop_machines = "suspend"
+ auto_start_machines = true
+ min_machines_running = 1
+ processes = ["app"]
+
+[http_service.concurrency]
+ type = "requests"
+ hard_limit = 1000
+ soft_limit = 500
+
+[deploy]
+ strategy = "bluegreen"
+
+[[http_service.checks]]
+ grace_period = "10s"
+ interval = "15s"
+ method = "GET"
+ timeout = "5s"
+ path = "/ping"
+
+[env]
+ NODE_ENV = "production"
+ PORT = "3000"
\ No newline at end of file
diff --git a/apps/workflows/package.json b/apps/workflows/package.json
new file mode 100644
index 00000000000..2b08ba5d406
--- /dev/null
+++ b/apps/workflows/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@openstatus/workflows",
+ "scripts": {
+ "dev": "NODE_ENV=development bun run --hot src/index.ts",
+ "start": "NODE_ENV=production bun run src/index.ts",
+ "test": "bun test"
+ },
+ "dependencies": {
+ "@google-cloud/tasks": "4.0.1",
+ "@openstatus/db": "workspace:*",
+ "@openstatus/emails": "workspace:*",
+ "@openstatus/utils": "workspace:*",
+ "hono": "4.5.3",
+ "zod": "3.23.8"
+ },
+ "devDependencies": {
+ "@openstatus/tsconfig": "workspace:*",
+ "@types/bun": "latest"
+ }
+}
diff --git a/apps/workflows/src/cron/checker.ts b/apps/workflows/src/cron/checker.ts
new file mode 100644
index 00000000000..9c61c35558c
--- /dev/null
+++ b/apps/workflows/src/cron/checker.ts
@@ -0,0 +1,214 @@
+import { CloudTasksClient } from "@google-cloud/tasks";
+import type { google } from "@google-cloud/tasks/build/protos/protos";
+import { z } from "zod";
+
+import { and, db, eq, gte, lte, notInArray } from "@openstatus/db";
+import {
+ maintenance,
+ maintenancesToMonitors,
+ monitor,
+ type monitorStatusSchema,
+ monitorStatusTable,
+ selectMonitorSchema,
+ selectMonitorStatusSchema,
+} from "@openstatus/db/src/schema";
+
+import type { monitorPeriodicitySchema } from "@openstatus/db/src/schema/constants";
+import type { httpPayloadSchema, tpcPayloadSchema } from "@openstatus/utils";
+import { env } from "../env";
+
+export const isAuthorizedDomain = (url: string) => {
+ return url.includes(env().SITE_URL);
+};
+
+const client = new CloudTasksClient({
+ projectId: env().GCP_PROJECT_ID,
+ credentials: {
+ client_email: env().GCP_CLIENT_EMAIL,
+ private_key: env().GCP_PRIVATE_KEY.replaceAll("\\n", "\n"),
+ },
+});
+
+export async function sendCheckerTasks(
+ periodicity: z.infer,
+) {
+ const parent = client.queuePath(
+ env().GCP_PROJECT_ID,
+ env().GCP_LOCATION,
+ periodicity,
+ );
+
+ const timestamp = Date.now();
+
+ const currentMaintenance = db
+ .select({ id: maintenance.id })
+ .from(maintenance)
+ .where(
+ and(lte(maintenance.from, new Date()), gte(maintenance.to, new Date())),
+ )
+ .as("currentMaintenance");
+
+ const currentMaintenanceMonitors = db
+ .select({ id: maintenancesToMonitors.monitorId })
+ .from(maintenancesToMonitors)
+ .innerJoin(
+ currentMaintenance,
+ eq(maintenancesToMonitors.maintenanceId, currentMaintenance.id),
+ );
+
+ const result = await db
+ .select()
+ .from(monitor)
+ .where(
+ and(
+ eq(monitor.periodicity, periodicity),
+ eq(monitor.active, true),
+ notInArray(monitor.id, currentMaintenanceMonitors),
+ ),
+ )
+ .all();
+
+ console.log(`Start cron for ${periodicity}`);
+
+ const monitors = z.array(selectMonitorSchema).safeParse(result);
+ const allResult = [];
+
+ if (!monitors.success) {
+ console.error(`Error while fetching the monitors ${monitors.error.errors}`);
+ throw new Error("Error while fetching the monitors");
+ }
+
+ for (const row of monitors.data) {
+ const result = await db
+ .select()
+ .from(monitorStatusTable)
+ .where(eq(monitorStatusTable.monitorId, row.id))
+ .all();
+ const monitorStatus = z.array(selectMonitorStatusSchema).safeParse(result);
+
+ if (!monitorStatus.success) {
+ console.error(
+ `Error while fetching the monitor status ${monitorStatus.error.errors}`,
+ );
+ continue;
+ }
+
+ for (const region of row.regions) {
+ const status =
+ monitorStatus.data.find((m) => region === m.region)?.status || "active";
+
+ const response = createCronTask({
+ monitor: row,
+ timestamp,
+ client,
+ parent,
+ status,
+ region,
+ });
+ allResult.push(response);
+
+ // REMINDER: vercel.json cron doesn't support seconds - so we need to schedule another task in 30s
+ if (periodicity === "30s") {
+ const response = createCronTask({
+ monitor: row,
+ timestamp: timestamp + 30 * 1000, // we schedule another task in 30s
+ client,
+ parent,
+ status,
+ region,
+ });
+ allResult.push(response);
+ }
+ }
+ }
+
+ const allRequests = await Promise.allSettled(allResult);
+
+ const success = allRequests.filter((r) => r.status === "fulfilled").length;
+ const failed = allRequests.filter((r) => r.status === "rejected").length;
+
+ console.log(
+ `End cron for ${periodicity} with ${allResult.length} jobs with ${success} success and ${failed} failed`,
+ );
+}
+
+async function createCronTask({
+ monitor,
+ timestamp,
+ client,
+ parent,
+ status,
+ region,
+}: {
+ monitor: z.infer;
+ status: z.infer;
+ /**
+ * timestamp needs to be in ms
+ */
+ timestamp: number;
+ client: CloudTasksClient;
+ parent: string;
+ region: string;
+}) {
+ let payload:
+ | z.infer
+ | z.infer
+ | null = null;
+ let url: string | null = null;
+
+ //
+ if (monitor.jobType === "http") {
+ payload = {
+ workspaceId: String(monitor.workspaceId),
+ monitorId: String(monitor.id),
+ url: monitor.url,
+ method: monitor.method || "GET",
+ cronTimestamp: timestamp,
+ body: monitor.body,
+ headers: monitor.headers,
+ status: status,
+ assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null,
+ degradedAfter: monitor.degradedAfter,
+ timeout: monitor.timeout,
+ trigger: "cron",
+ } satisfies z.infer;
+ url = `https://openstatus-checker.fly.dev/checker/http?monitor_id=${monitor.id}`;
+ }
+ if (monitor.jobType === "tcp") {
+ payload = {
+ workspaceId: String(monitor.workspaceId),
+ monitorId: String(monitor.id),
+ uri: monitor.url,
+ status: status,
+ assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null,
+ cronTimestamp: timestamp,
+ degradedAfter: monitor.degradedAfter,
+ timeout: monitor.timeout,
+ trigger: "cron",
+ } satisfies z.infer;
+ url = `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${monitor.id}`;
+ }
+
+ if (!payload || !url) {
+ throw new Error("Invalid jobType");
+ }
+
+ const newTask: google.cloud.tasks.v2beta3.ITask = {
+ httpRequest: {
+ headers: {
+ "Content-Type": "application/json", // Set content type to ensure compatibility your application's request parsing
+ "fly-prefer-region": region, // Specify the region you want the request to be sent to
+ Authorization: `Basic ${env().CRON_SECRET}`,
+ },
+ httpMethod: "POST",
+ url,
+ body: Buffer.from(JSON.stringify(payload)).toString("base64"),
+ },
+ scheduleTime: {
+ seconds: timestamp / 1000,
+ },
+ };
+
+ const request = { parent: parent, task: newTask };
+ return client.createTask(request);
+}
diff --git a/apps/workflows/src/cron/emails.ts b/apps/workflows/src/cron/emails.ts
new file mode 100644
index 00000000000..fb45f06f2d2
--- /dev/null
+++ b/apps/workflows/src/cron/emails.ts
@@ -0,0 +1,29 @@
+import { and, gte, lte } from "@openstatus/db";
+import { db } from "@openstatus/db/src/db";
+import { user } from "@openstatus/db/src/schema";
+import { EmailClient } from "@openstatus/emails";
+import { env } from "../env";
+
+const email = new EmailClient({ apiKey: env().RESEND_API_KEY });
+
+export async function sendFollowUpEmails() {
+ // Get users created 2-3 days ago
+ const date1 = new Date();
+ date1.setDate(date1.getDate() - 3);
+ const date2 = new Date();
+ date2.setDate(date2.getDate() - 2);
+
+ const users = await db
+ .select()
+ .from(user)
+ .where(and(gte(user.createdAt, date1), lte(user.createdAt, date2)))
+ .all();
+
+ console.log(`Found ${users.length} users to send follow ups.`);
+
+ for (const user of users) {
+ if (user.email) {
+ await email.sendFollowUp({ to: user.email });
+ }
+ }
+}
diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts
new file mode 100644
index 00000000000..ed3df21aabb
--- /dev/null
+++ b/apps/workflows/src/cron/index.ts
@@ -0,0 +1,46 @@
+import { monitorPeriodicitySchema } from "@openstatus/db/src/schema/constants";
+import { Hono } from "hono";
+import { env } from "../env";
+import { sendCheckerTasks } from "./checker";
+import { sendFollowUpEmails } from "./emails";
+
+const app = new Hono({ strict: false });
+
+app.use("*", async (c, next) => {
+ if (c.req.header("authorization") !== env().CRON_SECRET) {
+ return c.text("Unauthorized", 401);
+ }
+
+ return next();
+});
+
+app.get("/checker/:period", async (c) => {
+ const period = c.req.param("period");
+
+ const schema = monitorPeriodicitySchema.safeParse(period);
+
+ if (!schema.success) {
+ return c.json({ error: schema.error.issues?.[0].message }, 400);
+ }
+
+ try {
+ await sendCheckerTasks(schema.data);
+
+ return c.json({ success: schema.data }, 200);
+ } catch (e) {
+ console.error(e);
+ return c.text("Internal Server Error", 500);
+ }
+});
+
+app.get("/emails/follow-up", async (c) => {
+ try {
+ await sendFollowUpEmails();
+ return c.json({ success: true }, 200);
+ } catch (e) {
+ console.error(e);
+ return c.text("Internal Server Error", 500);
+ }
+});
+
+export { app as cronRouter };
diff --git a/apps/workflows/src/env.ts b/apps/workflows/src/env.ts
new file mode 100644
index 00000000000..ebbcd300a4c
--- /dev/null
+++ b/apps/workflows/src/env.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+export const env = () =>
+ z
+ .object({
+ NODE_ENV: z.string().default("development"),
+ PORT: z.coerce.number().default(3000),
+ GCP_PROJECT_ID: z.string().default(""),
+ GCP_CLIENT_EMAIL: z.string().default(""),
+ GCP_PRIVATE_KEY: z.string().default(""),
+ GCP_LOCATION: z.string().default("europe-west1"),
+ CRON_SECRET: z.string().default(""),
+ SITE_URL: z.string().default("http://localhost:3000"),
+ DATABASE_URL: z.string().default("http://localhost:8080"),
+ DATABASE_AUTH_TOKEN: z.string().default(""),
+ RESEND_API_KEY: z.string().default(""),
+ TINY_BIRD_API_KEY: z.string().default(""),
+ })
+ .parse(process.env);
diff --git a/apps/workflows/src/index.ts b/apps/workflows/src/index.ts
new file mode 100644
index 00000000000..a58f570d3f0
--- /dev/null
+++ b/apps/workflows/src/index.ts
@@ -0,0 +1,33 @@
+import { Hono } from "hono";
+import { showRoutes } from "hono/dev";
+import { logger } from "hono/logger";
+import { cronRouter } from "./cron";
+import { env } from "./env";
+
+const { NODE_ENV, PORT } = env();
+
+const app = new Hono({ strict: false });
+
+app.use("/*", logger());
+
+app.get("/", (c) => c.text("workflows", 200));
+
+/**
+ * Ping Pong
+ */
+app.get("/ping", (c) => c.json({ ping: "pong" }, 200));
+
+/**
+ * Cron Routes
+ */
+app.route("/cron", cronRouter);
+
+if (NODE_ENV === "development") {
+ showRoutes(app, { verbose: true, colorize: true });
+}
+
+console.log(`Starting server on port ${PORT}`);
+
+const server = { port: PORT, fetch: app.fetch };
+
+export default server;
diff --git a/apps/workflows/src/scripts/tinybird.ts b/apps/workflows/src/scripts/tinybird.ts
new file mode 100644
index 00000000000..b9180f8d3eb
--- /dev/null
+++ b/apps/workflows/src/scripts/tinybird.ts
@@ -0,0 +1,119 @@
+import { db, eq } from "@openstatus/db";
+import { type WorkspacePlan, workspace } from "@openstatus/db/src/schema";
+import { env } from "../env";
+
+import readline from "node:readline";
+
+// Function to prompt user for confirmation
+const askConfirmation = async (question: string): Promise => {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ return new Promise((resolve) => {
+ rl.question(`${question} (y/n): `, (answer) => {
+ rl.close();
+ resolve(answer.trim().toLowerCase() === "y");
+ });
+ });
+};
+
+/**
+ * Calculates the unix timestamp in milliseconds for a given number of days in the past.
+ * @param days The number of days to subtract from the current date.
+ * @returns The calculated unix timestamp in milliseconds.
+ */
+function calculatePastTimestamp(days: number) {
+ const date = new Date();
+ date.setDate(date.getDate() - days);
+ const timestamp = date.getTime();
+ console.log(`${days}: ${timestamp}`);
+ return timestamp;
+}
+
+/**
+ * Get the array of workspace IDs for a given plan.
+ * @param plan The plan to filter by.
+ * @returns The array of workspace IDs.
+ */
+async function getWorkspaceIdsByPlan(plan: WorkspacePlan) {
+ const workspaces = await db
+ .select()
+ .from(workspace)
+ .where(eq(workspace.plan, plan))
+ .all();
+ const workspaceIds = workspaces.map((w) => w.id);
+ console.log(`${plan}: ${workspaceIds}`);
+ return workspaceIds;
+}
+
+/**
+ *
+ * @param timestamp timestamp to delete logs before (in milliseconds)
+ * @param workspaceIds array of workspace IDs to delete logs for
+ * @param reverse allows to NOT delete the logs for the given workspace IDs
+ * @returns
+ */
+async function deleteLogs(
+ timestamp: number,
+ workspaceIds: number[],
+ reverse = false,
+) {
+ const response = await fetch(
+ "https://api.tinybird.co/v0/datasources/ping_response__v8/delete",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Bearer ${env().TINY_BIRD_API_KEY}`,
+ },
+ body: new URLSearchParams({
+ delete_condition: `timestamp <= ${timestamp} AND ${reverse ? "NOT" : ""} arrayExists(x -> x IN (${workspaceIds.join(", ")}), [workspaceId])`,
+ }),
+ },
+ );
+ const json = await response.json();
+ console.log(json);
+
+ return json;
+}
+
+async function main() {
+ // check if the script is running in production
+ console.log(`DATABASE_URL: ${env().DATABASE_URL}`);
+
+ const isConfirmed = await askConfirmation(
+ "Are you sure you want to run this script?",
+ );
+
+ if (!isConfirmed) {
+ console.log("Script execution cancelled.");
+ return;
+ }
+
+ const lastTwoWeeks = calculatePastTimestamp(14);
+ const lastThreeMonths = calculatePastTimestamp(90);
+ const lastYear = calculatePastTimestamp(365);
+ const lastTwoYears = calculatePastTimestamp(730);
+
+ const starters = await getWorkspaceIdsByPlan("starter");
+ const teams = await getWorkspaceIdsByPlan("team");
+ const pros = await getWorkspaceIdsByPlan("pro");
+
+ // all other workspaces, we need to 'reverse' the deletion here to NOT include those workspaces
+ const rest = [...starters, ...teams, ...pros];
+
+ deleteLogs(lastTwoWeeks, rest, true);
+ deleteLogs(lastThreeMonths, starters);
+ deleteLogs(lastYear, teams);
+ deleteLogs(lastYear, pros);
+}
+
+/**
+ * REMINDER: do it manually (to avoid accidental deletion on dev mode)
+ * Within the app/workflows folder, run the following command:
+ * $ bun src/scripts/tinybird.ts
+ */
+
+// main().catch(console.error);
diff --git a/apps/workflows/tsconfig.json b/apps/workflows/tsconfig.json
new file mode 100644
index 00000000000..b9ff712ceb4
--- /dev/null
+++ b/apps/workflows/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "@openstatus/tsconfig/base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ }
+}
diff --git a/packages/api/src/router/statusReport.ts b/packages/api/src/router/statusReport.ts
index 04a0199a613..a624f758374 100644
--- a/packages/api/src/router/statusReport.ts
+++ b/packages/api/src/router/statusReport.ts
@@ -16,7 +16,7 @@ import {
statusReportUpdate,
workspace,
} from "@openstatus/db/src/schema";
-import { sendBatchEmailHtml } from "@openstatus/emails/emails/send";
+import { sendBatchEmailHtml } from "@openstatus/emails/src/send";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
diff --git a/packages/emails/emails/alert.tsx b/packages/emails/emails/alert.tsx
index 4cf14fda384..e2e0cea49ae 100644
--- a/packages/emails/emails/alert.tsx
+++ b/packages/emails/emails/alert.tsx
@@ -1,4 +1,4 @@
-"use client";
+/** @jsxImportSource react */
import {
Body,
diff --git a/packages/emails/emails/followup.tsx b/packages/emails/emails/followup.tsx
index 461cef22d9e..5bbd0de8552 100644
--- a/packages/emails/emails/followup.tsx
+++ b/packages/emails/emails/followup.tsx
@@ -1,3 +1,5 @@
+/** @jsxImportSource react */
+
import { Body, Head, Html, Link, Preview } from "@react-email/components";
const FollowUpEmail = () => {
diff --git a/packages/emails/emails/subscribe.tsx b/packages/emails/emails/subscribe.tsx
index 7d5c223f401..994817f61a9 100644
--- a/packages/emails/emails/subscribe.tsx
+++ b/packages/emails/emails/subscribe.tsx
@@ -1,6 +1,8 @@
+/** @jsxImportSource react */
+
import { Body, Head, Html, Link, Preview } from "@react-email/components";
-const SubscribeEmail = ({
+export const SubscribeEmail = ({
token,
page,
domain,
@@ -36,5 +38,3 @@ const SubscribeEmail = ({