initial commit

This commit is contained in:
pdgit 2026-03-31 23:46:11 +00:00
commit 89b15af73e
12 changed files with 479 additions and 0 deletions

1
README.md Normal file
View file

@ -0,0 +1 @@
Project repo_PLTo23

View file

@ -0,0 +1,5 @@
# DO NOT EDIT THIS FILE
---
schema: workflow.state/2022.04
id: p_WxCpgo3

View file

@ -0,0 +1,34 @@
export default defineComponent({
async run({ steps }) {
const rows = steps.parse.$return_value.rows;
if (!rows || !rows.length) {
throw new Error("No parsed rows found from the parse step.");
}
const parseAmount = (value) => {
const num = parseFloat(String(value || "0").replace(/,/g, "").trim());
return isNaN(num) ? 0 : num;
};
const payments = rows.map((row) => ({
method: (row["Method"] || "").trim(),
payee: (row["Payee"] || "").trim(),
invoice_numbers: (row["Invoice Numbers"] || "").trim(),
send_payment_on: (row["Send Payment On"] || "").trim(),
amount_usd: parseAmount(row["Amount in USD"]),
status: (row["Status"] || "").trim(),
company_subsidiary: (row["Company/Subsidiary"] || "").trim(),
}));
const grand_total = payments.reduce((sum, p) => sum + p.amount_usd, 0);
return {
file_name: steps.parse.$return_value.file_name,
row_count: payments.length,
grand_count: payments.length,
grand_total: Number(grand_total.toFixed(2)),
payments,
};
},
});

View file

@ -0,0 +1,61 @@
export default {
type: "action",
props: {
_filter: "any",
},
run({ $ }) {
const _filter = this._filter
if (!_filter || typeof _filter !== "object") {
throw new Error("filter not configured correctly")
}
const messageOnContinue = "Continuing workflow"
const messageOnEnd = "Ending workflow"
let result = evalRuleGroup(_filter.rules)
const _continue = ((typeof _filter.continue === "undefined") || _filter.continue === "true") ? result : !result
return _continue ? $.export("$summary", messageOnContinue) : $.flow.exit(messageOnEnd)
},
}
// config.rules
function evalRuleGroup(rules) {
const and = rules.t === "and"
let v = and
for (const r of rules.r) {
const b = ("t" in r) ? evalRuleGroup(r) : evalRule(r)
v = and ? v && b : v || b
}
return v
}
// XXX if rule.a is {{something}} should probably NOT +rule.a, but if rule.a is "12" we should... hmm
function evalRule(rule) {
switch (rule.op) {
default: throw new Error(`unhandled op: ${rule.op}`)
case "exist": return rule.a !== undefined
case "!exist": return rule.a === undefined
case "null": return rule.a === null
case "!null": return rule.a !== null
case "str": return typeof rule.a === "string"
case "!str": return typeof rule.a !== "string"
case "str ": return typeof rule.a === "string" && !!rule.a.match(/^\s*$/)
case "str! ": return typeof rule.a === "string" && !rule.a.match(/^\s*$/)
case "str=": return typeof rule.a === "string" && typeof rule.b === "string" && (rule.a.localeCompare(rule.b, undefined, { sensitivity: rule.matchCase ? "variant" : "base" }) == 0)
case "str!=": return typeof rule.a === "string" && typeof rule.b === "string" && (rule.a.localeCompare(rule.b, undefined, { sensitivity: rule.matchCase ? "variant" : "base" }) != 0)
case "str^": return typeof rule.a === "string" && typeof rule.b === "string" && (rule.matchCase ? rule.a.startsWith(rule.b) : rule.a.toLowerCase().startsWith(rule.b.toLowerCase()))
case "str/": return typeof rule.a === "string" && typeof rule.b === "string" && (rule.matchCase ? rule.a.includes(rule.b) : rule.a.toLowerCase().includes(rule.b.toLowerCase()))
case "str!/": return typeof rule.a === "string" && typeof rule.b === "string" && (rule.matchCase ? !rule.a.includes(rule.b) : !rule.a.toLowerCase().includes(rule.b.toLowerCase()))
case "str$": return typeof rule.a === "string" && typeof rule.b === "string" && (rule.matchCase ? rule.a.endsWith(rule.b) : rule.a.toLowerCase().endsWith(rule.b.toLowerCase()))
case "num": return typeof rule.a === "number"
case "!num": return typeof rule.a !== "number"
case "num=": return typeof +rule.a === "number" && typeof +rule.b === "number" && +rule.a === +rule.b
case "num!=": return typeof +rule.a === "number" && typeof +rule.b === "number" && +rule.a !== +rule.b
case "num>": return typeof +rule.a === "number" && typeof +rule.b === "number" && +rule.a > +rule.b
case "num>=": return typeof +rule.a === "number" && typeof +rule.b === "number" && +rule.a >= +rule.b
case "num<": return typeof +rule.a === "number" && typeof +rule.b === "number" && +rule.a < +rule.b
case "num<=": return typeof +rule.a === "number" && typeof +rule.b === "number" && +rule.a <= +rule.b
case "true": return typeof rule.a === "boolean" && rule.a
case "false": return typeof rule.a === "boolean" && !rule.a
case "bool": return typeof rule.a === "boolean"
case "!bool": return typeof rule.a !== "boolean"
case "bool=": return typeof rule.a === "boolean" && typeof rule.b === "boolean" && rule.a === rule.b
}
}

View file

@ -0,0 +1,37 @@
import fs from "fs";
import { parse } from "csv-parse/sync";
export default defineComponent({
async run({ steps }) {
const filePath = `/tmp/${steps.trigger.event.name}`;
if (!fs.existsSync(filePath)) {
throw new Error(`File not found at ${filePath}`);
}
const csvText = fs.readFileSync(filePath, "utf8");
const rows = parse(csvText, {
columns: true,
skip_empty_lines: true,
trim: true,
});
const normalizedRows = rows.map((row) => {
const clean = {};
for (const [key, value] of Object.entries(row)) {
clean[String(key).trim()] = typeof value === "string" ? value.trim() : value;
}
return clean;
});
return {
file_path: filePath,
file_name: steps.trigger.event.name,
row_count: normalizedRows.length,
headers: normalizedRows.length ? Object.keys(normalizedRows[0]) : [],
sample_rows: normalizedRows.slice(0, 5),
rows: normalizedRows,
};
},
});

View file

@ -0,0 +1,8 @@
export default defineComponent({
async run({ steps }) {
return {
...steps.calculate.$return_value,
last_updated: new Date().toISOString(),
};
},
});

View file

@ -0,0 +1,49 @@
schema: workflow/2022.04
name: Payment Display
settings:
error_notification: true
triggers:
- id: dc_QyuaX1X
steps:
- namespace: filter
runtime: nodejs20.x
uses: "./filter/entry.js"
props:
_filter:
continue: 'true'
rules:
t: and
r:
- a: "{{steps.trigger.event.fileExtension}}"
op: str=
b: "{{steps.trigger.event.fileExtension}}"
matchCase: true
- namespace: download_file
runtime: nodejs20.x
uses: google_drive-download-file@0.1.22
props:
googleDrive:
authProvisionId: apn_dbh7Jkb
drive: My Drive
fileId: "{{steps.trigger.event.id}}"
filePath: "/tmp/{{steps.trigger.event.name}}"
- namespace: parse
runtime: nodejs20.x
uses: "./parse/entry.js"
- namespace: calculate
runtime: nodejs20.x
uses: "./calculate/entry.js"
- namespace: prepare_dashboard
runtime: nodejs20.x
uses: "./prepare_dashboard/entry.js"
- namespace: save_to_datastore
runtime: nodejs20.x
uses: data_stores-add-update-multiple-records@0.0.9
props:
dataStore:
storeId: ds_5ludZgA
data:
current_dashboard: "{{steps.prepare_dashboard.$return_value}}"
? "\n"
: ''

View file

@ -0,0 +1,5 @@
# DO NOT EDIT THIS FILE
---
schema: workflow.state/2022.04
id: p_3nC3laY

View file

@ -0,0 +1,182 @@
export default defineComponent({
async run({ steps }) {
const dashboard = steps.get_current_dashboard.$return_value;
const slackEvent = steps.handle_slack_event.$return_value || {};
const userId =
slackEvent.user_id ||
slackEvent.raw_body?.event?.user ||
slackEvent.raw_body?.user ||
slackEvent.raw_body?.authorizations?.[0]?.user_id;
if (!dashboard || Object.keys(dashboard).length === 0) {
throw new Error("No dashboard data found in Data Store.");
}
if (!userId) {
throw new Error("No Slack user ID found in the event payload.");
}
const payments = Array.isArray(dashboard.payments) ? dashboard.payments : [];
const formatCurrency = (value) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(Number(value || 0));
const parseMDYLocal = (value) => {
if (!value) return null;
const parts = String(value).split("/");
if (parts.length !== 3) return null;
const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10));
if (!mm || !dd || !yyyy) return null;
return new Date(yyyy, mm - 1, dd);
};
const formatDateKey = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const formatDisplayDate = (dateKey) => {
const [y, m, d] = dateKey.split("-").map(Number);
const dt = new Date(y, m - 1, d);
return dt.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
};
const today = new Date();
today.setHours(0, 0, 0, 0);
const futurePayments = payments.filter((p) => {
const dt = parseMDYLocal(p.send_payment_on);
return dt && dt >= today && (p.method === "ACH" || p.method === "Check");
});
const groupByMethodAndDay = (methodName) => {
const grouped = {};
for (const payment of futurePayments) {
if (payment.method !== methodName) continue;
const dt = parseMDYLocal(payment.send_payment_on);
if (!dt) continue;
const key = formatDateKey(dt);
if (!grouped[key]) {
grouped[key] = {
count: 0,
total: 0,
};
}
grouped[key].count += 1;
grouped[key].total += payment.amount_usd;
}
return Object.keys(grouped)
.sort()
.map((dateKey) => ({
date_key: dateKey,
display_date: formatDisplayDate(dateKey),
count: grouped[dateKey].count,
total: Number(grouped[dateKey].total.toFixed(2)),
}));
};
const achDays = groupByMethodAndDay("ACH");
const checkDays = groupByMethodAndDay("Check");
const buildDayBlocks = (title, days) => {
const total = days.reduce((sum, d) => sum + d.total, 0);
const count = days.reduce((sum, d) => sum + d.count, 0);
const blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${title}*\n${count} payments • ${formatCurrency(total)}`,
},
},
{
type: "divider",
},
];
if (!days.length) {
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: "_No upcoming payments found._",
},
});
blocks.push({ type: "divider" });
return blocks;
}
for (const day of days) {
blocks.push({
type: "section",
fields: [
{
type: "mrkdwn",
text: `*${day.display_date}*`,
},
{
type: "mrkdwn",
text: `${day.count} payments • ${formatCurrency(day.total)}`,
},
],
});
}
blocks.push({ type: "divider" });
return blocks;
};
return {
user_id: userId,
view: {
type: "home",
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "Payments Dashboard",
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `*Last updated:* ${dashboard.last_updated || "N/A"}`,
},
{
type: "mrkdwn",
text: `*Source:* ${dashboard.file_name || "N/A"}`,
},
],
},
{
type: "divider",
},
...buildDayBlocks("ACH", achDays),
...buildDayBlocks("Check", checkDays),
],
},
};
},
});

View file

@ -0,0 +1,32 @@
export default defineComponent({
async run({ steps, $ }) {
const body = steps.trigger.event.body || steps.trigger.event;
if (body.type === "url_verification") {
await $.respond({
status: 200,
headers: {
"Content-Type": "text/plain",
},
body: body.challenge,
});
return { verified: true };
}
await $.respond({
status: 200,
headers: {
"Content-Type": "application/json",
},
body: { ok: true },
});
return {
event_type: body.event?.type,
user_id: body.event?.user,
tab: body.event?.tab,
raw_body: body,
};
},
});

View file

@ -0,0 +1,34 @@
export default defineComponent({
props: {
slack_bot_token: {
type: "string",
label: "Slack Bot Token",
secret: true,
},
},
async run({ steps }) {
const user_id = steps.build_home_view.$return_value.user_id;
const view = steps.build_home_view.$return_value.view;
const res = await fetch("https://slack.com/api/views.publish", {
method: "POST",
headers: {
"Authorization": `Bearer ${this.slack_bot_token}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
user_id,
view,
}),
});
const data = await res.json();
if (!data.ok) {
throw new Error(`Slack API error: ${JSON.stringify(data)}`);
}
return data;
},
});

View file

@ -0,0 +1,31 @@
schema: workflow/2022.04
name: Slack App Home
settings:
error_notification: true
triggers:
- id: hi_JYH1xQr
steps:
- namespace: handle_slack_event
runtime: nodejs20.x
uses: "./handle_slack_event/entry.js"
- namespace: get_current_dashboard
runtime: nodejs20.x
uses: data_stores-get-record-or-create@0.0.15
_props_cursor: dyp_97UDZ5q7
props:
dataStore:
storeId: ds_5ludZgA
key:
__lv:
label: current_dashboard
value: current_dashboard
- namespace: build_home_view
runtime: nodejs20.x
uses: "./build_home_view/entry.js"
- namespace: publish_home_view
runtime: nodejs20.x
uses: "./publish_home_view/entry.js"
props:
slack_bot_token:
_enc: 3334db428df05327a883590347d39b440694f03a89b06503e73654e3596a52ab$$3g0UG9rIhQO0+wd9MbTJY9JBjSR6VpcXuem3drNL+PxsmBJzTVmt5avHP3RBbzs5m5t+mbJONef+qX1M2qvlfQZdpK9N--EzC2Qq8RMT6RKnjw--XsAu4D7SGePTz/THhitNIQ==