Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7981c715ce | ||
|
|
d173c9e9a0 | ||
|
|
e9a5ee9e90 | ||
|
|
458e67340c | ||
|
|
b12382b436 | ||
|
|
2620559975 | ||
|
|
ed19f51a52 | ||
|
|
e85db73dbc | ||
|
|
d38bbc52ec | ||
|
|
fe761e119e | ||
|
|
4a929f7e7c | ||
|
|
66d0d071b0 | ||
|
|
f269dc8bfa | ||
|
|
eef9a5f4c8 | ||
|
|
7f1bfe906e | ||
|
|
7797c45e9f | ||
|
|
dac1f1982f | ||
|
|
1d689d8f46 | ||
|
|
7bec2a13d8 | ||
|
|
127eaa9689 | ||
|
|
7226772c22 | ||
|
|
2fa12890f5 | ||
|
|
e77e5546d8 | ||
|
|
3e12597204 | ||
|
|
ec139737be | ||
|
|
872a661051 | ||
|
|
6331481f82 | ||
|
|
89611da202 | ||
|
|
a491ef3eed | ||
|
|
f4b92fa7a5 | ||
|
|
33833c3af9 | ||
|
|
c0cc0d2ac2 | ||
|
|
53a5fe8076 | ||
|
|
0332292ae9 | ||
|
|
ec0e9c2a5d | ||
|
|
a52e59cbc5 | ||
|
|
87d8743251 | ||
|
|
3125b52057 |
@@ -33,3 +33,20 @@ COMPANY_EMAIL=
|
||||
COMPANY_TAX_ID=
|
||||
COMPANY_WEBSITE=
|
||||
COMPANY_LOGO_PATH=
|
||||
|
||||
# Outbound mail (Amazon SES — the channel the office already uses for bulk
|
||||
# notification, see docs/MASS_EMAIL_NOTIFICATIONS.md). Without all four
|
||||
# vars the API still boots; in dev the MailService logs sends to stdout,
|
||||
# in production every send throws ServiceUnavailableException.
|
||||
SES_REGION=
|
||||
SES_ACCESS_KEY=
|
||||
SES_SECRET_KEY=
|
||||
SES_FROM=mail@jorgecuadros.com
|
||||
SES_FROM_NAME=Information Server
|
||||
# Optional — bounce/complaint event publishing configuration set.
|
||||
SES_CONFIGURATION_SET=
|
||||
|
||||
# Comma-separated addresses that receive the per-job admin summary email
|
||||
# (one summary per address, JSON body, sent after every sweep). Defaults to
|
||||
# the legacy pair if unset.
|
||||
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net
|
||||
|
||||
@@ -14,7 +14,16 @@
|
||||
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
|
||||
# container can report exactly what is deployed.
|
||||
#
|
||||
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images.
|
||||
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images
|
||||
# -> deploy-on-tag.yml waits for this run to go green and then
|
||||
# dispatches deploy-galactus.yml.
|
||||
#
|
||||
# This workflow BUILDS ONLY — it never deploys. The deploy chain used to be a
|
||||
# job here, gated to tag refs, but Gitea draws every job of a workflow into the
|
||||
# run graph before it evaluates the job's `if`: a routine master build showed a
|
||||
# pending "Deploy to galactus" and looked like prod was about to be redeployed
|
||||
# off an unreleased commit. Keeping the deploy in a `on: push: tags` workflow of
|
||||
# its own makes that structurally impossible.
|
||||
|
||||
name: Build and Push Images
|
||||
|
||||
|
||||
@@ -40,6 +40,21 @@
|
||||
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
||||
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
||||
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
||||
# Optional — outbound mail. Not needed to deploy; needed for
|
||||
# /notificaciones to send anything at all (the image sets
|
||||
# NODE_ENV=production, which disables MailService's stdout fallback, so
|
||||
# a blank config fails every send loudly):
|
||||
# SES_REGION e.g. us-west-2
|
||||
# SES_FROM a VERIFIED SES sending identity
|
||||
# SES_FROM_NAME display name, optional
|
||||
# SES_ACCESS_KEY / SES_SECRET_KEY
|
||||
# SES_CONFIGURATION_SET optional, for bounce/complaint events
|
||||
# NOTIFICATION_ADMIN_EMAILS fallback only — the summary recipients
|
||||
# are edited in the UI and stored in
|
||||
# app_settings; this is what a deployment
|
||||
# uses until somebody saves them there
|
||||
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
|
||||
# serves every deployment.
|
||||
# - The runner (which lives on cubex) must be able to reach BOTH
|
||||
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
|
||||
# If it cannot reach 3306, run the migration by hand from a host that can
|
||||
@@ -116,6 +131,13 @@ jobs:
|
||||
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
||||
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||
# Not required — the app boots fine without mail. Warned about below,
|
||||
# because the failure mode is remote: everything looks healthy until
|
||||
# someone clicks "Ejecutar" and every send fails.
|
||||
SES_REGION: ${{ secrets.SES_REGION }}
|
||||
SES_FROM: ${{ secrets.SES_FROM }}
|
||||
SES_ACCESS_KEY: ${{ secrets.SES_ACCESS_KEY }}
|
||||
SES_SECRET_KEY: ${{ secrets.SES_SECRET_KEY }}
|
||||
SCOPE: ${{ github.event.inputs.scope }}
|
||||
run: |
|
||||
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
|
||||
@@ -140,6 +162,20 @@ jobs:
|
||||
fi
|
||||
echo "all required secrets present for scope=$SCOPE"
|
||||
|
||||
# Mail is optional to deploy but not optional to work. Say so loudly
|
||||
# rather than letting /notificaciones fail one send at a time.
|
||||
mail_missing=""
|
||||
for name in SES_REGION SES_FROM SES_ACCESS_KEY SES_SECRET_KEY; do
|
||||
eval "value=\${$name}"
|
||||
[ -z "$value" ] && mail_missing="$mail_missing $name"
|
||||
done
|
||||
if [ -n "$mail_missing" ]; then
|
||||
echo "::warning::outbound mail is NOT configured, missing:$mail_missing"
|
||||
echo "::warning::the deploy will succeed, but every notification and"
|
||||
echo "::warning::renewal aviso will fail with 'El envío de correo no"
|
||||
echo "::warning::está configurado.' See docs/MASS_EMAIL_NOTIFICATIONS.md"
|
||||
fi
|
||||
|
||||
# --- full only: database ---------------------------------------------
|
||||
- name: Deploy database stack
|
||||
if: ${{ github.event.inputs.scope == 'full' }}
|
||||
@@ -259,8 +295,18 @@ jobs:
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
"OPS_DB_ADMIN_USER": "root",
|
||||
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||
"REPLICA_DB_HOST": "${{ secrets.REPLICA_DB_HOST }}",
|
||||
"REPLICA_DB_USER": "${{ secrets.REPLICA_DB_USER }}",
|
||||
"REPLICA_DB_PASS": "${{ secrets.REPLICA_DB_PASS }}",
|
||||
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
|
||||
"SES_REGION": "${{ secrets.SES_REGION }}",
|
||||
"SES_FROM": "${{ secrets.SES_FROM }}",
|
||||
"SES_FROM_NAME": "${{ secrets.SES_FROM_NAME }}",
|
||||
"SES_ACCESS_KEY": "${{ secrets.SES_ACCESS_KEY }}",
|
||||
"SES_SECRET_KEY": "${{ secrets.SES_SECRET_KEY }}",
|
||||
"SES_CONFIGURATION_SET": "${{ secrets.SES_CONFIGURATION_SET }}",
|
||||
"NOTIFICATION_ADMIN_EMAILS": "${{ secrets.NOTIFICATION_ADMIN_EMAILS }}"
|
||||
}
|
||||
|
||||
# --- prove it ----------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Chain the PROD deploy onto a green tag build.
|
||||
#
|
||||
# This is a SEPARATE workflow, not a job in build.yml, and the trigger is the
|
||||
# whole point: `on: push: tags` cannot fire on a push to master. When this was a
|
||||
# `deploy` job inside build.yml gated by `if: startsWith(github.ref,
|
||||
# 'refs/tags/v')`, Gitea still drew "Deploy to galactus" into the job graph of
|
||||
# every ordinary master build — the `if` is not evaluated until `needs` resolve,
|
||||
# so the job sits there looking like an imminent production deploy on a commit
|
||||
# nobody released. That is indistinguishable from a real misfire, and the only
|
||||
# safe reaction is to cancel the run, which kills the images with it.
|
||||
#
|
||||
# What it does NOT do is build. build.yml already builds and pushes both images
|
||||
# from one run; this waits for that run to go green and then dispatches
|
||||
# deploy-galactus.yml, which only pulls.
|
||||
#
|
||||
# Why wait for the build run rather than just dispatching: deploy-galactus.yml
|
||||
# pulls api and web at the same tag, and a half-pushed pair is exactly the state
|
||||
# that leaves prod running one new image and one old one. The build run turning
|
||||
# green is the signal that both are in the registry.
|
||||
#
|
||||
# Why a dispatch and not a `workflow_run:` trigger, which Gitea does support as
|
||||
# of 1.24: deploy-galactus.yml reads `github.event.inputs.*` in ten places (tag,
|
||||
# scope, bootstrap, skip_migrate). Under workflow_run every one of them is the
|
||||
# empty string, so the deploy would silently run with no tag and scope != 'full'.
|
||||
# A dispatch keeps that workflow's contract intact and keeps it hand-runnable for
|
||||
# rollbacks, which is the whole point of it.
|
||||
#
|
||||
# Kill switch: set the repo variable AUTO_DEPLOY_GALACTUS to `false` to cut the
|
||||
# chain and go back to dispatching the deploy by hand. Anything else (including
|
||||
# unset) deploys.
|
||||
|
||||
name: Deploy on tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to galactus
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:20-alpine
|
||||
steps:
|
||||
- name: Preflight — RELEASE_TOKEN
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
echo "::error::Secret RELEASE_TOKEN is not set, so this cannot wait"
|
||||
echo "::error::for the build or dispatch the deploy. Once build.yml"
|
||||
V=${GITHUB_REF#refs/tags/}
|
||||
echo "::error::is green, run 'Deploy to galactus' by hand with tag=${V#v}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Wait for the tag build, then dispatch deploy-galactus.yml
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
AUTO_DEPLOY: ${{ vars.AUTO_DEPLOY_GALACTUS }}
|
||||
TAG_REF: ${{ github.ref }}
|
||||
BUILD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
node -e '
|
||||
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
|
||||
const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
|
||||
// refs/tags/v1.2.3 — derived from github.ref rather than ref_name so
|
||||
// it does not depend on how Gitea populates GITHUB_REF_NAME.
|
||||
const tagRef = process.env.TAG_REF;
|
||||
const tag = tagRef.replace(/^refs\/tags\//, "");
|
||||
// The git tag carries the leading v; the image tag does not.
|
||||
const version = tag.replace(/^v/, "");
|
||||
const sha = process.env.BUILD_SHA;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const runs = async () => {
|
||||
const r = await fetch(`${base}/actions/runs?limit=50`, { headers });
|
||||
if (!r.ok) throw new Error(`runs query failed: HTTP ${r.status}`);
|
||||
return (await r.json()).workflow_runs || [];
|
||||
};
|
||||
|
||||
// The release commit and its tag are the SAME sha, and build.yml
|
||||
// skips the master run by design — so a sha match alone can latch
|
||||
// onto that skipped run and call the build green when no image was
|
||||
// ever pushed. Require the tag ref when the API reports one.
|
||||
const isTagRun = (r) => {
|
||||
const ref = r.head_branch || r.ref || "";
|
||||
return !ref || ref === tag || ref === tagRef;
|
||||
};
|
||||
|
||||
const buildRun = async () =>
|
||||
(await runs()).find(
|
||||
(r) =>
|
||||
r.head_sha === sha &&
|
||||
String(r.path || "").includes("build.yml") &&
|
||||
isTagRun(r),
|
||||
);
|
||||
|
||||
// Gitea reports a run as `status` and mirrors it into `conclusion`;
|
||||
// read whichever is populated rather than betting on one field.
|
||||
const outcome = (r) => String(r.conclusion || r.status || "").toLowerCase();
|
||||
const DONE = ["success", "failure", "cancelled", "canceled", "skipped"];
|
||||
|
||||
// Every deploy-galactus run id visible right now. A dispatch is only
|
||||
// confirmed by an id that is NOT in here — a plain "is there a deploy
|
||||
// run" check is satisfied by the PREVIOUS release run, and would
|
||||
// report success for a dispatch that never took.
|
||||
const deployRunIds = async () =>
|
||||
new Set(
|
||||
(await runs())
|
||||
.filter((r) => String(r.path || "").includes("deploy-galactus.yml"))
|
||||
.map((r) => r.id),
|
||||
);
|
||||
|
||||
(async () => {
|
||||
if (process.env.AUTO_DEPLOY === "false") {
|
||||
console.log("AUTO_DEPLOY_GALACTUS=false — not deploying.");
|
||||
console.log(`Deploy by hand with tag=${version} when ready.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ~20 min. A build is about 90s; the rest is queue time behind
|
||||
// other runs on a single runner.
|
||||
let run = null;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
run = await buildRun();
|
||||
if (run && DONE.includes(outcome(run))) break;
|
||||
if (!run && i === 11) {
|
||||
// Two minutes with no run at all. The post-receive hook drops
|
||||
// runs silently when it errors (this cost v1.0.3 its images),
|
||||
// so say so rather than timing out with no explanation.
|
||||
console.log(`::warning::No build.yml run for ${tag} yet after 2 min.`);
|
||||
console.log(`::warning::If the Gitea post-receive hook is broken, dispatch`);
|
||||
console.log(`::warning::"Build and Push Images" by hand with ref=${tag}.`);
|
||||
}
|
||||
await sleep(15_000);
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
console.log(`::error::No build.yml run for ${tag} (${sha}) after 20 min.`);
|
||||
console.log(`::error::Dispatch "Build and Push Images" with ref=${tag} (the`);
|
||||
console.log(`::error::tag, not master), then deploy by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = outcome(run);
|
||||
if (result !== "success") {
|
||||
console.log(`::error::build.yml for ${tag} ended as "${result}" — not deploying.`);
|
||||
console.log(`::error::Fix the build, re-run it, then deploy by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`build.yml for ${tag} is green (run ${run.id}). Deploying ${version}.`);
|
||||
const before = await deployRunIds();
|
||||
|
||||
// Dispatch against the TAG, not master: the deploy applies the
|
||||
// compose files under deploy/galactus/ from whatever ref it runs
|
||||
// on, and those must be the ones this release was cut with.
|
||||
const res = await fetch(
|
||||
`${base}/actions/workflows/deploy-galactus.yml/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: tagRef,
|
||||
inputs: {
|
||||
tag: version,
|
||||
scope: "app",
|
||||
bootstrap: "false",
|
||||
skip_migrate: "false",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.log(`::error::Dispatch returned HTTP ${res.status}: ${await res.text()}`);
|
||||
console.log(`::error::Images for ${version} are published. Run`);
|
||||
console.log(`::error::"Deploy to galactus" by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// A 204 only means Gitea accepted the request. Confirm a NEW run
|
||||
// exists — an accepted call that creates no run is the failure mode
|
||||
// that cost v1.0.3 its images.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(5_000);
|
||||
const fresh = [...(await deployRunIds())].filter((id) => !before.has(id));
|
||||
if (fresh.length) {
|
||||
console.log(`Deploy of ${version} to galactus is running (run ${fresh[0]}).`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`::error::Dispatch was accepted but no deploy run appeared.`);
|
||||
console.log(`::error::Run "Deploy to galactus" by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
})();
|
||||
'
|
||||
@@ -267,7 +267,14 @@ jobs:
|
||||
"OPS_DB_ADMIN_USER": "root",
|
||||
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
|
||||
"SES_REGION": "${{ secrets.SES_REGION }}",
|
||||
"SES_FROM": "${{ secrets.SES_FROM }}",
|
||||
"SES_FROM_NAME": "${{ secrets.SES_FROM_NAME }}",
|
||||
"SES_ACCESS_KEY": "${{ secrets.SES_ACCESS_KEY }}",
|
||||
"SES_SECRET_KEY": "${{ secrets.SES_SECRET_KEY }}",
|
||||
"SES_CONFIGURATION_SET": "${{ secrets.SES_CONFIGURATION_SET }}",
|
||||
"NOTIFICATION_ADMIN_EMAILS": "${{ secrets.NOTIFICATION_ADMIN_EMAILS }}"
|
||||
}
|
||||
|
||||
# --- prove it ----------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
||||
#
|
||||
# This does NOT build and does NOT deploy. Pushing the `vX.Y.Z` tag is what
|
||||
# triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and `latest`
|
||||
# image tags. Deploying stays a separate, deliberate act: once the build is
|
||||
# green, dispatch deploy-galactus.yml with `tag=X.Y.Z` (no leading v — the tag
|
||||
# carries the `v`, the image tag does not).
|
||||
# This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is
|
||||
# what triggers both build.yml, which publishes the `X.Y.Z`, `X.Y`,
|
||||
# `sha-<short>` and `latest` image tags, and deploy-on-tag.yml, which waits for
|
||||
# that build to go green and then dispatches deploy-galactus.yml with
|
||||
# `tag=X.Y.Z scope=app` (no leading v — the git tag carries the `v`, the image
|
||||
# tag does not). A tag is the only ref that starts either chain; pushing to
|
||||
# master builds images and stops there.
|
||||
#
|
||||
# So cutting a release DOES reach prod. To cut a version without deploying it,
|
||||
# set the repo variable AUTO_DEPLOY_GALACTUS=false first; deploy-on-tag.yml then
|
||||
# prints the manual command instead of running it.
|
||||
#
|
||||
# Why a workflow instead of three local commands: the release commit is the one
|
||||
# thing that must be identical every time, and cutting it from a laptop is how
|
||||
@@ -266,5 +272,9 @@ jobs:
|
||||
echo "Released v${VERSION}."
|
||||
echo ""
|
||||
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
|
||||
echo "When it is green, dispatch 'Deploy to galactus' with:"
|
||||
echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false"
|
||||
echo "deploy-on-tag.yml is watching that build; when it goes green it dispatches"
|
||||
echo "'Deploy to galactus' with tag=${VERSION} scope=app bootstrap=false skip_migrate=false."
|
||||
echo ""
|
||||
echo "Watch that run. If it did not start (or AUTO_DEPLOY_GALACTUS=false),"
|
||||
echo "dispatch 'Deploy to galactus' by hand with the same inputs."
|
||||
echo "Rollback = re-dispatch it with an older tag."
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Unified Customer / Insurance / Utilities Platform — Migration & Rebuild Plan
|
||||
|
||||
> **Looking for what is still outstanding?** → [`docs/BACKLOG.md`](docs/BACKLOG.md).
|
||||
> This document is the plan and its running status; the backlog collects every
|
||||
> open item — blocked-on-Jorge decisions, live data defects, unbuilt features
|
||||
> and deploy blockers — in one list, checked against the code rather than
|
||||
> against these notes.
|
||||
|
||||
## Context
|
||||
|
||||
Jorge Cuadros & Assoc. runs two lines of business — property/utility management (`UTILITIES.accdb`) and insurance brokerage (`SEGUROS 16.mdb` + its linked backend `SEGUROS 16_be.mdb`) — out of separate, decades-old MS Access databases, plus a third file (`SCOTHIA.mdb`) that's the office's own Scotiabank checking-account register ("chequera"). The same people are customers of both business lines, but today there's no shared customer record: a person's utility account and their insurance policies live in unrelated systems with independent, inconsistent copies of their name/address/contact info. The bank register is a fourth, disconnected source of truth for the money actually moving through the office's own account.
|
||||
@@ -134,16 +140,22 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
||||
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
|
||||
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
|
||||
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
|
||||
- **PDF/OCR auto-capture — DONE** (2026-08-01). The ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff key in by hand, built in `apps/api/src/statements/` and posting through §1.2's `createBatch` seam with `source: "OCR"` and a per-document `captureRef`. Web: `/recibos` + `/recibos/:id`. Abilities `statement:ingest`/`statement:review` (STAFF — the review step is what makes machine capture safe at that tier). OCR is self-hosted **Tesseract** behind a swappable `OcrProvider` interface; `tesseract-ocr`, `tesseract-ocr-data-spa` and `poppler-utils` were added to the API image.
|
||||
- **PDF/OCR auto-capture — DONE** (2026-08-01). As-built write-up in [`docs/STATEMENT_OCR.md`](docs/STATEMENT_OCR.md); the design and the measured evidence stay in the spec's §2. The ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff key in by hand, built in `apps/api/src/statements/` and posting through §1.2's `createBatch` seam with `source: "OCR"` and a per-document `captureRef`. Web: `/recibos` + `/recibos/:id`. Abilities `statement:ingest`/`statement:review` (STAFF — the review step is what makes machine capture safe at that tier). OCR is self-hosted **Tesseract** behind a swappable `OcrProvider` interface; `tesseract-ocr`, `tesseract-ocr-data-spa` and `poppler-utils` were added to the API image.
|
||||
**Every decision was driven by 10 real scans (46 pages).** Shipped-parser results on them: provider 46/46, account ref 43/46, amount 42/46, due date 44/46 — and against the dev database **39/46 (85%) exact auto-match, 40/46 (87%) identified**, the rest genuine review cases. The scans are pure images (no text layer), so OCR is mandatory, and they arrive **bundled one customer per page**.
|
||||
**The three gaps are closed, and two of them were mis-stated in the spec.** (a) `TELEPHONE` now exists and is backfilled from `Property.phone1` only — coverage is 534/18/1 across phone1/2/3, so phone is one billed line per property, not three. (b) **Clave catastral ≠ predial**: `DATMEX.clave` (934 rows, `KA903009`) is what CESPT and predial bills actually print, while `predial` — what `PROPERTY_TAX.accountNumber` holds — has only 663 distinct values across 1135 rows and appears on no statement; the clave now lives on `Property.cadastralKey` as the matcher's secondary key and predial is left untouched. (c) Gas was **not** a dead end: 160 of the 334 `DATMEX.gas` values are real account numbers (the rest are `ESTACIONARIO`/`CILINDRO` descriptors), all recovered into `GAS.meterNumber`.
|
||||
**Matching is scoped per service kind and never reads the customer name** — a CESPT receipt prints `ARNAIZ ROSAS ELSA AURORA` for an account this office holds under `CATT, RANDY`, because the name on a utility bill is the registrant, not the current owner. Normalisation is per provider: CFE strips leading zeros off `NO. DE SERVICIO`, Telnor strips the 664 LADA down to the stored local 7 digits. Where a provider prints a payment barcode it is preferred over the printed label (one CFE label OCR'd a digit too many while its barcode was correct) and the two are cross-checked, with disagreement forcing review. Confirming a document whose service had no reference writes it back, so gas and any other cold start is a one-time cost.
|
||||
- **Policy OCR capture — DONE** (2026-08-01), **unplanned — it came out of building the bullet above.** Full write-up in [`docs/POLICY_OCR.md`](docs/POLICY_OCR.md). Once the receipt pipeline existed it was obvious the same render→OCR→parse→match→review shape fits the *other* stack of paper this office keys in by hand: the carrier policy PDFs behind every `Policy` row. Built in `apps/api/src/policy-ocr/` with a GMX parser, `policy_ocr_batches`/`policy_ocr_documents`, and abilities `policy:ingest`/`policy:ocr-review` (STAFF, same trust tier and same reason). Web: `/polizas/captura` is the "automática" tab of the policy-creation screen (`/polizas/nuevo` is the manual one, both render `PolicyCaptura.tsx`) with the review queue at `/polizas/captura/[id]`. The `OcrProvider` seam was **extracted out of `StatementsModule` into its own `OcrModule`** to make this possible — that was blocking, not cosmetic; `StatementsModule` now imports it and binds nothing.
|
||||
**The statement pipeline's core assumption inverts here.** Utility statements arrive bundled *one customer per page*, so there a page is a document; a GMX certificate is one policy across two pages (header on 1, coverage table on 2), so the pipeline concatenates the pages and runs the parser and matcher **once per file**. `PolicyOcrDocument.pageNumber` is therefore the file ordinal in the batch, and `storageKey` points at the **source PDF** (the review screen embeds the exact artifact the office received) rather than at a page image. Matching is on `Policy.policyNumber` alone and never the printed insured name — the same registrant-vs-owner drift that rules names out on the utility side. Zero hits means a new policy and confirm creates it; more than one is surfaced, never auto-picked.
|
||||
**The GMX certificate carries no premium at all** — the figure lives on a separate `recibo` PDF — so the premium fields stay null with a note saying why, confirm never overwrites an existing premium with null, and the optional ledger write is gated on staff ticking `postPremium` *and* a premium actually parsing. 8/8 parser tests against one real document (`HC_Folio_000767_Traduccion.pdf`). GMX is the only carrier implemented; the dispatcher is a pattern table, so a second one is a parser function and two entries.
|
||||
- **Multi-bank chequera — DONE** (2026-07-27). `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register. `bank_transactions` gained a **required** `bankAccountId` (plus an `(bankAccountId, transactionDate)` index, since every read is now filtered by account and ordered by date), and all 22,669 existing rows were backfilled onto a seeded "Utilities — Scotiabank (MXN)" account by `migration/backfill_bank_accounts.py` — a standalone step because `prisma db push` cannot add a required column to a populated table. It is idempotent and now runs inside `run_all.py` (both normal and `--sync`) ahead of `transform_bank.py`, which fails fast if the account is missing. Every read path in `bank.service.ts` is account-scoped, including `facets()` (which had no filter at all) and *both* raw-SQL rollups in `summary()`. API: `?bankAccountId=` is required on `list`/`stats`/`facets`/`summary` — **not** optional-with-an-all-accounts-default, since summing an MXN and a USD register repeats exactly the currency-collapsing mistake the billing module exists to prevent — plus a new `bank/accounts` + `bank/banks` sub-resource under a MANAGER `bank:manage-accounts` ability. Web: `/banco` gained an account picker (remembered per browser) and reads every figure in the selected account's currency, `/banco/cuentas` manages banks and accounts, and `/inicio`'s chequera card names the account it is showing instead of implying one register. An account's `currency` is immutable after creation by design — its booked movements are denominated in it. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged.
|
||||
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
||||
|
||||
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
||||
12. **Insurance features — NOT STARTED, spec written.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11:
|
||||
- **Renewal notification emails** — a daily `@nestjs/schedule` sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern) — the office already runs SES, so provider and budget are settled, not open. The letter body is the *existing* `aviso-renovacion` report (`reports.registry.ts:623-799`); `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**. Also adds the manual mark-as-sent mutation the report's own comment anticipates, so the report's permanently-zero `enviadas` total becomes real. Smallest useful piece — do first.
|
||||
12. **Insurance features — one of four built, rest spec'd.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11:
|
||||
- **Renewal notification emails — DONE** (2026-08-01, extended 08-02). A sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern). The letter body is the *existing* `aviso-renovacion` report; `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**.
|
||||
**Three things came out differently from the spec.** (a) The manual mark-as-sent mutation was **dropped on purpose** — a button that marks a notice sent without sending anything lets the list claim a customer was told when they were not. `POST /renewals/send` replaced it: sending from the list *is* the marking, and the report's `enviadas` total becomes real the same way. (b) The send history is **not renewal-specific** — every attempt, including the failures and no-email skips a `RenewalNotice` row cannot represent, also writes `email_notification_log` as `RENEWAL_NOTICE`/`POLICIES`, shared with the four bulk jobs from [`docs/MASS_EMAIL_NOTIFICATIONS.md`](docs/MASS_EMAIL_NOTIFICATIONS.md). `RenewalNotice` stays *gating* state; the log is *history*. (c) The `@Cron("0 6 * * *")` literal the spec called for lasted one day: both this sweep and the servicios jobs now take their cadence from `NotificationScheduleService`, stored in `app_settings` and reinstalled on save — no redeploy. Defaults preserve the old behaviour (pólizas 06:00 daily, servicios off).
|
||||
**Both halves live on one screen.** `/notificaciones` has Servicios and Pólizas tabs over the one log; `/renovaciones` is an alias onto the Pólizas tab. The send flags (`debug` in particular) sit in the shell above the tabs and govern both — before that there was no way to test a renewal aviso without mailing a real customer. A debug send diverts the mail, skips the `RenewalNotice` upsert **and** does not advance the sweep's `lastSuccessfulAt`; all three are needed together, or a test run silently narrows tomorrow's window and drops the letters it only pretended to send.
|
||||
**Production status:** the `SES_*` Gitea secrets were created 2026-08-02, clearing the last blocker — but the feature has not shipped yet (master is well past the newest tag) and nothing has confirmed that `SES_FROM` is a verified SES identity or that the account is out of the sandbox. Run the first sweep with `debug` on. See [`docs/BACKLOG.md`](docs/BACKLOG.md) §0.
|
||||
- **Liquidación batch workflow** — ~70% already built (`liquidated`/`liquidationNumber`/`liquidationDate` are wired through DTOs, list filter, stats, form and detail page); only the *batch* print-and-mark step is missing, against a live pending set of 226 policies. Adds a ramo-parameterized pending report plus `POST /policies/liquidate-batch` under a new MANAGER `policy:liquidate` ability. Parameterized by ramo, not MULT-only — legacy `TABLA LIQUIDA MF` served `MULT`, `INCENDIO` and `M EMPR` alike.
|
||||
- **Certificate / "Solicitud Atlas"** — renders from the same `format: "letter"` machinery `aviso-renovacion` uses, then reaches customers as an extension of the step-8/9 replication (PDF generated here, pushed to MinIO, pointer replicated), **not** as a new public surface in this repo. Half-blocked: "Solicitud" has zero referent in the legacy system and normally means an *application form*, a different artifact from a certificate.
|
||||
- **Carrier API integration (ANA Seguros + GMX)** — shape only (`CarrierConnector` + an import-review queue rather than direct `Policy` writes, matching how step 11's OCR results are routed). Carrier research done 2026-07-27: **the two carriers are one company** — both belong to **Grupo Valore** (ANA writes autos, GMX writes daños, which is exactly this database's `AUTO`/`LICENCIAS` vs `MULT`/`INCENDIO`/`M_EMPR` split), so it is one commercial relationship, not two. **ANA has a real live SOAP service** (`server.anaseguros.com.mx/ananetws/service.asmx`, ASP.NET `.asmx`) with a published operation list — catalogs, `CalculaValor`/`CalculaMSI`, `ValidaSerie`, `RecuperaCotizacion`, `Transaccion`. **GMX publishes no machine interface at all**, only human agent portals. ⚠️ **Critical mismatch:** every ANA operation serves *new-business quoting/issuance*, not "list the policies where I am agent of record" — so if the ask is inbound portfolio sync, no evidence exists that either carrier sells it. Blocked on one phone call to Grupo Valore ((55) 5480-4000) for credentials + a direction answer, not on further research. ("GDMX" in the meeting notes was a typo for `GMX` — confirmed 2026-07-27.)
|
||||
@@ -162,7 +174,13 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
||||
|
||||
**Step 11 is now three-quarters built.** Receipt capture, the multi-bank chequera and PDF/OCR auto-capture are all done and verified; only customer-number recycling remains unbuilt. `docs/RECEIPT_CAPTURE_SPEC.md` carries a BUILT note per section recording what shipped and, for §2, the four things real scanned statements proved the spec had wrong or unknown.
|
||||
|
||||
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end.
|
||||
Each of the two OCR intakes now has an as-built doc separate from its spec — `docs/STATEMENT_OCR.md` and `docs/POLICY_OCR.md`. The specs record what was designed and why; those record what is in the code. They share one `OcrProvider` seam (`apps/api/src/ocr/`), so the Tesseract-vs-managed-API decision is one line for both.
|
||||
|
||||
**It also produced a feature nobody planned.** The statement OCR pipeline generalised: the same render→OCR→parse→match→review shape reads **carrier policy PDFs** into `Policy` rows, which is `docs/POLICY_OCR.md` (built 2026-08-01, GMX only so far). It belongs to step 12's subject matter but to step 11's lineage, and it is in no spec — worth knowing before reading `INSURANCE_FEATURES_SPEC.md`, which does not mention it. It also partly overlaps what §4's carrier API was wanted for, and unlike that section it is not blocked on a phone call.
|
||||
|
||||
**Step 12 is one-quarter built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. **§1 renewal emails is done** (2026-08-01/02) and carries a BUILT note recording the three places the build diverged from the spec; §2 liquidación is still the smallest remaining piece, since the per-policy fields are already wired end to end.
|
||||
|
||||
**Notifications are one screen, not two features.** The four legacy mass-email jobs (`docs/MASS_EMAIL_NOTIFICATIONS.md`) and the insurance renewal avisos both mean "tell a customer something by email", so they are tabs of `/notificaciones` over one `email_notification_log`, with one shared flags panel and one schedule editor. `app_settings` + `SettingsService` (db → env → default) is the operator-config seam they introduced: summary recipients and both sweep cadences live there, so changing any of them is a save, not a redeploy. Credentials stay in the environment.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ Internal platform for a Baja California insurance brokerage and property-service
|
||||
firm: a single expedient joining each client's **properties/services**,
|
||||
**insurance policies**, **account statement**, and the firm's **checkbook**.
|
||||
It replaces a legacy PHP/Access app (see `RESUME.md` and `PLAN.md` for the full
|
||||
history and rebuild rationale).
|
||||
history and rebuild rationale, and [`docs/BACKLOG.md`](docs/BACKLOG.md) for
|
||||
everything still outstanding).
|
||||
|
||||
The UI is Spanish-first; the codebase and this document are in English.
|
||||
|
||||
@@ -40,8 +41,21 @@ docker-compose.yml mysql + api + web
|
||||
```
|
||||
|
||||
API feature modules: `auth`, `users`, `customers`, `policies`, `properties`,
|
||||
`billing`, `bank`. Web routes: `/clientes`, `/polizas`, `/servicios`,
|
||||
`/estado-cuenta`, `/banco` (chequera), `/catalogos`, `/usuarios`, `/login`.
|
||||
`billing`, `bank`, `reports`, `notifications`, `renewals`, `mail`, `statements`,
|
||||
`policy-ocr`, `ocr`, `storage`, `settings`, `ops`.
|
||||
|
||||
Web routes: `/inicio`, `/clientes`, `/polizas` (+ `/polizas/captura`, policy
|
||||
PDF OCR capture), `/servicios`, `/estado-cuenta`, `/banco` (chequera),
|
||||
`/recibos` (utility statement OCR capture), `/notificaciones` (mass email +
|
||||
renewal avisos; `/renovaciones` is an alias onto its Pólizas tab), `/reportes`,
|
||||
`/catalogos`, `/operaciones` (DB ingest/backup, ADMIN), `/usuarios`, `/login`.
|
||||
|
||||
Two OCR intakes share one `OcrProvider` seam (`src/ocr/`, Tesseract today):
|
||||
utility statements → ledger rows ([`docs/STATEMENT_OCR.md`](docs/STATEMENT_OCR.md))
|
||||
and carrier policy PDFs → `Policy` rows ([`docs/POLICY_OCR.md`](docs/POLICY_OCR.md)).
|
||||
Both need `tesseract-ocr`, `tesseract-ocr-data-spa`, `poppler-utils` and object
|
||||
storage; each reports its own availability and disables only itself if either
|
||||
is missing.
|
||||
|
||||
---
|
||||
|
||||
@@ -195,6 +209,28 @@ python migration/run_all.py
|
||||
|
||||
---
|
||||
|
||||
## Scheduled jobs
|
||||
|
||||
The API runs two automatic email sweeps. Neither cadence is in the source:
|
||||
both are stored in `app_settings` and edited at `/notificaciones` →
|
||||
"Programación de envíos" (ADMIN, `setting:manage`), taking effect immediately
|
||||
without a restart. Shipped defaults:
|
||||
|
||||
| Job | Default | What it does |
|
||||
| --- | ------- | ------------ |
|
||||
| Pólizas | **on**, 06:00 daily (America/Tijuana) | Renewal avisos at 30/15 days before expiry and 7 days after. |
|
||||
| Servicios | **off** | All four mass-email jobs in order, same as "Ejecutar todos". |
|
||||
|
||||
A scheduled run never uses the UI's send flags — in particular it ignores
|
||||
`debug`, so a forgotten test toggle cannot silently stop customer mail. Full
|
||||
detail in [`docs/MASS_EMAIL_NOTIFICATIONS.md`](docs/MASS_EMAIL_NOTIFICATIONS.md).
|
||||
|
||||
Sending needs `SES_*` in the environment. Without it the API still boots and
|
||||
logs mail to stdout in dev; in production every send fails loudly and is
|
||||
recorded as `FAILED` rather than quietly going nowhere.
|
||||
|
||||
---
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when
|
||||
|
||||
@@ -447,6 +447,11 @@ for what's actually next.
|
||||
|
||||
## Statement OCR intake (`/recibos`) — DONE 2026-08-01
|
||||
|
||||
> As-built reference: **`docs/STATEMENT_OCR.md`** (written 2026-08-02) — the
|
||||
> parsers, the matcher's scoped-field rules, confirm/learning semantics and the
|
||||
> API surface. `docs/RECEIPT_CAPTURE_SPEC.md` §2 stays the design and the
|
||||
> measured evidence. This section is the session record of building it.
|
||||
|
||||
Plan step 11 §2 (`docs/RECEIPT_CAPTURE_SPEC.md` §2). The last big utilities
|
||||
feature: staff scan the month's utility bills and the machine proposes customer
|
||||
+ amount per page, instead of keying 300+ statements per company by hand. Built
|
||||
@@ -526,3 +531,167 @@ Implementation notes worth keeping:
|
||||
**Open:** whether the CFE charge should be the rounded barcode/headline figure
|
||||
(`$268`, what is paid at the window — what the parser uses today) or the exact
|
||||
breakdown total (`$268.88`). One question for Jorge.
|
||||
|
||||
## Policy OCR capture (`/polizas/captura`) — DONE 2026-08-01, unplanned
|
||||
|
||||
**This feature was not in any spec.** It is what the statement OCR work above
|
||||
turned into once the pipeline existed. Having built render → OCR → parse →
|
||||
match → review for CFE/CESPT/Telnor receipts, the same shape obviously fits
|
||||
the *other* stack of paper this office keys in by hand every week: the carrier
|
||||
policy PDFs behind every `Policy` row. Full write-up in `docs/POLICY_OCR.md`.
|
||||
|
||||
The pipeline was reused rather than copied. `OcrModule` was **extracted out of
|
||||
`StatementsModule`** in the same commit so `PolicyOcrModule` could inject
|
||||
`OCR_PROVIDER` without taking on the statement pipeline — that extraction was
|
||||
blocking, not tidying; the policy module could not resolve the provider at all
|
||||
until it existed. `StatementsModule` imports it now and binds nothing itself,
|
||||
so the Tesseract-vs-managed-API decision stays one line in one file for both
|
||||
features.
|
||||
|
||||
Screens mirror Captura exactly: `/polizas/nuevo` is the manual tab,
|
||||
`/polizas/captura` the automática one, both rendering `PolicyCaptura.tsx`, with
|
||||
the batch review queue at `/polizas/captura/[id]`. Abilities `policy:ingest` /
|
||||
`policy:ocr-review`, both STAFF — same trust tier as statement OCR, and for the
|
||||
same reason: nothing reaches the books unconfirmed.
|
||||
|
||||
**The statement pipeline's central assumption inverts here, and that is the
|
||||
thing to remember.** Utility statements arrive bundled *one customer per page*,
|
||||
so there a page is a document and the parser runs per page. A policy PDF is the
|
||||
opposite: the GMX certificate is one policy spread across two pages (contract
|
||||
header on page 1, the per-coverage table on page 2). So every page's text is
|
||||
concatenated and the parser and matcher run **once per file**. Consequences:
|
||||
`PolicyOcrDocument.pageNumber` is repurposed as the file ordinal within the
|
||||
batch (the `(batchId, pageNumber)` unique constraint still holds), `ocrConfidence`
|
||||
is the mean across the file's pages, and a file that fails to parse yields
|
||||
exactly one `OCR_FAILED` row.
|
||||
|
||||
`storageKey` points at the **source PDF**, not a rendered page image, so the
|
||||
review screen embeds the exact artifact the office received and gets the
|
||||
browser's native PDF scrolling, zoom and text selection for free. The page PNGs
|
||||
are still written for future re-OCR, but nothing treats them as the document's
|
||||
identity. (The statement side is the reverse, because there a page *is* the
|
||||
document.)
|
||||
|
||||
Findings worth keeping:
|
||||
|
||||
- **The GMX certificate has no premium on it at all.** Not intermittently
|
||||
missing — the figure lives on GMX's separate `recibo` PDF. The parser leaves
|
||||
the premium fields null and pushes a note saying so, confirm never overwrites
|
||||
an existing `Policy.netPremium` with null, and the optional ledger write is
|
||||
gated on staff ticking `postPremium` *and* a premium actually parsing.
|
||||
Without that second gate a premium-less certificate would book a $0 charge on
|
||||
every confirm.
|
||||
- **Match on `Policy.policyNumber`, never the printed insured name.** Same
|
||||
registrant-vs-current-owner drift that rules names out on the utility side.
|
||||
Zero hits means a new policy and confirm creates the row under a picked
|
||||
customer; more than one hit is surfaced for a human, never auto-picked —
|
||||
duplicate numbers across related parties do occur.
|
||||
- Deductible and loss participation are stored as **strings** (`"5%"`,
|
||||
`"USD 1,000"`): they are printed as a mix of percentages, amounts and free
|
||||
text, and normalising them would lose the distinction.
|
||||
- Carrier-portal PDFs are usually **born-digital**, so the text layer wins and
|
||||
no OCR runs at all most of the time — same precedence rule as the statement
|
||||
pipeline.
|
||||
- The digit-confusion map and the amount-by-separator-position parser are
|
||||
**duplicated on purpose** rather than imported, to keep the module
|
||||
self-contained. Fix a bug in one, check the other.
|
||||
|
||||
8/8 parser tests, all against verbatim text from one real document
|
||||
(`HC_Folio_000767_Traduccion.pdf`).
|
||||
|
||||
**Open:** GMX is the only carrier implemented — the dispatcher is a
|
||||
`[provider, pattern]` table plus a parser map, so a second carrier is a
|
||||
function and two entries, but no other layout has been seen. Reading the
|
||||
premium off the separate `recibo` PDF and pairing it to its certificate is the
|
||||
obvious next piece; it is what would let `postPremium` stop being a manual
|
||||
tick. And nothing versions a re-issued policy — confirm updates the existing
|
||||
row, so there is no record that this is the 2027 issue of that number.
|
||||
|
||||
|
||||
## Notificaciones (`/notificaciones`) — DONE 2026-08-01 → 08-02
|
||||
|
||||
Two features that were spec'd separately turned out to be one screen. The four
|
||||
legacy mass-email jobs (`docs/MASS_EMAIL_NOTIFICATIONS.md`, ported from
|
||||
`email.notifications/send*.php`) and the insurance renewal avisos
|
||||
(`docs/INSURANCE_FEATURES_SPEC.md` §1) both mean *tell a customer something by
|
||||
email*, so they are **tabs of one screen over one log**, not two menu entries.
|
||||
`/renovaciones` is an alias that lands on the Pólizas tab, the same pattern
|
||||
Captura uses.
|
||||
|
||||
- **Servicios tab** — the four jobs (pagos pendientes, confirmación de pago,
|
||||
estado de cuenta, fideicomiso), individually or "Ejecutar todos". Ability
|
||||
`notification:send` (MANAGER); STAFF sees the log read-only.
|
||||
- **Pólizas tab** — pending avisos at 30/15 days before expiry and 7 days
|
||||
after, sent one at a time or as a sweep. Ability `renewal:send` (MANAGER).
|
||||
|
||||
**One send log for the whole platform.** `email_notification_log` is not
|
||||
job-specific: renewals write it too (`RENEWAL_NOTICE` / `POLICIES`) through the
|
||||
same `NotificationLogService`. That is what makes "Registro de envíos" complete
|
||||
— the failures and no-email skips exist *only* there. `RenewalNotice` was not
|
||||
made redundant by it: that row is **gating** state (one per policy+generation,
|
||||
drives the pending list), the log is **history** (every attempt). `level` is
|
||||
therefore per-type and unreadable without its `notificationType` — 0/1
|
||||
yellow/red on `ACCOUNT_STATUS`, the aviso generation 1/2/3 on
|
||||
`RENEWAL_NOTICE`.
|
||||
|
||||
**Manual mark-as-sent was dropped on purpose.** The spec called for it; a
|
||||
button that marks a notice sent without sending anything is a button that lets
|
||||
the list claim a customer was told when they were not. `POST /renewals/send`
|
||||
replaced it — sending from the list *is* the marking.
|
||||
|
||||
**`app_settings` is the operator-config seam this work introduced.**
|
||||
`SettingsService` resolves every key **db → env → default** and reports which
|
||||
rung a value came from, so an existing deployment keeps behaving exactly as it
|
||||
did until somebody saves in the UI. Three keys today: the summary recipients
|
||||
(was `NOTIFICATION_ADMIN_EMAILS`, now a fallback) and the two sweep cadences.
|
||||
Credentials deliberately stay in the environment — SES keys, `DATABASE_URL`
|
||||
and S3 config are deployment identity, must exist before the app can reach its
|
||||
own database, and a table only widens who can read them.
|
||||
|
||||
**The send flags are global, and that was a real bug fix (08-02).** The
|
||||
`debug` / `ignoreDayRestriction` / `useEmailLimit` panel lived inside the
|
||||
Servicios tab, so there was **no way to test a renewal aviso without mailing a
|
||||
real customer**. It now lives in the shell above the tabs and both halves read
|
||||
it. On the pólizas path `debug` does three things, and all three are required
|
||||
together: it diverts the mail, it skips the `RenewalNotice` upsert, and it does
|
||||
not advance the sweep's `lastSuccessfulAt`. Miss the third and `renewalWindow()`
|
||||
narrows back to a single day on the next real run — a test send would silently
|
||||
destroy the letters it only pretended to send. Flags are per-visit UI state and
|
||||
are **never persisted**; a stored `debug` would survive a reload and swallow
|
||||
real customer mail until somebody noticed.
|
||||
|
||||
**Both cadences are operator-editable (08-02).** The renewal sweep's
|
||||
`@Cron("0 6 * * *")` literal lasted one day. `NotificationScheduleService` now
|
||||
owns both: the owning services register a handler in `onModuleInit`, the
|
||||
service compiles the stored `{hour, minute, weekdays}` to a cron expression and
|
||||
installs it in `SchedulerRegistry`, and saving from the UI reinstalls the job —
|
||||
no restart, which was the point. It lives in its own module for the same reason
|
||||
as `NotificationLogModule`: `NotificationsModule` and `RenewalsModule` both need
|
||||
it and neither may import the other. Defaults preserve prior behaviour exactly
|
||||
(pólizas 06:00 daily, servicios **off** — a default that starts mailing 260
|
||||
customers after a deploy is not a default, it's an incident). A scheduled run
|
||||
never inherits the UI flags: no `debug`, and no `ignoreDayRestriction`, since an
|
||||
automatic run on the operator's own cadence is precisely the case the
|
||||
Mon/Wed/Fri gate was written for.
|
||||
|
||||
Implementation notes worth keeping:
|
||||
|
||||
- `cron` had to become a **direct dependency of `apps/api`**. It is a
|
||||
transitive dep of `@nestjs/schedule`, but pnpm's strict layout does not hoist
|
||||
it, so `import { CronJob } from "cron"` does not resolve without it.
|
||||
- The pólizas sweep already had a DB lock (`scheduled_job_states`); the
|
||||
servicios run-all does not, and relies on the deployment being
|
||||
single-replica, which it is on galactus today.
|
||||
- Wire shapes of the four jobs are byte-for-byte the legacy PHP responses,
|
||||
quirks included (Job 1 reports `result`, not `request`).
|
||||
|
||||
**Open:** the `SES_*` Gitea secrets were created 2026-08-02, so the feature is
|
||||
no longer blocked — but it has not shipped (master is well past the newest tag)
|
||||
and two things nobody has checked decide whether mail leaves the building:
|
||||
`SES_FROM` must be a verified identity in `SES_REGION`, and the AWS account
|
||||
must be out of the SES sandbox, which otherwise restricts delivery to verified
|
||||
recipients and would fail a real sweep while looking correctly configured. Run
|
||||
the first sweep with `debug` on. Still open beyond that: the 78 policyholders
|
||||
with no email are logged as `SKIPPED_NO_EMAIL` but have no printable worklist,
|
||||
and the notice body is English-only (`Customer` carries no language
|
||||
preference).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
@@ -12,20 +12,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.665.0",
|
||||
"@aws-sdk/client-sesv2": "^3.1101.0",
|
||||
"@jorgecuadros/database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.4",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.4",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"argon2": "^0.41.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cron": "^3.2.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express-session": "^1.18.0",
|
||||
"pdfkit": "^0.15.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdfkit": "^0.15.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -34,11 +37,11 @@
|
||||
"@nestjs/testing": "^10.4.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/pdfkit": "^0.13.5",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pdfkit": "^0.13.5",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { MailModule } from "./mail/mail.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CustomersModule } from "./customers/customers.module";
|
||||
@@ -14,14 +16,18 @@ import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { OpsModule } from "./ops/ops.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { RenewalsModule } from "./renewals/renewals.module";
|
||||
import { NotificationsModule } from "./notifications/notifications.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
CommonModule,
|
||||
MailModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
CustomersModule,
|
||||
@@ -33,6 +39,8 @@ import { AppController } from "./app.controller";
|
||||
BankModule,
|
||||
OpsModule,
|
||||
ReportsModule,
|
||||
RenewalsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ export type Ability =
|
||||
| "policy:delete"
|
||||
| "policy:ingest"
|
||||
| "policy:ocr-review"
|
||||
| "renewal:send"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
@@ -38,7 +39,9 @@ export type Ability =
|
||||
| "statement:review"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
| "db:manage"
|
||||
| "notification:send"
|
||||
| "setting:manage";
|
||||
|
||||
/** Minimum role required for each ability. */
|
||||
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
@@ -52,6 +55,7 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
// upload + confirm, nothing reaches the books unconfirmed.
|
||||
"policy:ingest": "STAFF",
|
||||
"policy:ocr-review": "STAFF",
|
||||
"renewal:send": "MANAGER",
|
||||
"property:create": "STAFF",
|
||||
"property:update": "STAFF",
|
||||
"property:delete": "MANAGER",
|
||||
@@ -71,6 +75,15 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"lookup:manage": "MANAGER",
|
||||
"user:manage": "ADMIN",
|
||||
"db:manage": "ADMIN",
|
||||
// Mass email notifications — fires mail to customers on the office's
|
||||
// behalf, with no per-row review. Same trust tier as `renewal:send`:
|
||||
// a STAFF user typing one customer receipt is fine; a STAFF user firing
|
||||
// 260 mail merges on the customer base is not.
|
||||
"notification:send": "MANAGER",
|
||||
// Editing operator configuration. Above `notification:send` on purpose:
|
||||
// firing a sweep is the day job, but changing WHERE the audit summaries
|
||||
// land is how someone would quietly stop them being read.
|
||||
"setting:manage": "ADMIN",
|
||||
};
|
||||
|
||||
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import {
|
||||
BALANCE_FLOOR_JOIN,
|
||||
BALANCE_FORWARD_TYPE,
|
||||
BillingService,
|
||||
NOT_SUPERSEDED,
|
||||
} from "./billing.service";
|
||||
|
||||
/**
|
||||
* The balance floor drops rows a later BALANCE FORWARD already accounts for.
|
||||
*
|
||||
* It is worth testing because it fails silently: nothing throws, the numbers are
|
||||
* just wrong, and they were wrong for years — the whole book read +20.6M MXN in
|
||||
* credit because every customer's pre-cutover history was counted twice, once
|
||||
* inside their opening balance and once as itself.
|
||||
*/
|
||||
describe("balance floor", () => {
|
||||
describe("SQL fragments", () => {
|
||||
it("binds the type name rather than interpolating it", () => {
|
||||
// A literal would be a second place to edit if the label ever changes,
|
||||
// and this string reaches SQL from a module constant.
|
||||
expect(BALANCE_FLOOR_JOIN.values).toEqual([BALANCE_FORWARD_TYPE]);
|
||||
});
|
||||
|
||||
it("keys the floor to the row's own customer", () => {
|
||||
// Without this the derived table cross-joins and every customer inherits
|
||||
// the earliest BALANCE FORWARD in the book.
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain(
|
||||
"bfloor ON bfloor.customerId = t.customerId",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the most recent opening balance, not the first", () => {
|
||||
// A customer accumulates one BALANCE FORWARD per year. MIN would floor at
|
||||
// the oldest and leave every intervening year double-counted.
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain("MAX(bf.transactionDate)");
|
||||
expect(BALANCE_FLOOR_JOIN.sql).not.toContain("MIN(bf.transactionDate)");
|
||||
});
|
||||
|
||||
it("ignores voided opening balances when locating the floor", () => {
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain("bf.voidedAt IS NULL");
|
||||
});
|
||||
|
||||
it("is inclusive of the opening balance row itself", () => {
|
||||
// `>` instead of `>=` would drop the carried balance and understate every
|
||||
// customer by exactly that amount.
|
||||
expect(NOT_SUPERSEDED.sql).toContain("t.transactionDate >= bfloor.floorDate");
|
||||
expect(NOT_SUPERSEDED.sql).not.toMatch(/transactionDate\s*>\s*bfloor/);
|
||||
});
|
||||
|
||||
it("leaves customers with no opening balance untouched", () => {
|
||||
// NULL comparisons are never true, so without the explicit IS NULL branch
|
||||
// a customer who has no BALANCE FORWARD row loses their entire ledger.
|
||||
expect(NOT_SUPERSEDED.sql).toContain("bfloor.floorDate IS NULL");
|
||||
});
|
||||
|
||||
it("only ever references the alias the join defines", () => {
|
||||
// The predicate is useless without the join; pairing them wrongly is a
|
||||
// runtime "unknown column", so keep the alias identical in both.
|
||||
const aliases = NOT_SUPERSEDED.sql.match(/bfloor\.\w+/g) ?? [];
|
||||
expect(aliases.length).toBeGreaterThan(0);
|
||||
for (const ref of aliases) {
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain(ref.split(".")[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("statement()", () => {
|
||||
/**
|
||||
* One customer means one floor date, so the statement uses a scalar lookup
|
||||
* instead of the join. Asserting on the `where` Prisma is handed is the only
|
||||
* way to see it without a database.
|
||||
*/
|
||||
function serviceWith(floor: Date | null) {
|
||||
const findMany = jest.fn().mockResolvedValue([]);
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: "c1",
|
||||
name: "CUADROS, JORGE H.",
|
||||
preferredCurrency: "USD",
|
||||
_count: { properties: 0, policies: 0 },
|
||||
}),
|
||||
},
|
||||
transaction: {
|
||||
findFirst: jest
|
||||
.fn()
|
||||
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||
findMany,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
service: new BillingService(prisma as never),
|
||||
prisma,
|
||||
findMany,
|
||||
};
|
||||
}
|
||||
|
||||
it("looks the floor up from the customer's newest opening balance", async () => {
|
||||
const { service, prisma } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
customerId: "c1",
|
||||
voidedAt: null,
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
},
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds the statement at the floor, inclusive", async () => {
|
||||
const floor = new Date("2026-01-01T00:00:00Z");
|
||||
const { service, findMany } = serviceWith(floor);
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).toMatchObject({
|
||||
customerId: "c1",
|
||||
transactionDate: { gte: floor },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies no date bound when the customer has no opening balance", async () => {
|
||||
const { service, findMany } = serviceWith(null);
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||
"transactionDate",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||
// The two guards answer different questions — one reproduces legacy's
|
||||
// DATOS2-only materialization, the other drops superseded history — and
|
||||
// dropping either one changes the customer's balance.
|
||||
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
expect(where.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: NUMid 501", () => {
|
||||
/**
|
||||
* The arithmetic that exposed the bug, pinned so it cannot silently return.
|
||||
* Figures measured against the live ledger on 2026-08-05.
|
||||
*/
|
||||
const openingBalance = new Prisma.Decimal("-6732.29");
|
||||
const activitySinceOpening = new Prisma.Decimal("-7333.00");
|
||||
const preCutoverCashAlreadyInOpening = new Prisma.Decimal("3596.00");
|
||||
|
||||
it("matches the legacy portal once superseded rows are dropped", () => {
|
||||
expect(openingBalance.plus(activitySinceOpening).toFixed(2)).toBe(
|
||||
"-14065.29",
|
||||
);
|
||||
});
|
||||
|
||||
it("reproduces the wrong figure when they are not", () => {
|
||||
expect(
|
||||
openingBalance
|
||||
.plus(activitySinceOpening)
|
||||
.plus(preCutoverCashAlreadyInOpening)
|
||||
.toFixed(2),
|
||||
).toBe("-10469.29");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,24 +104,31 @@ interface BalanceRow {
|
||||
nameMissing: number;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
movements: bigint | number | string;
|
||||
movements: RawCount;
|
||||
balanceMxn: Prisma.Decimal | null;
|
||||
balanceUsd: Prisma.Decimal | null;
|
||||
chargesMxn: Prisma.Decimal | null;
|
||||
creditsMxn: Prisma.Decimal | null;
|
||||
chargesUsd: Prisma.Decimal | null;
|
||||
creditsUsd: Prisma.Decimal | null;
|
||||
utilityMovements: bigint | number | string;
|
||||
insuranceMovements: bigint | number | string;
|
||||
utilityMovements: RawCount;
|
||||
insuranceMovements: RawCount;
|
||||
lastMovement: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw-query counts come back in three shapes depending on the aggregate:
|
||||
* `COUNT(*)` as bigint, `SUM(bool)` as a decimal *string*, and plain numbers.
|
||||
* Normalize all of them before they reach the client as JSON.
|
||||
* Every shape a raw-query count can arrive in. `COUNT(*)` is a bigint,
|
||||
* `SUM(bool)` is a Prisma.Decimal, and plain numbers occur too — none of which
|
||||
* survive JSON serialization the way the client expects.
|
||||
*/
|
||||
function num(v: bigint | number | string | null | undefined): number {
|
||||
type RawCount = bigint | number | string | Prisma.Decimal;
|
||||
|
||||
/**
|
||||
* Normalizes a raw-query count before it reaches the client as JSON. A bigint
|
||||
* throws on JSON.stringify and a Decimal serializes to a *string*, so counts
|
||||
* must not be passed through untouched.
|
||||
*/
|
||||
function num(v: RawCount | null | undefined): number {
|
||||
if (v === null || v === undefined) return 0;
|
||||
return typeof v === "number" ? v : Number(v);
|
||||
}
|
||||
@@ -152,6 +159,56 @@ const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
||||
*/
|
||||
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
||||
|
||||
/**
|
||||
* The legacy type name for a carried-forward opening balance.
|
||||
*
|
||||
* These rows are not movements. Access materialized one per customer per year,
|
||||
* dated Jan 1, holding the closing balance of everything before it — that is
|
||||
* what let the portal keep each year in its own table (`datosfreak` = current,
|
||||
* `2025`, `2024`, ...) and still show a correct running balance from a single
|
||||
* year's rows.
|
||||
*/
|
||||
export const BALANCE_FORWARD_TYPE = "BALANCE FORWARD";
|
||||
|
||||
/**
|
||||
* Per-customer date of the most recent BALANCE FORWARD row.
|
||||
*
|
||||
* Joined rather than correlated: one small derived table (1,170 rows) beats a
|
||||
* subquery evaluated per ledger row.
|
||||
*/
|
||||
export const BALANCE_FLOOR_JOIN = Prisma.sql`
|
||||
LEFT JOIN (
|
||||
SELECT bf.customerId, MAX(bf.transactionDate) AS floorDate
|
||||
FROM transactions bf
|
||||
JOIN type_transactions bft ON bft.id = bf.typeId
|
||||
WHERE bft.nameEn = ${BALANCE_FORWARD_TYPE} AND bf.voidedAt IS NULL
|
||||
GROUP BY bf.customerId
|
||||
) bfloor ON bfloor.customerId = t.customerId`;
|
||||
|
||||
/**
|
||||
* Excludes rows a later BALANCE FORWARD already accounts for.
|
||||
*
|
||||
* WHY THIS EXISTS. The platform holds both the synthetic BALANCE FORWARD rows
|
||||
* and the real pre-cutover history they summarize, so summing a customer's
|
||||
* whole ledger counts that history twice — once inside the opening balance,
|
||||
* once as itself. NUMid 501 read -10,469.29 on the worklist against -14,065.29
|
||||
* on the customer's own statement and on the legacy portal, the gap being two
|
||||
* cash receipts from 2009 and 2012 that the 2026 opening balance had already
|
||||
* absorbed.
|
||||
*
|
||||
* The scale is what settles it: summed the old way the entire book came to
|
||||
* +20,605,447.86 MXN — the office owing its customers 20.6 million pesos.
|
||||
* Floored, it is -56,855.90, a modest net receivable. A receivables ledger
|
||||
* cannot be 20M in credit.
|
||||
*
|
||||
* Applies to BALANCES ONLY, in the same spirit as NOT_OUTSTANDING: the movement
|
||||
* browser still totals every captured row, because "how much water did we
|
||||
* capture in April" is a question about what was recorded, not about what is
|
||||
* owed. Customers with no BALANCE FORWARD row (the floor is NULL) are
|
||||
* unaffected.
|
||||
*/
|
||||
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
||||
|
||||
/**
|
||||
* Source tables excluded from the customer-facing statement.
|
||||
*
|
||||
@@ -402,22 +459,24 @@ export class BillingService {
|
||||
MAX(t.transactionDate) AS lastMovement
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||
${having}
|
||||
${orderBy}
|
||||
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
|
||||
`;
|
||||
|
||||
const counted = await this.prisma.$queryRaw<{ total: bigint | number | string }[]>`
|
||||
const counted = await this.prisma.$queryRaw<{ total: RawCount }[]>`
|
||||
SELECT COUNT(*) AS total FROM (
|
||||
SELECT c.id
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
-- Must match the page query's filters exactly, or the total disagrees
|
||||
-- with the rows. (The void exclusion was missing here before the
|
||||
-- outstanding work; a voided-only customer inflated the count.)
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id
|
||||
${having}
|
||||
) x
|
||||
@@ -458,9 +517,18 @@ export class BillingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Top-line figures for the billing page header. */
|
||||
/**
|
||||
* Top-line figures for the billing page header.
|
||||
*
|
||||
* Two different questions live here and they use different row sets.
|
||||
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED
|
||||
* and drops rows an opening balance already accounts for. The four aggregates
|
||||
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
|
||||
*/
|
||||
async stats() {
|
||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
||||
const [movements, ledgerCustomers] = await Promise.all([
|
||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||
this.prisma.transaction
|
||||
.findMany({
|
||||
@@ -469,34 +537,47 @@ export class BillingService {
|
||||
select: { customerId: true },
|
||||
})
|
||||
.then((r) => r.length),
|
||||
this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: NOT_VOIDED,
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.transaction.groupBy({
|
||||
by: ["domain", "currency"],
|
||||
where: NOT_VOIDED,
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const charges = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const credits = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
|
||||
const creditMap = new Map(credits.map((c) => [c.currency, c]));
|
||||
const byCurrency = await this.prisma.$queryRaw<
|
||||
{
|
||||
currency: string;
|
||||
net: Prisma.Decimal | null;
|
||||
count: RawCount;
|
||||
charges: Prisma.Decimal | null;
|
||||
chargeCount: RawCount;
|
||||
credits: Prisma.Decimal | null;
|
||||
creditCount: RawCount;
|
||||
}[]
|
||||
>`
|
||||
SELECT t.currency AS currency,
|
||||
SUM(t.amount) AS net,
|
||||
COUNT(*) AS count,
|
||||
SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END) AS charges,
|
||||
SUM(t.amount < 0) AS chargeCount,
|
||||
SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) AS credits,
|
||||
SUM(t.amount > 0) AS creditCount
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
GROUP BY t.currency
|
||||
`;
|
||||
|
||||
const byDomain = await this.prisma.$queryRaw<
|
||||
{
|
||||
domain: string;
|
||||
currency: string;
|
||||
net: Prisma.Decimal | null;
|
||||
count: RawCount;
|
||||
}[]
|
||||
>`
|
||||
SELECT t.domain AS domain, t.currency AS currency,
|
||||
SUM(t.amount) AS net, COUNT(*) AS count
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
GROUP BY t.domain, t.currency
|
||||
`;
|
||||
|
||||
// How many customers sit on each side of the line, per currency — the
|
||||
// headline for a receivables view. Counted in SQL; a customer can be
|
||||
@@ -504,16 +585,19 @@ export class BillingService {
|
||||
const sides = await this.prisma.$queryRaw<
|
||||
{
|
||||
currency: string;
|
||||
owing: bigint | number | string;
|
||||
inCredit: bigint | number | string;
|
||||
owing: RawCount;
|
||||
inCredit: RawCount;
|
||||
}[]
|
||||
>`
|
||||
SELECT currency,
|
||||
SUM(bal < -0.005) AS owing,
|
||||
SUM(bal > 0.005) AS inCredit
|
||||
FROM (
|
||||
SELECT customerId, currency, SUM(amount) AS bal
|
||||
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
|
||||
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
GROUP BY t.customerId, t.currency
|
||||
) x
|
||||
GROUP BY currency
|
||||
`;
|
||||
@@ -534,7 +618,7 @@ export class BillingService {
|
||||
|
||||
// Customers whose ledger spans both business lines — the whole reason this
|
||||
// module is one view instead of two.
|
||||
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
||||
const crossLine = await this.prisma.$queryRaw<{ n: RawCount }[]>`
|
||||
SELECT COUNT(*) AS n FROM (
|
||||
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
||||
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
||||
@@ -549,20 +633,20 @@ export class BillingService {
|
||||
lastMovement: lastRow?.transactionDate ?? null,
|
||||
byCurrency: byCurrency.map((c) => ({
|
||||
currency: c.currency,
|
||||
net: c._sum.amount,
|
||||
count: c._count._all,
|
||||
charges: chargeMap.get(c.currency)?._sum.amount ?? null,
|
||||
chargeCount: chargeMap.get(c.currency)?._count._all ?? 0,
|
||||
credits: creditMap.get(c.currency)?._sum.amount ?? null,
|
||||
creditCount: creditMap.get(c.currency)?._count._all ?? 0,
|
||||
net: c.net,
|
||||
count: num(c.count),
|
||||
charges: c.charges,
|
||||
chargeCount: num(c.chargeCount),
|
||||
credits: c.credits,
|
||||
creditCount: num(c.creditCount),
|
||||
owing: num(sideMap.get(c.currency)?.owing),
|
||||
inCredit: num(sideMap.get(c.currency)?.inCredit),
|
||||
})),
|
||||
byDomain: byDomain.map((d) => ({
|
||||
domain: d.domain,
|
||||
currency: d.currency,
|
||||
net: d._sum.amount,
|
||||
count: d._count._all,
|
||||
net: d.net,
|
||||
count: num(d.count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -589,7 +673,7 @@ export class BillingService {
|
||||
});
|
||||
|
||||
const years = await this.prisma.$queryRaw<
|
||||
{ year: number; count: bigint | number | string }[]
|
||||
{ year: number; count: RawCount }[]
|
||||
>`
|
||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
||||
@@ -647,9 +731,32 @@ export class BillingService {
|
||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
}
|
||||
|
||||
// One customer, so the balance floor is a single date rather than the
|
||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||
// the opening balance are already inside it, and showing them would both
|
||||
// double the total and make every balanceAfter below wrong.
|
||||
//
|
||||
// This is also what stops FEE ANUAL and fee15 leaking in. They are not in
|
||||
// STATEMENT_EXCLUDED_SOURCE_TABLES — that list exists to reproduce legacy's
|
||||
// DATOS2-only `datosfreak`, and it was letting 2,092 pre-cutover fee rows
|
||||
// across 1,062 customers through, skewing the statement by -5,129,764
|
||||
// against the number those customers have been quoted for years. Dating
|
||||
// rather than source is the right test: a FEE ANUAL row *after* the opening
|
||||
// balance is a real charge and still counts.
|
||||
const floor = await this.prisma.transaction.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
},
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
});
|
||||
|
||||
const rows = await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
||||
// (which has no legacySourceTable) silently vanished from the
|
||||
|
||||
@@ -29,6 +29,7 @@ export class CreateCustomerDto {
|
||||
@IsOptional() @IsString() mobile?: string;
|
||||
@IsOptional() @IsString() fax?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsBoolean() emailOptOut?: boolean;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsString() identificationType?: string;
|
||||
@IsOptional() @IsString() identificationNumber?: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ export class UpdateCustomerDto {
|
||||
@IsOptional() @IsString() mobile?: string;
|
||||
@IsOptional() @IsString() fax?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsBoolean() emailOptOut?: boolean;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsString() identificationType?: string;
|
||||
@IsOptional() @IsString() identificationNumber?: string;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { MailService } from "./mail.service";
|
||||
|
||||
/** Global so any feature module can inject MailService without re-importing.
|
||||
* Matches the StorageService pattern: env-driven, null when unconfigured,
|
||||
* and never blocks API boot. Notifications use it; renewals reuse it.
|
||||
* ConfigService comes from the global ConfigModule in AppModule. */
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
SESv2Client,
|
||||
SendEmailCommand,
|
||||
SendEmailCommandInput,
|
||||
SendEmailCommandOutput,
|
||||
} from "@aws-sdk/client-sesv2";
|
||||
|
||||
/**
|
||||
* Outbound mail transport. Amazon SES — the channel the office already uses
|
||||
* for bulk notification, per docs/INSURANCE_FEATURES_SPEC.md §1.3 (the
|
||||
* renewal-notice spec settled on SES for the same reason: established sender
|
||||
* reputation, existing IAM, negligible incremental cost at our volume).
|
||||
*
|
||||
* Mirrors `StorageService` exactly: env-driven config, null client when
|
||||
* unconfigured, `ServiceUnavailableException` on use, never blocks API boot.
|
||||
* When the env vars are missing AND we're in dev/test we fall back to a
|
||||
* console-logging transport so the NotificationsService can be exercised
|
||||
* end-to-end without SES credentials — a missing mail setup in production
|
||||
* still throws, so a real deployment can't accidentally no-op its sends.
|
||||
*
|
||||
* Env:
|
||||
* SES_REGION — required when client is configured
|
||||
* SES_ACCESS_KEY / SES_SECRET_KEY — required
|
||||
* SES_FROM — verified sending identity (e.g. mail@jorgecuadros.com)
|
||||
* SES_FROM_NAME — display name, optional
|
||||
* SES_CONFIGURATION_SET — optional, for bounce/complaint event publishing
|
||||
*/
|
||||
|
||||
export interface SendArgs {
|
||||
to: string;
|
||||
/** Optional display name; SES will not display it for "to" but we keep it on
|
||||
* the log row so customer-facing audit reads naturally. */
|
||||
toName?: string;
|
||||
subject: string;
|
||||
/** HTML body. The four notification jobs all produce HTML. */
|
||||
html: string;
|
||||
/** Optional override of the configured From; rare but useful for the
|
||||
* trust-payment test mail to a different identity. */
|
||||
from?: string;
|
||||
fromName?: string;
|
||||
/** Marker header kept on every send so a downstream mail-log search for
|
||||
* "X-Tracking: 1" surfaces only this app's outbound traffic. The legacy
|
||||
* PHP sendEmail() always set it; we keep the convention. */
|
||||
xTracking?: string;
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
/** SES MessageId (or our mock prefix in dev). Stored verbatim on the
|
||||
* notification log row so a SES bounce/complaint webhook can be matched
|
||||
* back to the exact send. */
|
||||
messageId: string;
|
||||
/** Truncated SES response payload (or empty in dev). 4k cap matches the
|
||||
* notification log column width. */
|
||||
response: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private readonly client: SESv2Client | null;
|
||||
private readonly fromAddress: string | null;
|
||||
private readonly fromName: string;
|
||||
private readonly configurationSet: string | undefined;
|
||||
private readonly devMode: boolean;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const region = config.get<string>("SES_REGION");
|
||||
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
|
||||
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
|
||||
this.fromAddress =
|
||||
config.get<string>("SES_FROM") ??
|
||||
config.get<string>("MAIL_FROM") ??
|
||||
null;
|
||||
this.fromName =
|
||||
config.get<string>("SES_FROM_NAME") ??
|
||||
config.get<string>("MAIL_FROM_NAME") ??
|
||||
"Information Server";
|
||||
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET");
|
||||
// Dev fallback: when nothing is configured, log sends to stdout instead
|
||||
// of throwing. Lets the API boot in a fresh checkout and lets the
|
||||
// notifications UI show "0 sent" meaningfully on `debug=1`. Production
|
||||
// (NODE_ENV !== development) still requires real config.
|
||||
this.devMode = process.env.NODE_ENV !== "production";
|
||||
|
||||
if (!region || !accessKeyId || !secretAccessKey || !this.fromAddress) {
|
||||
if (!this.devMode) {
|
||||
this.logger.warn(
|
||||
"SES not configured (SES_REGION / SES_ACCESS_KEY / SES_SECRET_KEY / SES_FROM). " +
|
||||
"Outbound mail will throw ServiceUnavailableException.",
|
||||
);
|
||||
}
|
||||
this.client = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new SESv2Client({
|
||||
region,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
});
|
||||
this.logger.log(
|
||||
`SES mail client configured (region=${region}, from=${this.fromAddress}).`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the deployment has a real mail transport. Callers use this to
|
||||
* refuse work up front — a mass-notification job that throws on its
|
||||
* first send half-completes and the log is unrecoverable, so we fail
|
||||
* fast at the controller. */
|
||||
get available(): boolean {
|
||||
return this.client !== null || this.devMode;
|
||||
}
|
||||
|
||||
/** True when the underlying transport is the dev console-log fallback. */
|
||||
get isDevFallback(): boolean {
|
||||
return this.client === null && this.devMode;
|
||||
}
|
||||
|
||||
private require(): SESv2Client {
|
||||
if (!this.client) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El envío de correo no está configurado.",
|
||||
);
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single HTML email. The dev fallback logs to stdout and returns a
|
||||
* synthetic `dev-<timestamp>` message id; the real transport talks to SES
|
||||
* and returns the SES MessageId.
|
||||
*
|
||||
* Throws `ServiceUnavailableException` when no transport is configured and
|
||||
* we are not in dev — the caller (NotificationsService) catches and records
|
||||
* it on the log row so a failed sweep produces a coherent audit trail
|
||||
* instead of an aborted one.
|
||||
*/
|
||||
async send(args: SendArgs): Promise<SendResult> {
|
||||
const from = `${args.fromName ?? this.fromName} <${
|
||||
args.from ?? this.fromAddress ?? ""
|
||||
}>`.trim();
|
||||
|
||||
if (!this.client) {
|
||||
if (!this.devMode) this.require();
|
||||
const fakeId = `dev-${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
this.logger.log(
|
||||
`[dev-mail] to=${args.to} subject="${args.subject}" id=${fakeId} ` +
|
||||
`len=${args.html.length}`,
|
||||
);
|
||||
return { messageId: fakeId, response: "" };
|
||||
}
|
||||
|
||||
const input: SendEmailCommandInput = {
|
||||
FromEmailAddress: from,
|
||||
Destination: { ToAddresses: [args.to] },
|
||||
Content: {
|
||||
Simple: {
|
||||
Subject: { Data: args.subject, Charset: "UTF-8" },
|
||||
Body: { Html: { Data: args.html, Charset: "UTF-8" } },
|
||||
},
|
||||
},
|
||||
...(this.configurationSet
|
||||
? { ConfigurationSetName: this.configurationSet }
|
||||
: {}),
|
||||
...(args.xTracking
|
||||
? {
|
||||
EmailTags: [
|
||||
{ Name: "X-Tracking", Value: args.xTracking },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const out: SendEmailCommandOutput = await this.client.send(
|
||||
new SendEmailCommand(input),
|
||||
);
|
||||
return {
|
||||
messageId: out.MessageId ?? "",
|
||||
response: JSON.stringify({ MessageId: out.MessageId ?? null }).slice(0, 4096),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationLogService } from "./notification-log.service";
|
||||
|
||||
/**
|
||||
* Just the log writer, so a feature that sends mail can record it without
|
||||
* importing `NotificationsModule` (which carries the four bulk-job pipelines
|
||||
* and their controller). Imported by `NotificationsModule` and
|
||||
* `RenewalsModule`.
|
||||
*/
|
||||
@Module({
|
||||
providers: [NotificationLogService],
|
||||
exports: [NotificationLogService],
|
||||
})
|
||||
export class NotificationLogModule {}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
EmailNotificationServicio,
|
||||
EmailNotificationStatus,
|
||||
EmailNotificationType,
|
||||
} from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { AttemptStatus } from "./notification.types";
|
||||
|
||||
/**
|
||||
* The single writer for `email_notification_log`.
|
||||
*
|
||||
* Extracted out of `NotificationsService` so the renewal sweep can write the
|
||||
* same rows as the four bulk jobs without pulling that service (and its four
|
||||
* job pipelines) into `RenewalsModule`. Every outbound email the platform
|
||||
* sends goes through here, which is what makes /notificaciones' "Registro de
|
||||
* envíos" complete rather than per-feature.
|
||||
*/
|
||||
export interface NotificationLogEntry {
|
||||
notificationType: EmailNotificationType;
|
||||
servicio: EmailNotificationServicio;
|
||||
/** Defaults to now(). Pass it when the row must line up exactly with
|
||||
* another record of the same send (the renewal sweep pins it to
|
||||
* `RenewalNotice.sentAt`). */
|
||||
sendDate?: Date;
|
||||
/** Type-dependent discriminator — see the `level` doc on the Prisma model.
|
||||
* 0/1 for ACCOUNT_STATUS, the generation for RENEWAL_NOTICE. */
|
||||
level?: number | null;
|
||||
customerId: string | null;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
subject: string;
|
||||
bodySnapshot: string;
|
||||
bodyRequestUrl?: string;
|
||||
status: AttemptStatus;
|
||||
debug: boolean;
|
||||
providerMessageId?: string;
|
||||
providerResponse?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** `providerResponse` is a VARCHAR(191); anything longer is a provider dump
|
||||
* we only need the head of. Errors go to the TEXT `error` column and get
|
||||
* the 4k cap the schema documents. */
|
||||
const PROVIDER_RESPONSE_MAX = 180;
|
||||
const ERROR_MAX = 4096;
|
||||
|
||||
@Injectable()
|
||||
export class NotificationLogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(entry: NotificationLogEntry): Promise<void> {
|
||||
await this.prisma.emailNotificationLog.create({
|
||||
data: {
|
||||
notificationType: entry.notificationType,
|
||||
servicio: entry.servicio,
|
||||
...(entry.sendDate && { sendDate: entry.sendDate }),
|
||||
level: entry.level ?? null,
|
||||
customerId: entry.customerId,
|
||||
customerName: entry.customerName,
|
||||
customerEmail: entry.customerEmail,
|
||||
subject: entry.subject,
|
||||
bodySnapshot: entry.bodySnapshot,
|
||||
bodyRequestUrl: entry.bodyRequestUrl ?? null,
|
||||
debug: entry.debug,
|
||||
providerMessageId: entry.providerMessageId ?? null,
|
||||
providerResponse:
|
||||
entry.providerResponse?.slice(0, PROVIDER_RESPONSE_MAX) ?? null,
|
||||
status: entry.status as EmailNotificationStatus,
|
||||
error: entry.error?.slice(0, ERROR_MAX) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsModule } from "../settings/settings.module";
|
||||
import { NotificationScheduleService } from "./notification-schedule.service";
|
||||
|
||||
/**
|
||||
* Just the cadence registry, split out for the same reason as
|
||||
* `NotificationLogModule`: both `NotificationsModule` and `RenewalsModule`
|
||||
* need it, and neither may import the other.
|
||||
*/
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
providers: [NotificationScheduleService],
|
||||
exports: [NotificationScheduleService],
|
||||
})
|
||||
export class NotificationScheduleModule {}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { CronJob } from "cron";
|
||||
import { SettingsService } from "../settings/settings.service";
|
||||
import type { ResolvedSetting } from "../settings/settings.service";
|
||||
|
||||
/**
|
||||
* When the two automatic envíos run.
|
||||
*
|
||||
* Both halves of /notificaciones used to be hardcoded: pólizas swept at 06:00
|
||||
* from a `@Cron` decorator, servicios had no automatic run at all and had to
|
||||
* be clicked. Neither could be changed without a redeploy. This service owns
|
||||
* the cadence for both, stores it in `app_settings`, and re-installs the job
|
||||
* the moment an operator saves — no restart.
|
||||
*
|
||||
* The owning services register their handler at boot rather than this service
|
||||
* importing them: `NotificationsService` and `RenewalsService` would otherwise
|
||||
* have to be injected here, and this file is imported by both.
|
||||
*/
|
||||
|
||||
export const SCHEDULE_TIME_ZONE = "America/Tijuana";
|
||||
|
||||
export type ScheduleKind = "servicios" | "polizas";
|
||||
|
||||
export const SCHEDULE_KINDS: ScheduleKind[] = ["servicios", "polizas"];
|
||||
|
||||
export interface NotificationSchedule {
|
||||
enabled: boolean;
|
||||
/** Local hour/minute in `SCHEDULE_TIME_ZONE`, not UTC — the office thinks
|
||||
* in Tijuana time and DST would otherwise drift the run by an hour. */
|
||||
hour: number;
|
||||
minute: number;
|
||||
/** 0 = Sunday … 6 = Saturday. Empty means every day. */
|
||||
weekdays: number[];
|
||||
}
|
||||
|
||||
export interface ResolvedSchedule extends ResolvedSetting<NotificationSchedule> {
|
||||
/** The cron expression the value compiles to, shown in the UI so the
|
||||
* operator can see exactly what was installed. */
|
||||
cron: string;
|
||||
/** Next fire time, or null when disabled. */
|
||||
nextRun: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults preserve what each half did before this existed: pólizas keeps its
|
||||
* 06:00 daily sweep, servicios stays OFF. Turning a mass send on is an
|
||||
* operator decision — a default that starts mailing 260 customers on its own
|
||||
* after a deploy is not a default, it's an incident.
|
||||
*/
|
||||
const DEFAULTS: Record<ScheduleKind, NotificationSchedule> = {
|
||||
servicios: { enabled: false, hour: 7, minute: 0, weekdays: [1, 3, 5] },
|
||||
polizas: { enabled: true, hour: 6, minute: 0, weekdays: [] },
|
||||
};
|
||||
|
||||
/** Human label used in log lines and audit entries. */
|
||||
export const SCHEDULE_LABELS: Record<ScheduleKind, string> = {
|
||||
servicios: "envíos de servicios",
|
||||
polizas: "avisos de renovación",
|
||||
};
|
||||
|
||||
export function scheduleCron(schedule: NotificationSchedule): string {
|
||||
const dow = schedule.weekdays.length
|
||||
? [...new Set(schedule.weekdays)].sort((a, b) => a - b).join(",")
|
||||
: "*";
|
||||
return `${schedule.minute} ${schedule.hour} * * ${dow}`;
|
||||
}
|
||||
|
||||
/** Reject anything that would compile to a cron we can't install. Returns the
|
||||
* normalized value, or a message naming the offending field. */
|
||||
export function parseSchedule(
|
||||
raw: unknown,
|
||||
): { ok: true; value: NotificationSchedule } | { ok: false; error: string } {
|
||||
const v = raw as Partial<NotificationSchedule> | null;
|
||||
if (!v || typeof v !== "object") return { ok: false, error: "Horario inválido." };
|
||||
const hour = Number(v.hour);
|
||||
const minute = Number(v.minute);
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
||||
return { ok: false, error: "La hora debe estar entre 0 y 23." };
|
||||
}
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||
return { ok: false, error: "Los minutos deben estar entre 0 y 59." };
|
||||
}
|
||||
const weekdays = Array.isArray(v.weekdays) ? v.weekdays.map(Number) : [];
|
||||
if (weekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
|
||||
return { ok: false, error: "Los días deben estar entre 0 (domingo) y 6." };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
enabled: !!v.enabled,
|
||||
hour,
|
||||
minute,
|
||||
weekdays: [...new Set(weekdays)].sort((a, b) => a - b),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationScheduleService {
|
||||
private readonly logger = new Logger(NotificationScheduleService.name);
|
||||
private readonly handlers = new Map<ScheduleKind, () => Promise<unknown>>();
|
||||
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly registry: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Called once per kind at boot by the service that owns the sweep. Installs
|
||||
* the job immediately so a freshly started process honours the stored
|
||||
* cadence without waiting for someone to open the UI.
|
||||
*/
|
||||
async register(kind: ScheduleKind, handler: () => Promise<unknown>) {
|
||||
this.handlers.set(kind, handler);
|
||||
await this.apply(kind);
|
||||
}
|
||||
|
||||
async get(kind: ScheduleKind): Promise<ResolvedSchedule> {
|
||||
const resolved = await this.settings.notificationSchedule(
|
||||
kind,
|
||||
DEFAULTS[kind],
|
||||
);
|
||||
const cron = scheduleCron(resolved.value);
|
||||
return { ...resolved, cron, nextRun: this.nextRun(kind) };
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<ScheduleKind, ResolvedSchedule>> {
|
||||
const entries = await Promise.all(
|
||||
SCHEDULE_KINDS.map(async (k) => [k, await this.get(k)] as const),
|
||||
);
|
||||
return Object.fromEntries(entries) as Record<ScheduleKind, ResolvedSchedule>;
|
||||
}
|
||||
|
||||
async set(
|
||||
kind: ScheduleKind,
|
||||
schedule: NotificationSchedule,
|
||||
userId: string,
|
||||
): Promise<ResolvedSchedule> {
|
||||
await this.settings.setNotificationSchedule(kind, schedule, userId);
|
||||
await this.apply(kind);
|
||||
return this.get(kind);
|
||||
}
|
||||
|
||||
/** (Re)install the cron job for one kind from whatever is stored now. */
|
||||
private async apply(kind: ScheduleKind): Promise<void> {
|
||||
const handler = this.handlers.get(kind);
|
||||
if (!handler) return;
|
||||
|
||||
this.remove(kind);
|
||||
|
||||
const { value } = await this.settings.notificationSchedule(
|
||||
kind,
|
||||
DEFAULTS[kind],
|
||||
);
|
||||
if (!value.enabled) {
|
||||
this.logger.log(`Horario de ${SCHEDULE_LABELS[kind]}: desactivado.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cron = scheduleCron(value);
|
||||
const job = new CronJob(
|
||||
cron,
|
||||
() => {
|
||||
void handler().catch((error) =>
|
||||
this.logger.error(
|
||||
`Falló la corrida programada de ${SCHEDULE_LABELS[kind]}: ` +
|
||||
`${(error as Error).message}`,
|
||||
),
|
||||
);
|
||||
},
|
||||
null,
|
||||
false,
|
||||
SCHEDULE_TIME_ZONE,
|
||||
);
|
||||
this.registry.addCronJob(this.jobName(kind), job);
|
||||
job.start();
|
||||
this.logger.log(
|
||||
`Horario de ${SCHEDULE_LABELS[kind]}: ${cron} (${SCHEDULE_TIME_ZONE}).`,
|
||||
);
|
||||
}
|
||||
|
||||
private remove(kind: ScheduleKind): void {
|
||||
const name = this.jobName(kind);
|
||||
// `deleteCronJob` throws when the job was never installed, which is the
|
||||
// normal case on first apply — presence check instead of try/catch so a
|
||||
// real failure still surfaces.
|
||||
if (!this.registry.doesExist("cron", name)) return;
|
||||
this.registry.getCronJob(name).stop();
|
||||
this.registry.deleteCronJob(name);
|
||||
}
|
||||
|
||||
private nextRun(kind: ScheduleKind): string | null {
|
||||
const name = this.jobName(kind);
|
||||
if (!this.registry.doesExist("cron", name)) return null;
|
||||
const next = this.registry.getCronJob(name).nextDate();
|
||||
return next ? next.toJSDate().toISOString() : null;
|
||||
}
|
||||
|
||||
private jobName(kind: ScheduleKind): string {
|
||||
return `notification-schedule:${kind}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { parseSchedule, scheduleCron } from "./notification-schedule.service";
|
||||
|
||||
/**
|
||||
* The cadence editor's only sharp edge: a stored value compiles to a cron
|
||||
* expression that the scheduler installs verbatim. A malformed one either
|
||||
* throws at install time (taking the sweep down) or silently installs the
|
||||
* wrong cadence, so validation happens before anything is written.
|
||||
*/
|
||||
|
||||
describe("scheduleCron", () => {
|
||||
it("compiles a daily schedule with no weekday filter", () => {
|
||||
expect(
|
||||
scheduleCron({ enabled: true, hour: 6, minute: 0, weekdays: [] }),
|
||||
).toBe("0 6 * * *");
|
||||
});
|
||||
|
||||
it("compiles the legacy Mon/Wed/Fri cadence, sorted and de-duplicated", () => {
|
||||
expect(
|
||||
scheduleCron({ enabled: true, hour: 7, minute: 30, weekdays: [5, 1, 3, 1] }),
|
||||
).toBe("30 7 * * 1,3,5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSchedule", () => {
|
||||
it("normalizes weekdays and coerces enabled to a boolean", () => {
|
||||
const parsed = parseSchedule({
|
||||
enabled: 1,
|
||||
hour: 6,
|
||||
minute: 0,
|
||||
weekdays: [3, 1, 3],
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
ok: true,
|
||||
value: { enabled: true, hour: 6, minute: 0, weekdays: [1, 3] },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a missing weekday list to every day", () => {
|
||||
const parsed = parseSchedule({ enabled: true, hour: 0, minute: 0 });
|
||||
expect(parsed.ok && parsed.value.weekdays).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ enabled: true, hour: 24, minute: 0 }, "hora"],
|
||||
[{ enabled: true, hour: 6, minute: 60 }, "minutos"],
|
||||
[{ enabled: true, hour: 6, minute: 0, weekdays: [7] }, "días"],
|
||||
[{ enabled: true, hour: 6.5, minute: 0 }, "hora"],
|
||||
])("rejects %p", (input, field) => {
|
||||
const parsed = parseSchedule(input);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(!parsed.ok && parsed.error.toLowerCase()).toContain(field);
|
||||
});
|
||||
|
||||
it("rejects a non-object", () => {
|
||||
expect(parseSchedule(null).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
EmailNotificationServicio,
|
||||
EmailNotificationType,
|
||||
} from "@jorgecuadros/database";
|
||||
import { IsBoolean, IsEnum, IsOptional } from "class-validator";
|
||||
|
||||
/**
|
||||
* Where `debug` sends everything. The PHP used `rmancinas@freakma.net`;
|
||||
* same here. Exported because the flag is platform-wide — the renewal
|
||||
* notices honour it too, and two copies of this address would eventually
|
||||
* disagree.
|
||||
*/
|
||||
export const DEBUG_RECIPIENT = "rmancinas@freakma.net";
|
||||
|
||||
/**
|
||||
* Shared flags for every notification send — the four servicios jobs and
|
||||
* the pólizas renewal notices alike. Every endpoint takes the same shape
|
||||
* so the UI can offer one set of switches for the whole screen; each flag
|
||||
* is documented inline so the per-job semantics are obvious in one place.
|
||||
*
|
||||
* `debug` — replace every recipient with `DEBUG_RECIPIENT` so a
|
||||
* real customer never receives mail during a test run.
|
||||
* Logged on every row. On the renewal side a debug send
|
||||
* also does NOT write the `RenewalNotice` row, so a test
|
||||
* can't gate the letter the customer is still owed.
|
||||
* `ignoreDayRestriction` — Job 3 only: bypass the Mon/Wed/Fri (red) and
|
||||
* Wed-only (yellow) day gates. Off by default so
|
||||
* the on-demand sweep behaves like the legacy
|
||||
* script.
|
||||
* `useEmailLimit` — Job 3 only: pause the sweep 1 hour after 100
|
||||
* sends (a vestigial SMTP-era throttling limit).
|
||||
* Off by default; SES does not need it.
|
||||
*/
|
||||
export class NotificationFlagsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
debug?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ignoreDayRestriction?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
useEmailLimit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What we know at job-end and put on the wire. Field names match the
|
||||
* legacy PHP scripts' `echo json_encode(...)` so a downstream log scraper
|
||||
* that already parses `notificationType: "sendPaymentConfirmation"`
|
||||
* keeps working — see `~/Documents/Claude-Memory/email-notifications-spec.md`
|
||||
* for the verbatim PHP shapes. Specifically: Job 1 reports
|
||||
* `notificationType: "sendPaymentConfirmation"` (the legacy literal), and
|
||||
* uses field `result` instead of `request`; the other three use
|
||||
* `notificationType` matching the script's purpose.
|
||||
*
|
||||
* Every variant carries `sent/skipped/failed/debug` for the audit log;
|
||||
* the legacy fields stay where they were so the response shape is
|
||||
* exactly backward-compatible.
|
||||
*/
|
||||
export type NotificationJobResponse =
|
||||
| {
|
||||
// Job 1
|
||||
result: "success";
|
||||
notificationType: "sendPaymentConfirmation";
|
||||
reason: string;
|
||||
statusCode: 200;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "OUTSTANDING_PAYMENT";
|
||||
}
|
||||
| {
|
||||
// Job 2
|
||||
request: "success";
|
||||
notificationType: "sendPaymentConfirmation";
|
||||
confirmationSent: string;
|
||||
statusCode: 200;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "PAYMENT_CONFIRMATION";
|
||||
}
|
||||
| {
|
||||
// Job 3 — sent/skipped/failed included so the audit log can record
|
||||
// totals without depending on (red+yellow) alone.
|
||||
request: "success";
|
||||
notificationType: "sendAccountStatus";
|
||||
statusSent: string;
|
||||
statusReport: string;
|
||||
statusCode: 200;
|
||||
red: number;
|
||||
yellow: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "ACCOUNT_STATUS";
|
||||
}
|
||||
| {
|
||||
// Job 4
|
||||
request: "success";
|
||||
notificationType: "sendTrustPaymentConfirmation";
|
||||
confirmationSent: string;
|
||||
statusCode: 200;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "TRUST_PAYMENT_CONFIRMATION";
|
||||
};
|
||||
|
||||
/** The four jobs, in the order the "ejecutar todos" sweep runs them. */
|
||||
export type NotificationJobKind =
|
||||
| "outstanding"
|
||||
| "payment"
|
||||
| "account"
|
||||
| "trust";
|
||||
|
||||
/**
|
||||
* One entry of the run-all sweep. A job that throws does NOT abort the
|
||||
* sweep — it is recorded with `ok: false` and the next job still runs, so a
|
||||
* single bad query can't silently block the other three envíos.
|
||||
*/
|
||||
export interface NotificationRunAllJobResult {
|
||||
kind: NotificationJobKind;
|
||||
ok: boolean;
|
||||
result?: NotificationJobResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate response for `POST /notifications/run-all`. `sent/skipped/failed`
|
||||
* are the sums across every job that completed; `jobs` keeps each job's own
|
||||
* verbatim legacy response so the UI can still show per-job detail.
|
||||
*/
|
||||
export interface NotificationRunAllResponse {
|
||||
request: "success";
|
||||
notificationType: "runAllNotifications";
|
||||
statusCode: 200;
|
||||
debug: boolean;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
/** Jobs that threw — sweep continued past them. */
|
||||
errors: number;
|
||||
jobs: NotificationRunAllJobResult[];
|
||||
type: "RUN_ALL";
|
||||
}
|
||||
|
||||
/** Normalized record for a single send attempt, fed by all four jobs. */
|
||||
export interface SendAttempt {
|
||||
notificationType: EmailNotificationType;
|
||||
servicio: EmailNotificationServicio;
|
||||
customerId: string | null;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
subject: string;
|
||||
bodySnapshot: string;
|
||||
bodyRequestUrl?: string;
|
||||
/** Account-status-only — 0 yellow / 1 red. Null on the other three jobs. */
|
||||
level?: 0 | 1;
|
||||
/** Account-status-only — DEBAJO DEL TIPO / EN ROJO. */
|
||||
historyTipo?: string;
|
||||
historyBalance?: string;
|
||||
historyTCambio?: string;
|
||||
historySolicitado?: string;
|
||||
}
|
||||
|
||||
/** Status enum values, mirrored from `EmailNotificationStatus`. */
|
||||
export type AttemptStatus = "SENT" | "FAILED" | "SKIPPED_NO_EMAIL" | "SKIPPED_GATE";
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import {
|
||||
EmailNotificationServicio,
|
||||
EmailNotificationStatus,
|
||||
EmailNotificationType,
|
||||
} from "@jorgecuadros/database";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { invalidEmails, SettingsService } from "../settings/settings.service";
|
||||
import {
|
||||
NotificationScheduleService,
|
||||
parseSchedule,
|
||||
SCHEDULE_KINDS,
|
||||
ScheduleKind,
|
||||
} from "./notification-schedule.service";
|
||||
import { NotificationFlagsDto } from "./notification.types";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
|
||||
/** Same flags for every job, query-string OR body (the PHP scripts took
|
||||
* both via STDIN vs HTTP-CGI — we accept either for parity). */
|
||||
class RunJobDto extends NotificationFlagsDto {}
|
||||
|
||||
class ListLogDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
|
||||
@IsOptional() @IsEnum(EmailNotificationType) type?: EmailNotificationType;
|
||||
/** One or more servicios, comma-separated. The /notificaciones tabs each
|
||||
* read their own slice of the one log: Servicios passes
|
||||
* `CUSTOMERS,TRUST`, Pólizas passes `POLICIES`. Omitted = every servicio. */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === "string"
|
||||
? value.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: value,
|
||||
)
|
||||
@IsEnum(EmailNotificationServicio, { each: true })
|
||||
servicio?: EmailNotificationServicio[];
|
||||
@IsOptional() @IsEnum(EmailNotificationStatus) status?: EmailNotificationStatus;
|
||||
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
|
||||
}
|
||||
|
||||
/** An empty array is valid and means "send no summaries" — the cap only
|
||||
* exists so a paste accident can't write an unbounded blob. */
|
||||
class AdminEmailsDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
emails!: string[];
|
||||
}
|
||||
|
||||
/** Cadence of one automatic envío. Ranges are re-checked by `parseSchedule`,
|
||||
* which is also what the scheduler itself uses — the decorators here only
|
||||
* reject wrong *types* so a bad payload fails at the edge. */
|
||||
class ScheduleDto {
|
||||
@IsBoolean() enabled!: boolean;
|
||||
@IsInt() @Min(0) @Max(23) hour!: number;
|
||||
@IsInt() @Min(0) @Max(59) minute!: number;
|
||||
@IsOptional() @IsArray() @IsInt({ each: true }) weekdays?: number[];
|
||||
}
|
||||
|
||||
function actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP surface for the mass-notification jobs. Four trigger endpoints +
|
||||
* two read endpoints (list log, stats). All mutations gated by the
|
||||
* `notification:send` ability so a STAFF user can't accidentally fire a
|
||||
* 260-mail sweep.
|
||||
*/
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("notifications")
|
||||
export class NotificationsController {
|
||||
constructor(
|
||||
private readonly svc: NotificationsService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly schedule: NotificationScheduleService,
|
||||
) {}
|
||||
|
||||
/* -------------------------------------------------------------- triggers */
|
||||
|
||||
@Post("outstanding-payments")
|
||||
@RequireAbility("notification:send")
|
||||
@HttpCode(200)
|
||||
async runOutstanding(
|
||||
@Body() body: RunJobDto,
|
||||
@Query() query: RunJobDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const flags = { ...query, ...body };
|
||||
const result = await this.svc.runOutstandingPayments(flags);
|
||||
void this.audit.log(actingId(req), "notification.outstanding.run", {
|
||||
debug: !!flags.debug,
|
||||
sent: result.sent,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("payment-confirmation")
|
||||
@RequireAbility("notification:send")
|
||||
@HttpCode(200)
|
||||
async runPaymentConfirm(
|
||||
@Body() body: RunJobDto,
|
||||
@Query() query: RunJobDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const flags = { ...query, ...body };
|
||||
const result = await this.svc.runPaymentConfirmation(flags);
|
||||
void this.audit.log(actingId(req), "notification.payment-confirm.run", {
|
||||
debug: !!flags.debug,
|
||||
sent: result.sent,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("account-status")
|
||||
@RequireAbility("notification:send")
|
||||
@HttpCode(200)
|
||||
async runAccountStatus(
|
||||
@Body() body: RunJobDto,
|
||||
@Query() query: RunJobDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const flags = { ...query, ...body };
|
||||
const result = await this.svc.runAccountStatus(flags);
|
||||
// Narrow the discriminated union to the ACCOUNT_STATUS variant before
|
||||
// pulling red/yellow/total — TS can't follow this through `await` alone.
|
||||
if (result.type === "ACCOUNT_STATUS") {
|
||||
void this.audit.log(actingId(req), "notification.account-status.run", {
|
||||
debug: !!flags.debug,
|
||||
red: result.red,
|
||||
yellow: result.yellow,
|
||||
total: result.total,
|
||||
sent: result.sent,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("trust-payment-confirmation")
|
||||
@RequireAbility("notification:send")
|
||||
@HttpCode(200)
|
||||
async runTrustConfirm(
|
||||
@Body() body: RunJobDto,
|
||||
@Query() query: RunJobDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const flags = { ...query, ...body };
|
||||
const result = await this.svc.runTrustConfirmation(flags);
|
||||
void this.audit.log(actingId(req), "notification.trust-confirm.run", {
|
||||
debug: !!flags.debug,
|
||||
sent: result.sent,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all four jobs sequentially with one set of flags. Audited as a
|
||||
* single `notification.run-all.run` entry carrying the aggregate totals
|
||||
* plus each job's outcome — the per-job endpoints are NOT re-audited, so
|
||||
* the log has exactly one row per staff click.
|
||||
*/
|
||||
@Post("run-all")
|
||||
@RequireAbility("notification:send")
|
||||
@HttpCode(200)
|
||||
async runAll(
|
||||
@Body() body: RunJobDto,
|
||||
@Query() query: RunJobDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const flags = { ...query, ...body };
|
||||
const result = await this.svc.runAll(flags);
|
||||
void this.audit.log(actingId(req), "notification.run-all.run", {
|
||||
debug: !!flags.debug,
|
||||
ignoreDayRestriction: !!flags.ignoreDayRestriction,
|
||||
useEmailLimit: !!flags.useEmailLimit,
|
||||
sent: result.sent,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
errors: result.errors,
|
||||
jobs: result.jobs.map((j) => ({ kind: j.kind, ok: j.ok })),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- read views */
|
||||
|
||||
@Get("log")
|
||||
listLog(@Query() q: ListLogDto) {
|
||||
const page = q.page ?? 1;
|
||||
const pageSize = q.pageSize ?? 50;
|
||||
return this.svc.listLog({
|
||||
page,
|
||||
pageSize,
|
||||
type: q.type,
|
||||
servicio: q.servicio,
|
||||
status: this.mapViewStatus(q.view, q.status),
|
||||
customerId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
stats(@Query() q: ListLogDto) {
|
||||
return this.svc.stats(q.servicio);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- settings */
|
||||
|
||||
/** Who receives the per-job summary email. Readable by any logged-in user
|
||||
* so the UI can show the current list; editing needs `setting:manage`. */
|
||||
@Get("settings/admin-emails")
|
||||
adminEmails() {
|
||||
return this.settings.notificationAdminEmails();
|
||||
}
|
||||
|
||||
@Put("settings/admin-emails")
|
||||
@RequireAbility("setting:manage")
|
||||
async setAdminEmails(@Body() dto: AdminEmailsDto, @Req() req: Request) {
|
||||
const emails = dto.emails.map((e) => e.trim()).filter(Boolean);
|
||||
const bad = invalidEmails(emails);
|
||||
if (bad.length) {
|
||||
throw new BadRequestException(
|
||||
`Correo inválido: ${bad.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const result = await this.settings.setNotificationAdminEmails(
|
||||
emails,
|
||||
actingId(req),
|
||||
);
|
||||
void this.audit.log(actingId(req), "notification.settings.admin-emails", {
|
||||
emails,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- schedule */
|
||||
|
||||
/**
|
||||
* Cadence of both automatic envíos. Readable by any logged-in user so the
|
||||
* screen can show "próxima corrida" without needing edit rights; changing
|
||||
* it needs `setting:manage`, same as the summary recipients.
|
||||
*/
|
||||
@Get("settings/schedule")
|
||||
schedules() {
|
||||
return this.schedule.getAll();
|
||||
}
|
||||
|
||||
@Put("settings/schedule/:kind")
|
||||
@RequireAbility("setting:manage")
|
||||
async setSchedule(
|
||||
@Param("kind") kind: string,
|
||||
@Body() dto: ScheduleDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
if (!SCHEDULE_KINDS.includes(kind as ScheduleKind)) {
|
||||
throw new BadRequestException(
|
||||
`Horario desconocido: ${kind}. Use ${SCHEDULE_KINDS.join(" o ")}.`,
|
||||
);
|
||||
}
|
||||
const parsed = parseSchedule({ ...dto, weekdays: dto.weekdays ?? [] });
|
||||
if (!parsed.ok) throw new BadRequestException(parsed.error);
|
||||
|
||||
const result = await this.schedule.set(
|
||||
kind as ScheduleKind,
|
||||
parsed.value,
|
||||
actingId(req),
|
||||
);
|
||||
void this.audit.log(actingId(req), "notification.settings.schedule", {
|
||||
kind,
|
||||
...parsed.value,
|
||||
cron: result.cron,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Resolve the UI's coarse view tabs to concrete statuses. An explicit
|
||||
* `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why
|
||||
* this returns a list rather than a single value. */
|
||||
private mapViewStatus(
|
||||
view: ListLogDto["view"],
|
||||
status: ListLogDto["status"],
|
||||
): EmailNotificationStatus[] | undefined {
|
||||
if (status) return [status];
|
||||
if (!view || view === "all") return undefined;
|
||||
if (view === "sent") return [EmailNotificationStatus.SENT];
|
||||
if (view === "failed") return [EmailNotificationStatus.FAILED];
|
||||
if (view === "skipped") {
|
||||
return [
|
||||
EmailNotificationStatus.SKIPPED_NO_EMAIL,
|
||||
EmailNotificationStatus.SKIPPED_GATE,
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationLogModule } from "./notification-log.module";
|
||||
import { NotificationScheduleModule } from "./notification-schedule.module";
|
||||
import { SettingsModule } from "../settings/settings.module";
|
||||
import { NotificationsController } from "./notifications.controller";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
|
||||
/**
|
||||
* Mass email notifications. MailModule is global (registered in AppModule),
|
||||
* so this module needs no MailService import — it picks it up by injection.
|
||||
*
|
||||
* The automatic sweep is registered by `NotificationsService` against
|
||||
* `NotificationScheduleService`, which owns the cadence for both halves of
|
||||
* /notificaciones and stores it in `app_settings`.
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationLogModule, NotificationScheduleModule, SettingsModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
renderAccountStatus,
|
||||
renderOutstanding,
|
||||
renderPaymentConfirm,
|
||||
renderTrustConfirm,
|
||||
} from "./render";
|
||||
|
||||
/**
|
||||
* Render-level tests. The legacy PHP scripts fetched these bodies by URL;
|
||||
* we render server-side and inline. The tests assert the *shape* of each
|
||||
* body — account id, name, subject, balance/tipo, color band — because
|
||||
* the customer base has been seeing these letters for years and a visual
|
||||
* regression costs trust faster than any backend change does.
|
||||
*/
|
||||
|
||||
describe("renderOutstanding", () => {
|
||||
it("includes the customer id, name, total, and per-row table", () => {
|
||||
const html = renderOutstanding({
|
||||
customerId: "C-001",
|
||||
customerName: "Acme & Co.",
|
||||
total: "1234.50",
|
||||
rows: [
|
||||
{
|
||||
date: "2026-07-01",
|
||||
reference: "INV-1",
|
||||
period: "Jul-26",
|
||||
type: "CHECK",
|
||||
amount: "-500.00",
|
||||
balance: "-500.00",
|
||||
},
|
||||
{
|
||||
date: "2026-07-15",
|
||||
reference: "INV-2",
|
||||
period: "Jul-26",
|
||||
type: "CASH",
|
||||
amount: "-734.50",
|
||||
balance: "-1234.50",
|
||||
},
|
||||
],
|
||||
year: 2026,
|
||||
});
|
||||
expect(html).toContain("Acme & Co.");
|
||||
expect(html).toContain("ACCOUNT #C-001");
|
||||
expect(html).toContain("$ 1,234.50");
|
||||
expect(html).toContain("INV-1");
|
||||
expect(html).toContain("CHECK");
|
||||
expect(html).toContain("IF YOU ALREADY SENT THE CHECK");
|
||||
});
|
||||
|
||||
it("escapes HTML in the customer name", () => {
|
||||
const html = renderOutstanding({
|
||||
customerId: "x",
|
||||
customerName: "<script>alert(1)</script>",
|
||||
total: "0.00",
|
||||
rows: [],
|
||||
year: 2026,
|
||||
});
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
expect(html).toContain("<script>alert(1)</script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPaymentConfirm", () => {
|
||||
it("uses the transaction type in the heading and the amount in the body", () => {
|
||||
const html = renderPaymentConfirm({
|
||||
customerId: "C-002",
|
||||
customerName: "Bob",
|
||||
typeOfTrx: "CHECK DEPOSIT",
|
||||
reference: "DEP-99",
|
||||
amount: "500.00",
|
||||
year: 2026,
|
||||
});
|
||||
expect(html).toContain("CHECK DEPOSIT CONFIRMATION");
|
||||
expect(html).toContain("HI, Bob");
|
||||
expect(html).toContain("REFER# DEP-99");
|
||||
expect(html).toContain("$ 500.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderAccountStatus", () => {
|
||||
it("uses the yellow band and the under-minimum phrasing for level=0", () => {
|
||||
const html = renderAccountStatus({
|
||||
customerId: "C-003",
|
||||
customerName: "Carol",
|
||||
level: 0,
|
||||
balance: "10.00",
|
||||
tipo: "40.00",
|
||||
year: 2026,
|
||||
});
|
||||
expect(html).toContain("#88D5EE");
|
||||
expect(html).toContain("under our minimum");
|
||||
expect(html).toContain("Carol");
|
||||
expect(html).toContain("$ 10.00");
|
||||
expect(html).toContain("$ 40.00");
|
||||
});
|
||||
|
||||
it("uses the red band and the rush phrasing for level=1", () => {
|
||||
const html = renderAccountStatus({
|
||||
customerId: "C-003",
|
||||
customerName: "Carol",
|
||||
level: 1,
|
||||
balance: "-25.50",
|
||||
tipo: "25.50",
|
||||
year: 2026,
|
||||
});
|
||||
expect(html).toContain("#FF8D71");
|
||||
expect(html).toContain("overdrawn");
|
||||
expect(html).toContain("reactivate your payments");
|
||||
expect(html).toContain("$ 25.50");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTrustConfirm", () => {
|
||||
it("labels the trust annual fee and quotes the amount", () => {
|
||||
const html = renderTrustConfirm({
|
||||
customerId: "C-004",
|
||||
customerName: "Dan",
|
||||
amount: "350.00",
|
||||
year: 2026,
|
||||
});
|
||||
expect(html).toContain("Annual Bank Fee Payment Confirmation");
|
||||
expect(html).toContain("$ 350.00");
|
||||
expect(html).toContain("Most banks always request");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* HTML body renderers for the four notification jobs. These are the modern
|
||||
* in-process equivalent of the legacy `getXxxForEmail.php` files the PHP
|
||||
* scripts `fetch()`ed by URL. Rendering server-side and inlining the body
|
||||
* in the response keeps a single SES MessageId tied to one frozen HTML
|
||||
* snapshot (vs. the legacy flow, where the URL kept re-rendering with
|
||||
* whatever the database looked like at click time).
|
||||
*
|
||||
* The visual style mirrors the legacy PHP templates where it makes sense
|
||||
* (the office's customer base has been seeing these letters for years;
|
||||
* gratuitous redesign costs trust). The body shell, table layout and the
|
||||
* canonical contact block are preserved verbatim. English copy because the
|
||||
* legacy letters were English; switching to Spanish is a future decision
|
||||
* (see INSURANCE_FEATURES_SPEC §1.6 "Spanish or English body?").
|
||||
*/
|
||||
|
||||
const HEAD = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>{title}</title>
|
||||
</head>`;
|
||||
|
||||
const FOOT_CONTACT = `<p>If you have any questions regarding this notice please contact us at:
|
||||
Tel. 011 52 (661) 612 - 1295 Fax. (661) 612 - 1285
|
||||
For any type of a 24 Hrs. emergencies: please dial 52 (664) 304 - 7778 |
|
||||
<a href="mailto:jorge@jorgecuadros.com">jorge@jorgecuadros.com</a> |
|
||||
<a href="https://www.jorgecuadros.com/contactus.php">Contact Us Form</a></p>`;
|
||||
|
||||
const SIGNED = (year: number) => `<center><span class="small">This message has been generated by the Jorge Cuadros & Assoc. Information Server.<br />Copyright ${year} <a href="http://www.freakma.net/">Developed by FreaKmA.Net</a></span></center>`;
|
||||
|
||||
const esc = (s: string | null | undefined): string =>
|
||||
String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
const usd = (n: number | string | null | undefined): string => {
|
||||
if (n === null || n === undefined) return "$ 0.00";
|
||||
const v = typeof n === "string" ? Number(n) : n;
|
||||
if (!isFinite(v)) return "$ 0.00";
|
||||
return `$ ${v.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
};
|
||||
|
||||
/** Shared shell: a 2-column table that matches the PHP output layout. */
|
||||
function shell(opts: {
|
||||
title: string;
|
||||
bg: string;
|
||||
heading: string;
|
||||
accountId: string | number;
|
||||
accountName: string;
|
||||
body: string;
|
||||
note?: string;
|
||||
statementLink?: string;
|
||||
year: number;
|
||||
}): string {
|
||||
const { title, bg, heading, accountId, accountName, body, note, statementLink, year } = opts;
|
||||
const stmt = statementLink ?? "https://my.jorgecuadros.com/";
|
||||
return `${HEAD.replace("{title}", esc(title))}
|
||||
<body style="background-color:${bg};color:#333;font-family:'Courier New', Courier, monospace;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td width="43%" style="font-size:20px;font-weight:bold;">${esc(heading)}</td>
|
||||
<td width="57%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php">Customer Support</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>${esc(accountName)}<br />ACCOUNT #${esc(String(accountId))}</strong></td>
|
||||
<td><div align="center"><a href="${esc(stmt)}" target="_blank" style="color:#006699;font-weight:bold">Click Here to View Your Account Statement</a></div></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"> </td></tr>
|
||||
<tr><td colspan="2">${body}</td></tr>
|
||||
<tr><td colspan="2"> </td></tr>
|
||||
${
|
||||
note
|
||||
? `<tr><td colspan="2"><h4>${esc(note)}</h4>${FOOT_CONTACT}</td></tr>`
|
||||
: `<tr><td colspan="2">${FOOT_CONTACT}</td></tr>`
|
||||
}
|
||||
<tr><td colspan="2"> </td></tr>
|
||||
<tr><td colspan="2">${SIGNED(year)}</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Outstanding payments — Job 1 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface OutstandingRow {
|
||||
date: Date | string;
|
||||
reference: string | null;
|
||||
period: string | null;
|
||||
type: string | null;
|
||||
/** Signed amount (negative for charges). */
|
||||
amount: number | string;
|
||||
/** Running balance in the customer's currency, after this row. */
|
||||
balance: number | string;
|
||||
}
|
||||
|
||||
export function renderOutstanding(args: {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
total: number | string;
|
||||
rows: OutstandingRow[];
|
||||
year: number;
|
||||
}): string {
|
||||
const rows = args.rows
|
||||
.map(
|
||||
(r) => `<tr>
|
||||
<td>${esc(String(r.date))}</td>
|
||||
<td>${esc(r.reference ?? "")}</td>
|
||||
<td>${esc(r.period ?? "")}</td>
|
||||
<td>${esc(r.type ?? "")}</td>
|
||||
<td align="right">${esc(usd(r.amount))}</td>
|
||||
<td align="right">${esc(usd(r.balance))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const body = `<p>This needs your prompt attention in order to avoid any disruption(s):</p>
|
||||
<p align="center"><strong><font color="#FF0000">TOTAL OF OUTSTANDING BILLS: ${esc(
|
||||
usd(args.total),
|
||||
)} PESOS.</font></strong></p>
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr><th>DATE</th><th>REFER</th><th>PERIOD</th><th>TYPEOFTRX</th><th>CHARGECREDIT</th><th>BALANCE</th></tr>
|
||||
${rows}
|
||||
</table>`;
|
||||
|
||||
return shell({
|
||||
title: "Outstanding Payments",
|
||||
bg: "#9CC",
|
||||
heading: "Outstanding Payments",
|
||||
accountId: args.customerId,
|
||||
accountName: args.customerName,
|
||||
body,
|
||||
note: "NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL",
|
||||
year: args.year,
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Payment confirmation — Job 2 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function renderPaymentConfirm(args: {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
typeOfTrx: string;
|
||||
reference: string | null;
|
||||
/** The deposited amount (positive number — credits are positive in the
|
||||
* unified ledger). */
|
||||
amount: number | string;
|
||||
year: number;
|
||||
}): string {
|
||||
const body = `<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td width="48%" style="font-size:20px;font-weight:bold;">${esc(
|
||||
args.typeOfTrx,
|
||||
)} CONFIRMATION</td>
|
||||
<td width="52%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php" target="_blank">Customer Support</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>HI, ${esc(args.customerName)}</strong><br/>
|
||||
<strong>ACCOUNT #${esc(args.customerId)}</strong><br/>
|
||||
<strong>REFER# ${esc(args.reference ?? "")}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div align="center" style="padding:20px;">
|
||||
<a href="https://my.jorgecuadros.com/" target="_blank" style="color:#006699;font-weight:bold"><em>Click Here to View Your Account Statement</em></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colspan="2"> </td></tr>
|
||||
<tr><td colspan="2">
|
||||
<p>Your account is now current to keep paying your future obligations. If for any reason your next bill is more than what's available; our system will email you our automatic alert requesting more funds. Thank You,</p>
|
||||
<p align="center" style="color:#006600;font-weight:bold;">Your deposit was for ${esc(
|
||||
usd(args.amount),
|
||||
)} PESOS.</p>
|
||||
</td></tr>
|
||||
<tr><td colspan="2"> </td></tr>
|
||||
<tr><td colspan="2"><h4>NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL</h4>${FOOT_CONTACT}</td></tr>
|
||||
<tr><td colspan="2"> </td></tr>
|
||||
<tr><td colspan="2">${SIGNED(args.year)}</td></tr>
|
||||
</table>`;
|
||||
return `${HEAD.replace("{title}", "Payment Confirmation")}<body>${body}</body></html>`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Account status — Job 3 (yellow + red) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function renderAccountStatus(args: {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
level: 0 | 1; // 0 = yellow (DEBAJO DEL TIPO), 1 = red (EN ROJO)
|
||||
balance: number | string;
|
||||
/** Amount the customer needs to deposit to clear the threshold. */
|
||||
tipo: number | string;
|
||||
year: number;
|
||||
}): string {
|
||||
const isYellow = args.level === 0;
|
||||
const body = isYellow
|
||||
? `<p>In order to avoid any disruptions please mail or bring ${esc(
|
||||
usd(args.tipo),
|
||||
)} USD ASAP. As your current Balance ${esc(
|
||||
usd(args.balance),
|
||||
)} is under our minimum required to run this account.</p>`
|
||||
: `<p>Sorry Account is overdrawn and all utility bills are on hold please rush ${esc(
|
||||
usd(args.tipo),
|
||||
)} USD these funds must be on hand ASAP to reactivate your payments.</p>`;
|
||||
return shell({
|
||||
title: "Account Alert",
|
||||
bg: isYellow ? "#88D5EE" : "#FF8D71",
|
||||
heading: "Account Alert",
|
||||
accountId: args.customerId,
|
||||
accountName: args.customerName,
|
||||
body,
|
||||
note: "NOTE : PLEASE MAKE YOUR CHECK PAYABLE TO UMC AND ASSOCIATES. IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL.",
|
||||
year: args.year,
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Trust payment confirmation — Job 4 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function renderTrustConfirm(args: {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
/** Annual fee amount posted (positive, in MXN per the PHP). */
|
||||
amount: number | string;
|
||||
year: number;
|
||||
}): string {
|
||||
const body = `<p>This automatic notice is to confirm, that your Annual Bank Fee has been paid by, and posted in your account. Thank You,</p>
|
||||
<p align="center"><strong>The annual fee was posted for the amount of <font color="#FF0000">${esc(
|
||||
usd(args.amount),
|
||||
)} PESOS.</font></strong></p>`;
|
||||
return shell({
|
||||
title: "Trust Payment Confirmation",
|
||||
bg: "#C0BEA0",
|
||||
heading: "Annual Bank Fee Payment Confirmation",
|
||||
accountId: args.customerId,
|
||||
accountName: args.customerName,
|
||||
body,
|
||||
note: "NOTE : Most banks always request to make such payment in advance.",
|
||||
statementLink: "https://my.jorgecuadros.com/",
|
||||
year: args.year,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { jobProgress } from "./ops.service";
|
||||
|
||||
/** Shape run_all.py emits, with the shell trace lines it interleaves. */
|
||||
const line = (i: number, n: number, name: string) =>
|
||||
`[paso ${i}/${n}] ${name}\n+ /repo/migration/.venv/bin/python /repo/migration/${name} --env prod\n[${name}] target env: prod\n validation: OK`;
|
||||
|
||||
describe("jobProgress", () => {
|
||||
it("returns null before any step marker appears", () => {
|
||||
// The safety backup runs before run_all.py, so this is the real state for
|
||||
// the first stretch of every REIMPORT.
|
||||
expect(jobProgress("== Respaldo de seguridad previo ==\ntablas capturadas: 39", "RUNNING")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for jobs that have no steps at all", () => {
|
||||
// BACKUP/RESTORE are a single mysqldump; a fabricated percentage would be
|
||||
// worse than none.
|
||||
expect(jobProgress("mysqldump ... done", "SUCCESS")).toBeNull();
|
||||
});
|
||||
|
||||
it("tracks the most recent marker, not the first", () => {
|
||||
const log = [line(1, 9, "transform_customers.py"), line(2, 9, "transform_properties.py")].join("\n");
|
||||
const p = jobProgress(log, "RUNNING");
|
||||
expect(p).toMatchObject({ step: 2, total: 9, name: "transform_properties.py" });
|
||||
});
|
||||
|
||||
/**
|
||||
* The point of the whole feature. While RUNNING, step i is IN PROGRESS, so
|
||||
* only i-1 are done. Counting i as complete would show 100% while the final
|
||||
* and slowest step (blob_extract) is still working.
|
||||
*/
|
||||
it("does not claim a running step is finished", () => {
|
||||
expect(jobProgress(line(1, 9, "transform_customers.py"), "RUNNING")?.percent).toBe(0);
|
||||
expect(jobProgress(line(9, 9, "blob_extract.py"), "RUNNING")?.percent).toBe(88);
|
||||
});
|
||||
|
||||
it("reaches 100 only once the job is no longer running", () => {
|
||||
expect(jobProgress(line(9, 9, "blob_extract.py"), "SUCCESS")?.percent).toBe(100);
|
||||
});
|
||||
|
||||
/** A job that died mid-way must report where it died, not 100%. */
|
||||
it("reports the failed step rather than completion", () => {
|
||||
const p = jobProgress(line(5, 9, "transform_transactions.py"), "FAILED");
|
||||
expect(p).toMatchObject({ step: 5, total: 9 });
|
||||
expect(p!.percent).toBe(55);
|
||||
});
|
||||
|
||||
it("handles the 8-step SYNC list as well as the 9-step REIMPORT one", () => {
|
||||
expect(jobProgress(line(8, 8, "transform_bank.py"), "SUCCESS")?.percent).toBe(100);
|
||||
expect(jobProgress(line(4, 8, "transform_policies.py"), "RUNNING")?.percent).toBe(37);
|
||||
});
|
||||
|
||||
/**
|
||||
* Captured verbatim from `run_all.run(..., step=8, total=9)`. This is the
|
||||
* contract between the Python and this parser; if run_all.py's format
|
||||
* changes, this fails rather than the panel silently showing no progress.
|
||||
*/
|
||||
it("parses the exact line run_all.py emits", () => {
|
||||
const real =
|
||||
"[paso 8/9] transform_bank.py\n+ /repo/migration/.venv/bin/python /repo/migration/transform_bank.py --env prod";
|
||||
expect(jobProgress(real, "RUNNING")).toMatchObject({
|
||||
step: 8,
|
||||
total: 9,
|
||||
name: "transform_bank.py",
|
||||
percent: 77,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a malformed marker instead of reporting NaN", () => {
|
||||
expect(jobProgress("[paso 3/0] x.py", "RUNNING")).toBeNull();
|
||||
});
|
||||
|
||||
/** The marker must be at line start so log text quoting it cannot spoof it. */
|
||||
it("does not match a marker embedded mid-line", () => {
|
||||
expect(jobProgress("some output mentioning [paso 4/9] fake.py", "RUNNING")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { OpsService } from "./ops.service";
|
||||
import { ReplicationService } from "./replication.service";
|
||||
import { StartJobDto } from "./start-job.dto";
|
||||
|
||||
/** Every route is ADMIN-only (ability "db:manage"). */
|
||||
@@ -28,6 +29,7 @@ import { StartJobDto } from "./start-job.dto";
|
||||
export class OpsController {
|
||||
constructor(
|
||||
private readonly ops: OpsService,
|
||||
private readonly replication: ReplicationService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@@ -96,6 +98,12 @@ export class OpsController {
|
||||
|
||||
/* --------------------------------------------------------------- jobs */
|
||||
|
||||
/** Health of the my.jorgecuadros.com read replica. Read-only, no audit entry. */
|
||||
@Get("replication")
|
||||
replicationStatus() {
|
||||
return this.replication.status();
|
||||
}
|
||||
|
||||
@Get("jobs")
|
||||
listJobs() {
|
||||
return this.ops.listJobs();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OpsController } from "./ops.controller";
|
||||
import { OpsService } from "./ops.service";
|
||||
import { ReplicationService } from "./replication.service";
|
||||
|
||||
@Module({
|
||||
controllers: [OpsController],
|
||||
providers: [OpsService],
|
||||
providers: [OpsService, ReplicationService],
|
||||
})
|
||||
export class OpsModule {}
|
||||
|
||||
@@ -67,6 +67,55 @@ export class OpsService implements OnModuleInit {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await fs.mkdir(this.ingestDir, { recursive: true });
|
||||
await fs.mkdir(this.backupDir, { recursive: true });
|
||||
await this.reconcileOrphanedJobs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail any job still marked RUNNING at startup.
|
||||
*
|
||||
* Jobs run as a child of THIS process, so no job can outlive it: if a row says
|
||||
* RUNNING while we are booting, its process died with the previous instance
|
||||
* and nothing will ever finalize it. Since startJob() refuses to start while
|
||||
* any RUNNING row exists, one interrupted job wedges the panel permanently
|
||||
* with no way out from the UI — it took a manual UPDATE against production to
|
||||
* recover the first time this happened, when a deploy landed 110 seconds into
|
||||
* a REIMPORT.
|
||||
*
|
||||
* Deliberately unconditional rather than filtered on age: "started recently"
|
||||
* does not mean "still alive" here, and a fresh boot is proof enough that
|
||||
* nothing survived.
|
||||
*/
|
||||
private async reconcileOrphanedJobs(): Promise<void> {
|
||||
try {
|
||||
// Read then write one by one rather than updateMany: the log needs the
|
||||
// reason APPENDED, and a job whose log just stops mid-step with no
|
||||
// explanation is what made the first occurrence hard to diagnose.
|
||||
const orphans = await this.prisma.opsJob.findMany({
|
||||
where: { status: "RUNNING" },
|
||||
select: { id: true, kind: true, log: true },
|
||||
});
|
||||
for (const job of orphans) {
|
||||
await this.prisma.opsJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
finishedAt: new Date(),
|
||||
log: {
|
||||
set:
|
||||
job.log +
|
||||
"\n[interrumpido: el contenedor se reinició mientras el trabajo corría; " +
|
||||
"el proceso hijo no sobrevive a un redespliegue. " +
|
||||
"Vuelva a ejecutar la operación desde el principio.]\n",
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.warn(`trabajo ${job.kind} ${job.id} quedó huérfano; marcado FAILED`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Never block startup on this. A failed reconcile leaves the panel
|
||||
// wedged, which is bad, but an API that will not boot is worse.
|
||||
this.logger.error(`no se pudieron reconciliar trabajos huérfanos: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- ingest */
|
||||
@@ -163,7 +212,9 @@ export class OpsService implements OnModuleInit {
|
||||
async getJob(id: string) {
|
||||
const job = await this.prisma.opsJob.findUnique({ where: { id } });
|
||||
if (!job) throw new NotFoundException("Trabajo no encontrado.");
|
||||
return job;
|
||||
// Derived, never stored: the log is the single source of truth for how far
|
||||
// a job got, so progress cannot drift out of sync with it.
|
||||
return { ...job, progress: jobProgress(job.log, job.status) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,7 +400,12 @@ export class OpsService implements OnModuleInit {
|
||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`${this.dumpCommand(flags, db, out)} && ` +
|
||||
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
||||
// --stage is not optional here. The staged Parquet lives in the image
|
||||
// at migration/output, NOT on a volume, so every redeploy wipes it and
|
||||
// a sync without --stage dies on a missing stg_*/*.parquet. Re-staging
|
||||
// is also the only thing that makes "desde carpeta de ingesta" true:
|
||||
// stale Parquet would sync the previous upload, not the current one.
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage --sync`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
}
|
||||
|
||||
@@ -465,3 +521,52 @@ export class OpsService implements OnModuleInit {
|
||||
function shq(v: string): string {
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** Progress derived from a job's log. Null when the job reports no steps. */
|
||||
export interface JobProgress {
|
||||
/** 1-based index of the step currently running (or last reached). */
|
||||
step: number;
|
||||
total: number;
|
||||
/** Script name, e.g. "transform_bank.py". */
|
||||
name: string;
|
||||
/** 0..100, floored. 100 only once the job is no longer RUNNING. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the "[paso i/N] name" markers migration/run_all.py emits.
|
||||
*
|
||||
* Progress is DERIVED from the log rather than tracked in a column: the log is
|
||||
* already the record of what happened, and a separate counter could disagree
|
||||
* with it — which is exactly the confusion a progress display is supposed to
|
||||
* remove. run_all.py owns the step count, so adding a step cannot desync this.
|
||||
*
|
||||
* BACKUP and RESTORE are a single mysqldump with no steps, so they return null
|
||||
* and the UI shows an indeterminate spinner. Reporting a fabricated percentage
|
||||
* for them would be worse than showing none.
|
||||
*/
|
||||
export function jobProgress(
|
||||
log: string,
|
||||
status: string,
|
||||
): JobProgress | null {
|
||||
// Last marker wins: the log grows, and the newest line is the current step.
|
||||
const matches = [...log.matchAll(/^\[paso (\d+)\/(\d+)\] (\S+)/gm)];
|
||||
const last = matches[matches.length - 1];
|
||||
if (!last) return null;
|
||||
|
||||
const step = Number(last[1]);
|
||||
const total = Number(last[2]);
|
||||
if (!Number.isFinite(step) || !Number.isFinite(total) || total <= 0) return null;
|
||||
|
||||
// While RUNNING, step i means i is IN PROGRESS, not finished — so report
|
||||
// (i-1) completed. Claiming 100% while the last step is still working is the
|
||||
// classic progress-bar lie, and here the last step (blob_extract) is also the
|
||||
// slowest, so it would sit at "100%" for the longest stretch of the job.
|
||||
const done = status === "RUNNING" ? step - 1 : step;
|
||||
return {
|
||||
step,
|
||||
total,
|
||||
name: last[3],
|
||||
percent: Math.max(0, Math.min(100, Math.floor((done / total) * 100))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/**
|
||||
* How far the SQL thread is behind the I/O thread, in source binlog bytes.
|
||||
*
|
||||
* This is a different question from `secondsBehind`, and it answers the case
|
||||
* that lag hides: while the SQL thread grinds through one huge transaction,
|
||||
* `Seconds_Behind_Source` can sit still or even read 0, but the relay backlog
|
||||
* is plainly shrinking (or not). It costs nothing extra — every field here
|
||||
* comes out of the same `SHOW REPLICA STATUS` the panel already runs.
|
||||
*
|
||||
* Both positions are coordinates in the SOURCE's binlog, so they are only
|
||||
* comparable while both threads are working on the SAME source file. When they
|
||||
* are not, the replica is whole files behind and the byte delta is meaningless
|
||||
* (positions restart at ~4 in each new file), so `backlogBytes` and `percent`
|
||||
* are null and `sameFile` says why.
|
||||
*/
|
||||
export interface ApplyProgress {
|
||||
/** Source binlog file the I/O thread is currently reading. */
|
||||
sourceLogFile: string | null;
|
||||
/** Position in `sourceLogFile` that the I/O thread has fetched up to. */
|
||||
readPos: number;
|
||||
/** Source binlog file the SQL thread is currently applying. */
|
||||
relayLogFile: string | null;
|
||||
/** Position in `relayLogFile` that the SQL thread has applied up to. */
|
||||
execPos: number;
|
||||
/** True while both threads are on the same source file. */
|
||||
sameFile: boolean;
|
||||
/** Fetched-but-not-yet-applied bytes. Null when the files differ. */
|
||||
backlogBytes: number | null;
|
||||
/**
|
||||
* `execPos / readPos` as a percentage, null when the files differ.
|
||||
*
|
||||
* Deliberately never rounded up to 100 while any backlog remains: binlog
|
||||
* positions are large, so a real backlog of a few KB is 99.99% of the file
|
||||
* and would render as "caught up" when it is not. Read `backlogBytes === 0`
|
||||
* for actually caught up.
|
||||
*/
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
export interface ReplicationStatus {
|
||||
/** false when the replica is not configured for this environment at all. */
|
||||
configured: boolean;
|
||||
/** true only when both threads run, no error is set, and lag is within bounds. */
|
||||
healthy: boolean;
|
||||
host: string | null;
|
||||
ioRunning: string | null;
|
||||
sqlRunning: string | null;
|
||||
/** null when MySQL reports NULL, which it does whenever a thread is down. */
|
||||
secondsBehind: number | null;
|
||||
lastIoError: string | null;
|
||||
lastSqlError: string | null;
|
||||
sourceHost: string | null;
|
||||
/** Relay-log apply progress. Null when the status output has no positions. */
|
||||
apply: ApplyProgress | null;
|
||||
/** Human-readable reason when healthy is false. */
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the my.jorgecuadros.com read replica is still replicating.
|
||||
*
|
||||
* The replica is what the public site reads once the platformDataSource flag is
|
||||
* on, and a replica that has silently stopped applying serves stale balances
|
||||
* rather than erroring — the failure is invisible from the site itself, which is
|
||||
* why it needs a panel.
|
||||
*
|
||||
* Shells out to the mysql client for the same reason the rest of OpsService
|
||||
* does: there is no MySQL driver in this API's dependencies, and the image
|
||||
* already ships one.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReplicationService {
|
||||
private readonly logger = new Logger(ReplicationService.name);
|
||||
|
||||
/** Lag above this many seconds is reported as unhealthy. */
|
||||
private readonly maxLagSeconds = Number(process.env.REPLICA_MAX_LAG ?? 60);
|
||||
|
||||
async status(): Promise<ReplicationStatus> {
|
||||
const host = process.env.REPLICA_DB_HOST;
|
||||
const user = process.env.REPLICA_DB_USER;
|
||||
const password = process.env.REPLICA_DB_PASS;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const empty: ReplicationStatus = {
|
||||
configured: false,
|
||||
healthy: false,
|
||||
host: host ?? null,
|
||||
ioRunning: null,
|
||||
sqlRunning: null,
|
||||
secondsBehind: null,
|
||||
lastIoError: null,
|
||||
lastSqlError: null,
|
||||
sourceHost: null,
|
||||
apply: null,
|
||||
problem: null,
|
||||
checkedAt: now,
|
||||
};
|
||||
|
||||
if (!host || !user || !password) {
|
||||
return { ...empty, problem: "REPLICA_DB_* no configuradas" };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
// --ssl is required: the replica sets require_secure_transport=ON.
|
||||
//
|
||||
// --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off
|
||||
// the website makes. This hop never leaves Tailscale — the replica is
|
||||
// reached on its CGNAT tailnet address and its firewall admits only this
|
||||
// host — so WireGuard already authenticates the peer. The DreamHost leg
|
||||
// crosses the public internet and therefore pins the CA instead. The
|
||||
// client here is MariaDB's, which rejects our self-signed CA outright
|
||||
// unless it is handed the CA file, which would mean shipping a cert into
|
||||
// this image for a link that is already authenticated.
|
||||
const { stdout } = await exec(
|
||||
"mysql",
|
||||
[
|
||||
`--host=${host}`,
|
||||
`--user=${user}`,
|
||||
"--ssl",
|
||||
"--ssl-verify-server-cert=0",
|
||||
"--connect-timeout=5",
|
||||
"-e",
|
||||
"SHOW REPLICA STATUS\\G",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, MYSQL_PWD: password },
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
raw = stdout;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`no se pudo consultar la réplica: ${msg}`);
|
||||
return { ...empty, configured: true, problem: `No se pudo conectar: ${msg}` };
|
||||
}
|
||||
|
||||
const field = (name: string): string | null => replicaField(raw, name);
|
||||
|
||||
// An empty result set means the server is not configured as a replica at
|
||||
// all — distinct from "configured but broken", and worth saying plainly.
|
||||
if (!raw.includes("Replica_IO_Running")) {
|
||||
return {
|
||||
...empty,
|
||||
configured: true,
|
||||
problem: "El servidor no está configurado como réplica",
|
||||
};
|
||||
}
|
||||
|
||||
const ioRunning = field("Replica_IO_Running");
|
||||
const sqlRunning = field("Replica_SQL_Running");
|
||||
const lagRaw = field("Seconds_Behind_Source");
|
||||
const secondsBehind =
|
||||
lagRaw === null || lagRaw === "NULL" ? null : Number(lagRaw);
|
||||
const lastIoError = field("Last_IO_Error");
|
||||
const lastSqlError = field("Last_SQL_Error");
|
||||
|
||||
// Order matters: report the most specific cause first. Checking lag before
|
||||
// the threads would blame "sin dato de retraso" for what is really a
|
||||
// stopped thread, because MySQL reports NULL lag whenever either is down.
|
||||
let problem: string | null = null;
|
||||
if (ioRunning !== "Yes") problem = "El hilo de E/S no está corriendo";
|
||||
else if (sqlRunning !== "Yes") problem = "El hilo SQL no está corriendo";
|
||||
else if (lastSqlError) problem = `Error SQL: ${lastSqlError}`;
|
||||
else if (lastIoError) problem = `Error de E/S: ${lastIoError}`;
|
||||
else if (secondsBehind === null) problem = "Sin dato de retraso";
|
||||
else if (secondsBehind > this.maxLagSeconds)
|
||||
problem = `Retraso de ${secondsBehind}s (máximo ${this.maxLagSeconds}s)`;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
healthy: problem === null,
|
||||
host,
|
||||
ioRunning,
|
||||
sqlRunning,
|
||||
secondsBehind,
|
||||
lastIoError,
|
||||
lastSqlError,
|
||||
sourceHost: field("Source_Host"),
|
||||
// Reported, never folded into `healthy`: a non-zero backlog is the normal
|
||||
// state of a working replica for the instant between fetch and apply, so
|
||||
// alarming on it would cry wolf. It is here to answer "is it moving?"
|
||||
// when the lag counter is stuck.
|
||||
apply: applyProgress(raw),
|
||||
problem,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive relay-apply progress from `SHOW REPLICA STATUS\G` output.
|
||||
*
|
||||
* Exported for testing. Free in query terms — it re-reads four more fields from
|
||||
* the output the caller already has, with no second round trip to the replica
|
||||
* and no connection to the source.
|
||||
*
|
||||
* @returns null when either position is missing or unparseable, which is what
|
||||
* happens on a server that is not a replica at all.
|
||||
*/
|
||||
export function applyProgress(raw: string): ApplyProgress | null {
|
||||
const num = (name: string): number | null => {
|
||||
const v = replicaField(raw, name);
|
||||
if (v === null || v === "NULL") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
const readPos = num("Read_Source_Log_Pos");
|
||||
const execPos = num("Exec_Source_Log_Pos");
|
||||
if (readPos === null || execPos === null) return null;
|
||||
|
||||
const sourceLogFile = replicaField(raw, "Source_Log_File");
|
||||
const relayLogFile = replicaField(raw, "Relay_Source_Log_File");
|
||||
const sameFile =
|
||||
sourceLogFile !== null && relayLogFile !== null && sourceLogFile === relayLogFile;
|
||||
|
||||
// Clamped at 0: the SQL thread cannot be ahead of the I/O thread, but the two
|
||||
// fields are sampled independently, so a rotation racing this read can print
|
||||
// a momentarily negative delta. Zero is the honest floor, not a bug.
|
||||
const backlogBytes = sameFile ? Math.max(0, readPos - execPos) : null;
|
||||
|
||||
let percent: number | null = null;
|
||||
if (backlogBytes !== null && readPos > 0) {
|
||||
// Truncate rather than round, and hold short of 100 while bytes remain —
|
||||
// see the doc on ApplyProgress.percent.
|
||||
const p = Math.floor((execPos / readPos) * 10_000) / 100;
|
||||
percent = backlogBytes === 0 ? 100 : Math.min(p, 99.99);
|
||||
}
|
||||
|
||||
return { sourceLogFile, readPos, relayLogFile, execPos, sameFile, backlogBytes, percent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one field out of `SHOW REPLICA STATUS\G` output.
|
||||
*
|
||||
* Exported for testing, and worth testing: the obvious regex is wrong.
|
||||
* `\s` matches newlines in JavaScript, so `^\s*NAME:\s*(.*)$` lets the `\s*`
|
||||
* after the colon swallow the line break of an EMPTY field and capture the
|
||||
* following line instead. Last_SQL_Error is empty on a healthy replica, so that
|
||||
* version reported the next line ("Replicate_Ignore_Server_Ids:") as a SQL
|
||||
* error and rendered a perfectly healthy replica as broken.
|
||||
*
|
||||
* Hence `[^\S\n]` — horizontal whitespace only — on both sides of the name.
|
||||
*
|
||||
* @returns the trimmed value, or null when the field is absent OR empty. Empty
|
||||
* and absent mean the same thing to every caller here: MySQL prints
|
||||
* error fields as blank rather than omitting them.
|
||||
*/
|
||||
export function replicaField(raw: string, name: string): string | null {
|
||||
const m = raw.match(new RegExp(`^[^\\S\\n]*${name}:[^\\S\\n]*(.*)$`, "m"));
|
||||
const v = m?.[1]?.trim();
|
||||
return v === undefined || v === "" ? null : v;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { applyProgress, replicaField } from "./replication.service";
|
||||
|
||||
/**
|
||||
* Verbatim shape of `SHOW REPLICA STATUS\G` from the live replica, trimmed to
|
||||
* the fields the panel reads plus the neighbours that matter.
|
||||
*
|
||||
* The empty `Last_SQL_Error:` immediately followed by
|
||||
* `Replicate_Ignore_Server_Ids:` is the whole point of the fixture — that exact
|
||||
* adjacency is what the first implementation misread.
|
||||
*/
|
||||
const HEALTHY = [
|
||||
"*************************** 1. row ***************************",
|
||||
" Replica_IO_State: Waiting for source to send event",
|
||||
" Source_Host: 100.103.77.46",
|
||||
" Source_User: repl",
|
||||
" Source_Log_File: binlog.000042",
|
||||
" Read_Source_Log_Pos: 194884231",
|
||||
" Relay_Source_Log_File: binlog.000042",
|
||||
" Exec_Source_Log_Pos: 194884231",
|
||||
" Replica_IO_Running: Yes",
|
||||
" Replica_SQL_Running: Yes",
|
||||
" Replicate_Do_DB: ",
|
||||
" Last_Errno: 0",
|
||||
" Last_Error: ",
|
||||
" Seconds_Behind_Source: 0",
|
||||
" Last_IO_Errno: 0",
|
||||
" Last_IO_Error: ",
|
||||
" Last_SQL_Errno: 0",
|
||||
" Last_SQL_Error: ",
|
||||
" Replicate_Ignore_Server_Ids: ",
|
||||
" Source_Server_Id: 1",
|
||||
].join("\n");
|
||||
|
||||
const BROKEN = [
|
||||
" Replica_IO_Running: Yes",
|
||||
" Replica_SQL_Running: No",
|
||||
" Seconds_Behind_Source: NULL",
|
||||
" Last_IO_Error: ",
|
||||
" Last_SQL_Error: Could not execute Write_rows event on table jorgecuadros.customers",
|
||||
" Replicate_Ignore_Server_Ids: ",
|
||||
].join("\n");
|
||||
|
||||
describe("replicaField", () => {
|
||||
it("reads plain values", () => {
|
||||
expect(replicaField(HEALTHY, "Replica_IO_Running")).toBe("Yes");
|
||||
expect(replicaField(HEALTHY, "Replica_SQL_Running")).toBe("Yes");
|
||||
expect(replicaField(HEALTHY, "Source_Host")).toBe("100.103.77.46");
|
||||
expect(replicaField(HEALTHY, "Seconds_Behind_Source")).toBe("0");
|
||||
});
|
||||
|
||||
/**
|
||||
* The regression this file exists for. `\s` matches newlines in JavaScript,
|
||||
* so `^\s*NAME:\s*(.*)$` walks past an empty field's line break and captures
|
||||
* the NEXT line — turning a healthy replica into
|
||||
* "Error SQL: Replicate_Ignore_Server_Ids:" in the admin panel.
|
||||
*/
|
||||
it("returns null for an empty field instead of the following line", () => {
|
||||
expect(replicaField(HEALTHY, "Last_SQL_Error")).toBeNull();
|
||||
expect(replicaField(HEALTHY, "Last_IO_Error")).toBeNull();
|
||||
expect(replicaField(HEALTHY, "Last_Error")).toBeNull();
|
||||
expect(replicaField(HEALTHY, "Replicate_Do_DB")).toBeNull();
|
||||
expect(replicaField(HEALTHY, "Replicate_Ignore_Server_Ids")).toBeNull();
|
||||
});
|
||||
|
||||
it("still reads a real error when there is one", () => {
|
||||
expect(replicaField(BROKEN, "Last_SQL_Error")).toBe(
|
||||
"Could not execute Write_rows event on table jorgecuadros.customers",
|
||||
);
|
||||
expect(replicaField(BROKEN, "Replica_SQL_Running")).toBe("No");
|
||||
});
|
||||
|
||||
/** NULL is a distinct state from empty and must survive as the literal. */
|
||||
it("preserves the literal NULL that MySQL prints for unknown lag", () => {
|
||||
expect(replicaField(BROKEN, "Seconds_Behind_Source")).toBe("NULL");
|
||||
});
|
||||
|
||||
it("returns null for a field that is not present at all", () => {
|
||||
expect(replicaField(HEALTHY, "Nonexistent_Field")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Field names are matched at the start of a line. Without the line anchor,
|
||||
* "Last_Error" would also match inside "Last_SQL_Error" and read the wrong
|
||||
* value — the two carry different things and both feed the panel.
|
||||
*/
|
||||
it("does not match a field name that is a suffix of another", () => {
|
||||
const raw = " Last_SQL_Error: boom\n Last_Error: ";
|
||||
expect(replicaField(raw, "Last_Error")).toBeNull();
|
||||
expect(replicaField(raw, "Last_SQL_Error")).toBe("boom");
|
||||
});
|
||||
});
|
||||
|
||||
/** Builds the four position fields the apply-progress reader cares about. */
|
||||
function positions(
|
||||
sourceFile: string,
|
||||
readPos: number | string,
|
||||
relayFile: string,
|
||||
execPos: number | string,
|
||||
): string {
|
||||
return [
|
||||
` Source_Log_File: ${sourceFile}`,
|
||||
` Read_Source_Log_Pos: ${readPos}`,
|
||||
` Relay_Source_Log_File: ${relayFile}`,
|
||||
` Exec_Source_Log_Pos: ${execPos}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
describe("applyProgress", () => {
|
||||
it("reports zero backlog and 100% when both positions match", () => {
|
||||
const p = applyProgress(HEALTHY)!;
|
||||
expect(p.sameFile).toBe(true);
|
||||
expect(p.sourceLogFile).toBe("binlog.000042");
|
||||
expect(p.readPos).toBe(194884231);
|
||||
expect(p.execPos).toBe(194884231);
|
||||
expect(p.backlogBytes).toBe(0);
|
||||
expect(p.percent).toBe(100);
|
||||
});
|
||||
|
||||
it("reports the byte delta when the SQL thread trails inside one file", () => {
|
||||
const p = applyProgress(positions("binlog.000042", 2_000_000, "binlog.000042", 1_500_000))!;
|
||||
expect(p.backlogBytes).toBe(500_000);
|
||||
expect(p.percent).toBe(75);
|
||||
});
|
||||
|
||||
/**
|
||||
* The reason the byte delta exists at all. `Seconds_Behind_Source` holds at 0
|
||||
* while the SQL thread is mid-transaction, so the backlog is the only field
|
||||
* that moves — and the only one that says the replica is not caught up.
|
||||
*/
|
||||
it("shows a backlog even when the lag counter reads zero", () => {
|
||||
const raw = [
|
||||
" Seconds_Behind_Source: 0",
|
||||
positions("binlog.000042", 900, "binlog.000042", 400),
|
||||
].join("\n");
|
||||
expect(replicaField(raw, "Seconds_Behind_Source")).toBe("0");
|
||||
expect(applyProgress(raw)!.backlogBytes).toBe(500);
|
||||
});
|
||||
|
||||
/**
|
||||
* Positions restart near 4 in every new binlog file, so subtracting across
|
||||
* files produces a number that is not a backlog — here it would be a large
|
||||
* NEGATIVE one, which would render as "ahead of the source".
|
||||
*/
|
||||
it("refuses to compare positions across different binlog files", () => {
|
||||
const p = applyProgress(positions("binlog.000043", 500, "binlog.000042", 194_000_000))!;
|
||||
expect(p.sameFile).toBe(false);
|
||||
expect(p.backlogBytes).toBeNull();
|
||||
expect(p.percent).toBeNull();
|
||||
expect(p.sourceLogFile).toBe("binlog.000043");
|
||||
expect(p.relayLogFile).toBe("binlog.000042");
|
||||
});
|
||||
|
||||
/**
|
||||
* Percent must not round up to 100 while bytes remain: binlog positions are
|
||||
* large, so a genuine backlog is a rounding error away from the whole file
|
||||
* and would otherwise render as "caught up" on a replica that is not.
|
||||
*/
|
||||
it("stops short of 100% while any backlog remains", () => {
|
||||
const p = applyProgress(positions("binlog.000042", 194_884_231, "binlog.000042", 194_884_230))!;
|
||||
expect(p.backlogBytes).toBe(1);
|
||||
expect(p.percent).toBe(99.99);
|
||||
});
|
||||
|
||||
/** Sampled independently, so a rotation racing the read can invert them. */
|
||||
it("clamps a momentarily negative delta to zero", () => {
|
||||
const p = applyProgress(positions("binlog.000042", 400, "binlog.000042", 500))!;
|
||||
expect(p.backlogBytes).toBe(0);
|
||||
expect(p.percent).toBe(100);
|
||||
});
|
||||
|
||||
it("returns null when the server is not a replica and prints no positions", () => {
|
||||
expect(applyProgress("")).toBeNull();
|
||||
expect(applyProgress(BROKEN)).toBeNull();
|
||||
});
|
||||
|
||||
/** A stopped thread makes MySQL print NULL, which is not a position. */
|
||||
it("returns null when a position is NULL", () => {
|
||||
expect(applyProgress(positions("binlog.000042", "NULL", "binlog.000042", 400))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -358,6 +358,7 @@ export class PoliciesService {
|
||||
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
|
||||
}
|
||||
|
||||
|
||||
private async ensurePolicy(id: string) {
|
||||
const found = await this.prisma.policy.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -143,6 +143,18 @@ async pageImage(
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Abandon a batch pending review — rejects every unapplied page. */
|
||||
@Post("batches/:id/discard")
|
||||
@RequireAbility("policy:ocr-review")
|
||||
async discard(@Param("id") id: string, @Req() req: Request) {
|
||||
const result = await this.policyOcr.discardBatch(id, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "policyOcr.batch.discard", {
|
||||
batchId: id,
|
||||
rejected: result.rejected,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("batches/:id/confirm")
|
||||
@RequireAbility("policy:ocr-review")
|
||||
async confirm(
|
||||
|
||||
@@ -364,10 +364,55 @@ export class PolicyOcrService {
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException("Este documento ya fue aplicado.");
|
||||
}
|
||||
return this.prisma.policyOcrDocument.update({
|
||||
const updated = await this.prisma.policyOcrDocument.update({
|
||||
where: { id },
|
||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||
});
|
||||
// Rejecting the last open page settles the batch just as confirming it
|
||||
// would — without this, a fully-rejected batch sat in READY_FOR_REVIEW
|
||||
// forever because only confirmBatch() ever closed one.
|
||||
await this.closeIfDone(doc.batchId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw away a whole batch that is pending review: every page that has not
|
||||
* been applied is marked REJECTED and the batch itself becomes DISCARDED.
|
||||
*
|
||||
* Refuses once any page is POSTED — a partly-applied batch has already
|
||||
* written Policy (and possibly Transaction) rows, and hiding the paperwork
|
||||
* behind a "discarded" label would leave those rows unexplained. Reject the
|
||||
* remaining pages individually instead.
|
||||
*/
|
||||
async discardBatch(batchId: string, reviewedById: string) {
|
||||
const batch = await this.prisma.policyOcrBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
});
|
||||
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||
if (batch.status === "DISCARDED") {
|
||||
throw new BadRequestException("Este lote ya fue descartado.");
|
||||
}
|
||||
|
||||
const posted = await this.prisma.policyOcrDocument.count({
|
||||
where: { batchId, status: "POSTED" },
|
||||
});
|
||||
if (posted > 0) {
|
||||
throw new BadRequestException(
|
||||
`No se puede descartar: ${posted} página(s) ya se aplicaron a una póliza.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { count } = await this.prisma.policyOcrDocument.updateMany({
|
||||
where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } },
|
||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.policyOcrBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "DISCARDED", completedAt: new Date() },
|
||||
});
|
||||
|
||||
return { batchId, rejected: count };
|
||||
}
|
||||
|
||||
// --- confirm --------------------------------------------------------------
|
||||
@@ -533,8 +578,10 @@ export class PolicyOcrService {
|
||||
},
|
||||
});
|
||||
if (open === 0) {
|
||||
await this.prisma.policyOcrBatch.update({
|
||||
where: { id: batchId },
|
||||
await this.prisma.policyOcrBatch.updateMany({
|
||||
// `updateMany` + a status filter so a discarded batch is never quietly
|
||||
// relabelled COMPLETED by a late reject on one of its pages.
|
||||
where: { id: batchId, status: { not: "DISCARDED" } },
|
||||
data: { status: "COMPLETED", completedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RenewalLetterRow } from "../reports/renewal-letter";
|
||||
import { renderRenewalEmail } from "./renewal-email";
|
||||
|
||||
function letter(overrides: Partial<RenewalLetterRow> = {}): RenewalLetterRow {
|
||||
return {
|
||||
__kind: "letter",
|
||||
policyId: "policy-1",
|
||||
policyNumber: "POL-123",
|
||||
policyType: "AUTO",
|
||||
customerName: "Ana Pérez",
|
||||
customerEmail: "ana@example.com",
|
||||
customerPhone: "664-111-2222",
|
||||
customerMobile: null,
|
||||
customerAddress: ["Calle Uno 123", "Tijuana, BC, 22000"],
|
||||
provider: "Aseguradora Uno",
|
||||
policyTo: "2026-09-01",
|
||||
netPremium: "1200.00",
|
||||
policyFee: null,
|
||||
total: "1392.00",
|
||||
currency: "MXN",
|
||||
coverageDays: null,
|
||||
cslLimit: null,
|
||||
medicalCoverage: null,
|
||||
propertyDamage: null,
|
||||
perPersonLiability: null,
|
||||
additionalService: null,
|
||||
vehicle: null,
|
||||
generation: 1,
|
||||
sentAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderRenewalEmail", () => {
|
||||
it("includes policy, premium, expiration, type, and customer information", () => {
|
||||
const result = renderRenewalEmail(letter());
|
||||
|
||||
expect(result.subject).toContain("POL-123");
|
||||
expect(result.html).toContain("primer aviso");
|
||||
expect(result.html).toContain("AUTO");
|
||||
expect(result.html).toContain("01/09/2026");
|
||||
expect(result.html).toContain("1,392.00");
|
||||
expect(result.html).toContain("Ana Pérez");
|
||||
expect(result.html).toContain("ana@example.com");
|
||||
expect(result.html).toContain("664-111-2222");
|
||||
expect(result.html).toContain("Calle Uno 123");
|
||||
});
|
||||
|
||||
it("uses overdue wording for generation three", () => {
|
||||
const result = renderRenewalEmail(letter({ generation: 3 }));
|
||||
|
||||
expect(result.subject).toContain("Póliza vencida");
|
||||
expect(result.html).toContain("está vencida");
|
||||
});
|
||||
|
||||
it("escapes customer-provided HTML", () => {
|
||||
const result = renderRenewalEmail(
|
||||
letter({ customerName: '<img src=x onerror="alert(1)">' }),
|
||||
);
|
||||
|
||||
expect(result.html).not.toContain("<img");
|
||||
expect(result.html).toContain("<img");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RenewalLetterRow } from "../reports/renewal-letter";
|
||||
|
||||
const GENERATION_TEXT: Record<number, string> = {
|
||||
1: "Le enviamos el primer aviso para renovar su póliza.",
|
||||
2: "Le enviamos el segundo aviso para renovar su póliza.",
|
||||
3: "Le informamos que su póliza está vencida.",
|
||||
};
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function displayDate(value: string): string {
|
||||
if (value === "—") return value;
|
||||
const [year, month, day] = value.split("-");
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
function money(value: string | null, currency: string): string {
|
||||
if (!value) return "No disponible";
|
||||
return new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(Number(value));
|
||||
}
|
||||
|
||||
function row(label: string, value: string): string {
|
||||
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
|
||||
}
|
||||
|
||||
export function renderRenewalEmail(letter: RenewalLetterRow): {
|
||||
subject: string;
|
||||
html: string;
|
||||
} {
|
||||
const expired = letter.generation === 3;
|
||||
const subject = expired
|
||||
? `Póliza vencida: ${letter.policyNumber}`
|
||||
: `Aviso de renovación: póliza ${letter.policyNumber}`;
|
||||
const phone = letter.customerMobile ?? letter.customerPhone ?? "No disponible";
|
||||
const address = letter.customerAddress.join(", ") || "No disponible";
|
||||
const premium = letter.total ?? letter.netPremium;
|
||||
|
||||
const details = [
|
||||
row("Número de póliza", letter.policyNumber),
|
||||
row("Tipo de póliza", letter.policyType),
|
||||
row("Aseguradora", letter.provider),
|
||||
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
||||
row("Prima", money(premium, letter.currency)),
|
||||
row("Cliente", letter.customerName),
|
||||
row("Correo", letter.customerEmail ?? "No disponible"),
|
||||
row("Teléfono", phone),
|
||||
row("Dirección", address),
|
||||
].join("");
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: `<div style="font-family:Arial,sans-serif;color:#222;line-height:1.5"><p>Estimado(a) ${escapeHtml(letter.customerName)}:</p><p>${escapeHtml(GENERATION_TEXT[letter.generation] ?? "Le enviamos un aviso sobre la renovación de su póliza.")}</p><table style="border-collapse:collapse;width:100%;max-width:680px">${details}</table><p>Por favor, comuníquese con Jorge Cuadros & Asociados para revisar su renovación.</p><p>Atentamente,<br>Jorge Cuadros & Asociados</p></div>`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { RenewalsService } from "./renewals.service";
|
||||
|
||||
/**
|
||||
* The renewal sweep's half of the unified notification log.
|
||||
*
|
||||
* `RenewalNotice` only records that a policy WAS notified — it has no way to
|
||||
* say a send failed or that a customer had no address. Those rows exist only
|
||||
* in `email_notification_log`, so they are what these tests pin down.
|
||||
*/
|
||||
|
||||
const POLICY_ID = "policy-1";
|
||||
const CUSTOMER_ID = "cust-1";
|
||||
|
||||
function makePolicy(email: string | null) {
|
||||
return {
|
||||
id: POLICY_ID,
|
||||
policyNumber: "700442181",
|
||||
policyTo: new Date("2026-09-01T00:00:00.000Z"),
|
||||
netPremium: null,
|
||||
policyFee: null,
|
||||
total: null,
|
||||
currency: "MXN",
|
||||
coveragesJson: null,
|
||||
customer: {
|
||||
id: CUSTOMER_ID,
|
||||
name: "ACME SA DE CV",
|
||||
nameMissing: false,
|
||||
email,
|
||||
phone: null,
|
||||
mobile: null,
|
||||
addressLine1: null,
|
||||
addressLine2: null,
|
||||
city: null,
|
||||
state: null,
|
||||
zipCode: null,
|
||||
country: null,
|
||||
},
|
||||
policyType: { name: "AUTO" },
|
||||
insuranceProvider: { name: "GMX" },
|
||||
vehicles: [],
|
||||
renewalNotices: [],
|
||||
};
|
||||
}
|
||||
|
||||
function build(overrides: {
|
||||
policies?: ReturnType<typeof makePolicy>[];
|
||||
sendImpl?: () => Promise<{ messageId: string; response: string }>;
|
||||
}) {
|
||||
const policies = overrides.policies ?? [makePolicy("cliente@example.com")];
|
||||
|
||||
const record = jest.fn().mockResolvedValue(undefined);
|
||||
const send =
|
||||
overrides.sendImpl ??
|
||||
jest.fn().mockResolvedValue({ messageId: "ses-1", response: "{}" });
|
||||
|
||||
const prisma = {
|
||||
// Only generation 1 has a candidate; the other two cadences return none,
|
||||
// so a sweep produces exactly one outcome to assert on.
|
||||
policy: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(policies)
|
||||
.mockResolvedValue([]),
|
||||
findFirst: jest.fn().mockResolvedValue(policies[0]),
|
||||
},
|
||||
renewalNotice: { upsert: jest.fn().mockResolvedValue({}) },
|
||||
scheduledJobState: {
|
||||
upsert: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue({ lastSuccessfulAt: null }),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
// `register` is a no-op here: these tests drive the sweep directly, so no
|
||||
// cron job is ever installed.
|
||||
const schedule = { register: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new RenewalsService(
|
||||
prisma as never,
|
||||
{ available: true, send } as never,
|
||||
{ log: jest.fn() } as never,
|
||||
{ record } as never,
|
||||
schedule as never,
|
||||
);
|
||||
|
||||
return { service, record, send, prisma };
|
||||
}
|
||||
|
||||
describe("renewal notices write the shared notification log", () => {
|
||||
it("records a SENT row tagged RENEWAL_NOTICE / POLICIES", async () => {
|
||||
const { service, record, prisma } = build({});
|
||||
|
||||
await service.sweep("user-1");
|
||||
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
const row = record.mock.calls[0][0];
|
||||
expect(row).toMatchObject({
|
||||
notificationType: "RENEWAL_NOTICE",
|
||||
servicio: "POLICIES",
|
||||
status: "SENT",
|
||||
customerId: CUSTOMER_ID,
|
||||
customerEmail: "cliente@example.com",
|
||||
providerMessageId: "ses-1",
|
||||
debug: false,
|
||||
});
|
||||
// `level` carries the aviso generation, not an alert colour.
|
||||
expect(row.level).toBe(1);
|
||||
expect(row.subject).toContain("700442181");
|
||||
expect(row.bodySnapshot).toContain("ACME SA DE CV");
|
||||
// The gating row is still written — the log does not replace it.
|
||||
expect(prisma.renewalNotice.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records a FAILED row and no gating row when the send throws", async () => {
|
||||
const { service, record, prisma } = build({
|
||||
sendImpl: jest.fn().mockRejectedValue(new Error("SES rejected")),
|
||||
});
|
||||
|
||||
const result = await service.sweep("user-1");
|
||||
|
||||
expect(result.sent).toBe(0);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
expect(record.mock.calls[0][0]).toMatchObject({
|
||||
status: "FAILED",
|
||||
error: "SES rejected",
|
||||
notificationType: "RENEWAL_NOTICE",
|
||||
});
|
||||
// Nothing was delivered, so nothing may gate tomorrow's retry.
|
||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records SKIPPED_NO_EMAIL for a candidate with no address", async () => {
|
||||
const { service, record, send, prisma } = build({
|
||||
policies: [makePolicy(" ")],
|
||||
});
|
||||
|
||||
const result = await service.sweep("user-1");
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||
expect(record.mock.calls[0][0]).toMatchObject({
|
||||
status: "SKIPPED_NO_EMAIL",
|
||||
customerEmail: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("diverts a debug sweep and leaves the notice pending", async () => {
|
||||
const { service, record, send, prisma } = build({});
|
||||
|
||||
const result = await service.sweep("user-1", { debug: true });
|
||||
|
||||
expect(result.sent).toBe(1);
|
||||
expect(result.debug).toBe(true);
|
||||
// The customer's own address is never contacted.
|
||||
expect(jest.mocked(send).mock.calls[0][0]).toMatchObject({
|
||||
to: "rmancinas@freakma.net",
|
||||
xTracking: "debug",
|
||||
});
|
||||
expect(record.mock.calls[0][0]).toMatchObject({
|
||||
status: "SENT",
|
||||
customerEmail: "rmancinas@freakma.net",
|
||||
debug: true,
|
||||
});
|
||||
// The letter is still owed, so nothing may gate it: no RenewalNotice row,
|
||||
// and `lastSuccessfulAt` must not advance past the days we only tested.
|
||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||
expect(release.data.lastSuccessfulAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends one notice on demand in debug without marking it sent", async () => {
|
||||
const { service, send, prisma } = build({});
|
||||
|
||||
const result = await service.sendOne(POLICY_ID, 1, "user-1", {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
expect(result.debug).toBe(true);
|
||||
expect(result.to).toBe("rmancinas@freakma.net");
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fail a delivered notice when the log write throws", async () => {
|
||||
const { service, record } = build({});
|
||||
record.mockRejectedValue(new Error("log table gone"));
|
||||
|
||||
const result = await service.sweep("user-1");
|
||||
|
||||
// The mail went out and the gating row was written; a lost audit row must
|
||||
// not report that as a failure, which would re-send tomorrow.
|
||||
expect(result.sent).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { RenewalsService } from "./renewals.service";
|
||||
|
||||
/** The pólizas half of the shared "Flags del envío" panel. Only `debug`
|
||||
* means anything here — the day gate and the send limit are estado-de-cuenta
|
||||
* concepts — so the other two are simply not accepted. */
|
||||
class RenewalFlagsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
class SendRenewalDto extends RenewalFlagsDto {
|
||||
@IsString()
|
||||
policyId!: string;
|
||||
|
||||
/** 1 = 30 días antes, 2 = 15 días antes, 3 = 7 días después. */
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3)
|
||||
generation!: number;
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("renewals")
|
||||
export class RenewalsController {
|
||||
constructor(private readonly renewals: RenewalsService) {}
|
||||
|
||||
@Get("pending")
|
||||
pending(@Query("days") days?: string) {
|
||||
return this.renewals.pending(
|
||||
Math.min(365, Math.max(1, Number(days) || 30)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("sweep")
|
||||
@RequireAbility("renewal:send")
|
||||
sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) {
|
||||
return this.renewals.sweep((req.user as { id: string }).id, {
|
||||
debug: dto?.debug,
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a single pending notice from the /notificaciones list. */
|
||||
@Post("send")
|
||||
@RequireAbility("renewal:send")
|
||||
@HttpCode(200)
|
||||
send(@Body() dto: SendRenewalDto, @Req() req: Request) {
|
||||
return this.renewals.sendOne(
|
||||
dto.policyId,
|
||||
dto.generation,
|
||||
(req.user as { id: string }).id,
|
||||
{ debug: dto.debug },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationLogModule } from "../notifications/notification-log.module";
|
||||
import { NotificationScheduleModule } from "../notifications/notification-schedule.module";
|
||||
import { RenewalsController } from "./renewals.controller";
|
||||
import { RenewalsService } from "./renewals.service";
|
||||
|
||||
@Module({
|
||||
// Renewal sends write to the same `email_notification_log` the four bulk
|
||||
// jobs write, so /notificaciones has one send history across both tabs, and
|
||||
// take their cadence from the same operator-editable schedule.
|
||||
imports: [NotificationLogModule, NotificationScheduleModule],
|
||||
controllers: [RenewalsController],
|
||||
providers: [RenewalsService],
|
||||
})
|
||||
export class RenewalsModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
addUtcDays,
|
||||
dateInTimeZone,
|
||||
renewalWindow,
|
||||
RENEWAL_CADENCE,
|
||||
} from "./renewals.service";
|
||||
|
||||
describe("renewal scheduling dates", () => {
|
||||
it("uses the America/Tijuana calendar date", () => {
|
||||
expect(dateInTimeZone(new Date("2026-08-01T05:00:00.000Z"))).toEqual(
|
||||
new Date("2026-07-31T00:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps generations to 30 days, 15 days, and 7 days overdue", () => {
|
||||
const today = new Date("2026-08-01T00:00:00.000Z");
|
||||
|
||||
expect(
|
||||
RENEWAL_CADENCE.map(({ generation, offsetDays }) => ({
|
||||
generation,
|
||||
target: addUtcDays(today, offsetDays).toISOString().slice(0, 10),
|
||||
})),
|
||||
).toEqual([
|
||||
{ generation: 1, target: "2026-08-31" },
|
||||
{ generation: 2, target: "2026-08-16" },
|
||||
{ generation: 3, target: "2026-07-25" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses an inclusive catch-up window after a missed run", () => {
|
||||
const window = renewalWindow(
|
||||
new Date("2026-08-10T00:00:00.000Z"),
|
||||
30,
|
||||
new Date("2026-08-07T18:00:00.000Z"),
|
||||
);
|
||||
|
||||
expect(window).toEqual({
|
||||
from: new Date("2026-09-07T00:00:00.000Z"),
|
||||
to: new Date("2026-09-09T00:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { NotificationLogService } from "../notifications/notification-log.service";
|
||||
import {
|
||||
NotificationScheduleService,
|
||||
SCHEDULE_TIME_ZONE,
|
||||
} from "../notifications/notification-schedule.service";
|
||||
import { DEBUG_RECIPIENT } from "../notifications/notification.types";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
RenewalLetterPolicy,
|
||||
renewalLetterSelect,
|
||||
toRenewalLetterRow,
|
||||
} from "../reports/renewal-letter";
|
||||
import { renderRenewalEmail } from "./renewal-email";
|
||||
|
||||
export const RENEWAL_CADENCE = [
|
||||
{ generation: 1, offsetDays: 30 },
|
||||
{ generation: 2, offsetDays: 15 },
|
||||
{ generation: 3, offsetDays: -7 },
|
||||
] as const;
|
||||
|
||||
const JOB_NAME = "renewal-email-sweep";
|
||||
/** The window maths runs in office time; the cadence itself is owned by
|
||||
* `NotificationScheduleService`, which uses the same zone. */
|
||||
const TIME_ZONE = SCHEDULE_TIME_ZONE;
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
export function dateInTimeZone(now: Date, timeZone = TIME_ZONE): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
const value = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((part) => part.type === type)?.value);
|
||||
return new Date(Date.UTC(value("year"), value("month") - 1, value("day")));
|
||||
}
|
||||
|
||||
export function addUtcDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
export function renewalWindow(
|
||||
today: Date,
|
||||
offsetDays: number,
|
||||
lastSuccessfulAt?: Date | null,
|
||||
): { from: Date; to: Date } {
|
||||
const to = addUtcDays(today, offsetDays);
|
||||
if (!lastSuccessfulAt) return { from: to, to };
|
||||
const previousDay = dateInTimeZone(lastSuccessfulAt);
|
||||
if (previousDay >= today) return { from: to, to };
|
||||
return { from: addUtcDays(previousDay, offsetDays + 1), to };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RenewalsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(RenewalsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mail: MailService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notificationLog: NotificationLogService,
|
||||
private readonly schedule: NotificationScheduleService,
|
||||
) {}
|
||||
|
||||
/** The cadence used to be a `@Cron("0 6 * * *")` literal here; it is now
|
||||
* operator-editable, and the stored value defaults to that same 06:00
|
||||
* daily run. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.schedule.register("polizas", () => this.scheduledSweep());
|
||||
}
|
||||
|
||||
/** The unattended run always sends for real: `debug` is a per-click switch
|
||||
* in the UI, never persisted, so the schedule cannot inherit a forgotten
|
||||
* test toggle and silently stop mailing customers. */
|
||||
async scheduledSweep(): Promise<void> {
|
||||
try {
|
||||
await this.sweep();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async pending(days = 30) {
|
||||
const today = dateInTimeZone(new Date());
|
||||
const state = await this.prisma.scheduledJobState.findUnique({
|
||||
where: { name: JOB_NAME },
|
||||
select: { lastSuccessfulAt: true },
|
||||
});
|
||||
const cadence = RENEWAL_CADENCE.filter(
|
||||
(item) => item.offsetDays < 0 || item.offsetDays <= days,
|
||||
);
|
||||
const groups = await Promise.all(
|
||||
cadence.map(async (item) => ({
|
||||
generation: item.generation,
|
||||
rows: await this.findCandidates(
|
||||
item,
|
||||
today,
|
||||
state?.lastSuccessfulAt ?? null,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return groups.flatMap(({ generation, rows }) =>
|
||||
rows
|
||||
.filter((policy) => Boolean(policy.customer.email?.trim()))
|
||||
.map((policy) => toRenewalLetterRow(policy, generation)),
|
||||
);
|
||||
}
|
||||
|
||||
async sweep(userId?: string, flags: { debug?: boolean } = {}) {
|
||||
const debug = !!flags.debug;
|
||||
const now = new Date();
|
||||
const state = await this.acquireLock(now);
|
||||
|
||||
try {
|
||||
if (!this.mail.available) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El servicio de correo no está configurado.",
|
||||
);
|
||||
}
|
||||
|
||||
const today = dateInTimeZone(now);
|
||||
let eligible = 0;
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
const failures: Array<{ policyId: string; generation: number; error: string }> = [];
|
||||
|
||||
for (const cadence of RENEWAL_CADENCE) {
|
||||
const policies = await this.findCandidates(
|
||||
cadence,
|
||||
today,
|
||||
state.lastSuccessfulAt,
|
||||
);
|
||||
eligible += policies.length;
|
||||
|
||||
for (const policy of policies) {
|
||||
const to = policy.customer.email?.trim();
|
||||
if (!to) {
|
||||
// Logged rather than silently counted: "we had nobody to mail"
|
||||
// is a finding the office acts on, and only the log survives the
|
||||
// HTTP response.
|
||||
await this.recordLog(policy, cadence.generation, "", {
|
||||
status: "SKIPPED_NO_EMAIL",
|
||||
debug,
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deliver(policy, cadence.generation, to, userId, debug);
|
||||
sent++;
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
eligible,
|
||||
sent,
|
||||
skipped,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
debug,
|
||||
};
|
||||
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
||||
// RenewalNotice rows, so the days it "covered" are still owed, and
|
||||
// narrowing tomorrow's window back to a single day would drop them.
|
||||
await this.releaseLock(!debug && failures.length === 0 ? now : null);
|
||||
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.releaseLock(null);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one pending renewal notice on demand, from the /notificaciones
|
||||
* list. Same path the sweep takes — render, send, then record the notice —
|
||||
* so a letter sent by hand is marked exactly like a swept one and drops
|
||||
* off the pending list. Refuses a generation already sent so a double
|
||||
* click can't mail the customer twice.
|
||||
*
|
||||
* Under `debug` the notice is NOT marked as sent, so the row stays in the
|
||||
* pending list — the customer has still not been told anything.
|
||||
*/
|
||||
async sendOne(
|
||||
policyId: string,
|
||||
generation: number,
|
||||
userId?: string,
|
||||
flags: { debug?: boolean } = {},
|
||||
) {
|
||||
const debug = !!flags.debug;
|
||||
if (!this.mail.available) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El servicio de correo no está configurado.",
|
||||
);
|
||||
}
|
||||
|
||||
const policy = await this.prisma.policy.findFirst({
|
||||
where: { id: policyId, archivedAt: null },
|
||||
select: renewalLetterSelect(generation),
|
||||
});
|
||||
if (!policy) {
|
||||
throw new NotFoundException("Póliza no encontrada.");
|
||||
}
|
||||
if (policy.renewalNotices.some((notice) => notice.sentAt)) {
|
||||
throw new ConflictException("Este aviso ya fue enviado.");
|
||||
}
|
||||
const to = policy.customer.email?.trim();
|
||||
if (!to) {
|
||||
throw new BadRequestException("El cliente no tiene correo registrado.");
|
||||
}
|
||||
|
||||
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
|
||||
policy,
|
||||
generation,
|
||||
to,
|
||||
userId,
|
||||
debug,
|
||||
);
|
||||
return {
|
||||
policyId,
|
||||
generation,
|
||||
// The address the mail actually went to — under debug that is the
|
||||
// override inbox, and the UI says so rather than claiming the customer
|
||||
// was notified.
|
||||
to: addressedTo,
|
||||
debug,
|
||||
sentAt: sentAt.toISOString(),
|
||||
providerMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render + send + record one notice. Shared by the sweep and `sendOne`.
|
||||
*
|
||||
* Two records come out of a send: the `RenewalNotice` row, which gates the
|
||||
* pending list, and an `email_notification_log` row, which is the send
|
||||
* history the /notificaciones "Registro de envíos" reads. A failed send
|
||||
* writes only the second — there is no notice to gate on — and rethrows so
|
||||
* the sweep counts it as a failure.
|
||||
*
|
||||
* Under `debug` the mail is diverted to `DEBUG_RECIPIENT` and the
|
||||
* `RenewalNotice` row is deliberately skipped: the customer was not
|
||||
* notified, so nothing may gate the letter they are still owed. Only the
|
||||
* log row is written, flagged `debug`. */
|
||||
private async deliver(
|
||||
policy: RenewalLetterPolicy,
|
||||
generation: number,
|
||||
to: string,
|
||||
userId?: string,
|
||||
debug = false,
|
||||
) {
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
||||
|
||||
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||
try {
|
||||
result = await this.mail.send({
|
||||
to: addressedTo,
|
||||
toName: letter.customerName,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
xTracking: debug ? "debug" : "renewals",
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
await this.recordLog(policy, generation, addressedTo, {
|
||||
status: "FAILED",
|
||||
error: detail,
|
||||
debug,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sentAt = new Date();
|
||||
|
||||
if (!debug) {
|
||||
await this.prisma.renewalNotice.upsert({
|
||||
where: {
|
||||
policyId_generation: { policyId: policy.id, generation },
|
||||
},
|
||||
create: {
|
||||
policyId: policy.id,
|
||||
generation,
|
||||
channel: "EMAIL",
|
||||
sentAt,
|
||||
sentById: userId,
|
||||
providerMessageId: result.messageId,
|
||||
},
|
||||
update: {
|
||||
channel: "EMAIL",
|
||||
sentAt,
|
||||
sentById: userId,
|
||||
providerMessageId: result.messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.recordLog(policy, generation, addressedTo, {
|
||||
status: "SENT",
|
||||
providerMessageId: result.messageId || undefined,
|
||||
providerResponse: result.response || undefined,
|
||||
sendDate: sentAt,
|
||||
debug,
|
||||
});
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
generation,
|
||||
debug,
|
||||
providerMessageId: result.messageId,
|
||||
});
|
||||
return { sentAt, providerMessageId: result.messageId, addressedTo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one row to the shared notification log.
|
||||
*
|
||||
* Never throws: the mail is already gone (or already failed) by the time we
|
||||
* get here, and losing the audit row must not turn a delivered notice into
|
||||
* a reported failure — which on the SENT path would also strand the
|
||||
* `RenewalNotice` we just wrote and re-send tomorrow.
|
||||
*/
|
||||
private async recordLog(
|
||||
policy: RenewalLetterPolicy,
|
||||
generation: number,
|
||||
/** Recipient as addressed. Empty on the SKIPPED_NO_EMAIL path — that
|
||||
* emptiness IS the reason the row exists. */
|
||||
to: string,
|
||||
outcome: {
|
||||
status: "SENT" | "FAILED" | "SKIPPED_NO_EMAIL";
|
||||
providerMessageId?: string;
|
||||
providerResponse?: string;
|
||||
error?: string;
|
||||
sendDate?: Date;
|
||||
debug?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
try {
|
||||
await this.notificationLog.record({
|
||||
notificationType: "RENEWAL_NOTICE",
|
||||
servicio: "POLICIES",
|
||||
sendDate: outcome.sendDate,
|
||||
// `level` carries the aviso generation for RENEWAL_NOTICE rows — see
|
||||
// the column doc on the Prisma model.
|
||||
level: generation,
|
||||
customerId: policy.customer.id,
|
||||
customerName: letter.customerName,
|
||||
customerEmail: to,
|
||||
subject: message.subject,
|
||||
bodySnapshot: message.html,
|
||||
status: outcome.status,
|
||||
debug: !!outcome.debug,
|
||||
providerMessageId: outcome.providerMessageId,
|
||||
providerResponse: outcome.providerResponse,
|
||||
error: outcome.error,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`No se pudo registrar el aviso de renovación en el log ` +
|
||||
`(póliza ${policy.id}, aviso ${generation}): ` +
|
||||
`${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private findCandidates(
|
||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||
today: Date,
|
||||
lastSuccessfulAt: Date | null,
|
||||
) {
|
||||
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||
return this.prisma.policy.findMany({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
policyTo: { gte: window.from, lte: window.to },
|
||||
customer: {
|
||||
archivedAt: null,
|
||||
emailOptOut: false,
|
||||
email: { not: "" },
|
||||
},
|
||||
renewalNotices: {
|
||||
none: { generation: cadence.generation, sentAt: { not: null } },
|
||||
},
|
||||
},
|
||||
orderBy: [{ policyTo: "asc" }, { policyNumber: "asc" }],
|
||||
select: renewalLetterSelect(cadence.generation),
|
||||
});
|
||||
}
|
||||
|
||||
private async acquireLock(now: Date) {
|
||||
await this.prisma.scheduledJobState.upsert({
|
||||
where: { name: JOB_NAME },
|
||||
create: { name: JOB_NAME },
|
||||
update: { updatedAt: now },
|
||||
});
|
||||
|
||||
const acquired = await this.prisma.scheduledJobState.updateMany({
|
||||
where: {
|
||||
name: JOB_NAME,
|
||||
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
|
||||
},
|
||||
data: { lockedUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
if (acquired.count !== 1) {
|
||||
throw new ConflictException(
|
||||
"Ya hay un barrido de renovaciones en curso.",
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.scheduledJobState.findUniqueOrThrow({
|
||||
where: { name: JOB_NAME },
|
||||
});
|
||||
}
|
||||
|
||||
private async releaseLock(lastSuccessfulAt: Date | null): Promise<void> {
|
||||
await this.prisma.scheduledJobState.update({
|
||||
where: { name: JOB_NAME },
|
||||
data: {
|
||||
lockedUntil: null,
|
||||
...(lastSuccessfulAt && { lastSuccessfulAt }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
|
||||
export function renewalLetterSelect(generation: number) {
|
||||
return Prisma.validator<Prisma.PolicySelect>()({
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
policyFee: true,
|
||||
total: true,
|
||||
currency: true,
|
||||
coveragesJson: true,
|
||||
customer: {
|
||||
select: {
|
||||
// Needed by the notification log's customerId FK, not by the letter.
|
||||
id: true,
|
||||
name: true,
|
||||
nameMissing: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
city: true,
|
||||
state: true,
|
||||
zipCode: true,
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
policyType: { select: { name: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
vehicles: {
|
||||
take: 1,
|
||||
select: {
|
||||
make: true,
|
||||
model: true,
|
||||
modelYear: true,
|
||||
bodyType: true,
|
||||
engineNumber: true,
|
||||
licensePlate: true,
|
||||
},
|
||||
},
|
||||
renewalNotices: {
|
||||
where: { generation },
|
||||
select: { sentAt: true, channel: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RenewalLetterPolicy = Prisma.PolicyGetPayload<{
|
||||
select: ReturnType<typeof renewalLetterSelect>;
|
||||
}>;
|
||||
|
||||
export interface RenewalLetterRow extends Record<string, unknown> {
|
||||
__kind: "letter";
|
||||
policyId: string;
|
||||
policyNumber: string;
|
||||
policyType: string;
|
||||
customerName: string;
|
||||
customerEmail: string | null;
|
||||
customerPhone: string | null;
|
||||
customerMobile: string | null;
|
||||
customerAddress: string[];
|
||||
provider: string;
|
||||
policyTo: string;
|
||||
netPremium: string | null;
|
||||
policyFee: string | null;
|
||||
total: string | null;
|
||||
currency: string;
|
||||
coverageDays: unknown;
|
||||
cslLimit: unknown;
|
||||
medicalCoverage: unknown;
|
||||
propertyDamage: unknown;
|
||||
perPersonLiability: unknown;
|
||||
additionalService: unknown;
|
||||
vehicle: {
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
modelYear: string | null;
|
||||
bodyType: string | null;
|
||||
engineNumber: string | null;
|
||||
licensePlate: string | null;
|
||||
} | null;
|
||||
generation: number;
|
||||
sentAt: string | null;
|
||||
}
|
||||
|
||||
export function toRenewalLetterRow(
|
||||
policy: RenewalLetterPolicy,
|
||||
generation: number,
|
||||
): RenewalLetterRow {
|
||||
const notice = policy.renewalNotices[0];
|
||||
const coverage = (policy.coveragesJson ?? {}) as Record<string, unknown>;
|
||||
const address = [
|
||||
policy.customer.addressLine1,
|
||||
policy.customer.addressLine2,
|
||||
[policy.customer.city, policy.customer.state, policy.customer.zipCode]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
policy.customer.country,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return {
|
||||
__kind: "letter",
|
||||
policyId: policy.id,
|
||||
policyNumber: policy.policyNumber,
|
||||
policyType: policy.policyType?.name ?? "—",
|
||||
customerName: policy.customer.nameMissing ? "(sin nombre)" : policy.customer.name,
|
||||
customerEmail: policy.customer.email,
|
||||
customerPhone: policy.customer.phone,
|
||||
customerMobile: policy.customer.mobile,
|
||||
customerAddress: address,
|
||||
provider: policy.insuranceProvider?.name ?? "—",
|
||||
policyTo: policy.policyTo ? policy.policyTo.toISOString().slice(0, 10) : "—",
|
||||
netPremium: policy.netPremium ? policy.netPremium.toFixed(2) : null,
|
||||
policyFee: policy.policyFee ? policy.policyFee.toFixed(2) : null,
|
||||
total: policy.total ? policy.total.toFixed(2) : null,
|
||||
currency: policy.currency,
|
||||
coverageDays: coverage.cobertura ?? null,
|
||||
cslLimit: coverage.csl_limite ?? null,
|
||||
medicalCoverage: coverage.gastos_medico ?? null,
|
||||
propertyDamage: coverage.propiedades ?? null,
|
||||
perPersonLiability: coverage.personas ?? null,
|
||||
additionalService:
|
||||
coverage.servicio_adicional ?? coverage.servicio_adiconal ?? null,
|
||||
vehicle: policy.vehicles[0]
|
||||
? {
|
||||
make: policy.vehicles[0].make,
|
||||
model: policy.vehicles[0].model,
|
||||
modelYear: policy.vehicles[0].modelYear,
|
||||
bodyType: policy.vehicles[0].bodyType,
|
||||
engineNumber: policy.vehicles[0].engineNumber,
|
||||
licensePlate: policy.vehicles[0].licensePlate,
|
||||
}
|
||||
: null,
|
||||
generation,
|
||||
sentAt: notice?.sentAt
|
||||
? notice.sentAt.toISOString().slice(0, 10)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
parseDate,
|
||||
type ReportDef,
|
||||
} from "./reports.types";
|
||||
import {
|
||||
renewalLetterSelect,
|
||||
toRenewalLetterRow,
|
||||
} from "./renewal-letter";
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
@@ -615,10 +619,7 @@ const vigente: ReportDef = {
|
||||
* covers every carrier and tier instead of a clone per combination.
|
||||
*
|
||||
* `sentStatus` is read from `RenewalNotice` (schema.prisma) — the
|
||||
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log
|
||||
* — but this report is read-only; marking a notice as sent is a separate
|
||||
* mutation (not yet built) that would upsert `RenewalNotice` by
|
||||
* `[policyId, generation]`.
|
||||
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log.
|
||||
*/
|
||||
const avisoRenovacion: ReportDef = {
|
||||
slug: "aviso-renovacion",
|
||||
@@ -711,78 +712,16 @@ const avisoRenovacion: ReportDef = {
|
||||
: {}),
|
||||
},
|
||||
orderBy: { policyTo: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
policyFee: true,
|
||||
total: true,
|
||||
currency: true,
|
||||
coveragesJson: true,
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
vehicles: {
|
||||
take: 1,
|
||||
select: {
|
||||
make: true,
|
||||
model: true,
|
||||
modelYear: true,
|
||||
bodyType: true,
|
||||
engineNumber: true,
|
||||
licensePlate: true,
|
||||
},
|
||||
},
|
||||
renewalNotices: {
|
||||
where: { generation },
|
||||
select: { sentAt: true, channel: true },
|
||||
},
|
||||
},
|
||||
select: renewalLetterSelect(generation),
|
||||
});
|
||||
|
||||
let totalPremium = new Prisma.Decimal(0);
|
||||
let sentCount = 0;
|
||||
const out = rows.map((r) => {
|
||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||
const notice = r.renewalNotices[0];
|
||||
if (notice?.sentAt) sentCount++;
|
||||
// Legacy coverage columns not modeled as first-class Policy fields —
|
||||
// see docs/RENEWAL_NOTICES.md's column-mapping table. Keys are best-
|
||||
// effort (derived from the source schema, not yet verified against a
|
||||
// live migrated DB) — confirm before relying on them in production.
|
||||
const cov = (r.coveragesJson ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
__kind: "letter",
|
||||
policyId: r.id,
|
||||
policyNumber: r.policyNumber,
|
||||
customerName: nameOf(r.customer),
|
||||
provider: r.insuranceProvider?.name ?? "—",
|
||||
policyTo: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
|
||||
netPremium: r.netPremium ? r.netPremium.toFixed(2) : null,
|
||||
policyFee: r.policyFee ? r.policyFee.toFixed(2) : null,
|
||||
total: r.total ? r.total.toFixed(2) : null,
|
||||
currency: r.currency,
|
||||
coverageDays: cov.cobertura ?? null,
|
||||
cslLimit: cov.csl_limite ?? null,
|
||||
medicalCoverage: cov.gastos_medico ?? null,
|
||||
propertyDamage: cov.propiedades ?? null,
|
||||
perPersonLiability: cov.personas ?? null,
|
||||
additionalService: cov.servicio_adicional ?? cov.servicio_adiconal ?? null,
|
||||
vehicle: r.vehicles[0]
|
||||
? {
|
||||
make: r.vehicles[0].make,
|
||||
model: r.vehicles[0].model,
|
||||
modelYear: r.vehicles[0].modelYear,
|
||||
bodyType: r.vehicles[0].bodyType,
|
||||
engineNumber: r.vehicles[0].engineNumber,
|
||||
licensePlate: r.vehicles[0].licensePlate,
|
||||
}
|
||||
: null,
|
||||
generation,
|
||||
sentAt: notice?.sentAt
|
||||
? notice.sentAt.toISOString().slice(0, 10)
|
||||
: null,
|
||||
};
|
||||
const letter = toRenewalLetterRow(r, generation);
|
||||
if (letter.sentAt) sentCount++;
|
||||
return letter;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsService } from "./settings.service";
|
||||
|
||||
/**
|
||||
* Operator-editable configuration. No controller of its own — each setting is
|
||||
* exposed by the feature that owns it (summary recipients live under
|
||||
* /notifications), so the validation and the permission live next to the
|
||||
* thing they protect rather than behind a generic key/value endpoint.
|
||||
*/
|
||||
@Module({
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { SettingsService, invalidEmails, parseEmailList } from "./settings.service";
|
||||
|
||||
/**
|
||||
* The db → env → default ladder is the whole contract of this service: it is
|
||||
* what lets the setting move out of the environment without changing how any
|
||||
* existing deployment behaves.
|
||||
*/
|
||||
|
||||
function build(row: { value: string } | null, env?: string) {
|
||||
const prisma = {
|
||||
appSetting: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
row ? { key: "k", updatedAt: new Date("2026-08-02"), updatedById: "u1", ...row } : null,
|
||||
),
|
||||
upsert: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
const config = { get: jest.fn().mockReturnValue(env) };
|
||||
return {
|
||||
service: new SettingsService(prisma as never, config as never),
|
||||
prisma,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notification admin emails resolve db > env > default", () => {
|
||||
it("prefers the stored row", async () => {
|
||||
const { service } = build({ value: "a@x.com,b@x.com" }, "env@x.com");
|
||||
|
||||
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
|
||||
value: ["a@x.com", "b@x.com"],
|
||||
source: "db",
|
||||
updatedById: "u1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the environment when nothing is stored", async () => {
|
||||
const { service } = build(null, "env@x.com, other@x.com");
|
||||
|
||||
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
|
||||
value: ["env@x.com", "other@x.com"],
|
||||
source: "env",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the built-in defaults when neither is set", async () => {
|
||||
const { service } = build(null, undefined);
|
||||
|
||||
const resolved = await service.notificationAdminEmails();
|
||||
expect(resolved.source).toBe("default");
|
||||
expect(resolved.value).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("treats a stored empty list as 'nobody', not as unset", async () => {
|
||||
// The regression this guards: falling through to env/defaults here would
|
||||
// keep mailing people who were deliberately removed.
|
||||
const { service } = build({ value: "" }, "env@x.com");
|
||||
|
||||
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
|
||||
value: [],
|
||||
source: "db",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the list back as CSV", async () => {
|
||||
const { service, prisma } = build({ value: "" });
|
||||
|
||||
await service.setNotificationAdminEmails(["a@x.com", "b@x.com"], "user-9");
|
||||
|
||||
expect(prisma.appSetting.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({ value: "a@x.com,b@x.com", updatedById: "user-9" }),
|
||||
update: expect.objectContaining({ value: "a@x.com,b@x.com", updatedById: "user-9" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("email list parsing", () => {
|
||||
it("trims and drops blanks", () => {
|
||||
expect(parseEmailList(" a@x.com , ,b@x.com ")).toEqual(["a@x.com", "b@x.com"]);
|
||||
});
|
||||
|
||||
it("rejects entries that are not addresses at all", () => {
|
||||
expect(invalidEmails(["ok@x.com", "nope", "also@bad"])).toEqual([
|
||||
"nope",
|
||||
"also@bad",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Reader/writer for `app_settings` — the configuration staff can change
|
||||
* without a redeploy.
|
||||
*
|
||||
* Every setting resolves through the same three-step ladder: the database row
|
||||
* if an operator has set one, else the environment variable it used to live
|
||||
* in, else a hardcoded default. That ordering is what makes this migration
|
||||
* safe — an existing deployment keeps behaving exactly as it did until
|
||||
* somebody edits the value in the UI, and `source` tells the UI which of the
|
||||
* three it is looking at so "this came from the env, editing it here will
|
||||
* take over" is visible rather than surprising.
|
||||
*/
|
||||
|
||||
export const SETTING_KEYS = {
|
||||
/** Comma-separated recipients of the per-job notification summary. */
|
||||
notificationAdminEmails: "notification.adminEmails",
|
||||
/** JSON cadence of the automatic servicios sweep. */
|
||||
scheduleServicios: "notification.schedule.servicios",
|
||||
/** JSON cadence of the automatic pólizas renewal sweep. */
|
||||
schedulePolizas: "notification.schedule.polizas",
|
||||
} as const;
|
||||
|
||||
/** Where a resolved value came from. Shown in the UI. */
|
||||
export type SettingSource = "db" | "env" | "default";
|
||||
|
||||
export interface ResolvedSetting<T> {
|
||||
value: T;
|
||||
source: SettingSource;
|
||||
updatedAt: Date | null;
|
||||
updatedById: string | null;
|
||||
}
|
||||
|
||||
/** Last resort when neither the database nor the environment says otherwise.
|
||||
* Matches what `NotificationsService` hardcoded before this table existed. */
|
||||
const DEFAULT_ADMIN_EMAILS = ["rmancinas@freakma.net", "mpulido@freakma.net"];
|
||||
|
||||
/** Deliberately permissive — this rejects "not an address at all", not
|
||||
* "not deliverable". Only SES can tell us the latter, and a validator strict
|
||||
* enough to argue with is a validator that blocks a legitimate address. */
|
||||
const EMAIL_RE = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/;
|
||||
|
||||
export function parseEmailList(raw: string): string[] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function invalidEmails(list: string[]): string[] {
|
||||
return list.filter((e) => !EMAIL_RE.test(e));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
private readonly logger = new Logger(SettingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Recipients of the per-job summary email.
|
||||
*
|
||||
* Read on every send rather than cached at boot: the point of moving this
|
||||
* out of the environment was that it changes while the app is running, and
|
||||
* a cache would reintroduce exactly the restart-to-apply behaviour we are
|
||||
* removing. It is one indexed primary-key lookup per sweep, not per email.
|
||||
*/
|
||||
async notificationAdminEmails(): Promise<ResolvedSetting<string[]>> {
|
||||
const row = await this.read(SETTING_KEYS.notificationAdminEmails);
|
||||
if (row) {
|
||||
const parsed = parseEmailList(row.value);
|
||||
// An empty stored value is a legitimate choice — "send no summaries" —
|
||||
// and must not silently fall through to the env or the defaults, or an
|
||||
// operator who cleared the field would keep receiving mail.
|
||||
return {
|
||||
value: parsed,
|
||||
source: "db",
|
||||
updatedAt: row.updatedAt,
|
||||
updatedById: row.updatedById,
|
||||
};
|
||||
}
|
||||
|
||||
const env = this.config.get<string>("NOTIFICATION_ADMIN_EMAILS");
|
||||
if (env && env.trim()) {
|
||||
return {
|
||||
value: parseEmailList(env),
|
||||
source: "env",
|
||||
updatedAt: null,
|
||||
updatedById: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: [...DEFAULT_ADMIN_EMAILS],
|
||||
source: "default",
|
||||
updatedAt: null,
|
||||
updatedById: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist the summary recipients. An empty list is stored as an empty
|
||||
* string and means "nobody" — see the read path above. */
|
||||
async setNotificationAdminEmails(
|
||||
emails: string[],
|
||||
userId: string,
|
||||
): Promise<ResolvedSetting<string[]>> {
|
||||
await this.write(
|
||||
SETTING_KEYS.notificationAdminEmails,
|
||||
emails.join(","),
|
||||
userId,
|
||||
);
|
||||
return this.notificationAdminEmails();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cadence of one automatic envío, stored as JSON.
|
||||
*
|
||||
* No env rung on this ladder: a schedule was never an environment variable
|
||||
* (it was a `@Cron` literal in the source), so the only two sources are the
|
||||
* operator's row and the caller's default — which is the previous hardcoded
|
||||
* behaviour. A row that fails to parse is treated as absent and logged
|
||||
* rather than thrown: a bad JSON blob must not take the scheduler down with
|
||||
* it, and falling back to the shipped cadence is the safe reading.
|
||||
*/
|
||||
async notificationSchedule<T>(
|
||||
kind: "servicios" | "polizas",
|
||||
fallback: T,
|
||||
): Promise<ResolvedSetting<T>> {
|
||||
const key =
|
||||
kind === "servicios"
|
||||
? SETTING_KEYS.scheduleServicios
|
||||
: SETTING_KEYS.schedulePolizas;
|
||||
const row = await this.read(key);
|
||||
if (row) {
|
||||
try {
|
||||
return {
|
||||
value: { ...fallback, ...(JSON.parse(row.value) as T) },
|
||||
source: "db",
|
||||
updatedAt: row.updatedAt,
|
||||
updatedById: row.updatedById,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Setting ${key} is not valid JSON, using the default: ` +
|
||||
`${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { value: fallback, source: "default", updatedAt: null, updatedById: null };
|
||||
}
|
||||
|
||||
async setNotificationSchedule(
|
||||
kind: "servicios" | "polizas",
|
||||
schedule: unknown,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await this.write(
|
||||
kind === "servicios"
|
||||
? SETTING_KEYS.scheduleServicios
|
||||
: SETTING_KEYS.schedulePolizas,
|
||||
JSON.stringify(schedule),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
private read(key: string) {
|
||||
return this.prisma.appSetting.findUnique({ where: { key } });
|
||||
}
|
||||
|
||||
private async write(key: string, value: string, userId: string) {
|
||||
await this.prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
create: { key, value, updatedById: userId },
|
||||
update: { value, updatedById: userId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,18 @@ export class StatementsController {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Abandon a batch pending review — rejects every unposted page. */
|
||||
@Post("batches/:id/discard")
|
||||
@RequireAbility("statement:review")
|
||||
async discard(@Param("id") id: string, @Req() req: Request) {
|
||||
const result = await this.statements.discardBatch(id, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "statement.batch.discard", {
|
||||
batchId: id,
|
||||
rejected: result.rejected,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Post every matched document in the batch, against one check. */
|
||||
@Post("batches/:id/confirm")
|
||||
@RequireAbility("statement:review")
|
||||
|
||||
@@ -342,10 +342,54 @@ export class StatementsService {
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException("Este documento ya fue registrado.");
|
||||
}
|
||||
return this.prisma.statementDocument.update({
|
||||
const updated = await this.prisma.statementDocument.update({
|
||||
where: { id },
|
||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||
});
|
||||
// Rejecting the last open page settles the batch just as posting it would
|
||||
// — without this, a fully-rejected batch sat in READY_FOR_REVIEW forever
|
||||
// because only confirmBatch() ever closed one.
|
||||
await this.closeIfDone(doc.batchId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw away a whole batch that is pending review: every page that has not
|
||||
* been posted is marked REJECTED and the batch itself becomes DISCARDED.
|
||||
*
|
||||
* Refuses once any page is POSTED — those pages already wrote ledger rows
|
||||
* against a check, and a "discarded" label on the batch would leave those
|
||||
* charges unexplained. Reject the remaining pages individually instead.
|
||||
*/
|
||||
async discardBatch(batchId: string, reviewedById: string) {
|
||||
const batch = await this.prisma.statementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
});
|
||||
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||
if (batch.status === "DISCARDED") {
|
||||
throw new BadRequestException("Este lote ya fue descartado.");
|
||||
}
|
||||
|
||||
const posted = await this.prisma.statementDocument.count({
|
||||
where: { batchId, status: "POSTED" },
|
||||
});
|
||||
if (posted > 0) {
|
||||
throw new BadRequestException(
|
||||
`No se puede descartar: ${posted} página(s) ya se registraron en el estado de cuenta.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { count } = await this.prisma.statementDocument.updateMany({
|
||||
where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } },
|
||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "DISCARDED", completedAt: new Date() },
|
||||
});
|
||||
|
||||
return { batchId, rejected: count };
|
||||
}
|
||||
|
||||
// --- posting --------------------------------------------------------------
|
||||
@@ -461,8 +505,10 @@ export class StatementsService {
|
||||
where: { batchId, status: { in: OPEN } },
|
||||
});
|
||||
if (open === 0) {
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batchId },
|
||||
await this.prisma.statementBatch.updateMany({
|
||||
// `updateMany` + a status filter so a discarded batch is never quietly
|
||||
// relabelled COMPLETED by a late reject on one of its pages.
|
||||
where: { id: batchId, status: { not: "DISCARDED" } },
|
||||
data: { status: "COMPLETED", completedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -212,6 +212,11 @@ button {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Secondary line inside a row or card — used alongside .muted throughout. */
|
||||
.small {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
App shell / top nav
|
||||
========================================================================== */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Notificaciones } from "@/components/Notificaciones";
|
||||
|
||||
export default function NotificacionesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Notificaciones initialTab="servicios" />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
deleteBackup,
|
||||
deleteIngest,
|
||||
getOpsJob,
|
||||
getReplicationStatus,
|
||||
listBackups,
|
||||
listIngest,
|
||||
listOpsJobs,
|
||||
@@ -22,10 +23,12 @@ import {
|
||||
} from "@/lib/api";
|
||||
import type { UploadProgress } from "@/lib/api";
|
||||
import type {
|
||||
ApplyProgress,
|
||||
BackupFile,
|
||||
IngestFile,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
ReplicationStatus,
|
||||
} from "@/lib/types";
|
||||
|
||||
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
@@ -223,10 +226,13 @@ function Operaciones() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<JobProgressBar job={activeJob} />
|
||||
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReplicationCard />
|
||||
|
||||
{/* Ingest folder */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||
@@ -596,3 +602,219 @@ function OpTile({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the read replica behind my.jorgecuadros.com.
|
||||
*
|
||||
* Worth a panel because the failure mode is silent: a replica whose SQL thread
|
||||
* has stopped keeps answering queries, just with data frozen at the moment it
|
||||
* stopped. Nothing on the customer site looks wrong — the balances are simply
|
||||
* out of date — so without this the only signal is a customer complaining.
|
||||
*/
|
||||
function ReplicationCard() {
|
||||
const [status, setStatus] = useState<ReplicationStatus | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getReplicationStatus()
|
||||
.then((s) => {
|
||||
setStatus(s);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => setFailed(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
// Not configured is the normal state in dev and before cutover, so it is a
|
||||
// quiet note rather than an alarm — showing red here would train people to
|
||||
// ignore the card.
|
||||
if (failed || (status && !status.configured)) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Réplica del sitio de clientes</h2>
|
||||
<p className="inline-form-note">
|
||||
{failed
|
||||
? "No se pudo consultar el estado de la réplica."
|
||||
: "No configurada en este entorno."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Réplica del sitio de clientes</h2>
|
||||
<p className="inline-form-note">Consultando…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
Réplica del sitio de clientes{" "}
|
||||
<span className={`badge ${status.healthy ? "badge-positive" : "badge-negative"}`}>
|
||||
{status.healthy ? "Replicando" : "Detenida"}
|
||||
</span>
|
||||
</h2>
|
||||
<button className="btn btn-ghost" type="button" onClick={load}>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status.problem && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{status.problem}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kv-grid" style={{ paddingLeft: 0, paddingRight: 0 }}>
|
||||
<KV label="Servidor" value={status.host} />
|
||||
<KV label="Origen" value={status.sourceHost} />
|
||||
<KV label="Hilo de E/S" value={status.ioRunning} />
|
||||
<KV label="Hilo SQL" value={status.sqlRunning} />
|
||||
{/* Never render a null lag as "0 s": MySQL reports NULL whenever a
|
||||
thread is down, so the honest word is "unknown", not "up to date". */}
|
||||
<KV
|
||||
label="Retraso"
|
||||
value={status.secondsBehind === null ? "sin dato" : `${status.secondsBehind} s`}
|
||||
/>
|
||||
<KV label="Pendiente de aplicar" value={backlogLabel(status.apply)} />
|
||||
<KV label="Consultado" value={formatDateTime(status.checkedAt)} />
|
||||
</div>
|
||||
|
||||
<ApplyProgressBar apply={status.apply} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes the replica has fetched but not yet applied.
|
||||
*
|
||||
* Kept separate from the lag figure because it answers a question the lag
|
||||
* cannot: while the SQL thread chews through one big transaction, the seconds
|
||||
* counter can hold still, but this number visibly falls.
|
||||
*/
|
||||
function backlogLabel(apply: ApplyProgress | null): string {
|
||||
if (!apply) return "sin dato";
|
||||
// Different source binlog files means the replica is whole files behind and
|
||||
// the byte delta is not a delta at all — positions restart in each new file.
|
||||
if (!apply.sameFile) return "más de un archivo de binlog";
|
||||
if (apply.backlogBytes === 0) return "al día";
|
||||
return formatBytes(apply.backlogBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied-vs-fetched bar. Rendered only when both threads are on the same
|
||||
* source binlog file, because that is the only case where the percentage is
|
||||
* arithmetic rather than a guess.
|
||||
*/
|
||||
function ApplyProgressBar({ apply }: { apply: ApplyProgress | null }) {
|
||||
if (!apply || !apply.sameFile || apply.percent === null) return null;
|
||||
|
||||
return (
|
||||
<div className="upload-progress" style={{ marginTop: 12 }}>
|
||||
<div
|
||||
className="progress-track"
|
||||
role="progressbar"
|
||||
aria-valuenow={apply.percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Eventos aplicados de los recibidos"
|
||||
>
|
||||
<div className="progress-fill" style={{ width: `${apply.percent}%` }} />
|
||||
</div>
|
||||
<div className="upload-progress-stats mono">
|
||||
<span>{apply.percent}% aplicado</span>
|
||||
<span>
|
||||
{apply.sourceLogFile} · {apply.execPos.toLocaleString("es-MX")} /{" "}
|
||||
{apply.readPos.toLocaleString("es-MX")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches the KV in the clientes/polizas/servicios detail pages. */
|
||||
function KV({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="kv-label">{label}</div>
|
||||
<div className="kv-value">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step progress for a running migration.
|
||||
*
|
||||
* Only REIMPORT and SYNC report steps; BACKUP and RESTORE are a single
|
||||
* mysqldump, so they render nothing here rather than a made-up bar — the
|
||||
* spinner in the heading already says "working".
|
||||
*
|
||||
* The safety backup runs before the migration, so `progress` is null for the
|
||||
* first stretch of every REIMPORT. That phase is named explicitly instead of
|
||||
* showing 0%, which would read as "stuck".
|
||||
*/
|
||||
function JobProgressBar({ job }: { job: OpsJob }) {
|
||||
const running = job.status === "RUNNING";
|
||||
const p = job.progress;
|
||||
|
||||
if (!p) {
|
||||
if (!running) return null;
|
||||
return (
|
||||
<p className="inline-form-note" style={{ marginTop: 8 }}>
|
||||
Respaldo de seguridad previo…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10, marginBottom: 4 }}>
|
||||
<div
|
||||
className="row-actions"
|
||||
style={{ justifyContent: "space-between", marginBottom: 6 }}
|
||||
>
|
||||
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||
Paso {p.step} de {p.total} — {p.name}
|
||||
</span>
|
||||
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||
{p.percent}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={p.percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Paso ${p.step} de ${p.total}`}
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "var(--line)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${p.percent}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
transition: "width 400ms ease",
|
||||
background:
|
||||
job.status === "FAILED"
|
||||
? "var(--negative)"
|
||||
: "var(--positive)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||
import {
|
||||
confirmStatementBatch,
|
||||
discardStatementBatch,
|
||||
getStatementBatch,
|
||||
listStatementDocuments,
|
||||
rejectStatementDocument,
|
||||
@@ -66,6 +68,7 @@ function BatchReview({ id }: { id: string }) {
|
||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [discarding, setDiscarding] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -108,9 +111,31 @@ function BatchReview({ id }: { id: string }) {
|
||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||
).length;
|
||||
|
||||
async function discard() {
|
||||
setDiscarding(true);
|
||||
setError(null);
|
||||
try {
|
||||
await discardStatementBatch(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo descartar el lote.");
|
||||
} finally {
|
||||
setDiscarding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-box">Cargando…</div>;
|
||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||
|
||||
const postedCount = batch.byStatus.POSTED ?? 0;
|
||||
// Discarding is only offered while the batch can still be abandoned whole:
|
||||
// nothing posted to the ledger yet, and not already settled.
|
||||
const canDiscard =
|
||||
canReview &&
|
||||
batch.status !== "DISCARDED" &&
|
||||
batch.status !== "COMPLETED" &&
|
||||
postedCount === 0;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
@@ -146,6 +171,15 @@ function BatchReview({ id }: { id: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{canDiscard && (
|
||||
<DiscardBatchCard
|
||||
busy={discarding}
|
||||
onDiscard={discard}
|
||||
pageCount={docs.length}
|
||||
what="recibo"
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="stack">
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow
|
||||
@@ -166,6 +200,7 @@ const STATUS_LABEL_BATCH: Record<string, string> = {
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Registrado",
|
||||
FAILED: "Falló",
|
||||
DISCARDED: "Descartado",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Notificaciones } from "@/components/Notificaciones";
|
||||
|
||||
/** Renewal notices moved into /notificaciones as its "Pólizas" tab. This route
|
||||
* stays so old bookmarks and links land on that tab instead of a 404. */
|
||||
export default function RenovacionesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Notificaciones initialTab="polizas" />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
getNotificationAdminEmails,
|
||||
setNotificationAdminEmails,
|
||||
type NotificationAdminEmails,
|
||||
} from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* Who receives the per-job summary email.
|
||||
*
|
||||
* This used to be NOTIFICATION_ADMIN_EMAILS in the deployment environment,
|
||||
* which made "add Beto to the summaries" a redeploy. It is now a stored
|
||||
* setting; the env var still acts as the fallback until someone saves here,
|
||||
* so nothing changes for a deployment that never touches this screen.
|
||||
*/
|
||||
|
||||
const SOURCE_NOTE: Record<NotificationAdminEmails["source"], string> = {
|
||||
db: "Guardado desde esta pantalla.",
|
||||
env: "Viene de la configuración del despliegue (NOTIFICATION_ADMIN_EMAILS). Al guardar aquí, este valor toma precedencia.",
|
||||
default: "Nadie lo ha configurado; se están usando los valores por omisión.",
|
||||
};
|
||||
|
||||
export function AdminEmailsSetting() {
|
||||
const canEdit = useCan("setting:manage");
|
||||
|
||||
const [setting, setSetting] = useState<NotificationAdminEmails | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await getNotificationAdminEmails();
|
||||
setSetting(data);
|
||||
setDraft(data.value.join(", "));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const emails = draft
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const data = await setNotificationAdminEmails(emails);
|
||||
setSetting(data);
|
||||
setDraft(data.value.join(", "));
|
||||
setEditing(false);
|
||||
setSaved(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setDraft(setting?.value.join(", ") ?? "");
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
if (!setting) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Destinatarios del resumen</h2>
|
||||
{error ? (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
Cargando…
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="section-title">Destinatarios del resumen</h2>
|
||||
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 620 }}>
|
||||
Después de cada envío se manda un correo interno con el resultado
|
||||
(enviados, omitidos, fallidos). Estas son las direcciones que lo
|
||||
reciben. No afecta a los correos que reciben los clientes.
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="field" style={{ marginBottom: 8 }}>
|
||||
<span className="field-label">
|
||||
Correos separados por coma (vacío = no enviar resumen a nadie)
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft}
|
||||
disabled={saving}
|
||||
placeholder="alguien@ejemplo.com, otro@ejemplo.com"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? "Guardando…" : "Guardar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={saving}
|
||||
onClick={cancel}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{setting.value.length === 0 ? (
|
||||
<span className="empty-inline">
|
||||
Nadie recibe el resumen de los envíos.
|
||||
</span>
|
||||
) : (
|
||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{setting.value.map((email) => (
|
||||
<li key={email} className="mono">
|
||||
{email}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="section-note" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||
{SOURCE_NOTE[setting.source]}
|
||||
{setting.updatedAt &&
|
||||
` Última edición: ${formatDateTime(setting.updatedAt)}.`}
|
||||
{saved && " Guardado."}
|
||||
</p>
|
||||
{!canEdit && (
|
||||
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
|
||||
Solo un ADMIN puede cambiar esta lista.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,9 @@ type NavLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
ability?: Ability;
|
||||
/** Shown when the user holds *any* of these — for a screen that merges two
|
||||
* separately-gated jobs (Notificaciones: servicios + pólizas). */
|
||||
anyAbility?: Ability[];
|
||||
exact?: boolean;
|
||||
/** Extra path prefixes that belong to this entry (e.g. a second route into
|
||||
* the same screen), so they highlight it instead of nothing. */
|
||||
@@ -78,6 +81,14 @@ const NAV: NavEntry[] = [
|
||||
label: "Cuentas de chequera",
|
||||
ability: "bank:manage-accounts",
|
||||
},
|
||||
// Mass email (servicios) and renewal notices (pólizas) are two tabs of
|
||||
// one screen; `/renovaciones` opens the same page on its pólizas tab.
|
||||
{
|
||||
href: "/notificaciones",
|
||||
label: "Notificaciones",
|
||||
anyAbility: ["notification:send", "renewal:send"],
|
||||
aliases: ["/renovaciones"],
|
||||
},
|
||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||
],
|
||||
@@ -91,7 +102,9 @@ const NAV_LINKS: NavLink[] = NAV.flatMap((entry) =>
|
||||
|
||||
/** The nav the given user may see, with empty groups dropped. */
|
||||
function visibleNav(user: AuthUser | null): NavEntry[] {
|
||||
const allowed = (item: NavLink) => !item.ability || can(user, item.ability);
|
||||
const allowed = (item: NavLink) =>
|
||||
(!item.ability || can(user, item.ability)) &&
|
||||
(!item.anyAbility || item.anyAbility.some((a) => can(user, a)));
|
||||
const out: NavEntry[] = [];
|
||||
for (const entry of NAV) {
|
||||
if (entry.kind === "link") {
|
||||
|
||||
@@ -30,6 +30,7 @@ type Values = {
|
||||
mobile: string;
|
||||
fax: string;
|
||||
email: string;
|
||||
emailOptOut: boolean;
|
||||
identificationType: string;
|
||||
identificationNumber: string;
|
||||
identificationExpiration: string;
|
||||
@@ -54,6 +55,7 @@ function initial(c?: CustomerDetail): Values {
|
||||
mobile: c?.mobile ?? "",
|
||||
fax: c?.fax ?? "",
|
||||
email: c?.email ?? "",
|
||||
emailOptOut: c?.emailOptOut ?? false,
|
||||
identificationType: c?.identificationType ?? "",
|
||||
identificationNumber: c?.identificationNumber ?? "",
|
||||
identificationExpiration: toDateInput(c?.identificationExpiration),
|
||||
@@ -103,6 +105,7 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
mobile: s(v.mobile),
|
||||
fax: s(v.fax),
|
||||
email: s(v.email),
|
||||
emailOptOut: v.emailOptOut,
|
||||
identificationType: s(v.identificationType),
|
||||
identificationNumber: s(v.identificationNumber),
|
||||
identificationExpiration: s(v.identificationExpiration),
|
||||
@@ -139,6 +142,13 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
<input className="input" type="email" value={v.email}
|
||||
onChange={(e) => set("email", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Notificaciones de renovación">
|
||||
<label>
|
||||
<input type="checkbox" checked={v.emailOptOut}
|
||||
onChange={(e) => set("emailOptOut", e.target.checked)} />
|
||||
{" "}No enviar correos
|
||||
</label>
|
||||
</Field>
|
||||
<Field label="Teléfono">
|
||||
<input className="input" value={v.phone}
|
||||
onChange={(e) => set("phone", e.target.value)} />
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* "Throw this batch away" control, shared by both OCR review queues
|
||||
* (recibos and pólizas).
|
||||
*
|
||||
* Confirmation is a two-step inline swap rather than `window.confirm`: the
|
||||
* dialog would block the page, and an accidental discard is not undoable from
|
||||
* the UI — the reviewer should read what they are about to lose, not dismiss
|
||||
* a modal reflexively.
|
||||
*
|
||||
* The card is only rendered when the batch is still discardable; the API
|
||||
* refuses again on its own (a page posted between render and click).
|
||||
*/
|
||||
export function DiscardBatchCard({
|
||||
busy,
|
||||
onDiscard,
|
||||
pageCount,
|
||||
what,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onDiscard: () => void;
|
||||
pageCount: number;
|
||||
/** Singular noun for what a page becomes — "recibo" / "póliza". */
|
||||
what: string;
|
||||
}) {
|
||||
const [armed, setArmed] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Descartar lote
|
||||
</h2>
|
||||
{armed ? (
|
||||
<>
|
||||
<p className="page-sub" style={{ marginBottom: 12 }}>
|
||||
Se descartarán las {pageCount} página(s) de este lote y no se
|
||||
creará ninguna {what}. Esto no se puede deshacer desde aquí; para
|
||||
volver a intentarlo hay que subir los PDFs otra vez.
|
||||
</p>
|
||||
<div className="inline-form" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={busy}
|
||||
onClick={onDiscard}
|
||||
>
|
||||
{busy ? "Descartando…" : "Sí, descartar el lote"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => setArmed(false)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="page-sub" style={{ marginBottom: 12 }}>
|
||||
Si el lote quedó mal (escaneo ilegible, PDFs equivocados, subida
|
||||
duplicada), descártelo para sacarlo de la cola de revisión.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => setArmed(true)}
|
||||
>
|
||||
Descartar lote
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { NotificacionesServicios } from "@/components/NotificacionesServicios";
|
||||
import { NotificacionesPolizas } from "@/components/NotificacionesPolizas";
|
||||
import { NotificationFlagsCard } from "@/components/NotificationFlagsCard";
|
||||
import { NotificationScheduleCard } from "@/components/NotificationScheduleCard";
|
||||
import type { NotificationFlags } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Notificaciones — one screen, two subsections:
|
||||
*
|
||||
* - **servicios** — the four mass-email jobs against customer ledgers
|
||||
* (pagos pendientes, confirmación de pago, estado de cuenta, fideicomiso).
|
||||
* - **polizas** — renewal notices, 30/15 days before and 7 days after a
|
||||
* policy expires.
|
||||
*
|
||||
* Both are "tell a customer something by email", so they are modes of one
|
||||
* screen rather than two menu entries. `/renovaciones` still resolves here on
|
||||
* the pólizas tab so old bookmarks keep working (same pattern as Captura).
|
||||
*
|
||||
* Two things are owned by this shell rather than by a tab, because they are
|
||||
* true of every notification: the send flags (`debug` in particular, which the
|
||||
* pólizas half honours exactly like the servicios half) and the automatic
|
||||
* cadence of both sweeps. Keeping the flags here also means switching tabs
|
||||
* cannot silently drop a `debug` the operator just ticked.
|
||||
*/
|
||||
|
||||
export type NotificacionesTab = "servicios" | "polizas";
|
||||
|
||||
const TAB_HINT: Record<NotificacionesTab, string> = {
|
||||
servicios:
|
||||
"Envíos masivos de cobranza y estado de cuenta a los clientes de servicios.",
|
||||
polizas: "Avisos de renovación de pólizas: 30 y 15 días antes, 7 días después.",
|
||||
};
|
||||
|
||||
export function Notificaciones({
|
||||
initialTab = "servicios",
|
||||
}: {
|
||||
initialTab?: NotificacionesTab;
|
||||
}) {
|
||||
const canNotify = useCan("notification:send");
|
||||
const canRenew = useCan("renewal:send");
|
||||
|
||||
// Gating is cosmetic (the API enforces every send), but a user who only has
|
||||
// one of the two abilities should land on the tab they can actually use.
|
||||
// Servicios stays visible read-only for STAFF, who can browse the log.
|
||||
const tabs: { key: NotificacionesTab; label: string }[] = [
|
||||
{ key: "servicios", label: "Servicios" },
|
||||
...(canRenew ? [{ key: "polizas" as const, label: "Pólizas" }] : []),
|
||||
];
|
||||
|
||||
const [tab, setTab] = useState<NotificacionesTab>(
|
||||
tabs.some((t) => t.key === initialTab) ? initialTab : "servicios",
|
||||
);
|
||||
// Defaults to debug ON: the safe end of the switch is the one you land on.
|
||||
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
||||
|
||||
if (!canNotify && !canRenew) {
|
||||
return (
|
||||
<div className="state-box state-error">
|
||||
No tienes permiso para enviar notificaciones.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<p className="eyebrow">Notificaciones</p>
|
||||
<h1 className="page-title">Notificaciones</h1>
|
||||
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
|
||||
{TAB_HINT[tab]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 16, marginBottom: 20 }}>
|
||||
<NotificationFlagsCard
|
||||
flags={flags}
|
||||
onChange={setFlags}
|
||||
disabled={!canNotify && !canRenew}
|
||||
/>
|
||||
<NotificationScheduleCard />
|
||||
</div>
|
||||
|
||||
{tabs.length > 1 && (
|
||||
<div className="seg" role="tablist" style={{ marginBottom: 20 }}>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
className={`seg-btn ${tab === t.key ? "active" : ""}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "servicios" ? (
|
||||
<NotificacionesServicios flags={flags} />
|
||||
) : (
|
||||
<NotificacionesPolizas flags={flags} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
|
||||
* renewal letters are pending in a window and lets staff send them, either
|
||||
* one row at a time or as a whole sweep. Sending is what marks a notice as
|
||||
* delivered — there is no manual "mark as sent", so the list can never claim
|
||||
* a letter went out when no mail was ever sent. Gated on `renewal:send`.
|
||||
*
|
||||
* Sends are recorded in the same `email_notification_log` the Servicios tab
|
||||
* reads, so "Registro de envíos" below is the same component with the
|
||||
* POLICIES slice — failures and no-email skips included, which the pending
|
||||
* list alone cannot show.
|
||||
*
|
||||
* `debug` comes from the shared flags card above the tabs and means the same
|
||||
* thing here as it does for servicios: the mail is diverted to the override
|
||||
* inbox. It additionally does NOT mark the notice as sent, so a test send
|
||||
* leaves the row exactly where it was — pending.
|
||||
*/
|
||||
|
||||
export interface RenewalLetter {
|
||||
policyId: string;
|
||||
policyNumber: string;
|
||||
policyType: string;
|
||||
customerName: string;
|
||||
customerEmail: string | null;
|
||||
provider: string;
|
||||
policyTo: string;
|
||||
netPremium: string | null;
|
||||
total: string | null;
|
||||
currency: string;
|
||||
generation: number;
|
||||
sentAt: string | null;
|
||||
}
|
||||
|
||||
export interface RenewalSweepResult {
|
||||
eligible: number;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
failures: { policyId: string; generation: number; error: string }[];
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export interface RenewalSendResult {
|
||||
policyId: string;
|
||||
generation: number;
|
||||
/** Where the mail actually went — the override inbox under debug. */
|
||||
to: string;
|
||||
debug: boolean;
|
||||
sentAt: string;
|
||||
providerMessageId?: string;
|
||||
}
|
||||
|
||||
const GENERATION_LABEL: Record<number, string> = {
|
||||
1: "Primer aviso (30 días antes)",
|
||||
2: "Segundo aviso (15 días antes)",
|
||||
3: "Tercer aviso (7 días después)",
|
||||
};
|
||||
|
||||
export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
const allowed = useCan("renewal:send");
|
||||
const debug = !!flags.debug;
|
||||
const [days, setDays] = useState(30);
|
||||
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
|
||||
const [pendingError, setPendingError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [sweeping, setSweeping] = useState(false);
|
||||
/** `policyId-generation` of the row currently being sent, if any. */
|
||||
const [sendingKey, setSendingKey] = useState<string | null>(null);
|
||||
/** Raised after every send so the log panel reloads. */
|
||||
const [logToken, setLogToken] = useState(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setPendingError(null);
|
||||
try {
|
||||
const data = await apiFetch<RenewalLetter[]>(
|
||||
`/renewals/pending?days=${days}`,
|
||||
);
|
||||
setPending(data);
|
||||
} catch (e) {
|
||||
setPendingError(
|
||||
(e as Error)?.message ?? "No se pudo cargar la lista de avisos.",
|
||||
);
|
||||
setPending([]);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowed) refresh();
|
||||
}, [allowed, refresh]);
|
||||
|
||||
async function handleSweep() {
|
||||
// Only worth confirming when debug is off — that is the case where real
|
||||
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
|
||||
if (!debug) {
|
||||
const ok = window.confirm(
|
||||
"debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?",
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setActionError(null);
|
||||
setNotice(null);
|
||||
setSweeping(true);
|
||||
try {
|
||||
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ debug }),
|
||||
});
|
||||
setNotice(
|
||||
`Enviados ${result.sent} avisos (${result.failed} con error).` +
|
||||
(result.debug
|
||||
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
|
||||
: ""),
|
||||
);
|
||||
setLogToken((t) => t + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
|
||||
setLogToken((t) => t + 1);
|
||||
} finally {
|
||||
setSweeping(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send this one notice now. The API records it as sent on success, so the
|
||||
* row leaves the pending list — that disappearance IS the "sent" signal,
|
||||
* backed by the confirmation line above the table.
|
||||
*/
|
||||
async function handleSend(letter: RenewalLetter) {
|
||||
setActionError(null);
|
||||
setNotice(null);
|
||||
setSendingKey(`${letter.policyId}-${letter.generation}`);
|
||||
try {
|
||||
const result = await apiFetch<RenewalSendResult>("/renewals/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
policyId: letter.policyId,
|
||||
generation: letter.generation,
|
||||
debug,
|
||||
}),
|
||||
});
|
||||
setNotice(
|
||||
result.debug
|
||||
? `Prueba enviada a ${result.to}. El aviso sigue pendiente: el cliente no ha recibido nada.`
|
||||
: `Aviso enviado a ${result.to}.`,
|
||||
);
|
||||
setLogToken((t) => t + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setActionError((e as Error)?.message ?? "No se pudo enviar el aviso.");
|
||||
// A rejected send may still have written a FAILED row; reload either way.
|
||||
setLogToken((t) => t + 1);
|
||||
} finally {
|
||||
setSendingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="empty-inline">
|
||||
No tiene permisos para enviar avisos de renovación.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = (pending ?? []).reduce<Record<number, number>>(
|
||||
(acc, item) => ({
|
||||
...acc,
|
||||
[item.generation]: (acc[item.generation] ?? 0) + 1,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 20 }}>
|
||||
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
|
||||
El sistema ejecuta un barrido automático (ver «Programación de envíos»
|
||||
arriba) que notifica a los clientes a 30, 15 y 7 días antes o después
|
||||
del vencimiento de su póliza. Esta sección muestra qué avisos están
|
||||
pendientes y permite ejecutarlo manualmente.
|
||||
</p>
|
||||
|
||||
{actionError && <div className="state-box state-error">{actionError}</div>}
|
||||
{notice && <div className="empty-inline">{notice}</div>}
|
||||
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<h2 className="section-title">Barrido manual</h2>
|
||||
<p className="muted small" style={{ marginTop: 4 }}>
|
||||
Usa la fecha actual del servidor como referencia para seleccionar
|
||||
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={sweeping}
|
||||
onClick={handleSweep}
|
||||
>
|
||||
{sweeping ? "Enviando…" : "Ejecutar barrido"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}>
|
||||
<span className="field-label">Ventana (días)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={days}
|
||||
onChange={(e) =>
|
||||
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pendingError && <div className="state-box state-error">{pendingError}</div>}
|
||||
|
||||
{!pendingError && grouped.length === 0 && (
|
||||
<div className="empty-inline">
|
||||
No hay avisos pendientes en esta ventana.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map((generation) => (
|
||||
<section className="card" key={generation} style={{ padding: 20 }}>
|
||||
<h2 className="section-title">{GENERATION_LABEL[generation]}</h2>
|
||||
<div className="tx-scroll" style={{ marginTop: 12 }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cliente</th>
|
||||
<th>Póliza</th>
|
||||
<th>Tipo</th>
|
||||
<th>Aseguradora</th>
|
||||
<th>Vence</th>
|
||||
<th className="num">Prima</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(pending ?? [])
|
||||
.filter((item) => item.generation === generation)
|
||||
.map((item) => (
|
||||
<tr key={`${item.policyId}-${item.generation}`}>
|
||||
<td>
|
||||
<div>{item.customerName}</div>
|
||||
<div className="muted small">
|
||||
{item.customerEmail ?? "Sin correo"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono">{item.policyNumber}</td>
|
||||
<td>{item.policyType}</td>
|
||||
<td>{item.provider}</td>
|
||||
<td>{formatDate(item.policyTo)}</td>
|
||||
<td className="num">
|
||||
{formatMoney(item.total ?? item.netPremium, item.currency)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleSend(item)}
|
||||
disabled={
|
||||
!item.customerEmail ||
|
||||
sweeping ||
|
||||
sendingKey !== null
|
||||
}
|
||||
>
|
||||
{sendingKey ===
|
||||
`${item.policyId}-${item.generation}`
|
||||
? "Enviando…"
|
||||
: "Enviar aviso"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<NotificationLogPanel
|
||||
servicio={POLIZAS_LOG_SCOPE}
|
||||
reloadToken={logToken}
|
||||
emptyHint="Todavía no se ha enviado ningún aviso de renovación con este filtro."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
formatDateTime,
|
||||
NOTIFICATION_STATUS_LABELS,
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
} from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { AdminEmailsSetting } from "@/components/AdminEmailsSetting";
|
||||
import {
|
||||
getNotificationStats,
|
||||
runAccountStatus,
|
||||
runAllNotifications,
|
||||
runOutstandingPayments,
|
||||
runPaymentConfirmation,
|
||||
runTrustConfirmation,
|
||||
SERVICIOS_LOG_SCOPE,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
NotificationFlags,
|
||||
NotificationJobResponse,
|
||||
NotificationRunAllResponse,
|
||||
NotificationStats,
|
||||
} from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Mass email notifications — the "Servicios" half of /notificaciones. Manual
|
||||
* triggers for the four jobs plus a paged log browser. Gated on
|
||||
* `notification:send`; a STAFF viewer sees the read-only log table but not the
|
||||
* trigger buttons.
|
||||
*
|
||||
* The send flags come from the shell above the tabs — they are shared with the
|
||||
* pólizas half — so this component only consumes them.
|
||||
*/
|
||||
|
||||
type JobKind = "outstanding" | "payment" | "account" | "trust";
|
||||
|
||||
interface JobDef {
|
||||
kind: JobKind;
|
||||
title: string;
|
||||
endpoint: string;
|
||||
description: string;
|
||||
servicio: "Clientes" | "Fideicomiso";
|
||||
flagsHint?: string;
|
||||
}
|
||||
|
||||
const JOBS: JobDef[] = [
|
||||
{
|
||||
kind: "outstanding",
|
||||
title: "Pagos pendientes",
|
||||
endpoint: "sendOutstandingPaymentAlerts",
|
||||
servicio: "Clientes",
|
||||
description:
|
||||
"Clientes con al menos un movimiento marcado como pendiente (outstanding). Equivale a la columna NOPAGO=1 del antiguo datosfreak.",
|
||||
},
|
||||
{
|
||||
kind: "payment",
|
||||
title: "Confirmación de pago",
|
||||
endpoint: "sendPaymentConfirmation",
|
||||
servicio: "Clientes",
|
||||
description:
|
||||
"Clientes con un crédito (abono) en las últimas 24 horas. Un correo por cliente con el pago más reciente.",
|
||||
},
|
||||
{
|
||||
kind: "account",
|
||||
title: "Estado de cuenta",
|
||||
endpoint: "sendAccountStatus",
|
||||
servicio: "Clientes",
|
||||
description:
|
||||
"Alerta amarilla (DEBAJO DEL TIPO) los miércoles y roja (EN ROJO) lunes/miércoles/viernes. El flag ignoreDayRestriction salta los gates.",
|
||||
flagsHint: "Solo este job respeta ignoreDayRestriction y useEmailLimit.",
|
||||
},
|
||||
{
|
||||
kind: "trust",
|
||||
title: "Confirmación fideicomiso",
|
||||
endpoint: "sendConfirmTrustPayment",
|
||||
servicio: "Fideicomiso",
|
||||
description:
|
||||
"Clientes con TrustAccount que recibieron un crédito en el dominio TRUST en las últimas 24 horas.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Job title by kind — used by the run-all summary, which only carries kinds. */
|
||||
const JOB_TITLES: Record<JobKind, string> = JOBS.reduce(
|
||||
(acc, j) => ({ ...acc, [j.kind]: j.title }),
|
||||
{} as Record<JobKind, string>,
|
||||
);
|
||||
|
||||
export function NotificacionesServicios({ flags }: { flags: NotificationFlags }) {
|
||||
const allowed = useCan("notification:send");
|
||||
|
||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||
/** Raised after every run so the shared log panel reloads. */
|
||||
const [logToken, setLogToken] = useState(0);
|
||||
const [busy, setBusy] = useState<JobKind | "all" | null>(null);
|
||||
const [lastResult, setLastResult] = useState<
|
||||
NotificationJobResponse | NotificationRunAllResponse | null
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStats(await getNotificationStats(SERVICIOS_LOG_SCOPE));
|
||||
setLogToken((t) => t + 1);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const run = useCallback(
|
||||
async (job: JobDef) => {
|
||||
if (!allowed) return;
|
||||
setBusy(job.kind);
|
||||
setError(null);
|
||||
try {
|
||||
let res: NotificationJobResponse;
|
||||
if (job.kind === "outstanding") res = await runOutstandingPayments(flags);
|
||||
else if (job.kind === "payment") res = await runPaymentConfirmation(flags);
|
||||
else if (job.kind === "account") res = await runAccountStatus(flags);
|
||||
else res = await runTrustConfirmation(flags);
|
||||
setLastResult(res);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[allowed, flags, refresh],
|
||||
);
|
||||
|
||||
// "Ejecutar todos" — one POST, the API runs the four jobs sequentially with
|
||||
// the same flags. Confirmation only matters when debug is off, since that
|
||||
// is the case where real customers receive mail.
|
||||
const runAll = useCallback(async () => {
|
||||
if (!allowed) return;
|
||||
if (!flags.debug) {
|
||||
const ok = window.confirm(
|
||||
"debug está desactivado: los cuatro envíos irán a los correos reales de los clientes. ¿Ejecutar todos?",
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy("all");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await runAllNotifications(flags);
|
||||
setLastResult(res);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [allowed, flags, refresh]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 20 }}>
|
||||
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
|
||||
Disparo manual de los cuatro envíos equivalentes a los scripts PHP de{" "}
|
||||
<code>email.notifications/</code>. Cada ejecución registra todas las filas
|
||||
(enviado, fallido, omitido) en <code>email_notification_log</code>.
|
||||
</p>
|
||||
|
||||
{!allowed && (
|
||||
<div className="empty-inline">
|
||||
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
|
||||
registro. Para disparar envíos pide a un MANAGER/ADMIN.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Ejecutar ahora</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!allowed || busy !== null}
|
||||
onClick={() => void runAll()}
|
||||
>
|
||||
{busy === "all" ? "Ejecutando todos…" : "Ejecutar todos"}
|
||||
</button>
|
||||
<span className="muted small">
|
||||
Dispara los cuatro envíos en orden (pagos pendientes, confirmación
|
||||
de pago, estado de cuenta, fideicomiso) con los flags de arriba. Si
|
||||
uno falla, los demás continúan. Es lo mismo que ejecuta la corrida
|
||||
programada de Servicios.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{JOBS.map((j) => (
|
||||
<article
|
||||
key={j.kind}
|
||||
className="card"
|
||||
style={{ padding: 18, display: "grid", gap: 8, alignContent: "start" }}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<strong>{j.title}</strong>
|
||||
<span
|
||||
className={
|
||||
j.servicio === "Fideicomiso"
|
||||
? "badge badge-fideicomiso"
|
||||
: "badge badge-servicios"
|
||||
}
|
||||
>
|
||||
<span className="dot" />
|
||||
{j.servicio}
|
||||
</span>
|
||||
</header>
|
||||
<p className="muted small" style={{ margin: 0 }}>
|
||||
{j.description}
|
||||
</p>
|
||||
{j.flagsHint && (
|
||||
<p className="section-note" style={{ margin: 0, fontStyle: "italic" }}>
|
||||
{j.flagsHint}
|
||||
</p>
|
||||
)}
|
||||
<div className="row-actions" style={{ marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!allowed || busy !== null}
|
||||
onClick={() => void run(j)}
|
||||
>
|
||||
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{stats && (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Estado del transporte</h2>
|
||||
<ul className="small" style={{ marginTop: 10, marginBottom: 0, paddingLeft: 18 }}>
|
||||
<li>
|
||||
SES configurado:{" "}
|
||||
<strong
|
||||
style={{
|
||||
color: stats.transport.available
|
||||
? "var(--positive)"
|
||||
: "var(--negative)",
|
||||
}}
|
||||
>
|
||||
{stats.transport.available ? "sí" : "no"}
|
||||
</strong>
|
||||
{stats.transport.devFallback && " (fallback dev: stdout)"}
|
||||
</li>
|
||||
<li>
|
||||
Último envío registrado:{" "}
|
||||
{stats.lastRun
|
||||
? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]} — ${formatDateTime(stats.lastRun.sendDate)}`
|
||||
: "—"}
|
||||
</li>
|
||||
<li>
|
||||
Totales:{" "}
|
||||
{stats.byStatus.map((s) => (
|
||||
<span key={s.status} style={{ marginRight: 12 }}>
|
||||
{NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all}
|
||||
</span>
|
||||
))}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AdminEmailsSetting />
|
||||
|
||||
{lastResult && (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Última respuesta</h2>
|
||||
{lastResult.type === "RUN_ALL" && (
|
||||
<ul
|
||||
className="small"
|
||||
style={{ marginTop: 10, marginBottom: 0, paddingLeft: 18 }}
|
||||
>
|
||||
<li>
|
||||
Totales: enviados {lastResult.sent} · omitidos{" "}
|
||||
{lastResult.skipped} · fallidos {lastResult.failed}
|
||||
{lastResult.errors > 0 && ` · jobs con error ${lastResult.errors}`}
|
||||
</li>
|
||||
{lastResult.jobs.map((j) => (
|
||||
<li key={j.kind}>
|
||||
{JOB_TITLES[j.kind]}:{" "}
|
||||
{j.ok && j.result ? (
|
||||
<>
|
||||
enviados {j.result.sent} · omitidos {j.result.skipped} ·
|
||||
fallidos {j.result.failed}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "var(--negative)" }}>
|
||||
error — {j.error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<pre
|
||||
className="mono"
|
||||
style={{
|
||||
margin: "10px 0 0",
|
||||
fontSize: 12,
|
||||
overflow: "auto",
|
||||
background: "var(--surface-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(lastResult, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<NotificationLogPanel
|
||||
servicio={SERVICIOS_LOG_SCOPE}
|
||||
reloadToken={logToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import type { NotificationFlags } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* The "Flags del envío" panel. It lives in the /notificaciones shell above the
|
||||
* tabs, not inside one of them, because the flags are platform-wide: `debug`
|
||||
* governs the pólizas avisos exactly as it governs the four servicios jobs,
|
||||
* and a switch that only protected half the screen was the bug this fixes.
|
||||
*
|
||||
* State is per-visit, never persisted — see the note on the schedule card. A
|
||||
* stored `debug` would survive a reload and silently swallow real customer
|
||||
* mail; the automatic corridas therefore always send for real.
|
||||
*/
|
||||
|
||||
export function NotificationFlagsCard({
|
||||
flags,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
flags: NotificationFlags;
|
||||
onChange: (next: NotificationFlags) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const set = (patch: Partial<NotificationFlags>) =>
|
||||
onChange({ ...flags, ...patch });
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Flags del envío</h2>
|
||||
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 620 }}>
|
||||
Se aplican a todo lo que se envía desde esta pantalla — servicios y
|
||||
pólizas — y solo a los envíos manuales. Las corridas automáticas siempre
|
||||
mandan de verdad.
|
||||
</p>
|
||||
<div style={{ display: "grid", gap: 4, marginTop: 14 }}>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.debug}
|
||||
disabled={disabled}
|
||||
onChange={(e) => set({ debug: e.target.checked })}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>debug</strong> — reescribe todos los destinatarios a{" "}
|
||||
<code>rmancinas@freakma.net</code>. Ningún cliente real recibe el
|
||||
correo mientras esté activo. Un aviso de renovación enviado en debug
|
||||
NO se marca como enviado: sigue pendiente en la lista.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.ignoreDayRestriction}
|
||||
disabled={disabled}
|
||||
onChange={(e) => set({ ignoreDayRestriction: e.target.checked })}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>ignoreDayRestriction</strong> — salta los gates de
|
||||
Mon/Wed/Fri del estado de cuenta. Útil para disparar en cualquier
|
||||
día sin esperar a la próxima corrida. Solo aplica a servicios.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 0 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.useEmailLimit}
|
||||
disabled={disabled}
|
||||
onChange={(e) => set({ useEmailLimit: e.target.checked })}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>useEmailLimit</strong> — pausa el estado de cuenta cada 100
|
||||
correos durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
||||
Solo aplica a servicios.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
listNotificationLog,
|
||||
type NotificationLogPage,
|
||||
type NotificationServicio,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
formatDateTime,
|
||||
NOTIFICATION_SERVICIO_LABELS,
|
||||
NOTIFICATION_STATUS_COLORS,
|
||||
NOTIFICATION_STATUS_LABELS,
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
notificationLevelLabel,
|
||||
} from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* "Registro de envíos" — the send history over `email_notification_log`.
|
||||
*
|
||||
* Every outbound email the platform sends writes to that one table (the four
|
||||
* bulk jobs and the renewal avisos alike), so this component is shared by
|
||||
* both /notificaciones tabs; each passes the `servicio` slice it owns. Rows
|
||||
* cover failures and skips too, which is the whole point: a notice that never
|
||||
* left is invisible everywhere else.
|
||||
*/
|
||||
|
||||
const LOG_VIEWS = [
|
||||
{ key: "all", label: "Todos" },
|
||||
{ key: "sent", label: "Enviados" },
|
||||
{ key: "failed", label: "Fallidos" },
|
||||
{ key: "skipped", label: "Omitidos" },
|
||||
] as const;
|
||||
|
||||
export type LogView = (typeof LOG_VIEWS)[number]["key"];
|
||||
|
||||
export function NotificationLogPanel({
|
||||
servicio,
|
||||
emptyHint = "Sin envíos con el filtro actual.",
|
||||
/** Bump to force a reload — the parent raises it after a send. */
|
||||
reloadToken = 0,
|
||||
}: {
|
||||
servicio: NotificationServicio[];
|
||||
emptyHint?: string;
|
||||
reloadToken?: number;
|
||||
}) {
|
||||
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<LogView>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// `servicio` is a literal array at every call site, so a new identity each
|
||||
// render would re-fetch forever. Key the effect on its contents instead.
|
||||
const servicioKey = servicio.join(",");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await listNotificationLog({
|
||||
page,
|
||||
pageSize: 50,
|
||||
servicio: servicioKey.split(",") as NotificationServicio[],
|
||||
view: view === "all" ? undefined : view,
|
||||
});
|
||||
setLog(data);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [page, view, servicioKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, reloadToken]);
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title">Registro de envíos</h2>
|
||||
<div className="seg" role="tablist">
|
||||
{LOG_VIEWS.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === v.key}
|
||||
className={`seg-btn ${view === v.key ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setView(v.key);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tx-scroll" style={{ marginTop: 12 }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Servicio</th>
|
||||
<th>Cliente</th>
|
||||
<th>Email</th>
|
||||
<th>Estado</th>
|
||||
<th>Asunto</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log?.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatDateTime(row.sendDate)}</td>
|
||||
<td>
|
||||
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
||||
{notificationLevelLabel(row.notificationType, row.level)}
|
||||
</td>
|
||||
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
||||
<td>
|
||||
{row.customerName}
|
||||
{row.debug ? " · debug" : ""}
|
||||
</td>
|
||||
<td>{row.customerEmail || "—"}</td>
|
||||
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
||||
{NOTIFICATION_STATUS_LABELS[row.status]}
|
||||
</td>
|
||||
<td>{row.subject}</td>
|
||||
<td className="muted small">
|
||||
{row.providerMessageId ?? row.error ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{log && log.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<span className="empty-inline">{emptyHint}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{log && log.pageCount > 1 && (
|
||||
<div className="pager" style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
|
||||
{log.pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page >= log.pageCount}
|
||||
onClick={() => setPage((p) => Math.min(log.pageCount, p + 1))}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
getNotificationSchedules,
|
||||
setNotificationSchedule,
|
||||
type NotificationSchedule,
|
||||
type NotificationSchedules,
|
||||
type ScheduleKind,
|
||||
} from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* When the two automatic envíos run.
|
||||
*
|
||||
* Both cadences used to be source code: pólizas barría a las 06:00 desde un
|
||||
* `@Cron` en el servidor y servicios no corría solo en absoluto. Cambiar
|
||||
* cualquiera de los dos era un redeploy. Ahora se guardan en `app_settings` y
|
||||
* el servidor reinstala el job al guardar — sin reinicio.
|
||||
*
|
||||
* Los flags de la tarjeta de arriba NO se aplican aquí: una corrida
|
||||
* automática siempre manda de verdad.
|
||||
*/
|
||||
|
||||
const KIND_LABEL: Record<ScheduleKind, string> = {
|
||||
servicios: "Servicios",
|
||||
polizas: "Pólizas",
|
||||
};
|
||||
|
||||
const KIND_HINT: Record<ScheduleKind, string> = {
|
||||
servicios:
|
||||
"Ejecuta los cuatro envíos en orden, igual que el botón «Ejecutar todos». El estado de cuenta sigue respetando sus gates de lunes/miércoles/viernes.",
|
||||
polizas:
|
||||
"Barrido de avisos de renovación: 30 y 15 días antes del vencimiento, y 7 días después.",
|
||||
};
|
||||
|
||||
const DAYS = [
|
||||
{ value: 0, label: "Dom" },
|
||||
{ value: 1, label: "Lun" },
|
||||
{ value: 2, label: "Mar" },
|
||||
{ value: 3, label: "Mié" },
|
||||
{ value: 4, label: "Jue" },
|
||||
{ value: 5, label: "Vie" },
|
||||
{ value: 6, label: "Sáb" },
|
||||
];
|
||||
|
||||
function timeValue(s: NotificationSchedule): string {
|
||||
return `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function describe(s: NotificationSchedule): string {
|
||||
if (!s.enabled) return "Desactivado — solo se envía manualmente.";
|
||||
const days = s.weekdays.length
|
||||
? s.weekdays
|
||||
.map((d) => DAYS.find((x) => x.value === d)?.label ?? d)
|
||||
.join(", ")
|
||||
: "todos los días";
|
||||
return `${days} a las ${timeValue(s)} (hora de Tijuana).`;
|
||||
}
|
||||
|
||||
export function NotificationScheduleCard() {
|
||||
const canEdit = useCan("setting:manage");
|
||||
|
||||
const [schedules, setSchedules] = useState<NotificationSchedules | null>(null);
|
||||
const [drafts, setDrafts] = useState<Partial<Record<ScheduleKind, NotificationSchedule>>>({});
|
||||
const [editing, setEditing] = useState<ScheduleKind | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState<ScheduleKind | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSchedules(await getNotificationSchedules());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function startEdit(kind: ScheduleKind) {
|
||||
if (!schedules) return;
|
||||
setDrafts((d) => ({ ...d, [kind]: { ...schedules[kind].value } }));
|
||||
setEditing(kind);
|
||||
setSaved(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function save(kind: ScheduleKind) {
|
||||
const draft = drafts[kind];
|
||||
if (!draft) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await setNotificationSchedule(kind, draft);
|
||||
setSchedules((prev) => (prev ? { ...prev, [kind]: result } : prev));
|
||||
setEditing(null);
|
||||
setSaved(kind);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!schedules) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Programación de envíos</h2>
|
||||
{error ? (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
Cargando…
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Programación de envíos</h2>
|
||||
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 660 }}>
|
||||
Cuándo corre solo cada envío. Los cambios aplican de inmediato, sin
|
||||
reiniciar el servidor. Una corrida automática nunca usa los flags de
|
||||
arriba: siempre manda a los clientes reales.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
||||
{(Object.keys(KIND_LABEL) as ScheduleKind[]).map((kind) => {
|
||||
const current = schedules[kind];
|
||||
const draft = drafts[kind];
|
||||
const isEditing = editing === kind && draft;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={kind}
|
||||
style={{
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{KIND_LABEL[kind]}</strong>
|
||||
<p className="muted small" style={{ margin: "4px 0 0", maxWidth: 560 }}>
|
||||
{KIND_HINT[kind]}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => startEdit(kind)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 10 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[kind]: { ...draft, enabled: e.target.checked },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>Corrida automática activada</strong>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="field" style={{ maxWidth: 160, marginBottom: 10 }}>
|
||||
<span className="field-label">Hora (Tijuana)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="time"
|
||||
value={timeValue(draft)}
|
||||
disabled={saving || !draft.enabled}
|
||||
onChange={(e) => {
|
||||
const [h, m] = e.target.value.split(":").map(Number);
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[kind]: {
|
||||
...draft,
|
||||
hour: Number.isFinite(h) ? h : draft.hour,
|
||||
minute: Number.isFinite(m) ? m : draft.minute,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="field" style={{ marginBottom: 10 }}>
|
||||
<span className="field-label">
|
||||
Días (ninguno seleccionado = todos los días)
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 4 }}>
|
||||
{DAYS.map((d) => {
|
||||
const on = draft.weekdays.includes(d.value);
|
||||
return (
|
||||
<button
|
||||
key={d.value}
|
||||
type="button"
|
||||
className={`btn btn-sm ${on ? "btn-primary" : "btn-outline"}`}
|
||||
disabled={saving || !draft.enabled}
|
||||
onClick={() =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[kind]: {
|
||||
...draft,
|
||||
weekdays: on
|
||||
? draft.weekdays.filter((x) => x !== d.value)
|
||||
: [...draft.weekdays, d.value].sort(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving}
|
||||
onClick={() => void save(kind)}
|
||||
>
|
||||
{saving ? "Guardando…" : "Guardar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
{describe(current.value)}
|
||||
</p>
|
||||
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
|
||||
<code>{current.cron}</code>
|
||||
{current.nextRun &&
|
||||
` · próxima corrida: ${formatDateTime(current.nextRun)}`}
|
||||
{current.source === "default" &&
|
||||
" · valor por omisión, nadie lo ha cambiado"}
|
||||
{current.updatedAt &&
|
||||
` · última edición: ${formatDateTime(current.updatedAt)}`}
|
||||
{saved === kind && " · guardado"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!canEdit && (
|
||||
<p className="section-note" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
Solo un ADMIN puede cambiar la programación.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const STATUS_LABEL: Record<PolicyOcrBatchStatus, string> = {
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Aplicado",
|
||||
FAILED: "Falló",
|
||||
DISCARDED: "Descartado",
|
||||
};
|
||||
|
||||
export function PolicyOcrIntake() {
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||
import {
|
||||
confirmPolicyOcrBatch,
|
||||
discardPolicyOcrBatch,
|
||||
getPolicyOcrBatch,
|
||||
listCustomers,
|
||||
listPolicyOcrDocuments,
|
||||
@@ -23,6 +25,8 @@ import type {
|
||||
PolicyOcrReviewInput,
|
||||
} from "@/lib/types";
|
||||
|
||||
/** Document and batch statuses share this map — the two enums have no
|
||||
* overlapping members, and the header renders a batch status through it. */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING_OCR: "Pendiente",
|
||||
OCR_FAILED: "Falló OCR",
|
||||
@@ -31,6 +35,12 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
CONFIRMED: "Confirmado",
|
||||
POSTED: "Aplicado",
|
||||
REJECTED: "Rechazado",
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando…",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Aplicado",
|
||||
FAILED: "Falló",
|
||||
DISCARDED: "Descartado",
|
||||
};
|
||||
|
||||
const OPEN_FIRST = [
|
||||
@@ -54,6 +64,7 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [discarding, setDiscarding] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -129,9 +140,33 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onDiscard() {
|
||||
if (!batch) return;
|
||||
setDiscarding(true);
|
||||
setError(null);
|
||||
try {
|
||||
await discardPolicyOcrBatch(batch.id);
|
||||
setEdits({});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo descartar el lote.");
|
||||
} finally {
|
||||
setDiscarding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-box">Cargando…</div>;
|
||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||
|
||||
const appliedCount = docs.filter((d) => d.status === "POSTED").length;
|
||||
// Discarding is only offered while the batch can still be abandoned whole:
|
||||
// nothing applied yet, and not already discarded.
|
||||
const canDiscard =
|
||||
canReview &&
|
||||
batch.status !== "DISCARDED" &&
|
||||
batch.status !== "COMPLETED" &&
|
||||
appliedCount === 0;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
@@ -175,6 +210,15 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canDiscard && (
|
||||
<DiscardBatchCard
|
||||
busy={discarding}
|
||||
onDiscard={onDiscard}
|
||||
pageCount={docs.length}
|
||||
what="póliza"
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="stack">
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow
|
||||
|
||||
@@ -45,6 +45,7 @@ const STATUS_LABEL: Record<StatementBatchStatus, string> = {
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Registrado",
|
||||
FAILED: "Falló",
|
||||
DISCARDED: "Descartado",
|
||||
};
|
||||
|
||||
export function StatementIntake() {
|
||||
|
||||
+310
-1
@@ -27,6 +27,7 @@ import type {
|
||||
CreateBankInput,
|
||||
CreateBankMovementInput,
|
||||
CreateMovementInput,
|
||||
DiscardBatchResult,
|
||||
UpdateBankAccountInput,
|
||||
ResolveOutstandingInput,
|
||||
ReviewDocumentInput,
|
||||
@@ -58,6 +59,7 @@ import type {
|
||||
LookupsResponse,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
ReplicationStatus,
|
||||
IngestFile,
|
||||
BackupFile,
|
||||
PropertyDetail,
|
||||
@@ -107,7 +109,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
@@ -938,6 +940,16 @@ export function deleteBackup(name: string): Promise<unknown> {
|
||||
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the read replica my.jorgecuadros.com serves customers from.
|
||||
*
|
||||
* A stopped replica does not error — it answers with stale balances — so this
|
||||
* is the only place the failure is visible.
|
||||
*/
|
||||
export function getReplicationStatus(): Promise<ReplicationStatus> {
|
||||
return apiFetch<ReplicationStatus>("/ops/replication");
|
||||
}
|
||||
|
||||
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||
}
|
||||
@@ -972,6 +984,293 @@ export function runReport(
|
||||
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Mass email notifications */
|
||||
|
||||
export type NotificationType =
|
||||
| "OUTSTANDING_PAYMENT"
|
||||
| "PAYMENT_CONFIRMATION"
|
||||
| "ACCOUNT_STATUS"
|
||||
| "TRUST_PAYMENT_CONFIRMATION"
|
||||
| "RENEWAL_NOTICE";
|
||||
|
||||
export type NotificationServicio = "CUSTOMERS" | "TRUST" | "POLICIES";
|
||||
|
||||
/** Which servicios each /notificaciones tab reads out of the shared log. */
|
||||
export const SERVICIOS_LOG_SCOPE: NotificationServicio[] = ["CUSTOMERS", "TRUST"];
|
||||
export const POLIZAS_LOG_SCOPE: NotificationServicio[] = ["POLICIES"];
|
||||
|
||||
export type NotificationStatus =
|
||||
| "SENT"
|
||||
| "FAILED"
|
||||
| "SKIPPED_NO_EMAIL"
|
||||
| "SKIPPED_GATE";
|
||||
|
||||
export interface NotificationLogRow {
|
||||
id: string;
|
||||
sendDate: string;
|
||||
notificationType: NotificationType;
|
||||
level: number | null;
|
||||
servicio: NotificationServicio;
|
||||
customerId: string | null;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
subject: string;
|
||||
debug: boolean;
|
||||
status: NotificationStatus;
|
||||
providerMessageId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationLogPage {
|
||||
items: NotificationLogRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface NotificationStats {
|
||||
byType: { notificationType: NotificationType; status: NotificationStatus; _count: { _all: number } }[];
|
||||
byStatus: { status: NotificationStatus; _count: { _all: number } }[];
|
||||
byServicio: { servicio: NotificationServicio; status: NotificationStatus; _count: { _all: number } }[];
|
||||
lastRun: { sendDate: string; notificationType: NotificationType } | null;
|
||||
transport: { available: boolean; devFallback: boolean };
|
||||
}
|
||||
|
||||
export type NotificationFlags = {
|
||||
debug?: boolean;
|
||||
ignoreDayRestriction?: boolean;
|
||||
useEmailLimit?: boolean;
|
||||
};
|
||||
|
||||
/** Job 1 (Outstanding) response — legacy `result` field. */
|
||||
export interface OutstandingResponse {
|
||||
result: "success";
|
||||
notificationType: "sendPaymentConfirmation";
|
||||
reason: string;
|
||||
statusCode: 200;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "OUTSTANDING_PAYMENT";
|
||||
}
|
||||
|
||||
/** Job 2 (Payment Confirmation) response. */
|
||||
export interface PaymentConfirmResponse {
|
||||
request: "success";
|
||||
notificationType: "sendPaymentConfirmation";
|
||||
confirmationSent: string;
|
||||
statusCode: 200;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "PAYMENT_CONFIRMATION";
|
||||
}
|
||||
|
||||
/** Job 3 (Account Status) response. */
|
||||
export interface AccountStatusResponse {
|
||||
request: "success";
|
||||
notificationType: "sendAccountStatus";
|
||||
statusSent: string;
|
||||
statusReport: string;
|
||||
statusCode: 200;
|
||||
red: number;
|
||||
yellow: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "ACCOUNT_STATUS";
|
||||
}
|
||||
|
||||
/** Job 4 (Trust Confirmation) response. */
|
||||
export interface TrustConfirmResponse {
|
||||
request: "success";
|
||||
notificationType: "sendTrustPaymentConfirmation";
|
||||
confirmationSent: string;
|
||||
statusCode: 200;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
debug: boolean;
|
||||
type: "TRUST_PAYMENT_CONFIRMATION";
|
||||
}
|
||||
|
||||
export type NotificationJobResponse =
|
||||
| OutstandingResponse
|
||||
| PaymentConfirmResponse
|
||||
| AccountStatusResponse
|
||||
| TrustConfirmResponse;
|
||||
|
||||
export function runOutstandingPayments(
|
||||
flags: NotificationFlags = {},
|
||||
): Promise<OutstandingResponse> {
|
||||
return apiFetch<OutstandingResponse>("/notifications/outstanding-payments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(flags),
|
||||
});
|
||||
}
|
||||
|
||||
export function runPaymentConfirmation(
|
||||
flags: NotificationFlags = {},
|
||||
): Promise<PaymentConfirmResponse> {
|
||||
return apiFetch<PaymentConfirmResponse>("/notifications/payment-confirmation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(flags),
|
||||
});
|
||||
}
|
||||
|
||||
export function runAccountStatus(
|
||||
flags: NotificationFlags = {},
|
||||
): Promise<AccountStatusResponse> {
|
||||
return apiFetch<AccountStatusResponse>("/notifications/account-status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(flags),
|
||||
});
|
||||
}
|
||||
|
||||
export function runTrustConfirmation(
|
||||
flags: NotificationFlags = {},
|
||||
): Promise<TrustConfirmResponse> {
|
||||
return apiFetch<TrustConfirmResponse>("/notifications/trust-payment-confirmation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(flags),
|
||||
});
|
||||
}
|
||||
|
||||
export type NotificationJobKind = "outstanding" | "payment" | "account" | "trust";
|
||||
|
||||
export interface NotificationRunAllJobResult {
|
||||
kind: NotificationJobKind;
|
||||
ok: boolean;
|
||||
result?: NotificationJobResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Aggregate response of the "Ejecutar todos" sweep. */
|
||||
export interface NotificationRunAllResponse {
|
||||
request: "success";
|
||||
notificationType: "runAllNotifications";
|
||||
statusCode: 200;
|
||||
debug: boolean;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: number;
|
||||
jobs: NotificationRunAllJobResult[];
|
||||
type: "RUN_ALL";
|
||||
}
|
||||
|
||||
export function runAllNotifications(
|
||||
flags: NotificationFlags = {},
|
||||
): Promise<NotificationRunAllResponse> {
|
||||
return apiFetch<NotificationRunAllResponse>("/notifications/run-all", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(flags),
|
||||
});
|
||||
}
|
||||
|
||||
export interface NotificationLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
type?: NotificationType;
|
||||
/** One or more servicios; omitted = the whole log. */
|
||||
servicio?: NotificationServicio[];
|
||||
status?: NotificationStatus;
|
||||
view?: "sent" | "failed" | "skipped" | "all";
|
||||
}
|
||||
|
||||
export function listNotificationLog(
|
||||
q: NotificationLogQuery = {},
|
||||
): Promise<NotificationLogPage> {
|
||||
const qs = new URLSearchParams();
|
||||
if (q.page) qs.set("page", String(q.page));
|
||||
if (q.pageSize) qs.set("pageSize", String(q.pageSize));
|
||||
if (q.type) qs.set("type", q.type);
|
||||
if (q.servicio?.length) qs.set("servicio", q.servicio.join(","));
|
||||
if (q.status) qs.set("status", q.status);
|
||||
if (q.view) qs.set("view", q.view);
|
||||
const tail = qs.toString();
|
||||
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`);
|
||||
}
|
||||
|
||||
/** Where a setting's current value came from — shown so an operator can tell
|
||||
* "nobody has set this, you are seeing the deploy's value" from "somebody
|
||||
* set this on purpose". */
|
||||
export type SettingSource = "db" | "env" | "default";
|
||||
|
||||
export interface NotificationAdminEmails {
|
||||
value: string[];
|
||||
source: SettingSource;
|
||||
updatedAt: string | null;
|
||||
updatedById: string | null;
|
||||
}
|
||||
|
||||
export function getNotificationAdminEmails(): Promise<NotificationAdminEmails> {
|
||||
return apiFetch<NotificationAdminEmails>("/notifications/settings/admin-emails");
|
||||
}
|
||||
|
||||
export function setNotificationAdminEmails(
|
||||
emails: string[],
|
||||
): Promise<NotificationAdminEmails> {
|
||||
return apiFetch<NotificationAdminEmails>("/notifications/settings/admin-emails", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ emails }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- envío scheduling */
|
||||
|
||||
/** The two automatic envíos, one per /notificaciones tab. */
|
||||
export type ScheduleKind = "servicios" | "polizas";
|
||||
|
||||
export interface NotificationSchedule {
|
||||
enabled: boolean;
|
||||
/** Local hour/minute in America/Tijuana. */
|
||||
hour: number;
|
||||
minute: number;
|
||||
/** 0 = domingo … 6 = sábado. Vacío = todos los días. */
|
||||
weekdays: number[];
|
||||
}
|
||||
|
||||
export interface ResolvedSchedule {
|
||||
value: NotificationSchedule;
|
||||
source: SettingSource;
|
||||
updatedAt: string | null;
|
||||
updatedById: string | null;
|
||||
/** Expression the value compiles to, shown verbatim in the UI. */
|
||||
cron: string;
|
||||
nextRun: string | null;
|
||||
}
|
||||
|
||||
export type NotificationSchedules = Record<ScheduleKind, ResolvedSchedule>;
|
||||
|
||||
export function getNotificationSchedules(): Promise<NotificationSchedules> {
|
||||
return apiFetch<NotificationSchedules>("/notifications/settings/schedule");
|
||||
}
|
||||
|
||||
export function setNotificationSchedule(
|
||||
kind: ScheduleKind,
|
||||
schedule: NotificationSchedule,
|
||||
): Promise<ResolvedSchedule> {
|
||||
return apiFetch<ResolvedSchedule>(`/notifications/settings/schedule/${kind}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(schedule),
|
||||
});
|
||||
}
|
||||
|
||||
export function getNotificationStats(
|
||||
servicio?: NotificationServicio[],
|
||||
): Promise<NotificationStats> {
|
||||
const tail = servicio?.length
|
||||
? `?servicio=${encodeURIComponent(servicio.join(","))}`
|
||||
: "";
|
||||
return apiFetch<NotificationStats>(`/notifications/stats${tail}`);
|
||||
}
|
||||
|
||||
/** Build a download URL for a report's file output. The session cookie
|
||||
* travels with the browser's same-origin navigation, so a plain `href`
|
||||
* is enough — no fetch-with-credentials dance. */
|
||||
@@ -1079,6 +1378,11 @@ export function confirmStatementBatch(
|
||||
});
|
||||
}
|
||||
|
||||
/** Abandon a batch pending review; rejects every page that is not posted. */
|
||||
export function discardStatementBatch(batchId: string): Promise<DiscardBatchResult> {
|
||||
return apiFetch(`/statements/batches/${batchId}/discard`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** The rendered page image. A plain <img src> — the cookie rides along. */
|
||||
export function statementPageUrl(documentId: string): string {
|
||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||
@@ -1168,6 +1472,11 @@ export function confirmPolicyOcrBatch(
|
||||
});
|
||||
}
|
||||
|
||||
/** Abandon a batch pending review; rejects every page that is not applied. */
|
||||
export function discardPolicyOcrBatch(batchId: string): Promise<DiscardBatchResult> {
|
||||
return apiFetch(`/policy-ocr/batches/${batchId}/discard`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* URL for the source PDF of a parsed policy document. The endpoint returns
|
||||
* the original upload (one PDF = one parsed policy), not a rendered page
|
||||
|
||||
@@ -379,3 +379,59 @@ export function sourceSystemLabel(source: string): string {
|
||||
};
|
||||
return map[source] ?? source;
|
||||
}
|
||||
|
||||
// ----- Mass email notifications -----
|
||||
|
||||
import type {
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
NotificationServicio,
|
||||
} from "./api";
|
||||
|
||||
export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
|
||||
OUTSTANDING_PAYMENT: "Pagos pendientes",
|
||||
PAYMENT_CONFIRMATION: "Confirmación de pago",
|
||||
ACCOUNT_STATUS: "Estado de cuenta",
|
||||
TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso",
|
||||
RENEWAL_NOTICE: "Aviso de renovación",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = {
|
||||
CUSTOMERS: "Clientes",
|
||||
TRUST: "Fideicomiso",
|
||||
POLICIES: "Pólizas",
|
||||
};
|
||||
|
||||
/**
|
||||
* The `level` column means something different per notification type, so it
|
||||
* can only be read alongside one. ACCOUNT_STATUS uses it for the alert colour;
|
||||
* RENEWAL_NOTICE for the aviso generation. Everything else leaves it null.
|
||||
*/
|
||||
export function notificationLevelLabel(
|
||||
type: NotificationType,
|
||||
level: number | null,
|
||||
): string {
|
||||
if (level === null) return "";
|
||||
if (type === "ACCOUNT_STATUS") return level === 0 ? " (amarilla)" : " (roja)";
|
||||
if (type === "RENEWAL_NOTICE") {
|
||||
if (level === 1) return " (1.º, 30 días antes)";
|
||||
if (level === 2) return " (2.º, 15 días antes)";
|
||||
if (level === 3) return " (3.º, 7 días después)";
|
||||
return ` (aviso ${level})`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
|
||||
SENT: "Enviado",
|
||||
FAILED: "Falló",
|
||||
SKIPPED_NO_EMAIL: "Sin email",
|
||||
SKIPPED_GATE: "Fuera de día",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_STATUS_COLORS: Record<NotificationStatus, string> = {
|
||||
SENT: "var(--positive)",
|
||||
FAILED: "var(--negative)",
|
||||
SKIPPED_NO_EMAIL: "var(--muted)",
|
||||
SKIPPED_GATE: "var(--muted-2)",
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export type Ability =
|
||||
| "policy:delete"
|
||||
| "policy:ingest"
|
||||
| "policy:ocr-review"
|
||||
| "renewal:send"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
@@ -26,7 +27,9 @@ export type Ability =
|
||||
| "statement:review"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
| "db:manage"
|
||||
| "notification:send"
|
||||
| "setting:manage";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -58,6 +61,14 @@ export interface UserRow {
|
||||
export type OpsJobKind = "BACKUP" | "RESTORE" | "REIMPORT" | "SYNC";
|
||||
export type OpsJobStatus = "RUNNING" | "SUCCESS" | "FAILED";
|
||||
|
||||
/** Derived from the job log by the API; null for jobs with no step markers. */
|
||||
export interface JobProgress {
|
||||
step: number;
|
||||
total: number;
|
||||
name: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface OpsJob {
|
||||
id: string;
|
||||
kind: OpsJobKind;
|
||||
@@ -67,6 +78,51 @@ export interface OpsJob {
|
||||
createdById: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
/** Only present on getOpsJob (the polled endpoint), not on the list. */
|
||||
progress?: JobProgress | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the MySQL read replica that my.jorgecuadros.com queries.
|
||||
*
|
||||
* `secondsBehind` is null whenever MySQL reports NULL, which it does when
|
||||
* EITHER thread is down — so null means "unknown", never "up to date". Read
|
||||
* `healthy`/`problem` rather than inferring health from the lag.
|
||||
*/
|
||||
export interface ReplicationStatus {
|
||||
configured: boolean;
|
||||
healthy: boolean;
|
||||
host: string | null;
|
||||
ioRunning: string | null;
|
||||
sqlRunning: string | null;
|
||||
secondsBehind: number | null;
|
||||
lastIoError: string | null;
|
||||
lastSqlError: string | null;
|
||||
sourceHost: string | null;
|
||||
apply: ApplyProgress | null;
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay-log apply progress, in source binlog bytes.
|
||||
*
|
||||
* Answers "is it moving?" when `secondsBehind` cannot: the lag counter sits
|
||||
* still while the SQL thread works through one large transaction, but the
|
||||
* backlog visibly shrinks. `backlogBytes === 0` is the only reading that means
|
||||
* caught up — `percent` deliberately stops at 99.99 while bytes remain.
|
||||
*
|
||||
* Null fields when the two threads are on different source binlog files
|
||||
* (`sameFile === false`), because the positions are then not comparable.
|
||||
*/
|
||||
export interface ApplyProgress {
|
||||
sourceLogFile: string | null;
|
||||
readPos: number;
|
||||
relayLogFile: string | null;
|
||||
execPos: number;
|
||||
sameFile: boolean;
|
||||
backlogBytes: number | null;
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
/** One of the four legacy Access files expected in the ingest folder. */
|
||||
@@ -963,6 +1019,7 @@ export interface CustomerDetail {
|
||||
mobile: string | null;
|
||||
fax: string | null;
|
||||
email: string | null;
|
||||
emailOptOut: boolean;
|
||||
notes: string | null;
|
||||
identificationType: string | null;
|
||||
identificationNumber: string | null;
|
||||
@@ -993,6 +1050,7 @@ export interface CustomerInput {
|
||||
mobile?: string;
|
||||
fax?: string;
|
||||
email?: string;
|
||||
emailOptOut?: boolean;
|
||||
notes?: string;
|
||||
identificationType?: string;
|
||||
identificationNumber?: string;
|
||||
@@ -1217,7 +1275,9 @@ export type StatementBatchStatus =
|
||||
| "PROCESSING"
|
||||
| "READY_FOR_REVIEW"
|
||||
| "COMPLETED"
|
||||
| "FAILED";
|
||||
| "FAILED"
|
||||
/** Abandoned by staff before anything was posted. */
|
||||
| "DISCARDED";
|
||||
|
||||
export type StatementDocumentStatus =
|
||||
| "PENDING_OCR"
|
||||
@@ -1296,6 +1356,12 @@ export interface ConfirmBatchResult {
|
||||
checkNumber: string;
|
||||
}
|
||||
|
||||
/** Shared by both OCR domains: how many pages the discard rejected. */
|
||||
export interface DiscardBatchResult {
|
||||
batchId: string;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ Policy OCR intake (GMX) */
|
||||
|
||||
export type PolicyOcrBatchStatus =
|
||||
@@ -1303,7 +1369,9 @@ export type PolicyOcrBatchStatus =
|
||||
| "PROCESSING"
|
||||
| "READY_FOR_REVIEW"
|
||||
| "COMPLETED"
|
||||
| "FAILED";
|
||||
| "FAILED"
|
||||
/** Abandoned by staff before anything was applied. */
|
||||
| "DISCARDED";
|
||||
|
||||
export type PolicyOcrDocumentStatus =
|
||||
| "PENDING_OCR"
|
||||
|
||||
@@ -69,10 +69,37 @@ services:
|
||||
# apps/api/src/ops/ops.service.ts.
|
||||
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
|
||||
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
|
||||
# Read-only replica that my.jorgecuadros.com serves customers from. Used
|
||||
# ONLY to report health on the Operaciones screen — the account holds
|
||||
# REPLICATION CLIENT and nothing else, so it cannot read a single row.
|
||||
# Unset is a supported state: the panel then says "no configurada"
|
||||
# instead of erroring, which is correct before cutover and in dev.
|
||||
REPLICA_DB_HOST: ${REPLICA_DB_HOST:-}
|
||||
REPLICA_DB_USER: ${REPLICA_DB_USER:-}
|
||||
REPLICA_DB_PASS: ${REPLICA_DB_PASS:-}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
||||
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
# Outbound mail (SES). Runtime config — read at container boot, never
|
||||
# baked into the image; the build does not send mail, this container
|
||||
# does. Values arrive the same way DATABASE_URL does: as Gitea repo
|
||||
# secrets, injected into this stack's env by the `env_data` block of
|
||||
# .gitea/workflows/deploy-galactus.yml.
|
||||
#
|
||||
# The image sets NODE_ENV=production, which disables MailService's
|
||||
# stdout dev fallback: leave these blank and every notification and
|
||||
# renewal aviso fails with "El envío de correo no está configurado."
|
||||
# rather than silently going nowhere.
|
||||
SES_REGION: ${SES_REGION:-}
|
||||
SES_FROM: ${SES_FROM:-}
|
||||
SES_FROM_NAME: ${SES_FROM_NAME:-}
|
||||
SES_ACCESS_KEY: ${SES_ACCESS_KEY:-}
|
||||
SES_SECRET_KEY: ${SES_SECRET_KEY:-}
|
||||
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-}
|
||||
# Who gets the per-job summary mail. Falls back to the two hardcoded
|
||||
# defaults in NotificationsService when unset.
|
||||
NOTIFICATION_ADMIN_EMAILS: ${NOTIFICATION_ADMIN_EMAILS:-}
|
||||
ports:
|
||||
- "${API_PORT:-3001}:3001"
|
||||
volumes:
|
||||
|
||||
@@ -32,3 +32,32 @@ S3_ENDPOINT=http://192.168.4.212:9000
|
||||
S3_BUCKET=jorgecuadros-documents
|
||||
MINIO_ROOT_USER=jc_minio
|
||||
MINIO_ROOT_PASSWORD=CHANGE_ME
|
||||
|
||||
# --- Outbound mail (Amazon SES) ----------------------------------------------
|
||||
# NOTE: for the Portainer-deployed stacks these do NOT come from a file on the
|
||||
# host — the deploy workflows build the stack env from Gitea repo secrets (see
|
||||
# the `env_data` blocks in .gitea/workflows/deploy*.yml). This file documents
|
||||
# the full variable set and is what you fill in for a hand-run stack.
|
||||
#
|
||||
# Either way they are RUNTIME config, read at container boot
|
||||
# (apps/api/src/mail/mail.service.ts) — never baked into the image.
|
||||
#
|
||||
# The production image sets NODE_ENV=production, which turns OFF the stdout dev
|
||||
# fallback. Leaving these blank does not silently swallow mail — every send
|
||||
# fails with "El envío de correo no está configurado.", and the failure is
|
||||
# recorded in the notification log. Fill them in before enabling any envío.
|
||||
#
|
||||
# SES_FROM must be a verified SES sending identity.
|
||||
SES_REGION=us-west-2
|
||||
SES_FROM=mail@jorgecuadros.com
|
||||
SES_FROM_NAME=Information Server
|
||||
SES_ACCESS_KEY=
|
||||
SES_SECRET_KEY=
|
||||
# Optional — only needed to publish bounce/complaint events.
|
||||
SES_CONFIGURATION_SET=
|
||||
|
||||
# Recipients of the per-job summary email. NOW EDITABLE IN THE UI
|
||||
# (/notificaciones > Servicios > "Destinatarios del resumen", ADMIN only), so
|
||||
# this is only the fallback for a deployment where nobody has set it there.
|
||||
# A saved value takes precedence and this var is ignored from then on.
|
||||
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net
|
||||
|
||||
@@ -55,6 +55,14 @@ services:
|
||||
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
# Outbound mail (SES) — runtime config, not a build-time CI secret.
|
||||
SES_REGION: ${SES_REGION:-}
|
||||
SES_FROM: ${SES_FROM:-}
|
||||
SES_FROM_NAME: ${SES_FROM_NAME:-}
|
||||
SES_ACCESS_KEY: ${SES_ACCESS_KEY:-}
|
||||
SES_SECRET_KEY: ${SES_SECRET_KEY:-}
|
||||
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-}
|
||||
NOTIFICATION_ADMIN_EMAILS: ${NOTIFICATION_ADMIN_EMAILS:-}
|
||||
ports:
|
||||
- target: 3001
|
||||
published: ${API_PORT:-3001}
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Is the my.jorgecuadros.com read replica actually replicating?
|
||||
#
|
||||
# deploy/scripts/check-replication.sh
|
||||
#
|
||||
# Answers it from the REPLICA alone, so it needs no credentials for the
|
||||
# galactus master — only ssh to the VPS. Exits non-zero when replication is
|
||||
# broken or lagging, so it is usable from cron or a monitor.
|
||||
#
|
||||
# Why not just eyeball `SHOW REPLICA STATUS`: the two obvious fields are both
|
||||
# misleading on their own.
|
||||
#
|
||||
# * "Replica_IO_Running: Yes" only means the network thread is alive. The SQL
|
||||
# thread can be stopped with a duplicate-key error while IO keeps happily
|
||||
# downloading binlog, so the replica looks busy and falls further behind.
|
||||
#
|
||||
# * "Seconds_Behind_Source: 0" reads 0 both when there is genuinely nothing
|
||||
# to apply AND when the IO thread is disconnected — there is no event to
|
||||
# measure staleness against, so absence of work is reported as being current.
|
||||
#
|
||||
# The trustworthy check is GTID_SUBTRACT(Retrieved, Executed): binlog we have
|
||||
# fetched but not yet applied. Empty means genuinely caught up.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPLICA_HOST="${REPLICA_HOST:-opc@163.192.62.37}"
|
||||
MAX_LAG="${MAX_LAG:-30}"
|
||||
|
||||
raw=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$REPLICA_HOST" \
|
||||
'sudo mysql -e "SHOW REPLICA STATUS\G"' 2>/dev/null)
|
||||
|
||||
if [ -z "$raw" ]; then
|
||||
echo "FAIL: could not reach $REPLICA_HOST or mysql returned nothing"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# sed rather than `head -n1`: on some machines `head` is shadowed by LWP's
|
||||
# HTTP head(1), which silently mangles the pipeline instead of erroring.
|
||||
field() { printf '%s\n' "$raw" | grep -E "^[[:space:]]*$1:" | sed -n '1p' | sed -E "s/^[[:space:]]*$1:[[:space:]]*//"; }
|
||||
|
||||
io=$(field Replica_IO_Running)
|
||||
sql=$(field Replica_SQL_Running)
|
||||
lag=$(field Seconds_Behind_Source)
|
||||
io_err=$(field Last_IO_Error)
|
||||
sql_err=$(field Last_SQL_Error)
|
||||
|
||||
# The authoritative "am I caught up" test: anything fetched but not applied.
|
||||
backlog=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$REPLICA_HOST" \
|
||||
'sudo mysql -NB -e "
|
||||
SELECT IFNULL(NULLIF(GTID_SUBTRACT(
|
||||
(SELECT RECEIVED_TRANSACTION_SET FROM performance_schema.replication_connection_status),
|
||||
@@GLOBAL.gtid_executed), \"\"), \"(none)\")" 2>/dev/null' 2>/dev/null)
|
||||
[ -z "$backlog" ] && backlog="(performance_schema off — using lag only)"
|
||||
|
||||
echo "replica : $REPLICA_HOST"
|
||||
echo "IO thread : $io"
|
||||
echo "SQL thread : $sql"
|
||||
if [ "$lag" = "NULL" ] || [ -z "$lag" ]; then
|
||||
echo "lag : NULL"
|
||||
else
|
||||
echo "lag : ${lag}s"
|
||||
fi
|
||||
echo "unapplied : $backlog"
|
||||
[ -n "$io_err" ] && echo "IO error : $io_err"
|
||||
[ -n "$sql_err" ] && echo "SQL error : $sql_err"
|
||||
|
||||
rc=0
|
||||
[ "$io" = "Yes" ] || { echo "FAIL: IO thread not running"; rc=1; }
|
||||
[ "$sql" = "Yes" ] || { echo "FAIL: SQL thread not running"; rc=1; }
|
||||
[ -n "$io_err" ] && { rc=1; }
|
||||
[ -n "$sql_err" ] && { rc=1; }
|
||||
# SHOW reports NULL lag whenever EITHER thread is down — there is no applied
|
||||
# event to measure against. Never report which one from the lag alone; the
|
||||
# thread fields above already said, and guessing produces a wrong diagnosis.
|
||||
if [ "$lag" = "NULL" ] || [ -z "$lag" ]; then
|
||||
echo "FAIL: lag is NULL (replication not applying)"
|
||||
rc=1
|
||||
elif [ "$lag" -gt "$MAX_LAG" ] 2>/dev/null; then
|
||||
echo "WARN: lag ${lag}s exceeds ${MAX_LAG}s"
|
||||
rc=1
|
||||
fi
|
||||
|
||||
[ $rc -eq 0 ] && echo "OK: replica is running and caught up"
|
||||
exit $rc
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# Backlog — what is pending, missing, and not yet built
|
||||
|
||||
One place for work that is known-outstanding. Compiled 2026-08-02 from
|
||||
`PLAN.md`, `RESUME.md`, the four specs and the two OCR docs, then **checked
|
||||
against the code and the dev database** rather than trusted — several items in
|
||||
those documents had already been closed, and two defects they describe are
|
||||
still live.
|
||||
|
||||
This file is an index, not a replacement. Each item points at the document that
|
||||
carries the reasoning. Close an item *there* as well as here, or the two drift.
|
||||
|
||||
**Verified against dev at compile time** (re-run before trusting the numbers):
|
||||
|
||||
```
|
||||
policy_types: AUTO, LICENCIAS, MULT
|
||||
policies NULL policyTypeId: 5
|
||||
policies pending liquidación: 226
|
||||
customers: 1536
|
||||
last tag: v1.0.6 (2026-08-02 02:06 UTC) — 14 commits, 5 migrations behind HEAD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Ship-blocked — read before the next deploy
|
||||
|
||||
**Everything from the notificaciones arc is unreleased.** `v1.0.6` predates it.
|
||||
Five migrations are waiting:
|
||||
|
||||
```
|
||||
20260801120000_ocr_batch_discarded
|
||||
20260801130000_renewal_email_notifications
|
||||
20260801200000_mass_email_notifications
|
||||
20260802120000_renewal_notices_unified_log
|
||||
20260802140000_app_settings
|
||||
```
|
||||
|
||||
Plus three backup-pipeline fixes that have never reached prod (`860d483`,
|
||||
`567b033`, `898cf48` — the last prod run went green through the whole chain and
|
||||
died on the final step wanting `deploy/.env.prod`).
|
||||
|
||||
> ### `SES_*` secrets created in Gitea 2026-08-02 — unblocked, unverified
|
||||
>
|
||||
> The variables were wired through both deploy workflows and the app stack but
|
||||
> had never been set. **They now exist.** What that clears: the production
|
||||
> image runs `NODE_ENV=production`, which disables the stdout dev fallback, so
|
||||
> a blank config made every send fail and log `FAILED` — and the pólizas sweep
|
||||
> defaults to **enabled, 06:00 America/Tijuana**, so the failure would have
|
||||
> repeated nightly.
|
||||
>
|
||||
> **Not yet confirmed, and the first deploy is what confirms it:**
|
||||
>
|
||||
> 1. **Names match.** The preflight checks `SES_REGION`, `SES_FROM`,
|
||||
> `SES_ACCESS_KEY`, `SES_SECRET_KEY` and warns by name if any is blank —
|
||||
> read that warning on the next run. No `_GALACTUS` suffix on any of them;
|
||||
> one SES identity serves every deployment.
|
||||
> 2. **`SES_FROM` is a verified identity in `SES_REGION`.** An unverified
|
||||
> sender is rejected per-send, which looks identical to a missing config in
|
||||
> the log.
|
||||
> 3. **The AWS account is out of the SES sandbox.** This is the one that would
|
||||
> hurt: in sandbox, SES only delivers to *verified* recipients, so a renewal
|
||||
> sweep across 815 policyholders would fail almost every send while the
|
||||
> config looks entirely correct. Check before letting a real sweep run.
|
||||
>
|
||||
> Until 2 and 3 are confirmed, run the first sweep with `debug` on — it diverts
|
||||
> every recipient to the override inbox, and on the pólizas side it also leaves
|
||||
> the avisos pending, so nothing is consumed by a failed test. See
|
||||
> [`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md) "Send flags".
|
||||
|
||||
Also outstanding on the deploy path: every pre-existing database still needs
|
||||
its one-time `prisma migrate resolve --applied 0000_init`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Blocked on a decision from Jorge
|
||||
|
||||
Nothing here is a build problem. Each one makes the work either impossible or a
|
||||
guess.
|
||||
|
||||
| # | Question | Blocks | Source |
|
||||
|---|---|---|---|
|
||||
| 1.1 | What "garantías" refers to | §2 liquidación's exclusion filter | INSURANCE §2 |
|
||||
| 1.2 | What "Solicitud Atlas" is — application form or certificate | §3 entirely | INSURANCE §3 |
|
||||
| 1.3 | Carrier API **direction**: outbound quote/issue (ANA supports today) or inbound portfolio sync (no evidence either carrier offers it) | whether §4 is buildable at all | INSURANCE §4 |
|
||||
| 1.4 | CFE amount: the rounded barcode figure (`$268`, what is paid at the window) or the exact breakdown total (`$268.88`) | the parser currently takes the barcode | STATEMENT_OCR / RECEIPT §2 |
|
||||
| 1.5 | The Seguros USD bank's name, currency and details | multi-bank is built; that account does not exist yet | RECEIPT §3 |
|
||||
| 1.6 | Recycling triggers — exact "1 year inactive" / "cancelled" definitions, and whether recycling ever means true data purge | §4 recycling | RECEIPT §4 |
|
||||
| 1.7 | Notice body in Spanish or English | `Customer` carries no language preference | INSURANCE §1 |
|
||||
| 1.8 | How to model `TRASPASOS PAYPAL` — a clearing account, not a customer, carrying −7.03M MXN over 309 movements and therefore topping the adeudo worklist | deliberately not special-cased in code | RESUME §6 |
|
||||
| 1.9 | The 78 policyholders with no email — skip silently or produce a print worklist | recommendation is the worklist | INSURANCE §1 |
|
||||
|
||||
1.3 also needs the practical half: WSDL + credentials for
|
||||
`server.anaseguros.com.mx/ananetws/service.asmx`, whether a cartera download
|
||||
exists for an agent's own book, whether GMX daños has any machine interface at
|
||||
all, and whether one Grupo Valore credential spans both carriers. All four go
|
||||
in the same phone call — (55) 5480-4000.
|
||||
|
||||
---
|
||||
|
||||
## 2. Live data defects — open, and confirmed open today
|
||||
|
||||
### 2.1 `policy_types` is missing `INCENDIO` and `M_EMPR`, and 5 policies are orphaned
|
||||
|
||||
`policyTypeId` is `String?` with a plain relation, so Prisma's default is
|
||||
`SetNull`. The spec's recommended `onDelete: Restrict` was **never applied**.
|
||||
Five `m_empr` policies lost their ramo; four of them are pending liquidación
|
||||
and are invisible to every ramo-filtered query — including the pending report
|
||||
§2 is supposed to produce.
|
||||
|
||||
Fix alongside the liquidación work (3.1), since it distorts that feature's own
|
||||
report. Source: INSURANCE "Two defects found while verifying this spec".
|
||||
|
||||
### 2.2 ≤41 MULT second settlements were dropped in migration
|
||||
|
||||
`MULT`/`INCENDIO` carry two settlement slots and `M EMPR` carries four; `Policy`
|
||||
collapses to one. Spec recommends moving settlement onto
|
||||
`PolicyPaymentInstallment` rather than adding a second slot. Open sub-question:
|
||||
whether to backfill the lost rows.
|
||||
|
||||
### 2.3 Three dead tables
|
||||
|
||||
`EmailTemplate`, `EmailCampaign` and `EmailLog` exist in the schema with
|
||||
**zero references anywhere in `apps/api/src` or `apps/web/src`**. They were
|
||||
scaffolded for plan step 10's "email campaigns"; notificaciones shipped against
|
||||
`email_notification_log` instead. Either wire them or drop them — a schema that
|
||||
carries tables nothing writes teaches the next reader the wrong thing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Spec'd, not built
|
||||
|
||||
| # | Item | State | Source |
|
||||
|---|---|---|---|
|
||||
| 3.1 | **Liquidación batch workflow** | ~70% of the fields already wired end to end. **226 policies pending.** Needs the ramo-parameterized pending report + `POST /policies/liquidate-batch` under a new `policy:liquidate` (MANAGER). Smallest remaining piece of step 12 | INSURANCE §2 |
|
||||
| 3.2 | **Certificate rendering** | The report half is buildable now off the same `format: "letter"` machinery as `aviso-renovacion`. Portal delivery waits on steps 8/9. Whole section waits on 1.2 | INSURANCE §3 |
|
||||
| 3.3 | **Carrier API integration** | Blocked on 1.3. ANA's SOAP service is real with a known operation list; GMX publishes nothing machine-readable and writes the larger half of this book | INSURANCE §4 |
|
||||
| 3.4 | **Customer-number recycling** | Not started. `Customer.customerNumber` **does not exist in the schema**. Backfill needs care: ~140 utilities rows and all insurance-only customers carry synthetic `rownum_N`/`insrow_N` placeholders, not real `NUM id`s. Last unbuilt piece of step 11 | RECEIPT §4 |
|
||||
|
||||
Note 3.3 partly overlaps what [`POLICY_OCR.md`](POLICY_OCR.md) already does —
|
||||
an OCR path that turns a carrier PDF into a `Policy` row covers some of what
|
||||
the API was wanted for, and unlike the API it is not waiting on a phone call.
|
||||
|
||||
---
|
||||
|
||||
## 4. Roadmap steps with no spec
|
||||
|
||||
| # | Item | State |
|
||||
|---|---|---|
|
||||
| 4.1 | **Step 8 — VPS provisioning** | Not started. Provider undecided (Hetzner vs DigitalOcean), size, Tailscale + MySQL replica. Pure ops; the design is settled. RESUME calls this *the only genuinely blocking item left on the roadmap* |
|
||||
| 4.2 | **Step 9 — sync worker** | Not built. Unblocked now that `utility_dbo` and the portal code are on disk, but depends on 4.1. Portal write points to poll: `peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes` |
|
||||
| 4.3 | **Step 10 — reports / campaigns / admin** | Mostly done by other work. `/reportes` exists; "email campaigns" landed as `/notificaciones` against a different table (see 2.3) |
|
||||
| 4.4 | **Phase B sync in production** | Verified 32/32 against dev, never run from the `/operaciones` UI (the `OpsService` path) nor against a prod-shaped database |
|
||||
|
||||
---
|
||||
|
||||
## 5. Gaps in features that already shipped
|
||||
|
||||
Each of these is a known, deliberate stopping point rather than a bug.
|
||||
|
||||
**Notificaciones** — [`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md)
|
||||
- No multi-replica lock on the servicios sweep (pólizas has one via
|
||||
`scheduled_job_states`). Safe only while the deployment stays single-replica.
|
||||
- No per-recipient preview of a sent body in the UI.
|
||||
- No SNS bounce/complaint webhook. `providerMessageId` is captured so one can
|
||||
be added.
|
||||
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
||||
|
||||
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
||||
- **GMX only.** The dispatcher is a `[provider, pattern]` table plus a parser
|
||||
map, so a second carrier is one function and two entries — but no other
|
||||
layout has been seen, and guessing produces a parser nobody can verify.
|
||||
- **The `recibo` PDF is unread.** The GMX certificate carries no premium at
|
||||
all; reading the separate receipt and pairing it to its certificate is what
|
||||
would let `postPremium` stop being a manual tick.
|
||||
- **No versioning.** A re-issued policy arrives as a new certificate with the
|
||||
same number and confirm updates the existing row. Nothing records that this
|
||||
is the 2027 issue of that policy.
|
||||
|
||||
**Statement OCR** — [`STATEMENT_OCR.md`](STATEMENT_OCR.md)
|
||||
- **CFE / CESPT / Telnor have no unit suite.** They predate the gas/predial
|
||||
extension and were verified end to end against the 46-page corpus only.
|
||||
Close this if those parsers are ever touched.
|
||||
- No way to re-run a corrected parser over a stored batch, though the source
|
||||
PDFs are kept precisely so it is possible.
|
||||
- Handwritten folder numbers are deliberately not an input to matching
|
||||
(Tesseract read `405` as `205`).
|
||||
|
||||
**Bank** — the concept→ramo classifier is **won't-build**, not pending.
|
||||
`concepto` is a payee name (0 of 22,354 match a category) and TABLA RAMODOS is
|
||||
a property-management expense chart, not the business-line split it was assumed
|
||||
to be. `/banco` intentionally has no category dimension. Recorded here only
|
||||
because `bank_transactions.categoryId` being null on every row otherwise reads
|
||||
as unfinished work.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security / hygiene
|
||||
|
||||
- **The old repo's `dbConnection.php` has a plaintext MySQL password committed
|
||||
to git history.** Not carried into this platform, but the credential is
|
||||
already exposed and has not been rotated. Rotate regardless of this repo.
|
||||
- The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because
|
||||
Portainer serves a self-signed certificate. Scoped to that one step; the real
|
||||
fix is replacing the certificate.
|
||||
|
||||
---
|
||||
|
||||
## Source documents
|
||||
|
||||
| Document | What it carries |
|
||||
|---|---|
|
||||
| [`../PLAN.md`](../PLAN.md) | build sequencing, locked decisions, per-step status |
|
||||
| [`../RESUME.md`](../RESUME.md) | session history and §6 open items |
|
||||
| [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) | §1 renewal emails (built), §2 liquidación, §3 certificate, §4 carrier APIs |
|
||||
| [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) | §1 Editor (built), §2 OCR (built), §3 multi-bank (built), §4 recycling |
|
||||
| [`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md) | mass email + schedules, as built |
|
||||
| [`STATEMENT_OCR.md`](STATEMENT_OCR.md) · [`POLICY_OCR.md`](POLICY_OCR.md) | the two OCR intakes, as built |
|
||||
| [`DEPLOY_AND_MIGRATIONS.md`](DEPLOY_AND_MIGRATIONS.md) | release chain, galactus, known caveats |
|
||||
@@ -325,3 +325,20 @@ backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
|
||||
- `bootstrap: true` lets the pre-migrate backup be skipped when no API container
|
||||
exists yet. Use it for a first-ever deploy only — it is the one switch that
|
||||
lets a migration run with no restore point.
|
||||
- **The API container sends mail on a timer.** Two sweeps run inside it
|
||||
(renewal avisos, on by default at 06:00 America/Tijuana; the servicios
|
||||
run-all, off by default) — see
|
||||
[`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md). Two
|
||||
consequences for deploys: the cadence lives in `app_settings`, so it
|
||||
**survives a redeploy and is not restored by rolling back an image**, and
|
||||
running more than one API replica would double-fire the servicios sweep,
|
||||
which has no DB lock (the pólizas one does). Keep it single-replica.
|
||||
- `SES_*` is optional to deploy — the preflight only warns — but the production
|
||||
image sets `NODE_ENV=production`, which disables the stdout dev fallback. A
|
||||
blank SES config therefore makes every send fail and log `FAILED`. The
|
||||
secrets were created 2026-08-02; the preflight warning on the next run is
|
||||
what confirms the names are right. Two things it cannot check: that
|
||||
`SES_FROM` is a **verified identity** in `SES_REGION`, and that the account
|
||||
is **out of the SES sandbox** (in sandbox, delivery is restricted to verified
|
||||
recipients, which would fail a real sweep while looking correctly
|
||||
configured).
|
||||
|
||||
@@ -11,6 +11,15 @@ Companion doc: [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) covers the
|
||||
Utility Management half of the same meeting (PLAN.md step 11). This doc is the
|
||||
insurance half (PLAN.md step 12).
|
||||
|
||||
> **A fifth insurance feature exists that this spec never proposed.**
|
||||
> [`POLICY_OCR.md`](POLICY_OCR.md) — OCR capture of carrier policy PDFs into
|
||||
> `Policy` rows, built 2026-08-01. It came out of the *utility* statement OCR
|
||||
> work in [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §2, not from
|
||||
> this meeting, which is why it is documented on its own rather than folded in
|
||||
> here. It is relevant to §4: an OCR path that turns a carrier PDF into a
|
||||
> `Policy` row already covers part of what a carrier API was wanted for, and
|
||||
> unlike the API it is not blocked on Grupo Valore returning a phone call.
|
||||
|
||||
## Why these four features are one spec
|
||||
|
||||
The meeting produced four insurance asks. They are specified together because
|
||||
@@ -199,6 +208,35 @@ recycling backfill that consulted `UTILSEG` would merge unrelated people.
|
||||
|
||||
## 1. Renewal notification emails
|
||||
|
||||
> **BUILT — 2026-08-01, extended 2026-08-02.** `apps/api/src/renewals/`
|
||||
> (sweep, `sendOne`, the `scheduled_job_states` lock) plus
|
||||
> `apps/api/src/mail/` (SES). Web: the **Pólizas** tab of `/notificaciones`;
|
||||
> `/renovaciones` is an alias that lands on it. Ability `renewal:send`
|
||||
> (MANAGER), as specced.
|
||||
>
|
||||
> Three things in the sections below were **superseded**, each noted inline:
|
||||
>
|
||||
> - **§1.1** — the `@Cron("0 6 * * *")` literal is gone. Both this sweep and
|
||||
> the servicios jobs take their cadence from `NotificationScheduleService`,
|
||||
> which stores it in `app_settings` and reinstalls the job on save. The
|
||||
> default is still 06:00 daily, so behaviour is unchanged until an operator
|
||||
> edits it. See [`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md),
|
||||
> "Scheduled runs".
|
||||
> - **§1.4 manual mark-as-sent — dropped, deliberately.** Sending from the
|
||||
> list is what marks a notice sent; there is no way to claim a letter went
|
||||
> out when no mail was sent. `RenewalNoticeChannel.MAIL` still exists for a
|
||||
> future paper path, but nothing writes it.
|
||||
> - **The send log is not renewal-specific.** Every attempt — including the
|
||||
> failures and no-email skips a `RenewalNotice` row cannot represent — also
|
||||
> writes `email_notification_log` as `RENEWAL_NOTICE` / `POLICIES`, shared
|
||||
> with the four bulk jobs. `RenewalNotice` stays the *gating* state; the log
|
||||
> is *history*.
|
||||
>
|
||||
> Also added 2026-08-02: the platform-wide `debug` flag reaches this path. A
|
||||
> debug send diverts the mail, skips the `RenewalNotice` upsert **and** does
|
||||
> not advance `lastSuccessfulAt` — see that doc's "Send flags" for why all
|
||||
> three are required together.
|
||||
|
||||
### What Jorge asked for
|
||||
|
||||
Automatic notice to the customer at **30 days before expiry, 15 days before,
|
||||
@@ -223,6 +261,13 @@ and 7 days after** — replacing the manual monthly run of the legacy
|
||||
|
||||
### 1.1 The scheduler
|
||||
|
||||
> **Superseded — the cadence is operator-editable, not a literal.**
|
||||
> `RenewalsService` registers its handler with `NotificationScheduleService`
|
||||
> in `onModuleInit`; that service compiles the stored
|
||||
> `{hour, minute, weekdays}` to a cron expression and installs it in
|
||||
> `SchedulerRegistry`. Default `0 6 * * *` / `America/Tijuana`, i.e. exactly
|
||||
> what the literal below did. The rest of this section still holds.
|
||||
|
||||
Add `@nestjs/schedule`. One `@Cron` job, daily, early morning local time.
|
||||
|
||||
```
|
||||
@@ -302,6 +347,12 @@ traced back to the notice that caused it. (`notes` stays free-text for staff.)
|
||||
|
||||
### 1.4 Manual mark-as-sent
|
||||
|
||||
> **Not built, and deliberately so.** A button that marks a notice sent
|
||||
> without sending anything is a button that lets the list claim a customer
|
||||
> was told when they were not — the exact failure the log exists to make
|
||||
> visible. `POST /renewals/send` replaced it: sending *is* the marking.
|
||||
> Revisit only when a real paper-mail workflow exists to record.
|
||||
|
||||
The `aviso-renovacion` doc comment (`reports.registry.ts:617-621`) already
|
||||
anticipates this: staff who *mail* a paper notice need to record it.
|
||||
`RenewalNoticeChannel` (`MAIL` | `EMAIL`) exists for exactly this distinction.
|
||||
@@ -321,12 +372,19 @@ customer to stop the mail.
|
||||
|
||||
### API surface
|
||||
|
||||
As built (the `/policies/:id/renewal-notices` mark-as-sent mutation was
|
||||
dropped — see §1.4):
|
||||
|
||||
| Method | Route | Ability |
|
||||
|---|---|---|
|
||||
| `POST` | `/policies/:id/renewal-notices` | `renewal:send` |
|
||||
| `POST` | `/renewals/sweep` (manual trigger of the cron body) | `renewal:send` |
|
||||
| `POST` | `/renewals/sweep` (manual trigger of the scheduled body; body `{ debug? }`) | `renewal:send` |
|
||||
| `POST` | `/renewals/send` (one notice; body `{ policyId, generation, debug? }`) | `renewal:send` |
|
||||
| `GET` | `/renewals/pending?days=` (what the next sweep would send) | read (AuthenticatedGuard) |
|
||||
|
||||
The cadence itself is edited through the notifications module
|
||||
(`GET`/`PUT /notifications/settings/schedule[/:kind]`, `setting:manage`),
|
||||
because one editor covers both sweeps.
|
||||
|
||||
### Abilities (new)
|
||||
|
||||
| Ability | Min role | Notes |
|
||||
@@ -706,9 +764,8 @@ equivalent is `InsuranceProvider`, which today holds only a name.
|
||||
|
||||
## Build sequencing
|
||||
|
||||
1. **§1 renewal emails** — highest value, schema already ready, no blocker
|
||||
beyond the SES sending account. ≈260 mails/month against a 91%-reachable
|
||||
policyholder base.
|
||||
1. ~~**§1 renewal emails**~~ — **DONE 2026-08-01/02.** See §1's BUILT note.
|
||||
≈260 mails/month against a 91%-reachable policyholder base.
|
||||
2. **§2 liquidación batch** — small, builds on fields already wired. Do the two
|
||||
defect fixes (missing `policy_types` rows + FK `ON DELETE RESTRICT`) as part
|
||||
of it, since both distort its own report.
|
||||
@@ -739,11 +796,18 @@ No collision with the abilities proposed in `RECEIPT_CAPTURE_SPEC.md`
|
||||
|
||||
## Open questions to take back to Jorge (collected)
|
||||
|
||||
**§1 — renewal emails**
|
||||
- Which SES region + verified identity/configuration set, and whether to reuse
|
||||
existing IAM credentials or create a scoped `ses:SendEmail` user.
|
||||
**§1 — renewal emails** (feature built; these three are still open)
|
||||
- ~~Which SES region + verified identity/configuration set, and whether to
|
||||
reuse existing IAM credentials or create a scoped `ses:SendEmail` user.~~
|
||||
**Answered in practice 2026-08-02** — the Gitea secrets were created. Two
|
||||
things the deploy preflight cannot verify and that decide whether mail
|
||||
actually goes out: `SES_FROM` must be a verified identity in `SES_REGION`,
|
||||
and the account must be out of the SES sandbox (which restricts delivery to
|
||||
verified recipients). See [`BACKLOG.md`](BACKLOG.md) §0.
|
||||
- The 78 policyholders with no email: skip silently, or produce a print
|
||||
worklist? (Recommend the worklist.)
|
||||
worklist? Currently they are **logged as `SKIPPED_NO_EMAIL`** in
|
||||
`email_notification_log` — visible in "Registro de envíos", but not yet a
|
||||
printable worklist. (Recommend the worklist.)
|
||||
- Spanish or English notice body?
|
||||
|
||||
**§2 — liquidación**
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# Mass Email Notifications
|
||||
|
||||
Modern replacement for the four PHP scripts under
|
||||
`email.notifications/send*.php` that fired bulk emails off the legacy
|
||||
`utility_dbo.email_alert_log` table. Lives in this codebase from
|
||||
`massive-email-notification` onward; the PHP scripts stay operational
|
||||
until the office flips over.
|
||||
|
||||
## Why
|
||||
|
||||
The legacy scripts did three things this app needed to keep doing: send
|
||||
outstanding-payment reminders, send payment-confirmation letters, and
|
||||
fire account-status alerts (red and yellow). They also sent a fourth
|
||||
trust-payment confirmation tied to `TRUSTHFEE`. Each was a separate CGI
|
||||
script the office hit manually or via cron, talking to `utility_dbo` over
|
||||
the same `mysqli` connection as the rest of the portal.
|
||||
|
||||
The unified schema (see [`PLAN.md`](../PLAN.md) and
|
||||
[`docs/INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md)) folded
|
||||
`datosfreak` and `TRUSTHFEE` into `customers` + `transactions` +
|
||||
`trust_accounts`, so the scripts' SQL no longer maps to anything. Rather
|
||||
than maintain parallel sync code to keep `utility_dbo` populated, this
|
||||
feature ports the four jobs onto the unified data and writes its own log.
|
||||
|
||||
## What ships
|
||||
|
||||
- `apps/api/src/mail/` — outbound mail transport. Amazon SES (matches
|
||||
the `StorageService` env-driven optional-client pattern). Dev falls
|
||||
back to stdout logging so a fresh checkout can exercise the jobs
|
||||
without SES credentials.
|
||||
- `apps/api/src/notifications/` — the four jobs (`outstanding`,
|
||||
`payment-confirm`, `account-status`, `trust-confirm`), each a public
|
||||
service method + a `POST /notifications/{slug}` HTTP endpoint gated on
|
||||
the new `notification:send` ability (MANAGER).
|
||||
- `apps/api/src/notifications/notification-schedule.service.ts` (+ its own
|
||||
module) — the cadence of **both** automatic sweeps, stored in
|
||||
`app_settings` and installed into `SchedulerRegistry` at boot. See
|
||||
"Scheduled runs" below.
|
||||
- `packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql`
|
||||
— two new tables (`email_notification_log`, `account_status_history`)
|
||||
with enums and FKs to `customers`.
|
||||
- `apps/web/src/app/notificaciones/` — admin page: a shared flags panel and
|
||||
schedule editor above the tabs, then per-tab trigger cards, a
|
||||
transport-status header, and a paginated log browser.
|
||||
|
||||
## Job semantics
|
||||
|
||||
Preserved from the PHP originals (see
|
||||
`~/Documents/Claude-Memory/email-notifications-spec.md`):
|
||||
|
||||
| Job | Recipients | Subject | Response key |
|
||||
|---|---|---|---|
|
||||
| 1. Outstanding payments | Customers with ≥1 outstanding Transaction (amount<0) | "Jorge Cuadros - Outstanding Payments" | `result:"success", notificationType:"sendPaymentConfirmation"` |
|
||||
| 2. Payment confirmation | Customers with a credit in last 24h | "Jorge Cuadros - Payment Confirmation" | `request:"success", notificationType:"sendPaymentConfirmation"` |
|
||||
| 3. Account status | All customers with a balance; yellow/red thresholds | "Jorge Cuadros - Account Status Alert" | `request:"success", notificationType:"sendAccountStatus"` |
|
||||
| 4. Trust confirmation | Customers with TrustAccount + recent TRUST-domain credit | "Jorge Cuadros - Trust Payment Confirmation" | `request:"success", notificationType:"sendTrustPaymentConfirmation"` |
|
||||
|
||||
Wire shapes match the PHP originals byte-for-byte so anything downstream
|
||||
that scrapes `notificationType:"sendPaymentConfirmation"` keeps working.
|
||||
Job 1 reports `result` (not `request`) and `notificationType` literally
|
||||
`sendPaymentConfirmation` — these are the legacy quirks, preserved.
|
||||
|
||||
### Day gates (Job 3 only)
|
||||
|
||||
- **Yellow** ("DEBAJO DEL TIPO"): Wed only (or `ignoreDayRestriction`).
|
||||
- **Red** ("EN ROJO"): Mon/Wed/Fri only (or `ignoreDayRestriction`).
|
||||
- A customer who is red on Tuesday is logged as `SKIPPED_GATE` until
|
||||
Wed, when both checks can fire on the same row.
|
||||
|
||||
### Threshold logic (Job 3)
|
||||
|
||||
The PHP used `datosfreak.TIPO` (50/100/200/300/500) and a hardcoded
|
||||
threshold table. The new schema encodes this as `Customer.minimumBalance`:
|
||||
|
||||
- Yellow: `0 ≤ balance < minimumBalance`
|
||||
- Red: `balance < 0`
|
||||
|
||||
Per-currency balance uses `BillingService.balances()` semantics (signed
|
||||
`SUM(transactions.amount)`, voided + outstanding excluded), so a
|
||||
yellow/red alert always lines up with what the receivables worklist shows
|
||||
staff. The customer-servicing letter reports in USD because the legacy
|
||||
letter was always USD; the union of `balanceUsd` and `balanceMxn` is
|
||||
reported per-customer, never collapsed (see `BillingService.balances()`).
|
||||
|
||||
### Rate limit (Job 3 only)
|
||||
|
||||
`useEmailLimit=true` enables a vestigial throttle: pause the sweep 1h
|
||||
after 100 sends. Off by default; SES does not need it.
|
||||
|
||||
## Send flags
|
||||
|
||||
The three flags are **platform-wide**, not per-tab. They live in the
|
||||
`/notificaciones` shell above the tabs (`NotificationFlagsCard`), and the
|
||||
shell passes them to both halves.
|
||||
|
||||
| Flag | Applies to | Effect |
|
||||
|---|---|---|
|
||||
| `debug` | everything | Rewrites every recipient to `DEBUG_RECIPIENT` (`rmancinas@freakma.net`), tags the log row `debug: true`, sends with `xTracking: "debug"`. |
|
||||
| `ignoreDayRestriction` | Job 3 only | Bypasses the Wed / Mon-Wed-Fri gates. |
|
||||
| `useEmailLimit` | Job 3 only | The vestigial throttle above. |
|
||||
|
||||
`debug` used to exist only on the servicios side, which meant there was no
|
||||
way to test a renewal aviso without mailing a real customer. On the pólizas
|
||||
path it now does three things beyond diverting the mail, all for the same
|
||||
reason — *the customer was not notified, so nothing may claim they were*:
|
||||
|
||||
1. no `RenewalNotice` row is written, so the aviso stays in the pending list;
|
||||
2. the sweep's `lastSuccessfulAt` is not advanced, because `renewalWindow()`
|
||||
uses it to widen the window over missed days — advancing it after a test
|
||||
run would narrow tomorrow's window and drop those candidates for good;
|
||||
3. the send response carries `debug: true` and the address actually used, so
|
||||
the UI says "prueba enviada … el cliente no ha recibido nada" rather than
|
||||
claiming a delivery.
|
||||
|
||||
Flags are **per-visit UI state and are never persisted.** A stored `debug`
|
||||
would survive a reload and silently swallow real customer mail for as long
|
||||
as nobody noticed. For the same reason the automatic runs below ignore them
|
||||
entirely and always send for real.
|
||||
|
||||
## Tables
|
||||
|
||||
### `email_notification_log`
|
||||
|
||||
One row per send attempt (sent, failed, skipped). Carries the rendered
|
||||
body verbatim so a customer reply quoting an old email can be traced to
|
||||
the exact letter sent. SES MessageId stored for bounce/complaint
|
||||
correlation.
|
||||
|
||||
Indexes: `(sendDate)`, `(notificationType, sendDate)`, `(customerId, sendDate)`.
|
||||
|
||||
**This table is not job-specific.** Insurance renewal avisos
|
||||
(`RenewalsService`, see [`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md)) write
|
||||
here too, as `notificationType = RENEWAL_NOTICE` /
|
||||
`servicio = POLICIES` — one send history for the whole platform rather
|
||||
than one per feature. `NotificationLogService` is the only writer;
|
||||
anything that sends mail goes through it.
|
||||
|
||||
`level` is therefore per-type and cannot be read without its
|
||||
`notificationType`: 0/1 (yellow/red) on `ACCOUNT_STATUS`, the aviso
|
||||
generation 1/2/3 on `RENEWAL_NOTICE`, null elsewhere. On the web side
|
||||
`notificationLevelLabel()` is the only place that branch lives.
|
||||
|
||||
Renewals keep their own `renewal_notices` row as well. The two are not
|
||||
redundant: `renewal_notices` is *gating* state (one row per
|
||||
policy+generation, "already notified" — it drives the pending list),
|
||||
while this log is *history* (every attempt, including the failures and
|
||||
no-email skips a gating row cannot represent).
|
||||
|
||||
### `account_status_history`
|
||||
|
||||
Mirrors the legacy `utility_dbo.send_account_status_history` table:
|
||||
`(customerId, customerName, customerEmail, tipo, tCambio, balance,
|
||||
solicitado, level)`. `tipo` is the literal `"DEBAJO DEL TIPO"` or
|
||||
`"EN ROJO"` string the PHP used. `solicitado` keeps the legacy formula
|
||||
(`0 - TIPO - BALANCE`) even though it double-subtracts; downstream
|
||||
reports depend on the exact figure.
|
||||
|
||||
Indexes: `(sendDate)`, `(customerId, sendDate)`, `(level, sendDate)`.
|
||||
|
||||
## Environment
|
||||
|
||||
```
|
||||
SES_REGION=us-east-1
|
||||
SES_ACCESS_KEY=...
|
||||
SES_SECRET_KEY=...
|
||||
SES_FROM=mail@jorgecuadros.com
|
||||
SES_FROM_NAME=Information Server
|
||||
SES_CONFIGURATION_SET=... # optional
|
||||
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net # fallback only
|
||||
```
|
||||
|
||||
`NOTIFICATION_ADMIN_EMAILS` is no longer the source of truth. The summary
|
||||
recipients are edited in the UI and stored in `app_settings`; the env var
|
||||
is the fallback for a deployment where nobody has saved them yet. See
|
||||
"Operator settings" below.
|
||||
|
||||
Without SES_* the API still boots and `MailService` falls back to stdout
|
||||
in dev (`NODE_ENV !== "production"`). In production every send throws
|
||||
`ServiceUnavailableException` and the row is recorded as `FAILED`.
|
||||
|
||||
These are **runtime** config — read at container boot, never baked into the
|
||||
image. For the Portainer deployments they are set as **Gitea repo secrets**
|
||||
and injected into the stack env by the `env_data` block of
|
||||
`.gitea/workflows/deploy-galactus.yml` (and `deploy.yml`), exactly like
|
||||
`DATABASE_URL` and `SESSION_SECRET`. Unlike most secrets there they carry no
|
||||
`_GALACTUS` suffix: one SES identity serves every deployment.
|
||||
|
||||
They are optional to *deploy* — the preflight only warns — but the
|
||||
production image sets `NODE_ENV=production`, which disables the stdout dev
|
||||
fallback, so a blank SES config makes every send fail loudly rather than
|
||||
quietly going nowhere.
|
||||
|
||||
## UI
|
||||
|
||||
`/notificaciones`, two tabs over the one log.
|
||||
|
||||
Above the tabs, owned by the shell because both halves are subject to them:
|
||||
|
||||
- **Flags del envío** — the three flags above.
|
||||
- **Programación de envíos** — the cadence of both automatic sweeps
|
||||
(`setting:manage` to edit; everyone can see when the next run is).
|
||||
|
||||
Then per tab:
|
||||
|
||||
- **Servicios** (`notification:send`) — an "Ejecutar todos" card, four
|
||||
trigger cards, a transport status header, and the summary-recipients
|
||||
setting. Reads the `CUSTOMERS` + `TRUST` slice.
|
||||
- **Pólizas** (`renewal:send`) — pending avisos and the manual sweep.
|
||||
Reads the `POLICIES` slice.
|
||||
|
||||
Both render the same `NotificationLogPanel` ("Registro de envíos"), which
|
||||
filters by servicio and by view (todos / enviados / fallidos / omitidos).
|
||||
STAFF users see the Servicios log read-only.
|
||||
|
||||
Both mass actions ("Ejecutar todos" and the pólizas sweep) confirm before
|
||||
firing **only when `debug` is off** — that is the case where real customers
|
||||
receive mail, and a confirm on every click trains people to dismiss it.
|
||||
|
||||
## API surface
|
||||
|
||||
| Method | Route | Ability |
|
||||
|---|---|---|
|
||||
| `POST` | `/notifications/outstanding-payments` | `notification:send` |
|
||||
| `POST` | `/notifications/payment-confirmation` | `notification:send` |
|
||||
| `POST` | `/notifications/account-status` | `notification:send` |
|
||||
| `POST` | `/notifications/trust-payment-confirmation` | `notification:send` |
|
||||
| `POST` | `/notifications/run-all` | `notification:send` |
|
||||
| `GET` | `/notifications/log`, `/notifications/stats` | authenticated |
|
||||
| `GET` | `/notifications/settings/admin-emails` | authenticated |
|
||||
| `PUT` | `/notifications/settings/admin-emails` | `setting:manage` |
|
||||
| `GET` | `/notifications/settings/schedule` | authenticated |
|
||||
| `PUT` | `/notifications/settings/schedule/:kind` | `setting:manage` |
|
||||
|
||||
Every trigger accepts the flags as **body or query string** — the PHP
|
||||
scripts took both (STDIN vs HTTP-CGI) and parity was cheap. The pólizas
|
||||
endpoints (`POST /renewals/sweep`, `POST /renewals/send`) accept `debug`
|
||||
only; the other two flags are estado-de-cuenta concepts and are not
|
||||
accepted there rather than being silently ignored.
|
||||
|
||||
## Operator settings
|
||||
|
||||
`app_settings` holds the configuration staff change without a redeploy.
|
||||
`SettingsService` resolves every key **db → env → default**, and reports
|
||||
which of the three a value came from so the UI can say so. Adding a key
|
||||
means adding a typed accessor there, not a generic getter.
|
||||
|
||||
Keys today:
|
||||
|
||||
| Key | Edited on | Notes |
|
||||
|---|---|---|
|
||||
| `notification.adminEmails` | Servicios tab | Summary recipients, comma-separated. |
|
||||
| `notification.schedule.servicios` | shell | JSON cadence of the automatic run-all. |
|
||||
| `notification.schedule.polizas` | shell | JSON cadence of the renewal sweep. |
|
||||
|
||||
All three are gated on `setting:manage` (ADMIN — above `notification:send`,
|
||||
because redirecting the audit summaries is how someone would stop them being
|
||||
read).
|
||||
|
||||
`notification.adminEmails` is read on every job rather than cached, so an
|
||||
edit takes effect on the next sweep with no restart. An empty saved list
|
||||
means "nobody" and deliberately does **not** fall through to the env.
|
||||
|
||||
The two schedule keys have **no env rung** on the db → env → default ladder:
|
||||
a cadence was never an environment variable (it was a `@Cron` literal in the
|
||||
source), so the only two sources are the operator's row and the shipped
|
||||
default. A row that fails to parse is logged and treated as absent — a bad
|
||||
JSON blob must not take the scheduler down with it.
|
||||
|
||||
Credentials do not belong here. SES keys, `DATABASE_URL` and S3 config stay
|
||||
in the environment: they are deployment identity, they must exist before
|
||||
the app can reach its own database, and a table only widens who can read
|
||||
them.
|
||||
|
||||
## Scheduled runs
|
||||
|
||||
Both halves run themselves on an **operator-editable** cadence. Nothing
|
||||
about the schedule is in the source any more:
|
||||
|
||||
| Kind | Handler | Default | Was |
|
||||
|---|---|---|---|
|
||||
| `servicios` | `NotificationsService.scheduledRunAll()` → all four jobs in order | **off**, 07:00 Mon/Wed/Fri when enabled | nothing — the four jobs were click-only |
|
||||
| `polizas` | `RenewalsService.scheduledSweep()` | **on**, 06:00 daily | `@Cron("0 6 * * *")` in `renewals.service.ts` |
|
||||
|
||||
The defaults preserve exactly what each half did before: pólizas keeps its
|
||||
06:00 sweep, servicios stays off. A default that starts mailing 260
|
||||
customers on its own after a deploy is not a default, it's an incident.
|
||||
|
||||
### How it works
|
||||
|
||||
`NotificationScheduleService` owns both cadences. The services that own the
|
||||
sweeps register a handler in `onModuleInit`:
|
||||
|
||||
```ts
|
||||
await this.schedule.register("polizas", () => this.scheduledSweep());
|
||||
```
|
||||
|
||||
The schedule service then compiles the stored value to a cron expression
|
||||
(`{hour, minute, weekdays}` → `m h * * dow`, empty weekdays = `*`) and
|
||||
installs a `CronJob` in `SchedulerRegistry` under
|
||||
`notification-schedule:<kind>`, in `America/Tijuana`. Saving from the UI
|
||||
re-reads the row, removes the old job and installs the new one — **no
|
||||
restart**, which was the whole point.
|
||||
|
||||
Handlers are registered rather than injected because
|
||||
`NotificationsModule` and `RenewalsModule` both need this service and
|
||||
neither may import the other. It lives in its own
|
||||
`NotificationScheduleModule` for the same reason as
|
||||
`NotificationLogModule`.
|
||||
|
||||
`cron` is a **direct dependency of `apps/api`**, not just a transitive one
|
||||
of `@nestjs/schedule`: pnpm's strict layout does not hoist it, so
|
||||
`import { CronJob } from "cron"` fails to resolve without it.
|
||||
|
||||
### What a scheduled run does not do
|
||||
|
||||
- **It never uses the UI flags.** No `debug` (so a forgotten test toggle
|
||||
cannot silently stop customer mail), and no `ignoreDayRestriction` — an
|
||||
automatic run on the operator's own cadence is exactly the case the
|
||||
Mon/Wed/Fri gate was written for, so bypassing it would mail the red list
|
||||
every scheduled day.
|
||||
- **It does not guard against multi-replica double-fire.** The pólizas sweep
|
||||
has always had its own DB lock (`scheduled_job_states`, taken in
|
||||
`RenewalsService.acquireLock`); the servicios run-all has no equivalent
|
||||
and relies on the deployment being single-replica, which it is today on
|
||||
galactus. Adding one means the `OpsService` single-running-job pattern —
|
||||
a DB row, not an in-process flag.
|
||||
|
||||
## What is intentionally NOT in scope
|
||||
|
||||
- Per-recipient preview / HTML view in the UI. The log table shows what
|
||||
was sent; previewing one requires fetching `bodySnapshot` and rendering
|
||||
HTML in the browser, deferred until a customer-service need surfaces.
|
||||
- Bounce / complaint webhooks. `providerMessageId` is captured so a future
|
||||
SNS topic can write back; the integration itself is a separate piece
|
||||
of work.
|
||||
- Spanish / English body toggle. Legacy letters are English; the legacy
|
||||
customer base is bilingual. `Customer` has no language preference.
|
||||
Add one when the need is concrete (same open question as
|
||||
[`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) §1.6).
|
||||
- Importing the legacy `utility_dbo.email_alert_log` rows. They reference
|
||||
the old `NUMid` (a stringified double) which no longer maps to a
|
||||
unified customer; an import would be destructive.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Insurance Policy OCR Capture
|
||||
|
||||
Reads an insurance policy PDF the office downloads from a carrier portal,
|
||||
proposes the `Policy` row it should become, and lets staff confirm. Built
|
||||
2026-08-01 (`5e9cb12`), live under `/polizas/captura`.
|
||||
|
||||
## Why this exists — it was not planned
|
||||
|
||||
This feature is **not in any spec**. It came out of building the utility
|
||||
statement OCR intake in [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md)
|
||||
§2: once there was a working render → OCR → parse → match → review pipeline
|
||||
for CFE/CESPT/Telnor receipts, it was obvious the same shape applies to the
|
||||
*other* stack of paper this office keys in by hand every week — the carrier
|
||||
policy PDFs behind every `Policy` row.
|
||||
|
||||
The two are the same job with a different document on the scanner. Keeping
|
||||
that recognition cheap is the whole point of how it was built: the pipeline
|
||||
was **reused, not copied**.
|
||||
|
||||
- `OcrModule` (`apps/api/src/ocr/ocr.module.ts`) was extracted out of
|
||||
`StatementsModule` in this same commit, purely so `PolicyOcrModule` can
|
||||
inject `OCR_PROVIDER` without dragging in the statement pipeline.
|
||||
`StatementsModule` now imports it and binds nothing itself. That extraction
|
||||
was **blocking**: without it the policy module could not resolve the
|
||||
provider at all.
|
||||
- The engine stays Tesseract behind the same swappable seam, so a managed
|
||||
extraction API remains a one-line change in one file for both features.
|
||||
- The intake screen is a *mode of* the existing policy-creation screen, the
|
||||
same way OCR receipt capture is a mode of Captura — not a new menu entry.
|
||||
|
||||
## What ships
|
||||
|
||||
| Piece | Path |
|
||||
|---|---|
|
||||
| API module | `apps/api/src/policy-ocr/` (service, controller, DTOs, matcher, parser) |
|
||||
| Shared OCR seam | `apps/api/src/ocr/ocr.module.ts` |
|
||||
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`) |
|
||||
| Web | `components/PolicyCaptura.tsx` (tab shell), `PolicyOcrIntake.tsx` (upload), `PolicyOcrReview.tsx` (review queue) |
|
||||
| Abilities | `policy:ingest`, `policy:ocr-review` — both **STAFF** |
|
||||
|
||||
Abilities are STAFF for the same reason statement OCR is: nothing reaches the
|
||||
books unconfirmed, and the review step is what makes machine capture safe at
|
||||
that tier.
|
||||
|
||||
## The screen
|
||||
|
||||
`PolicyCaptura` is one screen with two ways in, mirroring `Captura.tsx`:
|
||||
|
||||
- `/polizas/nuevo` → **manual** tab (`PolicyForm`, every field by hand)
|
||||
- `/polizas/captura` → **automática** tab (`PolicyOcrIntake`, drop a PDF)
|
||||
- `/polizas/captura/[id]` → the batch review queue
|
||||
|
||||
Both modes end at the same place — a `Policy` row on a customer's file — so
|
||||
they are modes of one screen rather than two menu entries. Either URL renders
|
||||
the same component, so the tab toggle works from either entry point and old
|
||||
bookmarks land on the right tab.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
upload PDF → store source → render pages → text layer? → parse → match → review → confirm
|
||||
```
|
||||
|
||||
1. **Store the source.** `policy-ocr/{batchId}/source-N.pdf`, before anything
|
||||
else touches it.
|
||||
2. **Render + read.** Every page is rendered to
|
||||
`policy-ocr/{batchId}/page-M.png`. Text-layer wins when the PDF has one
|
||||
(cheap, exact); the rendered image is OCR'd only when it does not — the
|
||||
same precedence rule as the statement pipeline. Carrier-portal PDFs are
|
||||
usually born-digital, so most of the time no OCR runs at all.
|
||||
3. **Parse.** Provider detected by brand signal first
|
||||
(`GMX`, `Grupo Mexicano de Seguros`, `gmx.com.mx`,
|
||||
`JUNTOS EL RIESGO ES MENOR`), layout patterns only as fallback — the same
|
||||
ordering rule the statement parser needed.
|
||||
4. **Match.** Against `Policy.policyNumber`.
|
||||
5. **Review + confirm.** Nothing is written to `Policy` until a human
|
||||
confirms.
|
||||
|
||||
### One PDF = one policy
|
||||
|
||||
This is the sharpest difference from statement OCR, and it inverts that
|
||||
feature's core assumption.
|
||||
|
||||
Utility statements arrive **bundled, one customer per page** — so there, one
|
||||
page is one document and the parser runs per page. A policy PDF is the
|
||||
opposite: the GMX certificate is a 2-page document where page 1 carries the
|
||||
contract header and page 2 carries the per-coverage table, and **both pages
|
||||
describe the same policy**. So the pipeline concatenates every page's text
|
||||
(`\n\n` between pages, which also keeps `ocrRawText` readable for debugging)
|
||||
and runs the parser and the matcher exactly **once per file**.
|
||||
|
||||
Consequences worth knowing before touching this code:
|
||||
|
||||
- `PolicyOcrDocument.pageNumber` is repurposed as the **file ordinal within
|
||||
the batch** (1, 2, 3…), not a page index. The
|
||||
`(batchId, pageNumber)` unique constraint still holds, and one batch still
|
||||
carries many policies — one per uploaded file.
|
||||
- Parser regexes are anchored across the whole concatenated text (`^From$`,
|
||||
`^Currency\s+…`), which is why the page-boundary blank line matters.
|
||||
- `ocrConfidence` on the row is the **mean** across the file's pages.
|
||||
- A file that fails to parse produces exactly one `OCR_FAILED` row — the right
|
||||
granularity, and the page PNGs stay on disk for a re-run after a parser fix.
|
||||
|
||||
### `storageKey` is the source PDF, not a page image
|
||||
|
||||
`PolicyOcrDocument.storageKey` points at `source-N.pdf`. The review screen
|
||||
embeds that file directly, so the reviewer looks at the **exact artifact the
|
||||
office received** and gets the browser's native PDF scrolling, zoom and text
|
||||
selection for free. Rendered PNGs are still written for future re-OCR or an
|
||||
image-based audit, but nothing points at them as the document's identity.
|
||||
|
||||
(The statement side does the opposite — there `storageKey` is the page image,
|
||||
because a page *is* the document.)
|
||||
|
||||
## Matching: policy number only, never the insured name
|
||||
|
||||
`PolicyMatcherService` matches on `Policy.policyNumber` and nothing else.
|
||||
|
||||
The certificate's "Insured" line is the account's registrant, which drifts
|
||||
from the customer the office actually holds the file under — the same finding
|
||||
the statement matcher is built around (a CESPT receipt reading
|
||||
`ARNAIZ ROSAS ELSA AURORA` for a customer this office holds as `CATT, RANDY`).
|
||||
Names are shown to the reviewer as a sanity check and never feed matching.
|
||||
|
||||
| Rows on `policyNumber` | Result |
|
||||
|---|---|
|
||||
| exactly 1 | `MATCHED`, confident — the only unambiguous hit |
|
||||
| 0 | new policy: review offers a customer picker, confirm **creates** the row |
|
||||
| >1 | surfaced as candidates, human picks |
|
||||
|
||||
More than one hit is never auto-resolved. Duplicate policy numbers across
|
||||
customers do occur (one group policy bound by two related parties), and
|
||||
picking arbitrarily would silently book the wrong coverage against the wrong
|
||||
person.
|
||||
|
||||
## What the parser reads, and the field it cannot
|
||||
|
||||
`ParsedPolicy` fields are all nullable on purpose: each carrier prints a
|
||||
different subset, and the matcher and review queue both work better with
|
||||
"field was read" vs "field was not" than with a guess.
|
||||
|
||||
Read from the GMX certificate: policy number, insured name, additional
|
||||
insured, broker (→ `Policy.agentName`), legal address, ZIP, `policyFrom` /
|
||||
`policyTo` / `policyDate`, currency, premium-payment cadence, and the full
|
||||
per-coverage table (risk, insured amount, deductible, loss participation)
|
||||
preserved verbatim.
|
||||
|
||||
> **The GMX certificate carries no premium.** Not "sometimes missing" — the
|
||||
> document does not have the figure. It lives on GMX's **separate `recibo`
|
||||
> PDF**. The parser leaves `netPremium` / `policyFee` / `brokerFee` / `total`
|
||||
> null and pushes a note onto the row —
|
||||
> *"esta página no trae prima; revisar el recibo de GMX por separado"* — so
|
||||
> the reviewer sees why the field is empty rather than assuming a read
|
||||
> failure.
|
||||
|
||||
This is also why confirm never overwrites an existing `Policy.netPremium`
|
||||
with null: the certificate not carrying a premium is not evidence that the
|
||||
premium is gone.
|
||||
|
||||
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
||||
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
||||
and free text, and normalising them would lose the distinction.
|
||||
|
||||
## Confirm: what actually gets written
|
||||
|
||||
Per confirmed document, in order:
|
||||
|
||||
1. **The `Policy` row** — updated if a policy was matched, created under the
|
||||
picked customer if not. Only non-null `extracted*` fields are written; null
|
||||
never overwrites existing data.
|
||||
2. **A `PolicyDocument`** — the source PDF is streamed into the policy's
|
||||
storage namespace and attached, so the paperwork stays with the policy.
|
||||
3. **Optionally a `Transaction`** — `INSURANCE` domain, negative amount
|
||||
(a charge), `captureSource: "OCR"`, `captureRef` = the document id.
|
||||
|
||||
The ledger write is **opt-in twice over**: staff must tick `postPremium`
|
||||
*and* a premium must have parsed to a positive number. Without that gate the
|
||||
premium-less certificate above would silently book a $0 charge on every
|
||||
confirm.
|
||||
|
||||
`createdPolicyId` and `postedTransactionId` are unique columns on the
|
||||
document row, so a double-confirm cannot re-apply — and a `POSTED` document
|
||||
is refused outright.
|
||||
|
||||
Discarding a batch is refused once any page is `POSTED`: a partly-applied
|
||||
batch has already written `Policy` (and possibly `Transaction`) rows, and
|
||||
hiding the paperwork behind a "discarded" label would leave those rows
|
||||
unexplained. Reject the remaining pages individually instead.
|
||||
|
||||
## API surface
|
||||
|
||||
| Method | Route | Ability |
|
||||
|---|---|---|
|
||||
| `GET` | `/policy-ocr/status` (is OCR + storage available) | authenticated |
|
||||
| `GET` | `/policy-ocr/batches`, `/batches/:id`, `/batches/:id/documents` | authenticated |
|
||||
| `GET` | `/policy-ocr/documents/:id/page` (streams the source PDF) | authenticated |
|
||||
| `POST` | `/policy-ocr/batches` (upload) | `policy:ingest` |
|
||||
| `PATCH` | `/policy-ocr/documents/:id` (edit the extracted fields) | `policy:ocr-review` |
|
||||
| `POST` | `/policy-ocr/documents/:id/reject` | `policy:ocr-review` |
|
||||
| `POST` | `/policy-ocr/batches/:id/discard` | `policy:ocr-review` |
|
||||
| `POST` | `/policy-ocr/batches/:id/confirm` | `policy:ocr-review` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Same as statement OCR: object storage (`S3_ENDPOINT` + credentials) for the
|
||||
source PDFs and page images, and `tesseract-ocr` / `tesseract-ocr-data-spa` /
|
||||
`poppler-utils` in the API image. `GET /policy-ocr/status` reports both; if
|
||||
either is missing the feature reports itself unavailable and only this
|
||||
feature is disabled.
|
||||
|
||||
## Tests
|
||||
|
||||
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 8 cases, all
|
||||
against verbatim text extracted from one real document,
|
||||
`HC_Folio_000767_Traduccion.pdf`: provider detection from the wordmark and
|
||||
from the footer URL, the header fields, every coverage row off the second
|
||||
page, the deductible/loss-participation strings, the missing-premium note,
|
||||
the broker line with the agent-number parens absent, and a page with no GMX
|
||||
signal at all (which must yield no provider rather than a bad guess).
|
||||
|
||||
## Not built
|
||||
|
||||
- **Only GMX.** The dispatcher (`detectPolicyProvider`) is a table of
|
||||
`[provider, pattern]` pairs plus a `parsers` map, so adding ANA or Qualitas
|
||||
is a parser function and two entries — but no other carrier's layout has
|
||||
been seen yet, and guessing at one produces a parser nobody can verify.
|
||||
- **The `recibo` PDF.** Reading the premium off GMX's separate receipt
|
||||
document, and pairing it to the certificate it belongs to, is the obvious
|
||||
next piece. It is what would let `postPremium` stop being a manual tick.
|
||||
- **Renewals from OCR.** A re-issued policy arrives as a new certificate with
|
||||
the same number; confirm updates the existing row rather than versioning
|
||||
it. Nothing tracks "this is the 2027 issue of that policy".
|
||||
|
||||
## Related
|
||||
|
||||
- [`STATEMENT_OCR.md`](STATEMENT_OCR.md) — the utility statement pipeline this
|
||||
was lifted from, as built. [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md)
|
||||
§2 is its design and the measured evidence behind it. Between them they are
|
||||
the origin of three rules the policy parser applies: detect the provider by brand before layout, only
|
||||
ever apply the Tesseract digit-confusion map (`O→0`, `S→5`, `B→8`, …) to
|
||||
fields known to be digits, and parse amounts by separator *position* rather
|
||||
than assuming `,` is thousands.
|
||||
|
||||
Those last two are **duplicated on purpose**, not imported: the module is
|
||||
kept self-contained, since sharing a helper would couple two unrelated
|
||||
domains through it. If you fix a bug in one, check the other.
|
||||
- [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) — the four
|
||||
insurance features that *were* planned. This is not one of them.
|
||||
@@ -131,7 +131,12 @@ single-movement form.
|
||||
|
||||
## 2. PDF / OCR auto-capture
|
||||
|
||||
> **BUILT — 2026-08-01.** Implemented and verified end to end against real
|
||||
> **BUILT — 2026-08-01.** As-built reference:
|
||||
> [`STATEMENT_OCR.md`](STATEMENT_OCR.md) — what the shipped feature does, its
|
||||
> parsers, matcher rules and API surface. This section stays the *design* and
|
||||
> the evidence behind it; go there for what is in the code today.
|
||||
>
|
||||
> Implemented and verified end to end against real
|
||||
> scanned statements. `apps/api/src/statements/` holds the module: a swappable
|
||||
> `OcrProvider` seam with a self-hosted Tesseract implementation, per-provider
|
||||
> parsers for CFE / CESPT / Telnor / gas / predial, a scoped matcher, and a
|
||||
@@ -151,6 +156,21 @@ single-movement form.
|
||||
> blobs. `GET /statements/status` reports `ocrAvailable` and `storageAvailable`,
|
||||
> and the upload card hides itself unless both hold.
|
||||
>
|
||||
> **This pipeline turned out to generalise, and a second feature came out of
|
||||
> it.** Once render → OCR → parse → match → review existed for utility
|
||||
> receipts, the same shape obviously fit the *other* stack of paper this
|
||||
> office keys in by hand — carrier policy PDFs. That is
|
||||
> [`POLICY_OCR.md`](POLICY_OCR.md), built 2026-08-01, and it is **not in any
|
||||
> spec**; it was a revelation from doing this one. The `OcrProvider` seam was
|
||||
> lifted out of `StatementsModule` into its own `OcrModule` so the policy
|
||||
> module could inject it without taking on the statement pipeline —
|
||||
> `StatementsModule` imports it now and binds nothing itself. The engine
|
||||
> choice stays a one-line change in one file, for both features.
|
||||
>
|
||||
> One assumption does **not** carry over: statements arrive bundled *one
|
||||
> customer per page*, so here a page is a document. A policy PDF is one
|
||||
> document across several pages. See that doc's "One PDF = one policy".
|
||||
>
|
||||
> **Measured, not assumed.** Ten real scans (46 pages of CFE, CESPT and Telnor
|
||||
> bills) drove every decision below. Against them the shipped parser identifies
|
||||
> the provider on **46/46**, reads an account reference on **43/46**, an amount
|
||||
|
||||
@@ -126,6 +126,16 @@ from data (one parameterized template), not from report design text. See
|
||||
`RenewalNotice` in `schema.prisma` and the `aviso-renovacion` entry in
|
||||
`apps/api/src/reports/reports.registry.ts` for the first cut at this.
|
||||
|
||||
> **Built 2026-08-01/02.** The three generations above are now
|
||||
> `RenewalNotice.generation` 1/2/3, mailed by `apps/api/src/renewals/` on an
|
||||
> operator-editable cadence (default 06:00 daily) and driven from the
|
||||
> **Pólizas** tab of `/notificaciones`. The `CONTROL … X MES` companion
|
||||
> reports have no equivalent and need none: every attempt — sent, failed, or
|
||||
> skipped for a missing address — lands in `email_notification_log`. See
|
||||
> [`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md) and
|
||||
> [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) §1. This document
|
||||
> stays a record of the **legacy** report chain, not of what shipped.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Only the ATLAS variants were extracted verbatim; the QUALITAS and
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Utility Statement OCR Capture (receipt capture)
|
||||
|
||||
Reads the stack of scanned utility bills the office pays every month, proposes
|
||||
the customer and the amount for each page, and posts the confirmed pages to the
|
||||
ledger as one batch against one check. Built 2026-08-01, live under `/recibos`.
|
||||
|
||||
This is the **as-built** record. The design and the reasoning behind it are
|
||||
[`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §2, which also carries the
|
||||
measured results and the four spec corrections the real scans forced. Read that
|
||||
for *why*; read this for *what is there*.
|
||||
|
||||
## The job it replaces
|
||||
|
||||
Staff receive 300+ pages per month per service provider — CFE electricity,
|
||||
CESPT water, Telnor phone, gas, municipal predial, federal zone — and key each
|
||||
one into the ledger by hand, as a charge against the customer whose property
|
||||
the bill belongs to. The whole stack is paid with one office check, so the
|
||||
capture is naturally a batch.
|
||||
|
||||
Auto-capture is a **mode of** the existing capture screen, not a separate
|
||||
feature: it is the same daily job with a scanner instead of a keyboard, and
|
||||
both modes post through the same ledger path.
|
||||
|
||||
## What ships
|
||||
|
||||
| Piece | Path |
|
||||
|---|---|
|
||||
| API module | `apps/api/src/statements/` (service, controller, DTOs, matcher, parsers) |
|
||||
| OCR seam | `apps/api/src/statements/ocr/` (interface + Tesseract), bound in `apps/api/src/ocr/ocr.module.ts` |
|
||||
| Tables | `statement_batches`, `statement_documents` (`20260731235721_statement_ocr_intake`) |
|
||||
| Web | `components/Captura.tsx` (tab shell), `StatementIntake.tsx` (upload + batch list), `/recibos/:id` (review queue) |
|
||||
| Abilities | `statement:ingest`, `statement:review` — both **STAFF** |
|
||||
|
||||
STAFF is deliberate: the review step is what makes machine capture safe at that
|
||||
tier, since nothing reaches the ledger unconfirmed.
|
||||
|
||||
## The screen
|
||||
|
||||
`Captura` is one screen with two ways in:
|
||||
|
||||
- `/estado-cuenta/lote` → **manual** tab (`ManualCheckCapture`, key each
|
||||
receipt against one check by hand)
|
||||
- `/recibos` → **automática (OCR)** tab (`StatementIntake`, upload the scans)
|
||||
- `/recibos/:id` → the batch review queue, page image beside the extracted
|
||||
fields
|
||||
|
||||
Both end in the same place — charges on customers' ledgers posted against one
|
||||
check — so they are modes of one screen. Staff pick by what is on the desk that
|
||||
morning. Either URL renders the same component, so old bookmarks land on the
|
||||
right tab.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
upload PDFs (+ service kind) → store source → render pages → text layer? → parse → match → review → confirm → ledger
|
||||
```
|
||||
|
||||
**A batch is one service kind.** The uploader labels it (ELECTRIC, WATER,
|
||||
TELEPHONE, GAS, PROPERTY_TAX, FEDERAL_ZONE) and that label is enforced: if the
|
||||
parser reads a page as a different provider, the page is rejected as
|
||||
mis-sorted rather than matched. Posting a phone bill as a water charge is the
|
||||
failure being prevented.
|
||||
|
||||
**Processing is not awaited.** 300 pages of OCR is minutes of CPU, far past any
|
||||
HTTP timeout, so `POST /statements/batches` returns the batch id immediately and
|
||||
the client polls. That is also what lets the review queue show partial progress.
|
||||
|
||||
**One page = one document.** Statements arrive **bundled, one customer per
|
||||
page** — Telnor's own `Pág 3 de 6` is its internal pagination, not the office's
|
||||
scan — so every rendered page becomes its own `StatementDocument` and the
|
||||
parser runs per page. (The policy OCR feature inverts this; see "Sibling
|
||||
feature" below.)
|
||||
|
||||
**One unreadable page must not abandon the other 299.** A page that throws
|
||||
becomes a single `OCR_FAILED` row and the loop continues.
|
||||
|
||||
Both the source PDFs (`statement/{batchId}/source-N.pdf`) and every rendered
|
||||
page image (`page-N.png`) are stored. The source is the artifact the office
|
||||
received and the only way to re-run a corrected parser over the original; the
|
||||
page image is what the reviewer looks at, because "what the parser read" is
|
||||
only checkable against a picture of the paper.
|
||||
|
||||
## The OCR seam
|
||||
|
||||
`OcrProvider` (`ocr/ocr.provider.ts`) is the swap point. Four methods:
|
||||
`available()`, `renderPages()`, `recognize()`, `textPages()`.
|
||||
|
||||
Everything above it works in terms of page text and word boxes, so the engine
|
||||
is replaceable without touching the parsers, the matcher or the schema. The
|
||||
shipped implementation is **self-hosted Tesseract**, and that choice is
|
||||
evidence-based rather than assumed — see the spec's measured results. A managed
|
||||
API (Textract, Document Intelligence, Document AI) fits behind the same
|
||||
interface with no schema change; at 300+ pages/month/company it would carry
|
||||
real recurring cost for accuracy that is not the bottleneck.
|
||||
|
||||
The binding lives in `apps/api/src/ocr/ocr.module.ts`, extracted out of
|
||||
`StatementsModule` when [`POLICY_OCR.md`](POLICY_OCR.md) needed the same seam.
|
||||
`StatementsModule` imports it and binds nothing itself, so the engine decision
|
||||
is one line in one file for both features.
|
||||
|
||||
### Text layer first, OCR as the fallback
|
||||
|
||||
**Not every statement is a scan.** The gas company sends born-digital CFDI
|
||||
invoices whose text layer is already exact and already positioned.
|
||||
`textPages()` reads it (`pdftotext -bbox-layout`, same poppler package as
|
||||
`pdftoppm`) and OCR runs only where there is none.
|
||||
|
||||
Rasterising a born-digital page and re-recognising it can only lose
|
||||
information — one sample turned `MEDIDOR: VM01014426` into
|
||||
`ar (LTR): 014420` — while costing about a minute of CPU for the privilege.
|
||||
Positions come back in the same pixel space `recognize()` uses, so the parsers'
|
||||
geometric helpers work unchanged on either source. When the text layer is used
|
||||
the document's notes say so verbatim: *"texto leído del PDF original, sin
|
||||
OCR"*.
|
||||
|
||||
### Word boxes, not just text
|
||||
|
||||
`OcrPage` carries `words[]` with pixel boxes because several real layouts are
|
||||
**tables**: the CESPT "RECIBO" prints `No. DE CUENTA` as a column header with
|
||||
the value in the row beneath it, which line-oriented text cannot associate.
|
||||
Parsers fall back to geometry for exactly those fields.
|
||||
|
||||
## Parsers
|
||||
|
||||
Eight providers, dispatched by a `BRAND` table checked before a `LAYOUT` table:
|
||||
|
||||
| Provider | Service kind |
|
||||
|---|---|
|
||||
| `CFE` | ELECTRIC |
|
||||
| `CESPT` | WATER |
|
||||
| `TELNOR` | TELEPHONE |
|
||||
| `GAS TIJUANA` | GAS |
|
||||
| `PREDIAL TIJUANA` / `PREDIAL ROSARITO` / `PREDIAL ENSENADA` | PROPERTY_TAX |
|
||||
| `ZONA FEDERAL TIJUANA` | FEDERAL_ZONE |
|
||||
|
||||
Three predial parsers rather than one because Tijuana, Rosarito and Ensenada
|
||||
issue three completely different documents — same tax, nothing else in common.
|
||||
|
||||
Rules that are load-bearing and easy to break:
|
||||
|
||||
- **Brand before layout, and never interleaved.** Scanned logos read badly (a
|
||||
CESPT header came back as `E BAJA ES PAGO / EALIFORNIA`), which is why the
|
||||
layout fallback exists — but *every* brand rule runs first, because a Telnor
|
||||
page contains words a CFE structural rule would otherwise claim.
|
||||
- **Tijuana bills predial and zona federal from the same treasury.** Same
|
||||
header, same address, same `ATB-541201` RFC, so every predial discriminator
|
||||
matches a zona federal page too. The words only that layout prints are
|
||||
`Marítimo Terrestre`, so its rule is asked ahead of all three predial ones.
|
||||
**Order matters here in a way that is invisible from the code shape.**
|
||||
- **Parse amounts by separator position.** A real Telnor bill OCR'd as
|
||||
`$ 649,00`; stripping commas as thousands separators makes that $64,900.
|
||||
- **A misread `$` is the dangerous failure, not a missing one.** An Ensenada
|
||||
receipt for `$2,203.00` OCR'd as `82,203.00` — the sign read as an 8, which
|
||||
would post a charge 37× too large and look entirely ordinary in the ledger.
|
||||
Every predial amount therefore requires a literal `$`; a page that cannot
|
||||
produce one reports no amount and goes to review.
|
||||
- **Digit-confusion repair only on fields known to be digits** (`O→0`, `S→5`,
|
||||
`B→8`, …), never on free text.
|
||||
- **The clave catastral is not `[A-Z]{2}[0-9]{6}`.** Position three is a letter
|
||||
in fifteen of the 932 stored claves (`MMB01041`, `CGH52121`). Digitising the
|
||||
whole tail maps that `B` to an `8` and yields a key matching no property.
|
||||
- **Barcodes beat printed labels.** Where a provider prints a payment barcode
|
||||
it is preferred and the two are cross-checked; disagreement sets
|
||||
`crossChecked: false` and forces review, because which of the two was
|
||||
misread is a judgement call.
|
||||
|
||||
## Matching
|
||||
|
||||
`StatementMatcherService`. Two rules govern everything:
|
||||
|
||||
**Match on one scoped field, never fuzzily across all identifiers.** Each
|
||||
service kind has exactly one column its statements print, and only that column
|
||||
is consulted. A blanket search over accountNumber/meterNumber/route would let a
|
||||
water account number collide with an unrelated phone number, and the mis-post
|
||||
would look perfectly ordinary in the ledger.
|
||||
|
||||
**Never match on the customer name.** A CESPT receipt for account `5365218`
|
||||
prints `ARNAIZ ROSAS ELSA AURORA`; the office's book, corroborated by the
|
||||
clave, has `CATT, RANDY`. The name on a utility bill is the registrant, not the
|
||||
current owner. Names are shown to the reviewer and are never an input.
|
||||
|
||||
### `scopedRefField` — which column each kind actually prints
|
||||
|
||||
| Kind | Column | Why |
|
||||
|---|---|---|
|
||||
| ELECTRIC, WATER, TELEPHONE, CABLE | `accountNumber` | the legacy column holds the printed number |
|
||||
| GAS | `meterNumber` | the number lived in free-text notes; `accountNumber` was never populated |
|
||||
| PROPERTY_TAX | `meterNumber` | `accountNumber` holds `DATMEX.predial`, an office file number that is neither unique nor printed anywhere |
|
||||
| FEDERAL_ZONE | `meterNumber` | `accountNumber` holds `DATMEX.zfed`, which is a **peso amount**, not a reference |
|
||||
|
||||
`scopedRefField` is exported because three places must agree on the answer: the
|
||||
lookup, the blank-service fill on review, and the write-back on confirm. When
|
||||
they disagree a reference gets learned into a column nothing searches, and the
|
||||
same page returns to the review queue every month forever.
|
||||
|
||||
The `FEDERAL_ZONE` case is the sharpest instance of a trap this codebase hits
|
||||
repeatedly (see also `policies.total`): a legacy column whose *name* promises
|
||||
an identifier and whose *contents* are something else. Three of its 77 values
|
||||
carry cents and one is negative. Worse than never matching — because every row
|
||||
already has a value, the `[field]: null` guards on learning and on the
|
||||
blank-service fill would never fire either.
|
||||
|
||||
### The clave catastral is a rescue on some layouts and the primary key on others
|
||||
|
||||
CESPT bills print the clave as well as an account number, so it rescues a page
|
||||
whose account number did not OCR — which happened on real samples. There it
|
||||
stays a hint.
|
||||
|
||||
On Rosarito and Ensenada predial the receipt prints **nothing else**, so a
|
||||
unique clave hit is a real match and auto-matches. Tijuana predial prints no
|
||||
clave at all; its only identifier is an 8-digit municipal account carried in a
|
||||
32-digit payment barcode (`account(8) + DDMMYY + amount(9) + folio(9)`) that
|
||||
the legacy database never held, so those pages start cold and are taught by the
|
||||
first confirm.
|
||||
|
||||
Multiple hits are always surfaced, never auto-picked — duplicate account
|
||||
numbers do occur in the legacy data, and the office's own `DUPLICADOS` report
|
||||
existed for a reason.
|
||||
|
||||
## Confirm: what gets written
|
||||
|
||||
`confirmBatch` posts through **`BillingService.createBatch`** — the same method
|
||||
the manual Editor screen uses — rather than writing `Transaction` rows
|
||||
directly, so OCR-sourced and hand-keyed receipts share one write path, one
|
||||
validation path and one audit trail.
|
||||
|
||||
- `source: "OCR"` and a per-line `captureRef` of the document id feed the
|
||||
duplicate-post guard, so a batch confirmed twice cannot double-charge.
|
||||
- `items[i]` is positionally parallel to `lines[i]` (a documented seam
|
||||
guarantee), so the created rows zip straight back onto the documents that
|
||||
produced them via `postedTransactionId`.
|
||||
- **The sign is applied here.** Charges are negative in this ledger; the parser
|
||||
reads the printed positive figure, and `-Math.abs()` is applied at the single
|
||||
point where a statement becomes a ledger row.
|
||||
- A missing amount blocks the confirm with the offending page numbers, rather
|
||||
than silently posting zero.
|
||||
|
||||
### Learning: the cold start is a one-time cost
|
||||
|
||||
After posting, `learnAccountRefs` writes each confirmed reference back onto the
|
||||
`PropertyService` that matched — **only where the field was null**. Never
|
||||
overwrites a number already on file, which would let one misread page rewrite
|
||||
good reference data.
|
||||
|
||||
This is what turns gas (whose numbers the migration never populated) and
|
||||
Tijuana predial (whose municipal account the legacy database never held) from a
|
||||
permanent review queue into a one-time cost: next month's statement for the
|
||||
same account matches on its own.
|
||||
|
||||
### Discarding
|
||||
|
||||
Refused once any page is `POSTED` — those pages already wrote ledger rows
|
||||
against a check, and a "discarded" label on the batch would leave the charges
|
||||
unexplained. Reject the remaining pages individually instead.
|
||||
|
||||
## API surface
|
||||
|
||||
| Method | Route | Ability |
|
||||
|---|---|---|
|
||||
| `GET` | `/statements/status` (is OCR + storage available) | authenticated |
|
||||
| `GET` | `/statements/batches`, `/batches/:id`, `/batches/:id/documents` | authenticated |
|
||||
| `GET` | `/statements/documents/:id/page` (streams the page image) | authenticated |
|
||||
| `POST` | `/statements/batches` (upload + service kind) | `statement:ingest` |
|
||||
| `PATCH` | `/statements/documents/:id` (correct a field or the match) | `statement:review` |
|
||||
| `POST` | `/statements/documents/:id/reject` | `statement:review` |
|
||||
| `POST` | `/statements/batches/:id/discard` | `statement:review` |
|
||||
| `POST` | `/statements/batches/:id/confirm` | `statement:review` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Object storage (`S3_ENDPOINT` + credentials) for the scans, and `tesseract-ocr`
|
||||
/ `tesseract-ocr-data-spa` / `poppler-utils` in the API image. Both are checked
|
||||
at upload rather than at the first write — a missing dependency should be a 400
|
||||
on the request, not a `FAILED` batch minutes later. `GET /statements/status`
|
||||
reports both and the upload card hides itself unless both hold.
|
||||
|
||||
## Tests
|
||||
|
||||
- `parsers/statement-parser.spec.ts` — `detectProvider`,
|
||||
`normalizeCadastralKey`, and one suite per newer parser
|
||||
(`parsePredialTijuana`, `parsePredialRosarito`, `parsePredialEnsenada`,
|
||||
`parseGas`, `parseZonaFederal`). Every fixture is a **verbatim OCR excerpt
|
||||
from a real receipt**, including the ones that bit: the `82,203.00` Ensenada
|
||||
dollar sign, the three-letter clave, the CESPT logo garbage.
|
||||
- `ocr/tesseract.provider.spec.ts` — `parseBboxLayout`, the
|
||||
`pdftotext -bbox-layout` reader that produces the text-layer `OcrPage`
|
||||
(positions included, which is what lets the geometric helpers work on
|
||||
born-digital input).
|
||||
|
||||
Note the CFE / CESPT / Telnor parsers themselves have **no unit suite** — they
|
||||
predate the gas/predial extension and were verified against the 46-page corpus
|
||||
end to end rather than in isolation. Worth closing if they are touched.
|
||||
|
||||
## Not built
|
||||
|
||||
- **Handwritten folder numbers.** Staff pencil a customer number on each bill
|
||||
(`9`, `405`); Tesseract read `405` as `205`. Handwriting is a review hint at
|
||||
best and is deliberately not an input to matching.
|
||||
- **Re-running a corrected parser over a stored batch.** The source PDFs are
|
||||
kept precisely so this is possible, but nothing exposes it yet.
|
||||
- **Providers beyond the eight above.** Adding one is a `BRAND` entry, an
|
||||
optional `LAYOUT` entry, and a parser function.
|
||||
|
||||
## Sibling feature
|
||||
|
||||
[`POLICY_OCR.md`](POLICY_OCR.md) — the same pipeline reading carrier policy
|
||||
PDFs into `Policy` rows, built out of this one. It reuses the seam, the
|
||||
provider-detection ordering, the digit-confusion map and the
|
||||
amount-by-separator rule.
|
||||
|
||||
**One assumption does not carry over.** Here a page *is* a document, because
|
||||
statements arrive one customer per page. A policy PDF is one document across
|
||||
several pages, so that feature concatenates the pages and parses once per file.
|
||||
If you are porting a change between the two, that is the difference to check
|
||||
first.
|
||||
+24
-5
@@ -15,6 +15,11 @@ Then:
|
||||
./.venv/bin/python run_all.py --env dev # data only (staging already present)
|
||||
./.venv/bin/python run_all.py --env prod --stage # re-extract from Access first, then load
|
||||
|
||||
--sync swaps the truncate+rebuild steps for the additive upsert ones. It reads
|
||||
the same staged Parquet, so it needs --stage too unless a previous run left
|
||||
migration/output populated on this machine — which is never true in a
|
||||
container, where that directory is part of the image and dies with it.
|
||||
|
||||
Reproducing dev -> prod is exactly `--env prod` (plus --stage if the staged
|
||||
Parquet isn't present on the machine running it).
|
||||
|
||||
@@ -78,7 +83,13 @@ SYNC_STEPS = [
|
||||
]
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> None:
|
||||
def run(cmd: list[str], step: int | None = None, total: int | None = None) -> None:
|
||||
# The "[paso i/N] name" marker is a contract with the Operaciones screen,
|
||||
# which parses the last one to show progress. Emitting it here rather than
|
||||
# letting the UI count STEPS itself keeps the two from drifting when a step
|
||||
# is added — the number of steps is only ever stated in this file.
|
||||
if step is not None and total is not None:
|
||||
print(f"[paso {step}/{total}] {Path(cmd[1]).name}", flush=True)
|
||||
print("+ " + " ".join(cmd), flush=True)
|
||||
r = subprocess.run(cmd)
|
||||
if r.returncode:
|
||||
@@ -94,14 +105,22 @@ def main() -> None:
|
||||
help="upsert legacy rows and archive removed legacy rows; preserve manual rows")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.stage:
|
||||
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")])
|
||||
steps = SYNC_STEPS if args.sync else STEPS
|
||||
# Staging counts as a step when it runs: it is the slowest part of the pass
|
||||
# (mdbtools re-reads every Access file), so leaving it outside the numbering
|
||||
# would park the Operaciones progress bar at "nothing yet" for minutes.
|
||||
total = len(steps) + (1 if args.stage else 0)
|
||||
offset = 1 if args.stage else 0
|
||||
|
||||
for step in SYNC_STEPS if args.sync else STEPS:
|
||||
if args.stage:
|
||||
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")],
|
||||
step=1, total=total)
|
||||
|
||||
for i, step in enumerate(steps, start=1 + offset):
|
||||
cmd = [PY, str(HERE / step), "--env", args.env]
|
||||
if args.sync:
|
||||
cmd.append("--sync")
|
||||
run(cmd)
|
||||
run(cmd, step=i, total=total)
|
||||
|
||||
print(f"\n✓ migration complete for env={args.env}")
|
||||
|
||||
|
||||
@@ -189,6 +189,11 @@ def customer_from_utilities(row, name_index) -> dict:
|
||||
customerSince=as_date(row["cliente_desde"]),
|
||||
status=as_bool(row["status"]),
|
||||
feeAmount=as_decimal(row["fee"]),
|
||||
# DATGRAL.TIPO is the minimum-balance threshold (100/200/300/500 —
|
||||
# 1,017 of 1,172 customers carry one), NOT an identification or account
|
||||
# type as the column name suggests. It reaches the website as
|
||||
# datosfreak.TIPO and is returned to the customer app as `minBalance`.
|
||||
minimumBalance=as_decimal(row["tipo"]),
|
||||
updatedAt=NOW,
|
||||
)
|
||||
|
||||
@@ -217,6 +222,7 @@ def customer_from_insurance(row, name_index) -> dict:
|
||||
customerSince=None,
|
||||
status=1,
|
||||
feeAmount=None,
|
||||
minimumBalance=None,
|
||||
updatedAt=NOW,
|
||||
)
|
||||
|
||||
@@ -225,7 +231,7 @@ _CUST_COLS = [
|
||||
"id", "name", "nameSource", "nameMissing", "addressLine1", "addressLine2", "city", "state", "zipCode",
|
||||
"country", "phone", "mobile", "fax", "email", "notes", "identificationType",
|
||||
"identificationNumber", "identificationExpiration", "customerSince",
|
||||
"status", "feeAmount", "updatedAt",
|
||||
"status", "feeAmount", "minimumBalance", "updatedAt",
|
||||
]
|
||||
|
||||
|
||||
@@ -316,7 +322,7 @@ def main() -> None:
|
||||
remap[rec["id"]] = stable or rec["id"]
|
||||
for rec in customers:
|
||||
rec["id"] = remap[rec["id"]]
|
||||
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||
for ref in refs:
|
||||
cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)",
|
||||
(ref[0], remap[ref[1]], ref[2], ref[3], ref[4]))
|
||||
|
||||
@@ -112,6 +112,29 @@ def main():
|
||||
type_rows.append((tid, en, s(r["espa_ol"]), 0))
|
||||
type_map[en.upper()] = tid
|
||||
|
||||
def type_id_for(raw) -> str | None:
|
||||
"""Resolve a transaction type, minting one when the lookup lacks it.
|
||||
|
||||
The Access `TYPE OF TRX` table is a stale pick-list, not a constraint —
|
||||
staff free-text straight into DATOS2, so 78 values covering 3,939 rows
|
||||
(BALANCE FORWARD 1,188, ANNUAL FEE 1,116, IZZI 367, ...) appear in the
|
||||
ledger but not the lookup. Leaving those unmapped stored typeId NULL and
|
||||
lost the label outright: nothing else on `transactions` carries the type
|
||||
text, so the row rendered blank and was unrecoverable after migration.
|
||||
Minting from the literal keeps the display string; nameEs stays NULL
|
||||
because only the lookup has translations.
|
||||
"""
|
||||
en = s(raw)
|
||||
if not en:
|
||||
return None
|
||||
key = en.upper()
|
||||
tid = type_map.get(key)
|
||||
if tid is None:
|
||||
tid = str(uuid.uuid4())
|
||||
type_rows.append((tid, en, None, 0))
|
||||
type_map[key] = tid
|
||||
return tid
|
||||
|
||||
xr = load("stg_utilities", "tipo_hist")
|
||||
xr_rows = []
|
||||
for _, r in xr.iterrows():
|
||||
@@ -126,10 +149,11 @@ def main():
|
||||
skip_cust = skip_date = skip_dupe = 0
|
||||
|
||||
def add(cid, domain, tdate, amount, currency, *, period=None, reference=None,
|
||||
typeid=None, check=None, message=None, src_db=None, src_tbl=None, legacy=None):
|
||||
typeid=None, check=None, message=None, src_db=None, src_tbl=None, legacy=None,
|
||||
outstanding=0):
|
||||
tx.append((str(uuid.uuid4()), cid, domain, typeid, tdate, period, reference,
|
||||
amount if amount is not None else Decimal(0), currency, None, check,
|
||||
message, 0, src_db, src_tbl, legacy))
|
||||
message, outstanding, src_db, src_tbl, legacy))
|
||||
|
||||
# Business key of a real cash payment. `folio` is deliberately excluded: it
|
||||
# is a per-table sequential number that collides between EFECTIVO and
|
||||
@@ -143,12 +167,24 @@ def main():
|
||||
s(r["conepto"]),
|
||||
)
|
||||
|
||||
def efectivo_like(src, name, domain, custmap, src_db, legacy_tbl, *, seen=None):
|
||||
def efectivo_like(src, name, domain, custmap, src_db, legacy_tbl, *, seen=None,
|
||||
type_label=None):
|
||||
"""Load an EFECTIVO-shaped cash ledger.
|
||||
|
||||
`seen` (a set) makes the load de-duplicating: keys are added to it as
|
||||
rows load, and a row whose key is already present is skipped. That is
|
||||
how EFECTIVO_BACKUP contributes only its genuinely-new rows.
|
||||
|
||||
`type_label` names the transaction type for every row. These tables have
|
||||
no type column at all — in Access the type is implied by which table the
|
||||
row lives in — so unlike DATOS2 there is no string to map and typeId came
|
||||
out NULL for all of them.
|
||||
|
||||
That is not merely a blank label. handleGetAccountDetails in
|
||||
my.jorgecuadros.com identifies payments by matching TYPEOFTRX against
|
||||
('PAYMENT THANK YOU', 'PAYPAL', 'CASH DEPOSIT', 'CHECK DEPOSIT') to reset
|
||||
the running balance in mode=current; an unlabelled payment is not
|
||||
recognised and the balance silently diverges from legacy.
|
||||
"""
|
||||
nonlocal skip_cust, skip_date, skip_dupe
|
||||
df = load(src, name)
|
||||
@@ -166,9 +202,20 @@ def main():
|
||||
skip_date += 1; continue
|
||||
add(cid, domain, td, dec(r["monto"], Decimal(0)), cur(r["monedas"]),
|
||||
reference=s(r["folio"]), message=s(r["conepto"]),
|
||||
typeid=type_id_for(type_label),
|
||||
src_db=src_db, src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
|
||||
|
||||
def fm3(name, legacy_tbl, check_col=None):
|
||||
"""FM3 fee streams. Deliberately left unlabelled, unlike EFECTIVO.
|
||||
|
||||
These rows (EFECTIVO FM3 627, CHEQUE FM3 157) also have no type column,
|
||||
but every one of them predates the two periods the site exposes — it
|
||||
allowlists only the current year and the prior year — so none can be
|
||||
matched against a legacy label, and none can reach a customer. Inventing
|
||||
a plausible name like "CHECK DEPOSIT" would feed the payment-detection
|
||||
list in handleGetAccountDetails on nothing but a guess. Leave them NULL
|
||||
until a real mapping is available.
|
||||
"""
|
||||
nonlocal skip_cust, skip_date
|
||||
df = load("stg_utilities", name)
|
||||
for _, r in df.iterrows():
|
||||
@@ -184,6 +231,16 @@ def main():
|
||||
src_db="UTILITIES", src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
|
||||
|
||||
def billing(name, legacy_tbl):
|
||||
"""Load a DATOS2-shaped billing ledger.
|
||||
|
||||
NOPAGO is the legacy "still owed" flag. The website reads it directly —
|
||||
`account.statement.php` splits the statement on `NOPAGO = 0` vs
|
||||
`NOPAGO = 1` and renders the latter as the "Outstanding Bills Requiring
|
||||
Attention" table — so dropping it does not merely lose a column, it
|
||||
silently empties that whole section for anyone served off the platform.
|
||||
Only these three tables carry it (76 rows set in DATOS2 today); the
|
||||
EFECTIVO/FM3 cash streams have no such column and stay 0.
|
||||
"""
|
||||
nonlocal skip_cust, skip_date
|
||||
df = load("stg_utilities", name)
|
||||
for _, r in df.iterrows():
|
||||
@@ -193,11 +250,12 @@ def main():
|
||||
td = dt(r["date"])
|
||||
if td is None:
|
||||
skip_date += 1; continue
|
||||
tid = type_map.get((s(r["type_of_trx"]) or "").upper())
|
||||
tid = type_id_for(r["type_of_trx"])
|
||||
add(cid, "UTILITY", td, dec(r["chargecredit"], Decimal(0)), "MXN",
|
||||
period=s(r["period"]), reference=s(r["refer"]), typeid=tid,
|
||||
check=s(r["cheque"]), src_db="UTILITIES", src_tbl=legacy_tbl,
|
||||
legacy=str(int(r["_row_num"])))
|
||||
legacy=str(int(r["_row_num"])),
|
||||
outstanding=1 if s(r["nopago"]) == "1" else 0)
|
||||
|
||||
def iva():
|
||||
nonlocal skip_cust
|
||||
@@ -214,17 +272,25 @@ def main():
|
||||
# order matters: EFECTIVO is the live table and loads first, so a collision
|
||||
# always resolves in its favour.
|
||||
cash_seen: set = set()
|
||||
# "CASH DEPOSIT" is not a guess: matching these rows to the live site on
|
||||
# (NUMid, date, amount) resolves to that label unanimously — 66/66 in the
|
||||
# current-year `datosfreak` and 100/100 in the prior-year `2025` table,
|
||||
# which are the only two periods the site exposes.
|
||||
efectivo_like("stg_utilities", "efectivo", "UTILITY", util_cust, "UTILITIES",
|
||||
"EFECTIVO", seen=cash_seen)
|
||||
"EFECTIVO", seen=cash_seen, type_label="CASH DEPOSIT")
|
||||
efectivo_like("stg_utilities", "efectivo_backup", "UTILITY", util_cust, "UTILITIES",
|
||||
"EFECTIVO_BACKUP", seen=cash_seen)
|
||||
"EFECTIVO_BACKUP", seen=cash_seen, type_label="CASH DEPOSIT")
|
||||
fm3("efectivo_fm3", "EFECTIVO FM3")
|
||||
fm3("cheque_fm3", "CHEQUE FM3", check_col="num_cheque")
|
||||
billing("datos2", "datos2")
|
||||
billing("fee_anual", "FEE ANUAL")
|
||||
billing("fee15", "fee15")
|
||||
iva()
|
||||
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be", "EFECTIVO")
|
||||
# Same record shape in the seguros DB. Labelled for consistency in the
|
||||
# platform's own UI; unverifiable against the site, which only ever reads
|
||||
# domain='UTILITY', so no customer-facing behaviour depends on it.
|
||||
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be",
|
||||
"EFECTIVO", type_label="CASH DEPOSIT")
|
||||
|
||||
if sync_mode:
|
||||
# Transaction types are rebuilt with fresh uuids each run; resolve them
|
||||
@@ -243,7 +309,7 @@ def main():
|
||||
if new_types:
|
||||
c.executemany("INSERT INTO type_transactions (id,nameEn,nameEs,isService) VALUES (%s,%s,%s,%s)", new_types)
|
||||
tx = [(t[0], t[1], t[2], (db_types.get(fresh_name.get(t[3])) if t[3] else None), *t[4:]) for t in tx]
|
||||
c.executemany("INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),domain=VALUES(domain),typeId=VALUES(typeId),transactionDate=VALUES(transactionDate),period=VALUES(period),reference=VALUES(reference),amount=VALUES(amount),currency=VALUES(currency),checkNumber=VALUES(checkNumber),message=VALUES(message),voidedAt=NULL", tx)
|
||||
c.executemany("INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),domain=VALUES(domain),typeId=VALUES(typeId),transactionDate=VALUES(transactionDate),period=VALUES(period),reference=VALUES(reference),amount=VALUES(amount),currency=VALUES(currency),checkNumber=VALUES(checkNumber),message=VALUES(message),outstanding=VALUES(outstanding),voidedAt=NULL", tx)
|
||||
else:
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
for t in ("transactions", "type_transactions", "exchange_rates"):
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add DISCARDED to both OCR batch status enums. Staff can now abandon a
|
||||
-- pending review queue outright instead of leaving it stuck in
|
||||
-- READY_FOR_REVIEW forever (rejecting every page never closed the batch).
|
||||
-- Purely additive: no existing row changes value.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `policy_ocr_batches`
|
||||
MODIFY `status` ENUM('UPLOADED', 'PROCESSING', 'READY_FOR_REVIEW', 'COMPLETED', 'FAILED', 'DISCARDED') NOT NULL DEFAULT 'UPLOADED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `statement_batches`
|
||||
MODIFY `status` ENUM('UPLOADED', 'PROCESSING', 'READY_FOR_REVIEW', 'COMPLETED', 'FAILED', 'DISCARDED') NOT NULL DEFAULT 'UPLOADED';
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE `customers`
|
||||
ADD COLUMN `emailOptOut` BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE `renewal_notices`
|
||||
ADD COLUMN `providerMessageId` VARCHAR(191) NULL;
|
||||
|
||||
CREATE TABLE `scheduled_job_states` (
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`lockedUntil` DATETIME(3) NULL,
|
||||
`lastSuccessfulAt` DATETIME(3) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`name`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
-- Mass email notifications — modern replacement for the legacy
|
||||
-- `utility_dbo.email_alert_log` + `utility_dbo.send_account_status_history`
|
||||
-- tables, fed by the four PHP scripts under
|
||||
-- `email.notifications/send*.php`. See
|
||||
-- docs/MASS_EMAIL_NOTIFICATIONS.md for the design.
|
||||
--
|
||||
-- The two legacy tables stay on `utility_dbo` untouched: their `NUMid`
|
||||
-- column references a string identifier that no longer exists in the
|
||||
-- unified schema, so a backfill would be destructive, not additive. New
|
||||
-- notifications log here against the unified `customers.id` (uuid) and
|
||||
-- the legacy rows are eventually retired by `utility_dbo` itself once
|
||||
-- the office flips to this codebase as the source of truth.
|
||||
|
||||
-- CreateTable
|
||||
-- ENUM values are declared inline per column (MySQL has no CREATE TYPE)
|
||||
-- and match the Prisma enums `EmailNotificationType`,
|
||||
-- `EmailNotificationServicio`, `EmailNotificationStatus`.
|
||||
CREATE TABLE `email_notification_log` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`sendDate` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`notificationType` ENUM('OUTSTANDING_PAYMENT', 'PAYMENT_CONFIRMATION', 'ACCOUNT_STATUS', 'TRUST_PAYMENT_CONFIRMATION') NOT NULL,
|
||||
`level` INTEGER NULL,
|
||||
`servicio` ENUM('CUSTOMERS', 'TRUST') NOT NULL,
|
||||
`customerId` VARCHAR(191) NULL,
|
||||
`customerName` VARCHAR(191) NOT NULL,
|
||||
`customerEmail` VARCHAR(191) NOT NULL,
|
||||
`subject` VARCHAR(191) NOT NULL,
|
||||
`bodyRequestUrl` TEXT NULL,
|
||||
`bodySnapshot` TEXT NOT NULL,
|
||||
`debug` BOOLEAN NOT NULL DEFAULT false,
|
||||
`providerMessageId` VARCHAR(191) NULL,
|
||||
`providerResponse` VARCHAR(191) NULL,
|
||||
`status` ENUM('SENT', 'FAILED', 'SKIPPED_NO_EMAIL', 'SKIPPED_GATE') NOT NULL,
|
||||
`error` TEXT NULL,
|
||||
|
||||
INDEX `email_notification_log_sendDate_idx`(`sendDate`),
|
||||
INDEX `email_notification_log_notificationType_sendDate_idx`(`notificationType`, `sendDate`),
|
||||
INDEX `email_notification_log_customerId_sendDate_idx`(`customerId`, `sendDate`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `account_status_history` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`sendDate` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`customerId` VARCHAR(191) NOT NULL,
|
||||
`customerName` VARCHAR(191) NOT NULL,
|
||||
`customerEmail` VARCHAR(191) NOT NULL,
|
||||
`tipo` VARCHAR(191) NOT NULL,
|
||||
`tCambio` DECIMAL(10, 4) NULL,
|
||||
`balance` DECIMAL(12, 2) NOT NULL,
|
||||
`solicitado` DECIMAL(12, 2) NOT NULL,
|
||||
`level` INTEGER NOT NULL,
|
||||
|
||||
INDEX `account_status_history_sendDate_idx`(`sendDate`),
|
||||
INDEX `account_status_history_customerId_sendDate_idx`(`customerId`, `sendDate`),
|
||||
INDEX `account_status_history_level_sendDate_idx`(`level`, `sendDate`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `email_notification_log` ADD CONSTRAINT `email_notification_log_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `account_status_history` ADD CONSTRAINT `account_status_history_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
-- Fold insurance renewal avisos into the one notification log.
|
||||
--
|
||||
-- Until now `renewal_notices` was the only record a renewal send left
|
||||
-- behind, and its sole job is gating: a row with `sentAt` removes the
|
||||
-- policy from the pending list. It cannot represent a failed send, a
|
||||
-- customer with no email, or a second attempt — so the Pólizas tab of
|
||||
-- /notificaciones had no "Registro de envíos" to show.
|
||||
--
|
||||
-- Extending the two enums lets `RenewalsService` write the same
|
||||
-- `email_notification_log` rows the four bulk jobs write. `renewal_notices`
|
||||
-- keeps its gating role unchanged.
|
||||
|
||||
-- AlterEnum: EmailNotificationType += RENEWAL_NOTICE
|
||||
ALTER TABLE `email_notification_log`
|
||||
MODIFY `notificationType` ENUM('OUTSTANDING_PAYMENT', 'PAYMENT_CONFIRMATION', 'ACCOUNT_STATUS', 'TRUST_PAYMENT_CONFIRMATION', 'RENEWAL_NOTICE') NOT NULL;
|
||||
|
||||
-- AlterEnum: EmailNotificationServicio += POLICIES
|
||||
ALTER TABLE `email_notification_log`
|
||||
MODIFY `servicio` ENUM('CUSTOMERS', 'TRUST', 'POLICIES') NOT NULL;
|
||||
|
||||
-- Backfill: every renewal notice this platform actually emailed.
|
||||
--
|
||||
-- Scope is deliberately `channel = 'EMAIL' AND sentAt IS NOT NULL`. MAIL-
|
||||
-- channel rows are printed letters carried over from the legacy book — they
|
||||
-- were never emails, and inventing log rows for them would misreport the
|
||||
-- send history. Emailed rows carry a real recipient (resolved through the
|
||||
-- policy's customer) and a real timestamp; the only fields we cannot
|
||||
-- recover are the rendered body and the exact subject, so `bodySnapshot`
|
||||
-- stays empty and the subject is reconstructed from the same two templates
|
||||
-- `renderRenewalEmail()` uses (generation 3 = expired wording).
|
||||
--
|
||||
-- Rows whose customer has no email are skipped: `customerEmail` is NOT NULL
|
||||
-- and a blank recipient would be a lie. Their `renewal_notices` row still
|
||||
-- gates the pending list exactly as before.
|
||||
INSERT INTO `email_notification_log` (
|
||||
`id`, `sendDate`, `notificationType`, `level`, `servicio`,
|
||||
`customerId`, `customerName`, `customerEmail`, `subject`,
|
||||
`bodyRequestUrl`, `bodySnapshot`, `debug`,
|
||||
`providerMessageId`, `providerResponse`, `status`, `error`
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
rn.`sentAt`,
|
||||
'RENEWAL_NOTICE',
|
||||
rn.`generation`,
|
||||
'POLICIES',
|
||||
c.`id`,
|
||||
c.`name`,
|
||||
c.`email`,
|
||||
CASE WHEN rn.`generation` = 3
|
||||
THEN CONCAT('Póliza vencida: ', p.`policyNumber`)
|
||||
ELSE CONCAT('Aviso de renovación: póliza ', p.`policyNumber`)
|
||||
END,
|
||||
NULL,
|
||||
'',
|
||||
FALSE,
|
||||
rn.`providerMessageId`,
|
||||
'backfill:20260802120000',
|
||||
'SENT',
|
||||
NULL
|
||||
FROM `renewal_notices` rn
|
||||
JOIN `policies` p ON p.`id` = rn.`policyId`
|
||||
JOIN `customers` c ON c.`id` = p.`customerId`
|
||||
WHERE rn.`channel` = 'EMAIL'
|
||||
AND rn.`sentAt` IS NOT NULL
|
||||
AND c.`email` IS NOT NULL
|
||||
AND c.`email` <> '';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Operator-editable configuration.
|
||||
--
|
||||
-- First tenant: the notification summary recipients, which were
|
||||
-- NOTIFICATION_ADMIN_EMAILS in the environment. That list changes when office
|
||||
-- staff change — a redeploy is the wrong unit of work for "Ana left, add
|
||||
-- Beto" — so it belongs in the database with a UI, not in a stack env var.
|
||||
--
|
||||
-- Credentials stay in env. See the model doc in schema.prisma for where the
|
||||
-- line is drawn.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `app_settings` (
|
||||
`key` VARCHAR(191) NOT NULL,
|
||||
`value` TEXT NOT NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
`updatedById` VARCHAR(191) NULL,
|
||||
|
||||
PRIMARY KEY (`key`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -99,6 +99,7 @@ model Customer {
|
||||
mobile String?
|
||||
fax String?
|
||||
email String?
|
||||
emailOptOut Boolean @default(false)
|
||||
notes String? @db.Text
|
||||
identificationType String?
|
||||
identificationNumber String?
|
||||
@@ -124,6 +125,9 @@ model Customer {
|
||||
statementDocuments StatementDocument[]
|
||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||
|
||||
emailNotificationLogs EmailNotificationLog[]
|
||||
accountStatusHistory AccountStatusHistory[]
|
||||
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
@@ -238,6 +242,7 @@ model RenewalNotice {
|
||||
channel RenewalNoticeChannel @default(MAIL)
|
||||
sentAt DateTime?
|
||||
sentById String?
|
||||
providerMessageId String?
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -399,6 +404,10 @@ enum PolicyOcrBatchStatus {
|
||||
READY_FOR_REVIEW
|
||||
COMPLETED
|
||||
FAILED
|
||||
/// Abandoned by staff before anything was applied — a bad scan, the wrong
|
||||
/// PDFs, a duplicate upload. Distinct from COMPLETED so the queue can tell
|
||||
/// "we did the work" from "we threw it away".
|
||||
DISCARDED
|
||||
}
|
||||
|
||||
/// One parsed policy page — one Policy → one Customer (after staff confirms).
|
||||
@@ -573,6 +582,10 @@ enum StatementBatchStatus {
|
||||
READY_FOR_REVIEW
|
||||
COMPLETED
|
||||
FAILED
|
||||
/// Abandoned by staff before anything was posted — a bad scan, the wrong
|
||||
/// PDFs, a duplicate upload. Distinct from COMPLETED so the queue can tell
|
||||
/// "we did the work" from "we threw it away".
|
||||
DISCARDED
|
||||
}
|
||||
|
||||
enum StatementDocumentStatus {
|
||||
@@ -909,6 +922,163 @@ model EmailLog {
|
||||
@@map("email_log")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mass email notifications — the modern replacement for the legacy
|
||||
// `email_alert_log` + `send_account_status_history` tables on utility_dbo and
|
||||
// the four PHP scripts under `email.notifications/`. See
|
||||
// docs/MASS_EMAIL_NOTIFICATIONS.md for the full design.
|
||||
//
|
||||
// The two legacy tables are not imported into this schema: the unified
|
||||
// `customers` model replaces `datosfreak` (no more NUMid-as-string), so the
|
||||
// rows would no longer carry their meaning. New tables follow the unified
|
||||
// shape (FK to `customers`, signed Decimal balance, proper enums) and the
|
||||
// four notification kinds are one `notificationType` enum rather than four
|
||||
// parallel column families.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which bulk-notification script produced a row. Mirrors the four PHP
|
||||
/// jobs in `email.notifications/send*.php`:
|
||||
/// - OUTSTANDING_PAYMENT → sendOutstandingPaymentAlerts.php
|
||||
/// - PAYMENT_CONFIRMATION → sendPaymentConfirmation.php (pagosemail)
|
||||
/// - ACCOUNT_STATUS → sendAccountStatus.php (datosfreak, both
|
||||
/// red and yellow; the threshold is in the
|
||||
/// `level` column, 0=yellow / 1=red)
|
||||
/// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE)
|
||||
///
|
||||
/// RENEWAL_NOTICE has no PHP ancestor — it is the insurance renewal aviso
|
||||
/// (`RenewalsService`), logged here so every outbound email the platform
|
||||
/// sends lands in one table. `RenewalNotice` remains the per-policy
|
||||
/// "already notified" record that drives the pending list; this log is the
|
||||
/// send history, including the failures and skips `RenewalNotice` cannot
|
||||
/// represent.
|
||||
enum EmailNotificationType {
|
||||
OUTSTANDING_PAYMENT
|
||||
PAYMENT_CONFIRMATION
|
||||
ACCOUNT_STATUS
|
||||
TRUST_PAYMENT_CONFIRMATION
|
||||
RENEWAL_NOTICE
|
||||
}
|
||||
|
||||
/// Which "servicio" (line of business) the notification draws its recipients
|
||||
/// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`);
|
||||
/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`);
|
||||
/// POLICIES = the insurance book (renewal avisos). Keeping the services
|
||||
/// tagged makes a per-line report trivial — and lets the /notificaciones
|
||||
/// tabs each show their own slice of the one log.
|
||||
enum EmailNotificationServicio {
|
||||
CUSTOMERS
|
||||
TRUST
|
||||
POLICIES
|
||||
}
|
||||
|
||||
/// Outcome of a single send attempt. SENT / FAILED are the meaningful ones;
|
||||
/// SKIPPED_NO_EMAIL records the dry-run path and the legacy's
|
||||
/// "EMAIL IS NULL" exclusion, SKIPPED_GATE records the Mon/Wed/Fri day gate
|
||||
/// on the red branch (and the Wed gate on yellow) — so a sweep that ran on
|
||||
/// the wrong day shows up as skipped rows, not as missing rows.
|
||||
enum EmailNotificationStatus {
|
||||
SENT
|
||||
FAILED
|
||||
SKIPPED_NO_EMAIL
|
||||
SKIPPED_GATE
|
||||
}
|
||||
|
||||
/// One row per send attempt. Captures both the deliverable (subject + body
|
||||
/// snapshot + provider message id) and the diagnostic (URL we would have
|
||||
/// fetched in the PHP version, SES response, error string). The body snapshot
|
||||
/// is intentionally kept: the PHP scripts only stored it on the error path;
|
||||
/// we store it always, so a customer reply quoting an old email can be traced
|
||||
/// to the exact letter that was sent.
|
||||
model EmailNotificationLog {
|
||||
id String @id @default(uuid())
|
||||
sendDate DateTime @default(now())
|
||||
notificationType EmailNotificationType
|
||||
/// Per-type discriminator, null where the type has none:
|
||||
/// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO")
|
||||
/// RENEWAL_NOTICE → the aviso generation (1 = 30d before, 2 = 15d
|
||||
/// before, 3 = 7d after expiry)
|
||||
/// Null on the remaining jobs. Readers MUST branch on notificationType
|
||||
/// before interpreting it.
|
||||
level Int?
|
||||
/// Which servicio sourced the recipient list. CUSTOMERS for jobs 1/2/3,
|
||||
/// TRUST for job 4, POLICIES for renewal avisos. Tagged here so a per-line
|
||||
/// audit doesn't need to join — and so the /notificaciones Servicios tab
|
||||
/// (CUSTOMERS + TRUST) and Pólizas tab (POLICIES) can filter one log.
|
||||
servicio EmailNotificationServicio
|
||||
/// FK to the customer that triggered the send. Trust-account notifications
|
||||
/// resolve the owner through `Property.customerId`, so this stays set on
|
||||
/// job 4 too. Null only on skipped rows where the lookup itself failed.
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
customerName String
|
||||
customerEmail String
|
||||
/// Subject line of the email we attempted to send.
|
||||
subject String
|
||||
/// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and
|
||||
/// fetched (kept verbatim so the legacy format is reproducible). Null on
|
||||
/// the other three jobs — the body is built inline.
|
||||
bodyRequestUrl String? @db.Text
|
||||
/// The HTML body that was sent (or that would have been sent, for SKIPPED
|
||||
/// rows). Stored verbatim so audit/customer-service can read the exact
|
||||
/// letter that went out without re-running the render.
|
||||
bodySnapshot String @db.Text
|
||||
/// True when `debug` was passed — the recipient was overridden to the
|
||||
/// admin address and no real customer received the mail. Kept here so a
|
||||
/// "where did all these emails go" investigation finds the answer in one
|
||||
/// place instead of "who ran what with what flags" archaeology.
|
||||
debug Boolean @default(false)
|
||||
/// SES SendEmail MessageId, when we actually got one back. Null on
|
||||
/// failures, skipped rows, and dev/mock transport.
|
||||
providerMessageId String?
|
||||
/// Free-form provider response (or error). Trimmed to 4k chars before
|
||||
/// insert so a verbose SES bounce payload can't blow the column.
|
||||
providerResponse String?
|
||||
status EmailNotificationStatus
|
||||
error String? @db.Text
|
||||
|
||||
@@index([sendDate])
|
||||
@@index([notificationType, sendDate])
|
||||
@@index([customerId, sendDate])
|
||||
@@map("email_notification_log")
|
||||
}
|
||||
|
||||
/// Mirrors the legacy `utility_dbo.send_account_status_history` table — one
|
||||
/// row per ACCOUNT_STATUS send, capturing the inputs the PHP version logged
|
||||
/// for audit ("what balance, what threshold, what category of alert did we
|
||||
/// fire"). Kept separate from `EmailNotificationLog` so the audit query
|
||||
/// ("every red alert we ever sent this customer") doesn't have to filter by
|
||||
/// notificationType; a one-row-per-send history is the whole point of the
|
||||
/// legacy table.
|
||||
model AccountStatusHistory {
|
||||
id String @id @default(uuid())
|
||||
sendDate DateTime @default(now())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
customerName String
|
||||
customerEmail String
|
||||
/// "DEBAJO DEL TIPO" or "EN ROJO" — the legacy literal strings. Kept
|
||||
/// verbatim (not an enum) because the PHP scripts and downstream reports
|
||||
/// filter by them, and "preserve legacy semantics" is the stated goal.
|
||||
tipo String
|
||||
/// Exchange rate at send time, kept for currency conversions downstream.
|
||||
/// Null when the customer has no exchange-rate context (no FX movement).
|
||||
tCambio Decimal? @db.Decimal(10, 4)
|
||||
/// Customer's balance at send time, in the customer's currency. Negative
|
||||
/// for red; 0..min for yellow.
|
||||
balance Decimal @db.Decimal(12, 2)
|
||||
/// Legacy formula: `0 - TIPO - BALANCE` — the amount the customer needs to
|
||||
/// deposit to clear the threshold. Preserved verbatim even though it
|
||||
/// double-subtracts; downstream reports depend on the exact figure.
|
||||
solicitado Decimal @db.Decimal(12, 2)
|
||||
/// 0 = yellow, 1 = red. Mirrors the legacy `level` column.
|
||||
level Int
|
||||
|
||||
@@index([sendDate])
|
||||
@@index([customerId, sendDate])
|
||||
@@index([level, sendDate])
|
||||
@@map("account_status_history")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin database operations (Operaciones): backup / restore / re-import / sync.
|
||||
// Each long-running op is one OpsJob row so the web UI can poll status + tail
|
||||
@@ -945,3 +1115,40 @@ model OpsJob {
|
||||
@@index([startedAt])
|
||||
@@map("ops_jobs")
|
||||
}
|
||||
|
||||
model ScheduledJobState {
|
||||
name String @id
|
||||
lockedUntil DateTime?
|
||||
lastSuccessfulAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("scheduled_job_states")
|
||||
}
|
||||
|
||||
/// Operator-editable configuration — the settings that staff must be able to
|
||||
/// change without a redeploy.
|
||||
///
|
||||
/// Deliberately NOT a home for everything in the environment. Credentials and
|
||||
/// endpoints (SES keys, DATABASE_URL, S3) stay in env: they are deployment
|
||||
/// identity, they must exist before the app can talk to its own database, and
|
||||
/// putting a secret in a table only widens who can read it. What belongs here
|
||||
/// is the opposite kind of value — no secret, changes on office business
|
||||
/// rhythm rather than deploy rhythm, and wrong far more often than the
|
||||
/// deployment is.
|
||||
///
|
||||
/// `value` is TEXT holding whatever encoding the owning feature defines
|
||||
/// (a comma-separated list, a JSON blob). Each setting has exactly one reader,
|
||||
/// which owns parsing and validation; there is no generic typed accessor,
|
||||
/// because a schema-less bag with a typed façade is just a schema with the
|
||||
/// checks moved somewhere easier to forget.
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value String @db.Text
|
||||
updatedAt DateTime @updatedAt
|
||||
/// Who last changed it. Null for rows written before the UI existed or by
|
||||
/// a migration. Not an FK: a setting must outlive the user who set it.
|
||||
updatedById String?
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
Generated
+291
@@ -13,6 +13,9 @@ importers:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.665.0
|
||||
version: 3.1093.0
|
||||
'@aws-sdk/client-sesv2':
|
||||
specifier: ^3.1101.0
|
||||
version: 3.1101.0
|
||||
'@jorgecuadros/database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/database
|
||||
@@ -31,6 +34,9 @@ importers:
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^10.4.4
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
'@nestjs/schedule':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
argon2:
|
||||
specifier: ^0.41.1
|
||||
version: 0.41.1
|
||||
@@ -40,6 +46,9 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.14.1
|
||||
version: 0.14.4
|
||||
cron:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
exceljs:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
@@ -165,18 +174,38 @@ packages:
|
||||
resolution: {integrity: sha512-7452vEdp/nihIBWijnmcTBujXEFfbs4F02wyBDGqmNr6pwyo5GmQorx0zQIVg8QGFLXiBvsWKXBhCdiBcxNnGA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-sesv2@3.1101.0':
|
||||
resolution: {integrity: sha512-5n4COAW5u6T1gOz4t6RueAncImDjK0wdoQEXu1ABnRfLAoNa2dlouubKq3lHLliQdhhEo4so7Cn88ylprab0Ng==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.976.0':
|
||||
resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.977.4':
|
||||
resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.60':
|
||||
resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.65':
|
||||
resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.62':
|
||||
resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.67':
|
||||
resolution: {integrity: sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.10':
|
||||
resolution: {integrity: sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.5':
|
||||
resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -185,22 +214,42 @@ packages:
|
||||
resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.72':
|
||||
resolution: {integrity: sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.71':
|
||||
resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.76':
|
||||
resolution: {integrity: sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.60':
|
||||
resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.65':
|
||||
resolution: {integrity: sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.4':
|
||||
resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.9':
|
||||
resolution: {integrity: sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.66':
|
||||
resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.71':
|
||||
resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.65':
|
||||
resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -209,14 +258,26 @@ packages:
|
||||
resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.39':
|
||||
resolution: {integrity: sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.41':
|
||||
resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.43':
|
||||
resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1092.0':
|
||||
resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1100.0':
|
||||
resolution: {integrity: sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.974.2':
|
||||
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -225,6 +286,10 @@ packages:
|
||||
resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.37':
|
||||
resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0':
|
||||
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -580,6 +645,12 @@ packages:
|
||||
'@nestjs/common': ^10.0.0
|
||||
'@nestjs/core': ^10.0.0
|
||||
|
||||
'@nestjs/schedule@4.1.2':
|
||||
resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
|
||||
'@nestjs/schematics@10.2.3':
|
||||
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
|
||||
peerDependencies:
|
||||
@@ -709,18 +780,38 @@ packages:
|
||||
resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.31.1':
|
||||
resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.12':
|
||||
resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.16':
|
||||
resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.13':
|
||||
resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.9':
|
||||
resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.9.13':
|
||||
resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.9.9':
|
||||
resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.6.12':
|
||||
resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.6.8':
|
||||
resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -814,6 +905,9 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/luxon@3.4.2':
|
||||
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
@@ -1349,6 +1443,9 @@ packages:
|
||||
create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
|
||||
cron@3.2.1:
|
||||
resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2220,6 +2317,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
luxon@3.5.0:
|
||||
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magic-string@0.30.8:
|
||||
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3125,6 +3226,10 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@11.0.3:
|
||||
resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
@@ -3308,6 +3413,18 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-sesv2@3.1101.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/credential-provider-node': 3.972.76
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.43
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/fetch-http-handler': 5.6.13
|
||||
'@smithy/node-http-handler': 4.9.13
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.976.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.2
|
||||
@@ -3319,6 +3436,17 @@ snapshots:
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.977.4':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@aws-sdk/xml-builder': 3.972.37
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/signature-v4': 5.6.12
|
||||
'@smithy/types': 4.16.1
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3327,6 +3455,14 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.65':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.62':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3337,6 +3473,32 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.67':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/fetch-http-handler': 5.6.13
|
||||
'@smithy/node-http-handler': 4.9.13
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.10':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/credential-provider-env': 3.972.65
|
||||
'@aws-sdk/credential-provider-http': 3.972.67
|
||||
'@aws-sdk/credential-provider-login': 3.972.72
|
||||
'@aws-sdk/credential-provider-process': 3.972.65
|
||||
'@aws-sdk/credential-provider-sso': 3.973.9
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.71
|
||||
'@aws-sdk/nested-clients': 3.997.39
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/credential-provider-imds': 4.4.16
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.5':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3362,6 +3524,15 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.72':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/nested-clients': 3.997.39
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.71':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.60
|
||||
@@ -3376,6 +3547,20 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.65
|
||||
'@aws-sdk/credential-provider-http': 3.972.67
|
||||
'@aws-sdk/credential-provider-ini': 3.973.10
|
||||
'@aws-sdk/credential-provider-process': 3.972.65
|
||||
'@aws-sdk/credential-provider-sso': 3.973.9
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.71
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/credential-provider-imds': 4.4.16
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3384,6 +3569,14 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.65':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.4':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3394,6 +3587,16 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.9':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/nested-clients': 3.997.39
|
||||
'@aws-sdk/token-providers': 3.1100.0
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.66':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3403,6 +3606,15 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.71':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/nested-clients': 3.997.39
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.65':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3423,6 +3635,17 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.39':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.43
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/fetch-http-handler': 5.6.13
|
||||
'@smithy/node-http-handler': 4.9.13
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.41':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.2
|
||||
@@ -3430,6 +3653,13 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.43':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/signature-v4': 5.6.12
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1092.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.976.0
|
||||
@@ -3439,6 +3669,15 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1100.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.4
|
||||
'@aws-sdk/nested-clients': 3.997.39
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.974.2':
|
||||
dependencies:
|
||||
'@smithy/types': 4.16.1
|
||||
@@ -3449,6 +3688,11 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.37':
|
||||
dependencies:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0': {}
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
@@ -3974,6 +4218,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
cron: 3.2.1
|
||||
uuid: 11.0.3
|
||||
|
||||
'@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
|
||||
@@ -4075,24 +4326,53 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.31.1':
|
||||
dependencies:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.12':
|
||||
dependencies:
|
||||
'@smithy/core': 3.29.7
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.16':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.13':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.9':
|
||||
dependencies:
|
||||
'@smithy/core': 3.29.7
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.9.13':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.9.9':
|
||||
dependencies:
|
||||
'@smithy/core': 3.29.7
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.6.12':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.1
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.6.8':
|
||||
dependencies:
|
||||
'@smithy/core': 3.29.7
|
||||
@@ -4215,6 +4495,8 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/luxon@3.4.2': {}
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
@@ -4842,6 +5124,11 @@ snapshots:
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cron@3.2.1:
|
||||
dependencies:
|
||||
'@types/luxon': 3.4.2
|
||||
luxon: 3.5.0
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -5960,6 +6247,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
luxon@3.5.0: {}
|
||||
|
||||
magic-string@0.30.8:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -6788,6 +7077,8 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@11.0.3: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user