Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5352d49ecf | ||
|
|
2169ffa78d | ||
|
|
17d83291c3 | ||
|
|
6a97242fc3 | ||
|
|
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.16",
|
||||
"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],
|
||||
})
|
||||
|
||||
@@ -21,11 +21,13 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
| "policy:ingest"
|
||||
| "policy:ocr-review"
|
||||
| "renewal:send"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
@@ -38,13 +40,21 @@ 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> = {
|
||||
"customer:create": "STAFF",
|
||||
"customer:update": "STAFF",
|
||||
"customer:delete": "ADMIN",
|
||||
// Assigning a portal NUMid is granting someone the ability to log in to
|
||||
// my.jorgecuadros.com and read an account, so it sits above customer:update:
|
||||
// editing a phone number is the day job, handing out portal identity is not.
|
||||
// It is also close to irreversible in practice — the id is what the customer
|
||||
// then types at every login.
|
||||
"customer:portal-access": "MANAGER",
|
||||
"policy:create": "STAFF",
|
||||
"policy:update": "STAFF",
|
||||
"policy:delete": "MANAGER",
|
||||
@@ -52,6 +62,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 +82,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;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
|
||||
@@ -24,6 +25,7 @@ import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
export class CustomersController {
|
||||
constructor(
|
||||
private readonly customers: CustomersService,
|
||||
private readonly numids: NumidService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +38,13 @@ export class CustomersController {
|
||||
return this.customers.stats();
|
||||
}
|
||||
|
||||
/** Reusable portal ids, lowest first. Declared above `:id` so the literal
|
||||
* path is not swallowed by the wildcard route. */
|
||||
@Get("numid/candidates")
|
||||
async numidCandidates() {
|
||||
return { candidates: await this.numids.emptyCandidates() };
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@@ -95,4 +104,27 @@ export class CustomersController {
|
||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give this customer a portal NUMid so they can log in to
|
||||
* my.jorgecuadros.com. Idempotent — a customer who already has one gets it
|
||||
* back rather than a second identity.
|
||||
*/
|
||||
@Post(":id/portal-access")
|
||||
@RequireAbility("customer:portal-access")
|
||||
async portalAccess(@Param("id") id: string, @Req() req: Request) {
|
||||
const allocation = await this.numids.allocate(id);
|
||||
if (allocation.origin !== "existing") {
|
||||
// Logged with the origin and the previous holder: a recycled id is the one
|
||||
// case where reading this record later has to answer "whose number was
|
||||
// this before, and was it taken or minted".
|
||||
void this.audit.log(this.actingId(req), "customer.portal-access", {
|
||||
customerId: id,
|
||||
numid: allocation.numid,
|
||||
origin: allocation.origin,
|
||||
previousCustomerId: allocation.previousCustomerId,
|
||||
});
|
||||
}
|
||||
return allocation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsModule } from "../settings/settings.module";
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
providers: [CustomersService, NumidService],
|
||||
exports: [NumidService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
/**
|
||||
* What matters about the allocator is the two things it must never do: hand the
|
||||
* same id to two customers, and hand out a recycled id while Access can still
|
||||
* take it back. Both are tested here; the emptiness SQL itself is exercised
|
||||
* against real data by scripts/numid-audit.mjs.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
existingRef?: { legacyId: string } | null;
|
||||
archived?: boolean;
|
||||
missing?: boolean;
|
||||
recycle?: boolean;
|
||||
empty?: { numid: string; refId: string; customerId: string }[];
|
||||
max?: number | null;
|
||||
/** Make the first N create() calls fail the unique key, as a race would. */
|
||||
createConflicts?: number;
|
||||
/** Make updateMany report "nothing matched", as a lost recycle race would. */
|
||||
recycleMisses?: number;
|
||||
}
|
||||
|
||||
function build(opts: Options = {}) {
|
||||
const created: { legacyId: string }[] = [];
|
||||
let conflictsLeft = opts.createConflicts ?? 0;
|
||||
let missesLeft = opts.recycleMisses ?? 0;
|
||||
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null },
|
||||
),
|
||||
},
|
||||
customerLegacyRef: {
|
||||
findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null),
|
||||
updateMany: jest.fn().mockImplementation(() => {
|
||||
if (missesLeft > 0) {
|
||||
missesLeft -= 1;
|
||||
return Promise.resolve({ count: 0 });
|
||||
}
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => {
|
||||
if (conflictsLeft > 0) {
|
||||
conflictsLeft -= 1;
|
||||
return Promise.reject(
|
||||
new Prisma.PrismaClientKnownRequestError("dup", {
|
||||
code: "P2002",
|
||||
clientVersion: "5",
|
||||
}),
|
||||
);
|
||||
}
|
||||
created.push(data);
|
||||
return Promise.resolve(data);
|
||||
}),
|
||||
},
|
||||
// Two different raw queries share one mock: the MAX lookup returns a single
|
||||
// {max} row, everything else is the empty-candidate list.
|
||||
$queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => {
|
||||
const text = String((sql as unknown as { sql?: string }).sql ?? "");
|
||||
if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]);
|
||||
return Promise.resolve(opts.empty ?? []);
|
||||
}),
|
||||
};
|
||||
|
||||
const settings = {
|
||||
numidRecycleEmpty: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ value: opts.recycle ?? false, source: "default" }),
|
||||
};
|
||||
|
||||
return {
|
||||
service: new NumidService(prisma as never, settings as never),
|
||||
prisma,
|
||||
created,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NUMid allocation", () => {
|
||||
it("returns the id a customer already holds instead of minting a second one", async () => {
|
||||
// A double-clicked button must not fork the customer's portal identity.
|
||||
const { service, prisma } = build({ existingRef: { legacyId: "501" } });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "501",
|
||||
origin: "existing",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allocates one past the highest id in the pool", async () => {
|
||||
const { service, created } = build({ max: 1171 });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(created[0]).toMatchObject({
|
||||
sourceSystem: "utilities",
|
||||
sourceTable: "DATGRAL",
|
||||
legacyId: "1172",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts at 1 when the pool is empty", async () => {
|
||||
const { service } = build({ max: null });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" });
|
||||
});
|
||||
|
||||
it("does NOT recycle while the setting is off, even with candidates free", async () => {
|
||||
// The default has to be the safe one: every reusable id still exists in
|
||||
// Access, and a --sync run reassigns it back to its Access owner.
|
||||
const { service, prisma } = build({
|
||||
max: 1171,
|
||||
empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the lowest empty id once recycling is switched on", async () => {
|
||||
const { service, prisma } = build({
|
||||
recycle: true,
|
||||
max: 1171,
|
||||
empty: [
|
||||
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "1089",
|
||||
origin: "recycled",
|
||||
previousCustomerId: "cust-old",
|
||||
});
|
||||
// Guarded on the owner read a moment ago, so a ref that moved underneath us
|
||||
// matches nothing rather than being stolen.
|
||||
expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ref-1089", customerId: "cust-old" },
|
||||
data: { customerId: "cust-new" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a candidate that someone else took first", async () => {
|
||||
const { service } = build({
|
||||
recycle: true,
|
||||
max: 1171,
|
||||
recycleMisses: 1,
|
||||
empty: [
|
||||
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1094",
|
||||
origin: "recycled",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a new id when recycling is on but nothing is free", async () => {
|
||||
const { service } = build({ recycle: true, max: 1171, empty: [] });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries when two writers pick the same id", async () => {
|
||||
// The unique key on (sourceSystem, sourceTable, legacyId) is what decides
|
||||
// the winner; the loser must retry, never overwrite.
|
||||
const { service, prisma } = build({ max: 1171, createConflicts: 1 });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("gives up loudly rather than looping forever", async () => {
|
||||
const { service } = build({ max: 1171, createConflicts: 99 });
|
||||
|
||||
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses an archived customer", async () => {
|
||||
const { service } = build({ archived: true });
|
||||
|
||||
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses a customer that does not exist", async () => {
|
||||
const { service } = build({ missing: true });
|
||||
|
||||
await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { SettingsService } from "../settings/settings.service";
|
||||
|
||||
/**
|
||||
* Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com
|
||||
* asks for at login.
|
||||
*
|
||||
* The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with
|
||||
* (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create`
|
||||
* deliberately writes none: a natively created customer has no legacy provenance.
|
||||
* The consequence is that every customer created in the staff UI is invisible to
|
||||
* the portal until this service gives them an id.
|
||||
*
|
||||
* WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the
|
||||
* platform before utilities, and an insurance-only customer has no reason to hold
|
||||
* a portal identity. Allocating on every create would spend utilities ids — and
|
||||
* the handful of reusable ones — on people who will never log in. So this is an
|
||||
* explicit staff action instead.
|
||||
*/
|
||||
|
||||
/** The pair that identifies a portal NUMid. */
|
||||
export const UTILITIES_SYSTEM = "utilities";
|
||||
export const UTILITIES_TABLE = "DATGRAL";
|
||||
|
||||
/**
|
||||
* insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name
|
||||
* and runs past 4,000. It must never be read as a NUMid, and never allocated
|
||||
* from: the portal cannot resolve those ids. Every query here filters on BOTH
|
||||
* columns for that reason, never on sourceTable alone. A customer can also hold
|
||||
* more than one insurance ref — 16 of them do, where several insurance rows
|
||||
* folded into one customer — so those are tested with EXISTS rather than joined.
|
||||
*/
|
||||
const POOL = {
|
||||
sourceSystem: UTILITIES_SYSTEM,
|
||||
sourceTable: UTILITIES_TABLE,
|
||||
} as const;
|
||||
|
||||
export type AllocationOrigin = "existing" | "new" | "recycled";
|
||||
|
||||
export interface Allocation {
|
||||
numid: string;
|
||||
origin: AllocationOrigin;
|
||||
/** Set only on a recycle — the customer the id was taken from. */
|
||||
previousCustomerId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NUMids that were created and never used, safe for an allocator to take.
|
||||
*
|
||||
* THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way
|
||||
* it does. "Owns no rows" matches nobody: migration gave all 1,171 NUMids a
|
||||
* property and a transaction. "No transaction in N years" also matches nobody:
|
||||
* every customer carries a synthetic Jan-1 opening-balance row, so everyone
|
||||
* looks active in the current year. That row has to be subtracted before any
|
||||
* activity test means anything, which is what `bf` does below.
|
||||
*
|
||||
* The balance-forward row is matched in two shapes on purpose.
|
||||
* transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD';
|
||||
* databases loaded before that change carry the same rows with typeId NULL,
|
||||
* dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors
|
||||
* nothing on such a database and turns the balance test into a raw lifetime sum
|
||||
* — the double-count that read the whole book as +20.6M MXN in credit before
|
||||
* d173c9e, and which here would mark live customers as empty.
|
||||
*
|
||||
* Services are tested as "any service" rather than "any ACTIVE service": a
|
||||
* deactivated water account is still a record of somebody having lived behind
|
||||
* this id.
|
||||
*
|
||||
* Kept in step with scripts/numid-audit.sql, which reports the same tier for a
|
||||
* human. That script is the reporting copy of this rule; change both together.
|
||||
*/
|
||||
const EMPTY_NUMID_SQL = Prisma.sql`
|
||||
WITH bf AS (
|
||||
SELECT t.id, t.customerId
|
||||
FROM transactions t
|
||||
LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.voidedAt IS NULL
|
||||
AND (
|
||||
tt.nameEn = 'BALANCE FORWARD'
|
||||
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
|
||||
AND t.legacySourceTable = 'datos2')
|
||||
)
|
||||
)
|
||||
SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId
|
||||
FROM customer_legacy_refs r
|
||||
JOIN customers c ON c.id = r.customerId
|
||||
WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE}
|
||||
AND (c.email IS NULL OR c.email = '')
|
||||
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.id NOT IN (SELECT id FROM bf))
|
||||
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM property_services ps
|
||||
JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM trust_accounts ta
|
||||
JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i
|
||||
WHERE i.customerId = c.id AND i.sourceSystem = 'insurance')
|
||||
ORDER BY CAST(r.legacyId AS UNSIGNED)`;
|
||||
|
||||
interface EmptyRow {
|
||||
numid: string;
|
||||
refId: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NumidService {
|
||||
private readonly logger = new Logger(NumidService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
/** The customer's portal id, or null if they have none. */
|
||||
async current(customerId: string): Promise<string | null> {
|
||||
const ref = await this.prisma.customerLegacyRef.findFirst({
|
||||
where: { customerId, ...POOL },
|
||||
select: { legacyId: true },
|
||||
});
|
||||
return ref?.legacyId ?? null;
|
||||
}
|
||||
|
||||
/** Reusable ids, lowest first. Empty unless recycling is switched on. */
|
||||
async emptyCandidates(): Promise<string[]> {
|
||||
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||
return rows.map((r) => r.numid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a customer a portal NUMid.
|
||||
*
|
||||
* Idempotent: a customer who already holds one gets it back rather than a
|
||||
* second id, so a double-clicked button cannot fork an identity.
|
||||
*/
|
||||
async allocate(customerId: string): Promise<Allocation> {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true, archivedAt: true },
|
||||
});
|
||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
if (customer.archivedAt) {
|
||||
throw new ConflictException(
|
||||
"No se puede asignar un número de portal a un cliente archivado",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.current(customerId);
|
||||
if (existing) return { numid: existing, origin: "existing" };
|
||||
|
||||
const recycle = await this.recycleEnabled();
|
||||
|
||||
// Two writers can pick the same id between the read and the write. The
|
||||
// unique key on (sourceSystem, sourceTable, legacyId) is what actually
|
||||
// decides the winner; the loser retries and takes the next id rather than
|
||||
// silently overwriting. Bounded so a genuinely wedged pool fails loudly.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
if (recycle) {
|
||||
const recycled = await this.tryRecycle(customerId);
|
||||
if (recycled) return recycled;
|
||||
}
|
||||
return await this.allocateNext(customerId);
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
this.logger.warn(
|
||||
`NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new ConflictException(
|
||||
"No se pudo asignar un número de portal; intente de nuevo",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recycle tier is live.
|
||||
*
|
||||
* Off by default, and that default is the safe one while Access is still the
|
||||
* utilities master. Every id in the pool ALSO exists in Access DATGRAL, and a
|
||||
* `--sync` migration run upserts refs with ON DUPLICATE KEY UPDATE customerId
|
||||
* (transform_customers.py:327) — so an id recycled today is silently handed
|
||||
* back to its Access owner on the next sync, and the customer who was given it
|
||||
* loses their portal identity. Turn this on once utilities has cut over, or
|
||||
* for ids that have been deleted at the source.
|
||||
*/
|
||||
private async recycleEnabled(): Promise<boolean> {
|
||||
const { value } = await this.settings.numidRecycleEmpty();
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Re-point the lowest empty id at this customer. Null when none is free. */
|
||||
private async tryRecycle(customerId: string): Promise<Allocation | null> {
|
||||
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||
for (const row of rows) {
|
||||
// Guarded by the owner we just read: if anything moved the ref in the
|
||||
// meantime the update matches nothing and we fall through to the next
|
||||
// candidate rather than stealing an id that is no longer empty.
|
||||
const moved = await this.prisma.customerLegacyRef.updateMany({
|
||||
where: { id: row.refId, customerId: row.customerId },
|
||||
data: { customerId },
|
||||
});
|
||||
if (moved.count === 1) {
|
||||
this.logger.log(
|
||||
`NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`,
|
||||
);
|
||||
return {
|
||||
numid: row.numid,
|
||||
origin: "recycled",
|
||||
previousCustomerId: row.customerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One past the highest id in the pool. */
|
||||
private async allocateNext(customerId: string): Promise<Allocation> {
|
||||
const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>(
|
||||
// MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain
|
||||
// MAX returns '999' as the highest of 1,171 rows and the allocator hands
|
||||
// out an id that is already taken.
|
||||
Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max
|
||||
FROM customer_legacy_refs
|
||||
WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`,
|
||||
);
|
||||
const numid = String(Number(max ?? 0) + 1);
|
||||
await this.prisma.customerLegacyRef.create({
|
||||
data: { customerId, ...POOL, legacyId: numid },
|
||||
});
|
||||
return { numid, origin: "new" };
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||
);
|
||||
}
|
||||
@@ -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,30 @@ export class OpsController {
|
||||
|
||||
/* --------------------------------------------------------------- jobs */
|
||||
|
||||
/** Health of the my.jorgecuadros.com read replica. Read-only, no audit entry. */
|
||||
@Get("replication")
|
||||
replicationStatus() {
|
||||
return this.replication.status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full row-by-row comparison of the customer-visible tables against the master.
|
||||
*
|
||||
* POST rather than GET despite reading nothing: it is a full scan of both
|
||||
* servers and must not be something a browser prefetch, a retry, or a refresh
|
||||
* can set off. Audited for the same reason — it is a deliberate, costly act,
|
||||
* and "who ran this while the site was slow" is a question worth answering.
|
||||
*/
|
||||
@Post("replication/verify")
|
||||
async verifyReplication(@Req() req: Request) {
|
||||
const result = await this.replication.verify();
|
||||
void this.audit.log(this.actingId(req), "ops.replication.verify", {
|
||||
identical: result.identical,
|
||||
elapsedMs: result.elapsedMs,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get("jobs")
|
||||
listJobs() {
|
||||
return this.ops.listJobs();
|
||||
@@ -109,11 +135,17 @@ export class OpsController {
|
||||
@Post("jobs")
|
||||
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||
const userId = this.actingId(req);
|
||||
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
||||
const job = await this.ops.startJob(
|
||||
dto.kind,
|
||||
{ file: dto.file, forceFull: dto.forceFull },
|
||||
userId,
|
||||
);
|
||||
void this.audit.log(userId, "ops.job.start", {
|
||||
jobId: job.id,
|
||||
kind: dto.kind,
|
||||
file: dto.file,
|
||||
// Recorded because this is the flag that authorised deleting native rows.
|
||||
forceFull: dto.forceFull,
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -359,12 +415,19 @@ export class OpsService implements OnModuleInit {
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
// run_all.py runs native_guard.py before it truncates anything and exits
|
||||
// without touching the database when the target holds rows that only
|
||||
// exist here — allocated portal NUMids, app-created customers, OCR
|
||||
// captures. --force-full is what the operator ticks to delete them
|
||||
// anyway; without it the job fails with the list.
|
||||
const force = params.forceFull === true;
|
||||
const cmd =
|
||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`${this.dumpCommand(flags, db, out)} && ` +
|
||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage` +
|
||||
(force ? " --force-full" : "");
|
||||
return { cmd, resolvedParams: { safetyBackup: file, forceFull: force } };
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||
@@ -465,3 +528,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,639 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the replica's executed history is from the master's, in transactions.
|
||||
*
|
||||
* This is the check `SHOW REPLICA STATUS` cannot give you, and it is stronger
|
||||
* than everything else on the card for one specific reason: every other field is
|
||||
* self-reported by the replica. `Seconds_Behind_Source` reads 0 both when there
|
||||
* is genuinely nothing to apply AND when the I/O thread is disconnected — with
|
||||
* no incoming event there is nothing to measure staleness against, so a dead
|
||||
* link reports as perfectly current. `GTID_SUBTRACT(master, replica)` asks the
|
||||
* master what it has done and the replica what it has applied, so a silent
|
||||
* disconnect shows up immediately as a growing number.
|
||||
*/
|
||||
export interface GtidDrift {
|
||||
/** Transactions the master executed that the replica has not. 0 = identical. */
|
||||
missingTransactions: number;
|
||||
/** The missing GTID set verbatim. Null when nothing is missing. */
|
||||
missingGtidSet: string | null;
|
||||
/**
|
||||
* Transactions in the replica's `gtid_executed` under its OWN server UUID —
|
||||
* writes that happened here and exist nowhere on the master.
|
||||
*
|
||||
* Reported, never alarmed on. A non-zero count is the expected residue of the
|
||||
* seed load: restoring a dump executes its statements locally, and they take
|
||||
* GTIDs from this server's UUID. They never propagate (`log_replica_updates`
|
||||
* is off and nothing sources from this node), so they are harmless — right up
|
||||
* until someone tries to promote this box, where they become a real divergence.
|
||||
*/
|
||||
localTransactions: number;
|
||||
}
|
||||
|
||||
/** One table's row count and content fingerprint, on one side of the link. */
|
||||
export interface TableFingerprint {
|
||||
table: string;
|
||||
masterRows: number;
|
||||
replicaRows: number;
|
||||
/** Order-independent checksum over every column of every row. */
|
||||
masterChecksum: string;
|
||||
replicaChecksum: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
/** True only when every table matched on both count and checksum. */
|
||||
identical: boolean;
|
||||
tables: TableFingerprint[];
|
||||
/** Set instead of `tables` when the comparison could not be run at all. */
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
/** Wall-clock cost, because this is a full scan and the caller should see it. */
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
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;
|
||||
/** GTID comparison against the master. Null when the master was unreachable. */
|
||||
drift: GtidDrift | null;
|
||||
/** Human-readable reason when healthy is false. */
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tables `my.jorgecuadros.com` reads through the `web_reader` grant.
|
||||
*
|
||||
* This list is the verification surface, not the replication surface — the
|
||||
* replica carries the whole schema. These are the eight whose divergence would
|
||||
* actually be visible to a customer, so they are the ones worth a full scan.
|
||||
*/
|
||||
export const REPLICATED_TABLES = [
|
||||
"transactions",
|
||||
"customers",
|
||||
"customer_legacy_refs",
|
||||
"type_transactions",
|
||||
"exchange_rates",
|
||||
"properties",
|
||||
"property_services",
|
||||
"trust_accounts",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
drift: null,
|
||||
problem: null,
|
||||
checkedAt: now,
|
||||
};
|
||||
|
||||
if (!host || !user || !password) {
|
||||
return { ...empty, problem: "REPLICA_DB_* no configuradas" };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await this.onReplica("SHOW REPLICA STATUS\\G");
|
||||
} 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),
|
||||
// Also reported rather than alarmed on, for the same reason: a busy master
|
||||
// is always a few transactions ahead for the instant they are in flight.
|
||||
// Null rather than zero when the master could not be reached — "unknown"
|
||||
// and "identical" must not render the same.
|
||||
drift: await this.gtidDrift(),
|
||||
problem,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare executed history between master and replica.
|
||||
*
|
||||
* Two round trips: ask the master what it has executed, then ask the replica
|
||||
* to subtract its own history from that. The subtraction runs on the replica
|
||||
* rather than in TypeScript because `GTID_SUBTRACT` already implements the
|
||||
* interval algebra correctly, and reimplementing set subtraction over binlog
|
||||
* ranges is exactly the kind of thing that looks right and is wrong at the
|
||||
* boundaries.
|
||||
*
|
||||
* @returns null on any failure — a broken drift check must never be mistaken
|
||||
* for a healthy zero.
|
||||
*/
|
||||
private async gtidDrift(): Promise<GtidDrift | null> {
|
||||
try {
|
||||
const masterGtid = (await this.onMaster("SELECT @@gtid_executed")).trim();
|
||||
|
||||
// GTID sets are UUIDs, digits, colons, commas, hyphens, whitespace and
|
||||
// (since 8.4) alphanumeric tags. Nothing else is legal, so rejecting
|
||||
// anything outside that alphabet is a whitelist, not a blacklist: with no
|
||||
// quote and no backslash able to survive it, the value cannot escape the
|
||||
// string literal it is interpolated into below.
|
||||
if (masterGtid && !/^[0-9a-fA-F:,\s_-]+$/.test(masterGtid)) {
|
||||
this.logger.warn("gtid_executed del maestro con formato inesperado");
|
||||
return null;
|
||||
}
|
||||
// An empty set means the master has GTID mode off, and there is nothing
|
||||
// meaningful to compare.
|
||||
if (!masterGtid) return null;
|
||||
|
||||
const flat = masterGtid.replace(/\s+/g, "");
|
||||
// Every GTID set is flattened with REPLACE before it leaves the server.
|
||||
// MySQL wraps `gtid_executed` across lines once it holds more than one
|
||||
// source UUID, and this is read back as tab-separated columns — an
|
||||
// embedded newline would split one row into two and silently truncate the
|
||||
// set at the first UUID.
|
||||
const out = await this.onReplica(
|
||||
"SELECT REPLACE(GTID_SUBTRACT(" +
|
||||
`'${flat}', @@gtid_executed), '\\n', ''), ` +
|
||||
"@@server_uuid, REPLACE(@@gtid_executed, '\\n', '')",
|
||||
["-N"],
|
||||
);
|
||||
|
||||
// Trailing newline only — never `.trim()`. When nothing is missing the
|
||||
// first column is the empty string, so the line begins with a tab, and
|
||||
// trimming it would shift every column one position left and report the
|
||||
// replica's own UUID as the missing GTID set.
|
||||
const [missingSet = "", serverUuid = "", executed = ""] = out
|
||||
.replace(/\r?\n+$/, "")
|
||||
.split("\t");
|
||||
|
||||
return {
|
||||
missingTransactions: countGtids(missingSet),
|
||||
missingGtidSet: missingSet || null,
|
||||
localTransactions: countGtids(gtidsForUuid(executed, serverUuid)),
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`no se pudo comparar GTIDs con el maestro: ${msg}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-scan comparison of the customer-visible tables on both sides.
|
||||
*
|
||||
* Deliberately NOT part of `status()`: this reads every row of every table in
|
||||
* `REPLICATED_TABLES` on both servers, so it belongs behind a button, not a
|
||||
* 30-second poll.
|
||||
*
|
||||
* It answers the one question GTID drift cannot. GTIDs prove the replica
|
||||
* applied every transaction the master produced; they say nothing about rows
|
||||
* changed on the replica by some other route. A local write is invisible to
|
||||
* every other field on the card and shows up here as a checksum mismatch.
|
||||
*/
|
||||
async verify(): Promise<VerifyResult> {
|
||||
const started = Date.now();
|
||||
const base: VerifyResult = {
|
||||
identical: false,
|
||||
tables: [],
|
||||
problem: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
elapsedMs: 0,
|
||||
};
|
||||
|
||||
let sql: string;
|
||||
try {
|
||||
sql = await this.fingerprintSql();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ...base, problem: `No se pudo leer el esquema: ${msg}`, elapsedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
let masterOut: string;
|
||||
let replicaOut: string;
|
||||
try {
|
||||
// Sequential, not parallel. Running both at once would have the master
|
||||
// scan under the replica's own read load only sometimes, which makes a
|
||||
// slow run hard to attribute; and the boxes are small enough that two
|
||||
// concurrent full scans is a real memory event on the 946MB replica.
|
||||
masterOut = await this.onMaster(sql);
|
||||
replicaOut = await this.onReplica(sql);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ...base, problem: `No se pudo comparar: ${msg}`, elapsedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
const master = parseFingerprints(masterOut);
|
||||
const replica = parseFingerprints(replicaOut);
|
||||
|
||||
const tables: TableFingerprint[] = REPLICATED_TABLES.map((table) => {
|
||||
const m = master.get(table);
|
||||
const r = replica.get(table);
|
||||
return {
|
||||
table,
|
||||
masterRows: m?.rows ?? -1,
|
||||
replicaRows: r?.rows ?? -1,
|
||||
masterChecksum: m?.checksum ?? "?",
|
||||
replicaChecksum: r?.checksum ?? "?",
|
||||
// Both sides must have answered. A missing row on either side is a
|
||||
// mismatch, never a pass — `undefined === undefined` would otherwise
|
||||
// report two failed reads as agreement.
|
||||
matches:
|
||||
m !== undefined && r !== undefined && m.rows === r.rows && m.checksum === r.checksum,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
identical: tables.every((t) => t.matches),
|
||||
tables,
|
||||
problem: null,
|
||||
checkedAt: base.checkedAt,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the count+checksum query from the live column list.
|
||||
*
|
||||
* The columns come from `information_schema` on the master rather than being
|
||||
* hardcoded, so the check keeps covering the whole row after a migration adds
|
||||
* one. Reading the schema from the master is safe by construction: if the two
|
||||
* schemas had diverged, replication would already be broken.
|
||||
*/
|
||||
private async fingerprintSql(): Promise<string> {
|
||||
const list = REPLICATED_TABLES.map((t) => `'${t}'`).join(",");
|
||||
const raw = await this.onMaster(
|
||||
"SELECT CONCAT(TABLE_NAME, '\\t', COLUMN_NAME) FROM information_schema.COLUMNS " +
|
||||
`WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${list}) ` +
|
||||
"ORDER BY TABLE_NAME, ORDINAL_POSITION",
|
||||
["-N"],
|
||||
);
|
||||
|
||||
const cols = new Map<string, string[]>();
|
||||
for (const line of raw.split("\n")) {
|
||||
const [table, column] = line.trim().split("\t");
|
||||
if (!table || !column) continue;
|
||||
cols.set(table, [...(cols.get(table) ?? []), column]);
|
||||
}
|
||||
|
||||
const selects = REPLICATED_TABLES.map((table) => {
|
||||
const columns = cols.get(table);
|
||||
if (!columns?.length) throw new Error(`tabla ${table} sin columnas`);
|
||||
// CONVERT(... USING binary), never CAST(... AS CHAR).
|
||||
//
|
||||
// CAST to CHAR transcodes into the *connection* character set, which is
|
||||
// not the same on the two servers: the mysql client inside the master's
|
||||
// container negotiates latin1, while the replica's negotiates utf8mb4.
|
||||
// Every accented character in a Mexican name, street or note therefore
|
||||
// hashes to different bytes on each side, and the comparison reports a
|
||||
// permanent mismatch on exactly the tables that hold free text — a
|
||||
// verification tool that always cries wolf, which is worse than none.
|
||||
// Comparing the stored bytes sidesteps the session entirely. (Verified
|
||||
// 2026-08-06: with CAST, `customers.name` gave 3344437324815 vs
|
||||
// 3339150372121; with CONVERT both give 3339150372121.)
|
||||
//
|
||||
// 0x1f (unit separator) joins the columns and 0x1e (record separator)
|
||||
// stands in for NULL. Both matter: CONCAT_WS *skips* NULLs rather than
|
||||
// emitting an empty field, so without a placeholder the rows
|
||||
// ('a', NULL, 'b') and ('a', 'b', NULL) produce the same string and a
|
||||
// column-shifting bug would checksum as identical.
|
||||
const expr = columns
|
||||
.map((c) => `IFNULL(CONVERT(\`${c}\` USING binary), 0x1e)`)
|
||||
.join(", 0x1f, ");
|
||||
// SUM, not a running hash: addition is commutative, so the result does not
|
||||
// depend on the order rows come back in. The two servers have no reason to
|
||||
// scan in the same order and are not asked to.
|
||||
return (
|
||||
`SELECT '${table}' AS t, COUNT(*) AS n, ` +
|
||||
`IFNULL(SUM(CRC32(CONCAT_WS(0x1f, ${expr}))), 0) AS c FROM \`${table}\``
|
||||
);
|
||||
});
|
||||
|
||||
return selects.join(" UNION ALL ");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ plumbing */
|
||||
|
||||
/**
|
||||
* Run a statement on the replica.
|
||||
*
|
||||
* --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 the tailnet ACL 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.
|
||||
*/
|
||||
private async onReplica(sql: string, extra: string[] = []): Promise<string> {
|
||||
const host = process.env.REPLICA_DB_HOST!;
|
||||
const user = process.env.REPLICA_DB_USER!;
|
||||
const password = process.env.REPLICA_DB_PASS!;
|
||||
const { stdout } = await exec(
|
||||
"mysql",
|
||||
[
|
||||
`--host=${host}`,
|
||||
`--user=${user}`,
|
||||
"--ssl",
|
||||
"--ssl-verify-server-cert=0",
|
||||
"--connect-timeout=5",
|
||||
...extra,
|
||||
"-e",
|
||||
sql,
|
||||
],
|
||||
{ env: { ...process.env, MYSQL_PWD: password }, timeout: VERIFY_TIMEOUT_MS },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a statement on the master, using the application's own DATABASE_URL.
|
||||
*
|
||||
* The app credential is enough here on purpose — everything this class sends
|
||||
* to the master is a SELECT against `information_schema` or a system variable.
|
||||
* Reaching for OPS_DB_ADMIN_* the way OpsService does would hand a monitoring
|
||||
* read path a credential that can also restore a dump.
|
||||
*/
|
||||
private async onMaster(sql: string, extra: string[] = []): Promise<string> {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new Error("DATABASE_URL no está configurada");
|
||||
const u = new URL(raw);
|
||||
const { stdout } = await exec(
|
||||
"mysql",
|
||||
[
|
||||
`--host=${u.hostname}`,
|
||||
`--port=${u.port || "3306"}`,
|
||||
`--user=${decodeURIComponent(u.username)}`,
|
||||
"--connect-timeout=5",
|
||||
...extra,
|
||||
"-N",
|
||||
"-e",
|
||||
sql,
|
||||
u.pathname.replace(/^\//, ""),
|
||||
],
|
||||
{
|
||||
env: { ...process.env, MYSQL_PWD: decodeURIComponent(u.password) },
|
||||
timeout: VERIFY_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full scans on a 1-vCPU replica are not fast; 15s would cut them off. */
|
||||
const VERIFY_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Parse the `t\tn\tc` rows the fingerprint query emits under `mysql -N`. */
|
||||
function parseFingerprints(raw: string): Map<string, { rows: number; checksum: string }> {
|
||||
const out = new Map<string, { rows: number; checksum: string }>();
|
||||
for (const line of raw.split("\n")) {
|
||||
const [table, n, c] = line.trim().split("\t");
|
||||
if (!table || n === undefined || c === undefined) continue;
|
||||
const rows = Number(n);
|
||||
if (!Number.isFinite(rows)) continue;
|
||||
// The checksum stays a string. Sums of CRC32 over 40k rows exceed 2^53, so
|
||||
// parsing it as a number would round and make distinct tables compare equal.
|
||||
out.set(table, { rows, checksum: c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the transactions in a GTID set.
|
||||
*
|
||||
* Exported for testing. The format is `uuid[:tag]:interval[:interval]...`,
|
||||
* comma-separated, where an interval is `N` or `N-M` inclusive at both ends —
|
||||
* so `1-5` is five transactions, not four.
|
||||
*
|
||||
* MySQL 8.4 added an optional alphanumeric tag between the UUID and the first
|
||||
* interval. It is skipped rather than parsed: any segment that is not a number
|
||||
* or a number range is not an interval, whatever else it may be.
|
||||
*/
|
||||
export function countGtids(set: string): number {
|
||||
if (!set.trim()) return 0;
|
||||
let total = 0;
|
||||
for (const group of set.split(",")) {
|
||||
for (const part of group.trim().split(":").slice(1)) {
|
||||
const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim());
|
||||
if (!m) continue;
|
||||
const from = Number(m[1]);
|
||||
const to = m[2] === undefined ? from : Number(m[2]);
|
||||
if (Number.isFinite(from) && Number.isFinite(to) && to >= from) total += to - from + 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a GTID set to the intervals belonging to one server UUID.
|
||||
*
|
||||
* Exported for testing. Used to isolate the replica's own writes from the
|
||||
* history it replicated, which are interleaved in the same `gtid_executed`.
|
||||
*/
|
||||
export function gtidsForUuid(set: string, uuid: string): string {
|
||||
if (!uuid.trim()) return "";
|
||||
const wanted = uuid.trim().toLowerCase();
|
||||
return set
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter((g) => g.toLowerCase().startsWith(`${wanted}:`))
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,254 @@
|
||||
import {
|
||||
applyProgress,
|
||||
countGtids,
|
||||
gtidsForUuid,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Real GTID sets from the live pair, captured 2026-08-06. The replica's own
|
||||
* server UUID (3b103283…) carries the transactions the seed dump load executed
|
||||
* locally; the master's UUID (defc34e2…) carries the replicated history.
|
||||
*/
|
||||
const REPLICA_EXECUTED =
|
||||
"3b103283-8f15-11f1-a52b-020017027b33:1-513," +
|
||||
"defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-525";
|
||||
const REPLICA_UUID = "3b103283-8f15-11f1-a52b-020017027b33";
|
||||
|
||||
describe("countGtids", () => {
|
||||
it("counts an inclusive range at both ends", () => {
|
||||
// 1-5 is five transactions. Off-by-one here understates the gap, which is
|
||||
// the direction that hides a problem.
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5")).toBe(5);
|
||||
});
|
||||
|
||||
it("counts a bare single transaction", () => {
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:7")).toBe(1);
|
||||
});
|
||||
|
||||
it("sums several intervals under one UUID", () => {
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5:8:10-12")).toBe(9);
|
||||
});
|
||||
|
||||
it("sums across UUIDs, including the wrapped form MySQL prints", () => {
|
||||
expect(countGtids(REPLICA_EXECUTED)).toBe(513 + 525);
|
||||
// `gtid_executed` comes back wrapped once it holds more than one UUID.
|
||||
expect(countGtids(REPLICA_EXECUTED.replace(",", ",\n"))).toBe(513 + 525);
|
||||
});
|
||||
|
||||
/** An empty subtraction result is the caught-up case and must be zero. */
|
||||
it("returns 0 for an empty or blank set", () => {
|
||||
expect(countGtids("")).toBe(0);
|
||||
expect(countGtids(" \n ")).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* MySQL 8.4 allows an alphanumeric tag between the UUID and the intervals.
|
||||
* It is not an interval and must not be counted as one.
|
||||
*/
|
||||
it("skips a tag without counting it", () => {
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:mytag:1-3")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gtidsForUuid", () => {
|
||||
it("isolates the replica's own transactions from the replicated history", () => {
|
||||
expect(countGtids(gtidsForUuid(REPLICA_EXECUTED, REPLICA_UUID))).toBe(513);
|
||||
});
|
||||
|
||||
it("returns nothing for a UUID that is not in the set", () => {
|
||||
expect(gtidsForUuid(REPLICA_EXECUTED, "00000000-0000-0000-0000-000000000000")).toBe("");
|
||||
});
|
||||
|
||||
/**
|
||||
* The colon matters. Without it a UUID prefix would match a longer UUID that
|
||||
* merely starts the same way, and the replica's local writes would be
|
||||
* over-reported.
|
||||
*/
|
||||
it("does not match on a bare prefix", () => {
|
||||
expect(gtidsForUuid(REPLICA_EXECUTED, "3b103283")).toBe("");
|
||||
});
|
||||
|
||||
it("returns nothing when the UUID is blank", () => {
|
||||
expect(gtidsForUuid(REPLICA_EXECUTED, "")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||
import { OpsJobKind } from "@jorgecuadros/database";
|
||||
|
||||
export class StartJobDto {
|
||||
@@ -9,4 +9,13 @@ export class StartJobDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
file?: string;
|
||||
|
||||
/**
|
||||
* REIMPORT only: proceed even though the rebuild deletes rows that exist only
|
||||
* in the platform. Off by default, so the guard in run_all.py stops the job
|
||||
* and lists what would be lost rather than the operator finding out after.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
forceFull?: boolean;
|
||||
}
|
||||
|
||||
@@ -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,218 @@
|
||||
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",
|
||||
/** Whether the NUMid allocator may reuse empty portal ids. */
|
||||
numidRecycleEmpty: "numid.recycleEmpty",
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the NUMid allocator may reuse empty portal ids instead of only
|
||||
* issuing new ones.
|
||||
*
|
||||
* Defaults to OFF, and the default is the safety property rather than a
|
||||
* preference: while Access remains the utilities master, every reusable id
|
||||
* still exists in DATGRAL, and a `--sync` migration run reassigns the ref back
|
||||
* to its Access owner (transform_customers.py:327). Recycling before utilities
|
||||
* cuts over therefore hands out ids that quietly stop working. No env rung —
|
||||
* this has never been an environment variable and should be flipped
|
||||
* deliberately, in the UI, by someone who knows the cutover happened.
|
||||
*/
|
||||
async numidRecycleEmpty(): Promise<ResolvedSetting<boolean>> {
|
||||
const row = await this.read(SETTING_KEYS.numidRecycleEmpty);
|
||||
if (row) {
|
||||
return {
|
||||
value: row.value === "true",
|
||||
source: "db",
|
||||
updatedAt: row.updatedAt,
|
||||
updatedById: row.updatedById,
|
||||
};
|
||||
}
|
||||
return { value: false, source: "default", updatedAt: null, updatedById: null };
|
||||
}
|
||||
|
||||
async setNumidRecycleEmpty(
|
||||
enabled: boolean,
|
||||
userId: string,
|
||||
): Promise<ResolvedSetting<boolean>> {
|
||||
await this.write(SETTING_KEYS.numidRecycleEmpty, String(enabled), userId);
|
||||
return this.numidRecycleEmpty();
|
||||
}
|
||||
|
||||
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.16",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ContextReports } from "@/components/ContextReports";
|
||||
import {
|
||||
archiveCustomer,
|
||||
getCustomer,
|
||||
grantPortalAccess,
|
||||
policyDocumentDownloadUrl,
|
||||
propertyDocumentDownloadUrl,
|
||||
restoreCustomer,
|
||||
@@ -151,9 +152,43 @@ function CustomerActions({
|
||||
}) {
|
||||
const canEdit = useCan("customer:update");
|
||||
const canDelete = useCan("customer:delete");
|
||||
const canGrantPortal = useCan("customer:portal-access");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const archived = customer.archivedAt != null;
|
||||
|
||||
// The portal NUMid is a legacy ref, not a column: (utilities, DATGRAL) is the
|
||||
// "Security Number" my.jorgecuadros.com asks for. An insurance ref is a
|
||||
// different id space entirely and does not let anyone log in, so both columns
|
||||
// are checked — matching on sourceTable alone would hide the button from
|
||||
// customers who cannot actually reach the portal.
|
||||
const hasPortalId = customer.legacyRefs.some(
|
||||
(r) => r.sourceSystem === "utilities" && r.sourceTable === "DATGRAL",
|
||||
);
|
||||
|
||||
async function grantPortal() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"¿Asignar un número de portal a este cliente? Con él podrá entrar a " +
|
||||
"my.jorgecuadros.com.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { numid, origin } = await grantPortalAccess(customer.id);
|
||||
window.alert(
|
||||
origin === "existing"
|
||||
? `Este cliente ya tenía el número de portal ${numid}.`
|
||||
: `Número de portal asignado: ${numid}.`,
|
||||
);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
const verb = archived ? "restaurar" : "archivar";
|
||||
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
||||
@@ -169,11 +204,23 @@ function CustomerActions({
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit && !canDelete) return null;
|
||||
const showPortal = canGrantPortal && !hasPortalId && !archived;
|
||||
if (!canEdit && !canDelete && !showPortal) return null;
|
||||
|
||||
return (
|
||||
<div className="row-actions">
|
||||
{archived && <span className="badge badge-negative">Archivado</span>}
|
||||
{showPortal && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={grantPortal}
|
||||
disabled={busy}
|
||||
title="Asigna el número que el cliente usa para entrar al portal"
|
||||
>
|
||||
Habilitar acceso al portal
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||
Editar
|
||||
|
||||
@@ -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,18 +14,24 @@ import {
|
||||
deleteBackup,
|
||||
deleteIngest,
|
||||
getOpsJob,
|
||||
getReplicationStatus,
|
||||
listBackups,
|
||||
listIngest,
|
||||
listOpsJobs,
|
||||
startOpsJob,
|
||||
uploadIngest,
|
||||
verifyReplication,
|
||||
} from "@/lib/api";
|
||||
import type { UploadProgress } from "@/lib/api";
|
||||
import type {
|
||||
ApplyProgress,
|
||||
BackupFile,
|
||||
IngestFile,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
GtidDrift,
|
||||
ReplicationStatus,
|
||||
VerifyResult,
|
||||
} from "@/lib/types";
|
||||
|
||||
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
@@ -56,6 +62,7 @@ function Operaciones() {
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [forceFull, setForceFull] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
@@ -156,12 +163,12 @@ function Operaciones() {
|
||||
}
|
||||
}
|
||||
|
||||
async function start(kind: OpsJobKind, file?: string) {
|
||||
async function start(kind: OpsJobKind, file?: string, force?: boolean) {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setStarting(true);
|
||||
try {
|
||||
const job = await startOpsJob(kind, file);
|
||||
const job = await startOpsJob(kind, file, force);
|
||||
setActiveJob(job);
|
||||
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
||||
} catch (e) {
|
||||
@@ -174,6 +181,9 @@ function Operaciones() {
|
||||
function askConfirm(state: ConfirmState) {
|
||||
setConfirm(state);
|
||||
setConfirmText("");
|
||||
// Always re-armed: ticking "delete native rows" once must not carry into
|
||||
// the next reimport.
|
||||
setForceFull(false);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}
|
||||
@@ -182,7 +192,7 @@ function Operaciones() {
|
||||
if (!confirm) return;
|
||||
const c = confirm;
|
||||
setConfirm(null);
|
||||
if (c.kind === "REIMPORT") await start("REIMPORT");
|
||||
if (c.kind === "REIMPORT") await start("REIMPORT", undefined, forceFull);
|
||||
else if (c.kind === "SYNC") await start("SYNC");
|
||||
else await start("RESTORE", c.file);
|
||||
}
|
||||
@@ -223,10 +233,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>
|
||||
@@ -472,11 +485,24 @@ function Operaciones() {
|
||||
</h2>
|
||||
<p className="inline-form-note">
|
||||
{confirm.kind === "REIMPORT"
|
||||
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
|
||||
? "Esto BORRA todos los datos actuales y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático. Si la base contiene registros que sólo existen en la plataforma (clientes creados aquí, números de portal asignados, pólizas capturadas por OCR, movimientos capturados), la operación se detiene y los enumera sin tocar nada."
|
||||
: confirm.kind === "SYNC"
|
||||
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
|
||||
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
||||
</p>
|
||||
{confirm.kind === "REIMPORT" && (
|
||||
<label className="inline-form-note" style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceFull}
|
||||
onChange={(e) => setForceFull(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Borrar también los registros que sólo existen en la plataforma
|
||||
(ignorar la verificación). Sólo marque esto si de verdad quiere
|
||||
perderlos.
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||
<input
|
||||
@@ -596,3 +622,363 @@ 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 [verify, setVerify] = useState<VerifyResult | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyError, setVerifyError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getReplicationStatus()
|
||||
.then((s) => {
|
||||
setStatus(s);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => setFailed(true));
|
||||
}, []);
|
||||
|
||||
const runVerify = useCallback(() => {
|
||||
setVerifying(true);
|
||||
setVerifyError(null);
|
||||
verifyReplication()
|
||||
.then(setVerify)
|
||||
.catch((e: unknown) =>
|
||||
setVerifyError(e instanceof Error ? e.message : "No se pudo comparar."),
|
||||
)
|
||||
.finally(() => setVerifying(false));
|
||||
}, []);
|
||||
|
||||
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="Diferencia con el maestro" value={driftLabel(status.drift)} />
|
||||
<KV label="Consultado" value={formatDateTime(status.checkedAt)} />
|
||||
</div>
|
||||
|
||||
<ApplyProgressBar apply={status.apply} />
|
||||
|
||||
{/* Only worth showing when it is not zero, and even then as a note rather
|
||||
than a warning: these are the seed load's own transactions, and they
|
||||
are inert until someone tries to promote this box. */}
|
||||
{status.drift !== null && status.drift.localTransactions > 0 && (
|
||||
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
||||
La réplica tiene {status.drift.localTransactions.toLocaleString("es-MX")} transacciones
|
||||
propias (de la carga inicial). No se propagan y no afectan la lectura; sólo importarían
|
||||
si este servidor pasara a ser maestro.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<VerifyPanel
|
||||
result={verify}
|
||||
running={verifying}
|
||||
error={verifyError}
|
||||
onRun={runVerify}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-by-row comparison against the master, on demand.
|
||||
*
|
||||
* Separate from the polled fields because it costs a full scan of both servers.
|
||||
* It is the only check here that can catch a row changed on the replica by
|
||||
* something other than replication — the GTID and lag figures would both still
|
||||
* read perfectly healthy in that case.
|
||||
*/
|
||||
function VerifyPanel({
|
||||
result,
|
||||
running,
|
||||
error,
|
||||
onRun,
|
||||
}: {
|
||||
result: VerifyResult | null;
|
||||
running: boolean;
|
||||
error: string | null;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginTop: 16, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
|
||||
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||
Compara fila por fila las 8 tablas que lee el sitio de clientes. Recorre ambos
|
||||
servidores por completo, así que tarda.
|
||||
</span>
|
||||
<button className="btn btn-ghost" type="button" onClick={onRun} disabled={running}>
|
||||
{running ? "Comparando…" : "Comparar con el maestro"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.problem && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{result.problem}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.problem && (
|
||||
<>
|
||||
<p style={{ marginTop: 12, marginBottom: 8 }}>
|
||||
<span className={`badge ${result.identical ? "badge-positive" : "badge-negative"}`}>
|
||||
{result.identical ? "Idénticas" : "Hay diferencias"}
|
||||
</span>{" "}
|
||||
<span className="inline-form-note">
|
||||
{formatDateTime(result.checkedAt)} · {(result.elapsedMs / 1000).toFixed(1)} s
|
||||
</span>
|
||||
</p>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tabla</th>
|
||||
<th style={{ textAlign: "right" }}>Maestro</th>
|
||||
<th style={{ textAlign: "right" }}>Réplica</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.tables.map((t) => (
|
||||
<tr key={t.table}>
|
||||
<td>{t.table}</td>
|
||||
{/* -1 is the sentinel for "that server did not answer for
|
||||
this table", which is not the same as zero rows. */}
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{t.masterRows < 0 ? "—" : t.masterRows.toLocaleString("es-MX")}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{t.replicaRows < 0 ? "—" : t.replicaRows.toLocaleString("es-MX")}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${t.matches ? "badge-positive" : "badge-negative"}`}>
|
||||
{t.matches
|
||||
? "igual"
|
||||
: t.masterRows !== t.replicaRows
|
||||
? "difieren en filas"
|
||||
: "difieren en contenido"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactions the master has executed that the replica has not.
|
||||
*
|
||||
* Rendered as its own line rather than folded into the lag figure because the
|
||||
* two disagree in exactly the case that matters: a disconnected I/O thread
|
||||
* reports 0 seconds of lag (no event has arrived to be late) while this number
|
||||
* climbs.
|
||||
*/
|
||||
function driftLabel(drift: GtidDrift | null): string {
|
||||
// Null means the master could not be reached. Saying "al día" here would be a
|
||||
// lie of the worst kind — it is the reading a broken check produces.
|
||||
if (drift === null) return "sin dato";
|
||||
if (drift.missingTransactions === 0) return "al día";
|
||||
return `${drift.missingTransactions.toLocaleString("es-MX")} transacciones atrás`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
|
||||
+345
-3
@@ -27,6 +27,7 @@ import type {
|
||||
CreateBankInput,
|
||||
CreateBankMovementInput,
|
||||
CreateMovementInput,
|
||||
DiscardBatchResult,
|
||||
UpdateBankAccountInput,
|
||||
ResolveOutstandingInput,
|
||||
ReviewDocumentInput,
|
||||
@@ -58,6 +59,8 @@ import type {
|
||||
LookupsResponse,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
ReplicationStatus,
|
||||
VerifyResult,
|
||||
IngestFile,
|
||||
BackupFile,
|
||||
PropertyDetail,
|
||||
@@ -107,7 +110,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
@@ -230,6 +233,20 @@ export function restoreCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface NumidAllocation {
|
||||
numid: string;
|
||||
/** "existing" when the customer already had one — the call is idempotent. */
|
||||
origin: "existing" | "new" | "recycled";
|
||||
previousCustomerId?: string;
|
||||
}
|
||||
|
||||
/** Give a customer the portal NUMid they log in to my.jorgecuadros.com with. */
|
||||
export function grantPortalAccess(id: string): Promise<NumidAllocation> {
|
||||
return apiFetch<NumidAllocation>(`/customers/${id}/portal-access`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Policies module */
|
||||
|
||||
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
||||
@@ -938,6 +955,28 @@ 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare every customer-visible table against the master, row by row.
|
||||
*
|
||||
* Slow by nature — it is a full scan of both servers — so it is a button, not
|
||||
* part of the poll. Answers the question replication status cannot: GTIDs prove
|
||||
* the replica applied everything the master sent, not that nothing else changed
|
||||
* the rows here.
|
||||
*/
|
||||
export function verifyReplication(): Promise<VerifyResult> {
|
||||
return apiFetch<VerifyResult>("/ops/replication/verify", { method: "POST" });
|
||||
}
|
||||
|
||||
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||
}
|
||||
@@ -947,10 +986,16 @@ export function getOpsJob(id: string): Promise<OpsJob> {
|
||||
}
|
||||
|
||||
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
||||
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
||||
/** `forceFull` applies to REIMPORT only: proceed even though the rebuild
|
||||
* deletes rows that exist only in the platform. */
|
||||
export function startOpsJob(
|
||||
kind: OpsJobKind,
|
||||
file?: string,
|
||||
forceFull?: boolean,
|
||||
): Promise<OpsJob> {
|
||||
return apiFetch<OpsJob>("/ops/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, file }),
|
||||
body: JSON.stringify({ kind, file, forceFull }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -972,6 +1017,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 +1411,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 +1505,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)",
|
||||
};
|
||||
|
||||
+114
-3
@@ -9,11 +9,13 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
| "policy:ingest"
|
||||
| "policy:ocr-review"
|
||||
| "renewal:send"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
@@ -26,7 +28,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 +62,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 +79,93 @@ 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;
|
||||
drift: GtidDrift | null;
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed-history gap between master and replica, in transactions.
|
||||
*
|
||||
* The only field on the card that is not self-reported by the replica, and the
|
||||
* only one that catches a silently disconnected I/O thread: with no incoming
|
||||
* events, `secondsBehind` reads 0 because there is nothing to measure staleness
|
||||
* against, so a dead link looks perfectly current. This number grows instead.
|
||||
*
|
||||
* Null when the master could not be reached — "unknown" must not render as
|
||||
* "identical".
|
||||
*/
|
||||
export interface GtidDrift {
|
||||
missingTransactions: number;
|
||||
missingGtidSet: string | null;
|
||||
/**
|
||||
* Transactions written on the replica under its own server UUID, which exist
|
||||
* nowhere on the master. Non-zero is expected — restoring the seed dump
|
||||
* executed its statements locally — and harmless while nothing replicates
|
||||
* from this node.
|
||||
*/
|
||||
localTransactions: number;
|
||||
}
|
||||
|
||||
/** One table compared on both sides of the link. */
|
||||
export interface TableFingerprint {
|
||||
table: string;
|
||||
masterRows: number;
|
||||
replicaRows: number;
|
||||
masterChecksum: string;
|
||||
replicaChecksum: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
identical: boolean;
|
||||
tables: TableFingerprint[];
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +1062,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 +1093,7 @@ export interface CustomerInput {
|
||||
mobile?: string;
|
||||
fax?: string;
|
||||
email?: string;
|
||||
emailOptOut?: boolean;
|
||||
notes?: string;
|
||||
identificationType?: string;
|
||||
identificationNumber?: string;
|
||||
@@ -1217,7 +1318,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 +1399,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 +1412,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
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
# 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 | Whether recycling ever means a true data purge. The *triggers* are now settled and built (see §5 "NUMid allocation"); what is still open is whether a recycled id's old rows are ever deleted rather than left attached to the previous customer | nothing — the allocator ships without a purge | 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`).
|
||||
|
||||
**NUMid allocation** — `POST /customers/:id/portal-access` assigns the portal
|
||||
"Security Number", on a staff action rather than at create time, because an
|
||||
insurance-only customer has no reason to hold a utilities id.
|
||||
|
||||
- **Recycling is built but switched off.** `numid.recycleEmpty` in `app_settings`
|
||||
defaults to false, and that default is a safety property, not a preference:
|
||||
every reusable id still exists in Access DATGRAL, and a `--sync` migration run
|
||||
upserts refs with `ON DUPLICATE KEY UPDATE customerId`
|
||||
(`transform_customers.py:327`), so an id recycled today is handed back to its
|
||||
Access owner on the next sync and the customer given it loses portal access.
|
||||
**Flip it on after utilities cuts over**, or for ids deleted at the source.
|
||||
- **A full re-import would destroy every natively allocated id — now guarded.**
|
||||
`transform_customers.py:246` truncates `customers` and `customer_legacy_refs`
|
||||
(and the other transforms truncate everything they own), then rebuild from
|
||||
Access alone. `migration/native_guard.py` runs before any of it and refuses
|
||||
when the target holds rows Access has never seen; `run_all.py --force-full`,
|
||||
or the checkbox in the REIMPORT confirm, overrides and deletes them. **`--sync`
|
||||
remains the correct path for any database with native rows** — the guard stops
|
||||
the loss, it does not make full mode preserve anything.
|
||||
- **The empty-id rule exists twice**: enforced in `numid.service.ts`
|
||||
(`EMPTY_NUMID_SQL`) and reported by `scripts/numid-audit.sql`. They agree today
|
||||
(both return 1089, 1094, 1134, 1143 on dev); they are not mechanically kept in
|
||||
step, so change them together.
|
||||
- **No un-assign.** Nothing removes a NUMid once given, and nothing reports which
|
||||
ids were recycled from whom beyond the `customer.portal-access` activity-log
|
||||
entry.
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Refuse a full re-import that would delete platform-native data.
|
||||
|
||||
A full `run_all.py` pass truncates and rebuilds every table it owns from the
|
||||
Access extract:
|
||||
|
||||
transform_customers.py customers, customer_legacy_refs
|
||||
transform_properties.py properties, property_services, service_documents,
|
||||
trust_accounts
|
||||
transform_policies.py policies + installments, vehicles, drivers,
|
||||
beneficiaries, claims, adjusters, policy_types,
|
||||
insurance_providers
|
||||
transform_transactions.py transactions, type_transactions, exchange_rates
|
||||
transform_bank.py bank tables
|
||||
blob_extract.py service_documents, policy_documents
|
||||
|
||||
That was harmless while the platform was a read-only mirror of Access: every
|
||||
row came from the extract, so wiping and rebuilding lost nothing. It stopped
|
||||
being harmless when the platform started minting rows Access has never heard
|
||||
of — portal NUMids from the allocator (apps/api/src/customers/numid.service.ts),
|
||||
customers created in the staff UI, OCR-captured policies, app-booked ledger
|
||||
rows, uploaded documents. None of those come back.
|
||||
|
||||
`--sync` already avoids all of it: it upserts legacy rows against the existing
|
||||
refs and leaves everything else alone. So this guard does not try to teach the
|
||||
full path to preserve anything — it stops the full path when there is something
|
||||
to preserve, and points at the additive one.
|
||||
|
||||
python native_guard.py --env prod # report only, exit 3 if blocking
|
||||
python run_all.py --env prod --stage # runs this first, refuses on 3
|
||||
python run_all.py --env prod --force-full # ignore the guard (deletes them)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from dbenv import connect
|
||||
|
||||
STG = Path(__file__).parent / "output"
|
||||
|
||||
# Exit code the orchestrator looks for. Distinct from 1 so a connection failure
|
||||
# or a bad query is not silently read as "native rows found".
|
||||
BLOCKED = 3
|
||||
|
||||
# transform_customers.py mints these when a legacy row carries no id of its own.
|
||||
# They are regenerated by every full pass, so they are legacy-owned, not native.
|
||||
SYNTHETIC_REF_PREFIXES = ("rownum_", "insrow_")
|
||||
|
||||
# App-uploaded documents are stored as `<prefix>/<parent>/<uuid>.<ext>`, while
|
||||
# blob_extract writes `<prefix>/<parent>/<stagedtable>_<row>_<col>.<ext>`.
|
||||
# service_documents carries no provenance column, so the key shape is the only
|
||||
# signal available — approximate, and reported as such. Matched in MySQL rather
|
||||
# than in Python so the whole scan stays one round trip per table.
|
||||
UUID_KEY_SQL = "/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\."
|
||||
|
||||
|
||||
def staged_legacy_ids() -> dict[str, set[str]] | None:
|
||||
"""The ids Access will re-create, per source system.
|
||||
|
||||
None when the staged Parquet is absent — which is not the same as "no legacy
|
||||
ids". Returning an empty set there would mark all 1,171 refs as native and
|
||||
block every run; returning None lets the caller say "cannot verify" instead.
|
||||
"""
|
||||
sources = {"utilities": "stg_utilities", "insurance": "stg_seguros"}
|
||||
out: dict[str, set[str]] = {}
|
||||
for system, folder in sources.items():
|
||||
path = STG / folder / "datgral.parquet"
|
||||
if not path.exists():
|
||||
return None
|
||||
df = pd.read_parquet(path, columns=["num_id"])
|
||||
ids = set()
|
||||
for v in df["num_id"].astype("string"):
|
||||
if v is None or pd.isna(v):
|
||||
continue
|
||||
v = str(v).strip()
|
||||
if v.endswith(".0"): # some numeric ids serialize as "521.0"
|
||||
v = v[:-2]
|
||||
if v and v != "0":
|
||||
ids.add(v)
|
||||
out[system] = ids
|
||||
return out
|
||||
|
||||
|
||||
def native_refs(cur, staged: dict[str, set[str]] | None) -> tuple[int, list[str]]:
|
||||
"""Legacy refs with no counterpart in the Access extract.
|
||||
|
||||
This is the check that catches an allocated portal NUMid: the customer holds
|
||||
a perfectly ordinary-looking (utilities, DATGRAL, '1172') ref, so "customer
|
||||
has no refs" does not see it. Only comparing against staging does.
|
||||
"""
|
||||
cur.execute(
|
||||
"SELECT sourceSystem, legacyId FROM customer_legacy_refs ORDER BY sourceSystem, legacyId"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if staged is None:
|
||||
return 0, []
|
||||
|
||||
found = []
|
||||
for system, legacy_id in rows:
|
||||
if legacy_id.startswith(SYNTHETIC_REF_PREFIXES):
|
||||
continue
|
||||
known = staged.get(system)
|
||||
# An unknown source system has no extract to compare against, so it
|
||||
# cannot be re-created either — treat it as native rather than ignoring.
|
||||
if known is None or legacy_id not in known:
|
||||
found.append(f"{system}/{legacy_id}")
|
||||
return len(found), found
|
||||
|
||||
|
||||
def scan(conn) -> tuple[list[tuple[str, int, str]], bool]:
|
||||
"""(label, count, detail) per source of native rows, plus whether staging
|
||||
was available to verify the refs."""
|
||||
cur = conn.cursor()
|
||||
staged = staged_legacy_ids()
|
||||
findings: list[tuple[str, int, str]] = []
|
||||
|
||||
ref_count, ref_examples = native_refs(cur, staged)
|
||||
if ref_count:
|
||||
shown = ", ".join(ref_examples[:8])
|
||||
more = f" (+{ref_count - 8} more)" if ref_count > 8 else ""
|
||||
findings.append(("customer_legacy_refs", ref_count, f"{shown}{more}"))
|
||||
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM customers c"
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM customer_legacy_refs r WHERE r.customerId = c.id)"
|
||||
)
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append(("customers", n, "created in the staff UI, no legacy ref"))
|
||||
|
||||
# Every transform writes legacyId on what it loads, so a NULL is the app's.
|
||||
for table, detail in (
|
||||
("transactions", "booked in the app (captura, OCR, manual)"),
|
||||
("policies", "created in the app or captured by policy OCR"),
|
||||
("properties", "created in the app"),
|
||||
("vehicles", "created in the app"),
|
||||
("bank_transactions", "booked in the chequera"),
|
||||
):
|
||||
cur.execute(f"SELECT COUNT(*) FROM {table} WHERE legacyId IS NULL")
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append((table, n, detail))
|
||||
|
||||
# blob_extract always writes originalColumn; the app never does.
|
||||
cur.execute("SELECT COUNT(*) FROM policy_documents WHERE originalColumn IS NULL")
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append(("policy_documents", n, "uploaded in the app"))
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM service_documents WHERE storageKey REGEXP %s",
|
||||
(UUID_KEY_SQL,))
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append(("service_documents", n, "uploaded in the app (key shape, approximate)"))
|
||||
|
||||
return findings, staged is not None
|
||||
|
||||
|
||||
def report(findings, staged_ok: bool, env: str) -> int:
|
||||
print(f"=== Verificación de datos nativos (env={env}) ===", flush=True)
|
||||
|
||||
if not staged_ok:
|
||||
print(
|
||||
" ! No hay Parquet en migration/output, así que no se pueden verificar\n"
|
||||
" los refs contra el extracto de Access. Ejecute con --stage.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not findings:
|
||||
print(" Sin filas nativas. Una reimportación completa no destruye nada.", flush=True)
|
||||
return 0 if staged_ok else BLOCKED
|
||||
|
||||
total = sum(n for _, n, _ in findings)
|
||||
print(f" {total} filas existen SÓLO en la plataforma y se perderían:", flush=True)
|
||||
for label, n, detail in findings:
|
||||
print(f" {n:>7} {label:<22} {detail}", flush=True)
|
||||
print(
|
||||
"\n Una reimportación completa vacía estas tablas y las reconstruye desde\n"
|
||||
" Access, que no conoce ninguna de estas filas.\n"
|
||||
"\n Use la sincronización aditiva (run_all.py --sync), que respeta lo\n"
|
||||
" capturado en la plataforma. Para reimportar de todos modos y BORRARLAS,\n"
|
||||
" ejecute run_all.py --force-full.",
|
||||
flush=True,
|
||||
)
|
||||
return BLOCKED
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("--env", default="dev")
|
||||
args = ap.parse_args()
|
||||
|
||||
conn = connect(args.env)
|
||||
try:
|
||||
findings, staged_ok = scan(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
raise SystemExit(report(findings, staged_ok, args.env))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+64
-5
@@ -15,6 +15,17 @@ 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
|
||||
|
||||
A full pass truncates and rebuilds every table it owns from the Access extract,
|
||||
so anything the platform minted itself — allocated portal NUMids, customers
|
||||
created in the staff UI, OCR-captured policies, app-booked ledger rows, uploaded
|
||||
documents — is destroyed. native_guard.py runs first and refuses when the target
|
||||
database holds any of it; --force-full overrides and deletes them.
|
||||
|
||||
--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 +89,40 @@ SYNC_STEPS = [
|
||||
]
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> None:
|
||||
# native_guard.py exits with this when the target database holds rows that only
|
||||
# exist in the platform. Kept in step with the constant there.
|
||||
GUARD_BLOCKED = 3
|
||||
|
||||
|
||||
def guard(env: str, force: bool) -> None:
|
||||
"""Stop a full pass that would delete platform-native rows.
|
||||
|
||||
Only the full path needs this: --sync upserts legacy rows against the
|
||||
existing refs and leaves everything else alone, so it cannot lose anything.
|
||||
Run after staging, because the guard verifies legacy refs against the staged
|
||||
Parquet and cannot tell an allocated NUMid from an Access one without it.
|
||||
"""
|
||||
cmd = [PY, str(HERE / "native_guard.py"), "--env", env]
|
||||
print("+ " + " ".join(cmd), flush=True)
|
||||
r = subprocess.run(cmd)
|
||||
if r.returncode == GUARD_BLOCKED and not force:
|
||||
sys.exit(r.returncode)
|
||||
if r.returncode == GUARD_BLOCKED and force:
|
||||
print(
|
||||
"\n! --force-full: continuando y BORRANDO las filas nativas listadas.\n",
|
||||
flush=True,
|
||||
)
|
||||
elif r.returncode:
|
||||
sys.exit(r.returncode)
|
||||
|
||||
|
||||
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:
|
||||
@@ -92,16 +136,31 @@ def main() -> None:
|
||||
help="re-run the raw staging load first (needs the Access files + mdbtools)")
|
||||
ap.add_argument("--sync", action="store_true",
|
||||
help="upsert legacy rows and archive removed legacy rows; preserve manual rows")
|
||||
ap.add_argument("--force-full", action="store_true",
|
||||
help="run the full truncate+rebuild even when it deletes platform-native 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)
|
||||
|
||||
# Deliberately not counted as a step: it is a precondition, it takes a
|
||||
# second, and the Operaciones progress bar parses those numbers.
|
||||
if not args.sync:
|
||||
guard(args.env, args.force_full)
|
||||
|
||||
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.16",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.16",
|
||||
"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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user