feat(workflows): add Gmail sending and testing workflows

- Introduced a new `send-gmail` workflow for sending emails via Gmail SMTP, with configurable parameters for recipients, subject, and body content.
- Added a `test-send-gmail` workflow to facilitate testing the email sending functionality with sample data.
- Implemented a `track` workflow to monitor Gojek trips, enhancing the server's capabilities for tracking and notifications.
- Updated the workflow management interface to include new workflows and improved organization of existing scripts.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
2026-08-15 18:33:13 +07:00
parent 94ba931d9b
commit 7fe5defc43
22 changed files with 2486 additions and 159 deletions
+308
View File
@@ -0,0 +1,308 @@
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
const KNOWN_TOP = new Set(["name", "description", "enabled", "scripts", "triggers"]);
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
export const NEW_WORKFLOW_YAML = `name: new workflow
scripts:
- get-current-time.js
triggers:
- type: HTTP
method: POST
path: /new
`;
let uidSeq = 0;
export function nextUiId(prefix = "ui") {
uidSeq += 1;
return `${prefix}-${uidSeq}-${Math.random().toString(36).slice(2, 8)}`;
}
export function namespacedPath(owner, triggerPath) {
const cleaned = String(triggerPath ?? "")
.replace(/^\/+/, "")
.trim();
return `/u/${owner}/${cleaned}`;
}
/**
* @param {string} text
* @returns {{ doc: WorkflowDoc | null, parseError: string | null }}
*/
export function parseWorkflowYaml(text) {
let raw;
try {
raw = parseYaml(text ?? "");
} catch (err) {
return {
doc: null,
parseError: err instanceof Error ? err.message : String(err),
};
}
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
return { doc: null, parseError: "workflow yaml must be an object" };
}
return { doc: yamlObjectToDoc(raw), parseError: null };
}
/**
* @param {Record<string, unknown>} raw
*/
export function yamlObjectToDoc(raw) {
/** @type {Record<string, unknown>} */
const extra = {};
for (const [key, value] of Object.entries(raw)) {
if (!KNOWN_TOP.has(key)) extra[key] = value;
}
return {
name: typeof raw.name === "string" ? raw.name : "",
description: raw.description == null ? "" : String(raw.description),
enabled: raw.enabled !== false,
scripts: Array.isArray(raw.scripts) ? raw.scripts.map((step) => normalizeStep(step)) : [],
triggers: Array.isArray(raw.triggers)
? raw.triggers.map((t) => normalizeTrigger(t))
: [],
extra,
};
}
export function stringifyWorkflowDoc(doc) {
/** @type {Record<string, unknown>} */
const out = {};
if (doc?.name) out.name = doc.name;
if (doc?.description) out.description = doc.description;
if (doc?.enabled === false) out.enabled = false;
Object.assign(out, doc?.extra ?? {});
out.scripts = (doc?.scripts ?? []).map(dumpStep);
out.triggers = (doc?.triggers ?? []).map(dumpTrigger);
return stringifyYaml(out, { indent: 2, lineWidth: 0 });
}
export function newScriptStep(script, config = {}) {
return {
uiId: nextUiId("step"),
kind: "script",
script,
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
id: "",
when: "",
needs: null,
};
}
export function newSetStep() {
return {
uiId: nextUiId("step"),
kind: "set",
script: "set",
as: "value",
expression: "",
id: "",
when: "",
needs: null,
};
}
export function newHttpTrigger() {
return {
uiId: nextUiId("trig"),
type: "HTTP",
method: "POST",
path: "/new",
schedule: "",
auth: null,
response: "",
unauthorized: null,
};
}
export function newCronTrigger() {
return {
uiId: nextUiId("trig"),
type: "cron",
method: "POST",
path: "",
schedule: "* * * * *",
auth: null,
response: "",
unauthorized: null,
};
}
export function newWorkflowTrigger() {
return {
uiId: nextUiId("trig"),
type: "workflow",
method: "POST",
path: "",
schedule: "",
auth: null,
response: "",
unauthorized: null,
};
}
export function hasWorkflowTrigger(workflow) {
return (workflow?.triggers ?? []).some((t) => String(t?.type ?? "").toLowerCase() === "workflow");
}
export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
return (workflows ?? []).filter((w) => {
if (owner && w.owner !== owner) return false;
if (excludeFile && w.file === excludeFile && w.owner === owner) return false;
return hasWorkflowTrigger(w);
});
}
export { HTTP_METHODS };
function normalizeStep(step) {
const uiId = nextUiId("step");
if (typeof step === "string") {
return {
uiId,
kind: "script",
script: step,
config: {},
id: "",
when: "",
needs: null,
};
}
if (step == null || typeof step !== "object" || Array.isArray(step)) {
return {
uiId,
kind: "script",
script: "",
config: {},
id: "",
when: "",
needs: null,
};
}
if (step.set != null) {
const spec = step.set && typeof step.set === "object" ? step.set : {};
return {
uiId,
kind: "set",
script: "set",
as: typeof spec.as === "string" ? spec.as : "",
expression: typeof spec.expression === "string" ? spec.expression : "",
id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "",
needs: null,
};
}
const known = new Set(["script", "config", "id", "when", "needs", "set"]);
/** @type {Record<string, unknown>} */
const extra = {};
for (const [key, value] of Object.entries(step)) {
if (!known.has(key)) extra[key] = value;
}
const config =
step.config && typeof step.config === "object" && !Array.isArray(step.config)
? { ...step.config }
: {};
return {
uiId,
kind: "script",
script: typeof step.script === "string" ? step.script : "",
config,
id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "",
needs: step.needs ?? null,
extra: Object.keys(extra).length ? extra : undefined,
};
}
function normalizeTrigger(raw) {
const uiId = nextUiId("trig");
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
return newHttpTrigger();
}
let type = raw.type ?? "HTTP";
if (String(type).toLowerCase() === "http") type = "HTTP";
const known =
type === "HTTP"
? new Set(["type", "method", "path", "auth", "response", "unauthorized"])
: type === "cron"
? new Set(["type", "schedule"])
: new Set(["type"]);
/** @type {Record<string, unknown>} */
const extra = {};
for (const [key, value] of Object.entries(raw)) {
if (!known.has(key)) extra[key] = value;
}
return {
uiId,
type,
method: typeof raw.method === "string" && raw.method ? raw.method : "POST",
path: typeof raw.path === "string" ? raw.path : "",
schedule: typeof raw.schedule === "string" ? raw.schedule : "",
auth: raw.auth ?? null,
response: typeof raw.response === "string" ? raw.response : "",
unauthorized: raw.unauthorized ?? null,
extra: Object.keys(extra).length ? extra : undefined,
};
}
function dumpStep(step) {
if (step.kind === "set") {
/** @type {Record<string, unknown>} */
const out = {};
if (step.id) out.id = step.id;
out.set = {
expression: step.expression ?? "",
as: step.as ?? "",
};
if (step.when) out.when = step.when;
return out;
}
/** @type {Record<string, unknown>} */
const out = {};
if (step.id) out.id = step.id;
out.script = step.script ?? "";
const config = step.config;
if (config && typeof config === "object" && !Array.isArray(config) && Object.keys(config).length) {
out.config = config;
}
if (step.when) out.when = step.when;
if (step.needs != null && !isEmptyNeeds(step.needs)) out.needs = step.needs;
Object.assign(out, step.extra ?? {});
return out;
}
function dumpTrigger(t) {
const type = String(t?.type ?? "").toLowerCase() === "http" ? "HTTP" : t?.type;
if (type === "HTTP") {
/** @type {Record<string, unknown>} */
const out = {
type: "HTTP",
method: t.method || "POST",
path: t.path || "/",
};
if (t.auth != null && t.auth !== false && t.auth !== "") out.auth = t.auth;
if (t.response) out.response = t.response;
if (t.unauthorized != null && t.unauthorized !== "") out.unauthorized = t.unauthorized;
Object.assign(out, t.extra ?? {});
return out;
}
if (type === "cron") {
/** @type {Record<string, unknown>} */
const out = { type: "cron", schedule: t.schedule || "" };
Object.assign(out, t.extra ?? {});
return out;
}
if (type === "workflow") {
return { type: "workflow", ...(t.extra ?? {}) };
}
return { type: t?.type ?? "HTTP", ...(t.extra ?? {}) };
}
function isEmptyNeeds(needs) {
if (needs == null) return true;
if (Array.isArray(needs)) return needs.length === 0;
if (typeof needs === "object") return Object.keys(needs).length === 0;
return false;
}