feat(deploy): prisma migration history, /version, galactus standalone deploy
Closes the gap between "what tag did I deploy" and "what is actually running", and gives the schema a history that can be reasoned about across releases. Migrations - Baseline the existing schema as 0000_init (migrate diff --from-empty). The schema had only ever been applied with `prisma db push`, so no history existed and schema state was disconnected from app version. Existing databases must be baselined once with `migrate resolve --applied 0000_init`; the workflows print this remedy on P3005. - Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a CMD, N replicas would race each other applying the same migration. Version reporting - GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that build.yml already baked into both images but nothing ever read. - The web footer shows the web build and flags an api/web mismatch. The two cannot drift at build time (one matrix run) but can at deploy time. - Both deploy workflows now fail if the running API does not report the tag that was dispatched — a stack naming a tag is not proof of what is running. - scripts/set-version.mjs stamps every package.json, which had all sat at 0.1.0 while real releases shipped as v1.x. Pre-migrate backup - deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the still-running old API container over Portainer's Docker API, so the file lands in the volume the Operaciones restore screen reads. A dump taken on the CI runner would be unreachable by the only restore path we have. Verifies the artefact with `gzip -t` before letting the migration proceed. galactus - deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks. Plain compose silently ignores `deploy:`, so restart_policy becomes `restart: unless-stopped` — without it nothing returns after a host reboot. - .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets. Fixes - deploy.yml passed `endpoint_id` and `pull_image` to cssnr/portainer-stack-deploy-action, which has no such inputs (they are `endpoint` and `pull`). The endpoint was silently never set. docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema changes: Prisma has no down-migrations, so a code rollback never rolls the schema back, and restoring the replication master from a dump diverges every replica. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
|||||||
|
# Manual PROD deploy to galactus — the office server, Portainer endpoint 3.
|
||||||
|
#
|
||||||
|
# galactus is STANDALONE Docker (`swarm: inactive`), so this workflow applies
|
||||||
|
# the compose files under deploy/galactus/, NOT the Swarm files in deploy/.
|
||||||
|
# .gitea/workflows/deploy.yml is the cubex/Swarm equivalent; the two are kept
|
||||||
|
# separate on purpose because plain compose silently ignores Swarm's `deploy:`
|
||||||
|
# keys rather than failing on them.
|
||||||
|
#
|
||||||
|
# This does NOT build. build.yml already built + pushed both images from one
|
||||||
|
# matrix run, so api and web at the same tag are always in step.
|
||||||
|
#
|
||||||
|
# Order of operations, and why:
|
||||||
|
# 1. db + minio (scope=full only) — the API depends on both.
|
||||||
|
# 2. pre-migrate backup dumped INSIDE the still-running OLD api container,
|
||||||
|
# so the file lands in the volume the Operaciones
|
||||||
|
# restore screen reads. Must precede the migration.
|
||||||
|
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
||||||
|
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
||||||
|
# the rule, the backup is the emergency lever.
|
||||||
|
# 4. app (api + web) the new images.
|
||||||
|
# 5. verify ask the running API what it actually is.
|
||||||
|
#
|
||||||
|
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
||||||
|
# schema stays forward. This is exactly why every schema change must be
|
||||||
|
# backward-compatible with the previous release.
|
||||||
|
#
|
||||||
|
# Prereqs (once):
|
||||||
|
# - Gitea repo secrets, galactus-specific (suffix _GALACTUS so the cubex
|
||||||
|
# secrets keep working side by side):
|
||||||
|
# PORTAINER_URL_GALACTUS https://100.103.77.46:9443
|
||||||
|
# PORTAINER_API_KEY_GALACTUS Portainer access token for galactus
|
||||||
|
# PORTAINER_ENDPOINT_ID_GALACTUS 3
|
||||||
|
# PORTAINER_APP_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-app
|
||||||
|
# PORTAINER_DB_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-db
|
||||||
|
# PORTAINER_MINIO_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-minio
|
||||||
|
# DATABASE_URL_GALACTUS mysql://jorgecuadros:<pass>@<galactus>:3306/jorgecuadros
|
||||||
|
# APP_API_ORIGIN_GALACTUS browser-facing API URL
|
||||||
|
# APP_WEB_ORIGIN_GALACTUS web public origin (API CORS)
|
||||||
|
# APP_S3_ENDPOINT_GALACTUS server-side minio URL
|
||||||
|
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
||||||
|
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
||||||
|
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
||||||
|
# - 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
|
||||||
|
# and dispatch with skip_migrate=true.
|
||||||
|
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
||||||
|
# with `prisma db push`): baseline it before the first run, or step 3 fails
|
||||||
|
# with P3005 "database schema is not empty":
|
||||||
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
||||||
|
# --schema packages/database/prisma/schema.prisma
|
||||||
|
|
||||||
|
name: Deploy to galactus
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Image tag to deploy (1.2.3 — no leading v — or sha-<short>, or latest)"
|
||||||
|
required: true
|
||||||
|
default: "latest"
|
||||||
|
scope:
|
||||||
|
description: "What to deploy"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: "app"
|
||||||
|
options:
|
||||||
|
- app
|
||||||
|
- full
|
||||||
|
bootstrap:
|
||||||
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
skip_migrate:
|
||||||
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mancinas.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy ${{ github.event.inputs.tag }} (${{ github.event.inputs.scope }})
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
||||||
|
# runtime and cannot resolve one without it.
|
||||||
|
run: apk add --no-cache openssl ca-certificates git
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# --- full only: database ---------------------------------------------
|
||||||
|
- name: Deploy database stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-db.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MYSQL_SERVER_ID": "1",
|
||||||
|
"MYSQL_PORT": "3306",
|
||||||
|
"MYSQL_DATABASE": "jorgecuadros",
|
||||||
|
"MYSQL_USER": "jorgecuadros",
|
||||||
|
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
|
||||||
|
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- full only: object storage ---------------------------------------
|
||||||
|
- name: Deploy minio stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-minio.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MINIO_API_PORT": "9000",
|
||||||
|
"MINIO_CONSOLE_PORT": "9001",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- restore point, taken while the OLD api container is still up ------
|
||||||
|
- name: Pre-migrate backup
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
||||||
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
||||||
|
# Portainer serves a self-signed certificate. Scoped to this step
|
||||||
|
# only, which does nothing but talk to Portainer.
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
- name: Apply database migrations
|
||||||
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SCHEMA=packages/database/prisma/schema.prisma
|
||||||
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
||||||
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
||||||
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
||||||
|
echo "::error::the database predates migration history — baseline it once with:"
|
||||||
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- always: the app (web + api) -------------------------------------
|
||||||
|
- name: Deploy app stack
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-app.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
pull: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
|
"API_PORT": "3001",
|
||||||
|
"WEB_PORT": "3000",
|
||||||
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
||||||
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
||||||
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
||||||
|
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
||||||
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prove it ----------------------------------------------------------
|
||||||
|
- name: Verify running version
|
||||||
|
env:
|
||||||
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
||||||
|
WANT: ${{ github.event.inputs.tag }}
|
||||||
|
# The stack file naming a tag is not proof the container is running it —
|
||||||
|
# a skipped pull or a cached layer can leave the old code up. Ask it.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apk add --no-cache curl >/dev/null
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS "$API_ORIGIN/version" > /tmp/version.json; then break; fi
|
||||||
|
echo "waiting for API ($i/30)..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
cat /tmp/version.json
|
||||||
|
GOT=$(node -e 'console.log(require("/tmp/version.json").version)')
|
||||||
|
# Only a semver dispatch is directly comparable: metadata-action's
|
||||||
|
# {{version}} turns tag v1.2.3 into image 1.2.3, while `latest` and
|
||||||
|
# `sha-*` report the branch or short sha instead.
|
||||||
|
case "$WANT" in
|
||||||
|
[0-9]*.[0-9]*.[0-9]*)
|
||||||
|
if [ "$GOT" != "$WANT" ]; then
|
||||||
|
echo "::error::deployed $WANT but the API reports $GOT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "verified: API is running $GOT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "dispatched '$WANT'; API reports '$GOT' (not directly comparable)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+110
-5
@@ -8,6 +8,17 @@
|
|||||||
# app = web + api only (the usual app release) [default]
|
# app = web + api only (the usual app release) [default]
|
||||||
# full = db + minio + web + api (bring up / update the whole platform)
|
# full = db + minio + web + api (bring up / update the whole platform)
|
||||||
#
|
#
|
||||||
|
# The `tag` input carries NO leading `v`: metadata-action's {{version}} turns
|
||||||
|
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
||||||
|
#
|
||||||
|
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
||||||
|
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
||||||
|
# an older tag; that rolls back CODE only, never the schema, which is why every
|
||||||
|
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
||||||
|
#
|
||||||
|
# galactus (the office server) is standalone Docker, not this Swarm — it has its
|
||||||
|
# own workflow, .gitea/workflows/deploy-galactus.yml.
|
||||||
|
#
|
||||||
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates
|
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates
|
||||||
# it on every run, so no manual stack pre-creation in the Portainer UI. On a
|
# it on every run, so no manual stack pre-creation in the Portainer UI. On a
|
||||||
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
|
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
|
||||||
@@ -36,6 +47,14 @@
|
|||||||
# # Database stack (full only)
|
# # Database stack (full only)
|
||||||
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||||
# MYSQL_ROOT_PASSWORD mysql root password
|
# MYSQL_ROOT_PASSWORD mysql root password
|
||||||
|
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
|
||||||
|
# migration step connects to the database directly. If it cannot reach 3306,
|
||||||
|
# migrate by hand and dispatch with skip_migrate=true.
|
||||||
|
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
||||||
|
# that exists today): baseline it before the first run, or the migrate step
|
||||||
|
# fails with P3005 "database schema is not empty":
|
||||||
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
||||||
|
# --schema packages/database/prisma/schema.prisma
|
||||||
|
|
||||||
name: Deploy to Portainer
|
name: Deploy to Portainer
|
||||||
|
|
||||||
@@ -54,6 +73,16 @@ on:
|
|||||||
options:
|
options:
|
||||||
- app
|
- app
|
||||||
- full
|
- full
|
||||||
|
bootstrap:
|
||||||
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
skip_migrate:
|
||||||
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.mancinas.io
|
REGISTRY: git.mancinas.io
|
||||||
@@ -63,8 +92,13 @@ jobs:
|
|||||||
name: Deploy (${{ github.event.inputs.scope }})
|
name: Deploy (${{ github.event.inputs.scope }})
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: node:18-alpine
|
image: node:20-alpine
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
||||||
|
# runtime and cannot resolve one without it.
|
||||||
|
run: apk add --no-cache openssl ca-certificates git
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# --- full only: database ---------------------------------------------
|
# --- full only: database ---------------------------------------------
|
||||||
@@ -77,7 +111,7 @@ jobs:
|
|||||||
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
||||||
file: deploy/jorgecuadros-db.stack.yml
|
file: deploy/jorgecuadros-db.stack.yml
|
||||||
type: file
|
type: file
|
||||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"MYSQL_SERVER_ID": "1",
|
"MYSQL_SERVER_ID": "1",
|
||||||
@@ -98,7 +132,7 @@ jobs:
|
|||||||
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
||||||
file: deploy/jorgecuadros-minio.stack.yml
|
file: deploy/jorgecuadros-minio.stack.yml
|
||||||
type: file
|
type: file
|
||||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"MINIO_API_PORT": "9000",
|
"MINIO_API_PORT": "9000",
|
||||||
@@ -107,6 +141,44 @@ jobs:
|
|||||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- restore point, taken while the OLD api container is still up ------
|
||||||
|
# Dumped INSIDE the running api container so the file lands in the volume
|
||||||
|
# the "Operaciones" restore screen reads — a dump on the runner would be
|
||||||
|
# unreachable by the only restore path this platform has.
|
||||||
|
- name: Pre-migrate backup
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
||||||
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
||||||
|
# Portainer serves a self-signed certificate. Scoped to this step
|
||||||
|
# only, which does nothing but talk to Portainer.
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
# Prisma has no down-migrations: a code rollback does NOT roll the schema
|
||||||
|
# back. See docs/DEPLOY_AND_MIGRATIONS.md — every change must be
|
||||||
|
# expand/contract so the previous release still runs against the new
|
||||||
|
# schema. Run as a deploy STEP, never as the container CMD: N replicas
|
||||||
|
# would race each other applying the same migration.
|
||||||
|
- name: Apply database migrations
|
||||||
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SCHEMA=packages/database/prisma/schema.prisma
|
||||||
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
||||||
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
||||||
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
||||||
|
echo "::error::the database predates migration history — baseline it once with:"
|
||||||
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# --- always: the app (web + api) -------------------------------------
|
# --- always: the app (web + api) -------------------------------------
|
||||||
- name: Deploy app stack
|
- name: Deploy app stack
|
||||||
uses: cssnr/portainer-stack-deploy-action@v1
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
@@ -116,8 +188,8 @@ jobs:
|
|||||||
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
||||||
file: deploy/jorgecuadros-app.stack.yml
|
file: deploy/jorgecuadros-app.stack.yml
|
||||||
type: file
|
type: file
|
||||||
pull_image: true
|
pull: true
|
||||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
@@ -132,3 +204,36 @@ jobs:
|
|||||||
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- prove it ----------------------------------------------------------
|
||||||
|
# A stack naming a tag is not proof the container is running it — a
|
||||||
|
# skipped pull leaves the old code up. Ask the API what it actually is.
|
||||||
|
- name: Verify running version
|
||||||
|
env:
|
||||||
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
|
||||||
|
WANT: ${{ github.event.inputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apk add --no-cache curl >/dev/null
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS "$API_ORIGIN/version" > /tmp/version.json; then break; fi
|
||||||
|
echo "waiting for API ($i/30)..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
cat /tmp/version.json
|
||||||
|
GOT=$(node -e 'console.log(require("/tmp/version.json").version)')
|
||||||
|
# Only a semver dispatch is directly comparable: metadata-action's
|
||||||
|
# {{version}} turns tag v1.2.3 into image 1.2.3, while `latest` and
|
||||||
|
# `sha-*` report the branch or short sha instead.
|
||||||
|
case "$WANT" in
|
||||||
|
[0-9]*.[0-9]*.[0-9]*)
|
||||||
|
if [ "$GOT" != "$WANT" ]; then
|
||||||
|
echo "::error::deployed $WANT but the API reports $GOT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "verified: API is running $GOT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "dispatched '$WANT'; API reports '$GOT' (not directly comparable)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -6,4 +6,25 @@ export class AppController {
|
|||||||
health() {
|
health() {
|
||||||
return { status: "ok" };
|
return { status: "ok" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is actually running. The three values are baked into the image at
|
||||||
|
* build time by .gitea/workflows/build.yml (see docker/api.Dockerfile) and
|
||||||
|
* are the only way to confirm a deploy — or a rollback — landed: the tag you
|
||||||
|
* dispatched and the code inside the container can disagree if a stack was
|
||||||
|
* applied without pulling, or if the app stack still names an older tag.
|
||||||
|
*
|
||||||
|
* Deliberately unauthenticated, same as /health: the deploy workflow has to
|
||||||
|
* read it with no session, and it exposes nothing an attacker could not
|
||||||
|
* already infer from the repo.
|
||||||
|
*/
|
||||||
|
@Get("version")
|
||||||
|
version() {
|
||||||
|
return {
|
||||||
|
service: "api",
|
||||||
|
version: process.env.APP_VERSION ?? "dev",
|
||||||
|
gitSha: process.env.GIT_SHA ?? "unknown",
|
||||||
|
buildDate: process.env.BUILD_DATE ?? "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,38 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Deployed-build line. Quiet by default — it only needs to be legible when
|
||||||
|
someone is verifying a release or a rollback. */
|
||||||
|
.shell-footer {
|
||||||
|
max-width: var(--shell-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.75rem 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted-2);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.shell-footer-build {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.shell-footer-warn {
|
||||||
|
color: var(--negative);
|
||||||
|
background: var(--negative-tint);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.shell-footer {
|
||||||
|
padding: 1rem 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { readBuildInfoFromEnv } from "@/lib/build-info";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Jorge Cuadros & Asociados — Plataforma",
|
title: "Jorge Cuadros & Asociados — Plataforma",
|
||||||
@@ -20,6 +21,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
process.env.API_ORIGIN ??
|
process.env.API_ORIGIN ??
|
||||||
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||||
"http://localhost:3001";
|
"http://localhost:3001";
|
||||||
|
// Same reason as the API origin: read on the server per request so the built
|
||||||
|
// image is not pinned to one build identity in its client bundle.
|
||||||
|
const build = readBuildInfoFromEnv();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
@@ -27,7 +31,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
{/* Must run before the app bundle so lib/api.ts sees it at import. */}
|
{/* Must run before the app bundle so lib/api.ts sees it at import. */}
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};`,
|
__html:
|
||||||
|
`window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};` +
|
||||||
|
`window.__APP_BUILD__=${JSON.stringify(build)};`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Text-size preference, applied before first paint so the page never
|
{/* Text-size preference, applied before first paint so the page never
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { logout, me, updateUiScale } from "@/lib/api";
|
import { getApiVersion, logout, me, updateUiScale } from "@/lib/api";
|
||||||
|
import { shortSha, webBuildInfo } from "@/lib/build-info";
|
||||||
import { AuthContext, can } from "@/lib/abilities";
|
import { AuthContext, can } from "@/lib/abilities";
|
||||||
import { ROLE_LABEL } from "@/lib/labels";
|
import { ROLE_LABEL } from "@/lib/labels";
|
||||||
import {
|
import {
|
||||||
@@ -181,6 +182,52 @@ function NavMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is deployed, from both halves. build.yml builds api + web in one matrix
|
||||||
|
* run, so their versions cannot drift at build time — but they can at DEPLOY
|
||||||
|
* time, if a stack is applied with only one image's tag moved. Showing both and
|
||||||
|
* flagging a mismatch is the cheap check that catches a half-applied release.
|
||||||
|
*/
|
||||||
|
function BuildFooter() {
|
||||||
|
const web = webBuildInfo();
|
||||||
|
const [api, setApi] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getApiVersion()
|
||||||
|
.then((v) => {
|
||||||
|
if (alive) setApi(v.version);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// The shell already redirects to /login when the API is unreachable;
|
||||||
|
// a missing version line is not worth a second error surface.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const mismatch = api !== null && api !== web.version;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="shell-footer">
|
||||||
|
<span>Jorge Cuadros & Asociados</span>
|
||||||
|
<span
|
||||||
|
className="shell-footer-build"
|
||||||
|
title={`web ${web.version} (${shortSha(web.gitSha)}) — ${web.buildDate}`}
|
||||||
|
>
|
||||||
|
v{web.version}
|
||||||
|
{api !== null && (mismatch ? ` · API v${api}` : "")}
|
||||||
|
</span>
|
||||||
|
{mismatch && (
|
||||||
|
<span className="shell-footer-warn" role="status">
|
||||||
|
versiones desincronizadas
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -381,6 +428,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="shell-main">{children}</main>
|
<main className="shell-main">{children}</main>
|
||||||
|
<BuildFooter />
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,18 @@ export function logout(): Promise<{ success: boolean }> {
|
|||||||
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceVersion {
|
||||||
|
service: string;
|
||||||
|
version: string;
|
||||||
|
gitSha: string;
|
||||||
|
buildDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the API container reports it is running. Unauthenticated by design. */
|
||||||
|
export function getApiVersion(): Promise<ServiceVersion> {
|
||||||
|
return apiFetch<ServiceVersion>("/version");
|
||||||
|
}
|
||||||
|
|
||||||
export function getStats(): Promise<CustomerStats> {
|
export function getStats(): Promise<CustomerStats> {
|
||||||
return apiFetch<CustomerStats>("/customers/stats");
|
return apiFetch<CustomerStats>("/customers/stats");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* The web image's own build identity.
|
||||||
|
*
|
||||||
|
* Same runtime-injection trick as API_ORIGIN (lib/api.ts): docker/web.Dockerfile
|
||||||
|
* bakes APP_VERSION / GIT_SHA / BUILD_DATE as ENV, layout.tsx reads them on the
|
||||||
|
* server per request and paints them into window.__APP_BUILD__. Reading
|
||||||
|
* process.env directly from a client component would return undefined — Next
|
||||||
|
* only inlines NEXT_PUBLIC_* into the browser bundle, and baking the version in
|
||||||
|
* at build time is exactly what we are avoiding elsewhere.
|
||||||
|
*/
|
||||||
|
export interface BuildInfo {
|
||||||
|
version: string;
|
||||||
|
gitSha: string;
|
||||||
|
buildDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNKNOWN_BUILD: BuildInfo = {
|
||||||
|
version: "dev",
|
||||||
|
gitSha: "unknown",
|
||||||
|
buildDate: "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Server-side read, used by layout.tsx to produce the injected payload. */
|
||||||
|
export function readBuildInfoFromEnv(): BuildInfo {
|
||||||
|
return {
|
||||||
|
version: process.env.APP_VERSION ?? UNKNOWN_BUILD.version,
|
||||||
|
gitSha: process.env.GIT_SHA ?? UNKNOWN_BUILD.gitSha,
|
||||||
|
buildDate: process.env.BUILD_DATE ?? UNKNOWN_BUILD.buildDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-side read of what layout.tsx injected. */
|
||||||
|
export function webBuildInfo(): BuildInfo {
|
||||||
|
if (typeof window === "undefined") return readBuildInfoFromEnv();
|
||||||
|
const injected = (window as { __APP_BUILD__?: BuildInfo }).__APP_BUILD__;
|
||||||
|
return injected ?? UNKNOWN_BUILD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First 7 chars, the length git itself abbreviates to. */
|
||||||
|
export function shortSha(sha: string): string {
|
||||||
|
return sha === "unknown" ? sha : sha.slice(0, 7);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# NestJS API + Next.js web on galactus (standalone Docker, Portainer endpoint 3).
|
||||||
|
#
|
||||||
|
# Standalone port of deploy/jorgecuadros-app.stack.yml — see the header of
|
||||||
|
# deploy/galactus/jorgecuadros-db.compose.yml for the Swarm keys plain compose
|
||||||
|
# silently ignores. The one that matters most here: without
|
||||||
|
# `restart: unless-stopped` neither service returns after a host reboot.
|
||||||
|
#
|
||||||
|
# Cross-stack traffic still goes over the HOST, not service DNS. db and minio
|
||||||
|
# are separate Portainer stacks, so they are on separate compose networks and
|
||||||
|
# their service names do not resolve from here. DATABASE_URL / S3_ENDPOINT must
|
||||||
|
# name galactus's own address and the published port — exactly as on cubex
|
||||||
|
# today. Do not "simplify" them to `mysql:3306`.
|
||||||
|
#
|
||||||
|
# The web image is NOT URL-baked: the browser's API origin is injected at
|
||||||
|
# runtime from API_ORIGIN (apps/web/src/app/layout.tsx), so the same image works
|
||||||
|
# for any deployment. APP_VERSION / GIT_SHA / BUILD_DATE come baked in from
|
||||||
|
# build.yml and are surfaced at GET /version (api) and in the web footer.
|
||||||
|
#
|
||||||
|
# Keep in sync with deploy/jorgecuadros-app.stack.yml when either changes.
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
# Stable handle for deploy/scripts/pre-migrate-backup.sh, which finds this
|
||||||
|
# container by label to run mysqldump into the backup volume. A label
|
||||||
|
# survives stack renames; the compose service name does not.
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "api"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
|
WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set}
|
||||||
|
PORT: "3001"
|
||||||
|
INGEST_DIR: /data/ingest
|
||||||
|
BACKUP_DIR: /data/backups
|
||||||
|
MIGRATION_ENV: prod
|
||||||
|
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}
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-3001}:3001"
|
||||||
|
volumes:
|
||||||
|
# Uploaded Access files and DB backups. Named, so they survive every
|
||||||
|
# redeploy — and so the pre-migrate dump the deploy takes is the same
|
||||||
|
# file the "Operaciones" restore screen lists.
|
||||||
|
- ingest_data:/data/ingest
|
||||||
|
- backup_data:/data/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "web"
|
||||||
|
environment:
|
||||||
|
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||||
|
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
# Unlike Swarm — which ignores depends_on entirely — plain compose honours
|
||||||
|
# this, so web waits for the API to pass its healthcheck.
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ingest_data:
|
||||||
|
backup_data:
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# MySQL for the Jorge Cuadros platform on galactus — the PROD source of truth.
|
||||||
|
#
|
||||||
|
# galactus is STANDALONE Docker (Portainer endpoint 3, `swarm: inactive`), not
|
||||||
|
# the 3-node Swarm on cubex. deploy/jorgecuadros-db.stack.yml is the Swarm
|
||||||
|
# version of this file; the deltas are called out below because plain compose
|
||||||
|
# SILENTLY IGNORES the Swarm keys rather than erroring on them:
|
||||||
|
#
|
||||||
|
# 1. `deploy.restart_policy` is ignored -> `restart: unless-stopped` instead.
|
||||||
|
# Without this MySQL does not come back after a host reboot. This is the
|
||||||
|
# single highest-risk difference.
|
||||||
|
# 2. `deploy.placement.constraints` is meaningless on one host — dropped,
|
||||||
|
# along with its `docker node update --label-add jorgecuadros_db=true`
|
||||||
|
# prerequisite.
|
||||||
|
# 3. `deploy.replicas` / `update_config` are ignored — dropped.
|
||||||
|
# 4. `ports: {mode: ingress}` long syntax is Swarm-only -> short syntax.
|
||||||
|
# 5. Named volumes stay exactly as they were: the node-pinning hazard that
|
||||||
|
# motivated them was purely a Swarm problem, and Portainer still namespaces
|
||||||
|
# the volume by stack name.
|
||||||
|
#
|
||||||
|
# This node is the REPLICATION MASTER for the whole topology. Every other MySQL
|
||||||
|
# is a replica of it. server-id must be unique across the topology (prod=1,
|
||||||
|
# cubex dev=11); a duplicate silently breaks replication. binlog + GTID are on
|
||||||
|
# from first boot so a replica can attach with SOURCE_AUTO_POSITION=1 and no
|
||||||
|
# file/position bookkeeping.
|
||||||
|
#
|
||||||
|
# Keep in sync with deploy/jorgecuadros-db.stack.yml when either changes.
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
# (caching_sha2_password is already the default in 8.4; the old
|
||||||
|
# --default-authentication-plugin flag was REMOVED in 8.4 and aborts boot.)
|
||||||
|
- --server-id=${MYSQL_SERVER_ID:-1}
|
||||||
|
- --log-bin=mysql-bin
|
||||||
|
- --binlog-format=ROW
|
||||||
|
- --gtid-mode=ON
|
||||||
|
- --enforce-gtid-consistency=ON
|
||||||
|
# A replica offline longer than this needs a full re-seed, because the
|
||||||
|
# binlogs it still needs are gone. The 8.4 default is 30 days; raise it
|
||||||
|
# here rather than discovering the gap during an outage.
|
||||||
|
- --binlog-expire-logs-seconds=${MYSQL_BINLOG_EXPIRE_SECONDS:-5184000}
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-jorgecuadros}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-jorgecuadros}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||||
|
ports:
|
||||||
|
# Standalone: binds directly on the host. Reachable at
|
||||||
|
# <galactus>:${MYSQL_PORT}. Replicas connect here — see
|
||||||
|
# docs/DEPLOY_AND_MIGRATIONS.md on NOT exposing raw 3306 to the internet.
|
||||||
|
- "${MYSQL_PORT:-3306}:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# MinIO object storage on galactus (standalone Docker, Portainer endpoint 3).
|
||||||
|
#
|
||||||
|
# Holds the document blobs extracted from the Access LONGBINARY columns; MySQL
|
||||||
|
# keeps only the storageKey pointer. Standalone port of
|
||||||
|
# deploy/jorgecuadros-minio.stack.yml — see the header of
|
||||||
|
# deploy/galactus/jorgecuadros-db.compose.yml for the full list of Swarm keys
|
||||||
|
# that plain compose silently ignores.
|
||||||
|
#
|
||||||
|
# Keep in sync with deploy/jorgecuadros-minio.stack.yml when either changes.
|
||||||
|
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mc ready local || curl -f http://localhost:9000/minio/health/live || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio_data:
|
||||||
@@ -27,6 +27,11 @@ version: "3.8"
|
|||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
||||||
|
# Container label (not `deploy.labels`, which labels the swarm SERVICE).
|
||||||
|
# deploy/scripts/pre-migrate-backup.mjs finds the container by this label to
|
||||||
|
# run its pre-migrate mysqldump into the backup volume.
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "api"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
@@ -68,6 +73,8 @@ services:
|
|||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "web"
|
||||||
environment:
|
environment:
|
||||||
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||||
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Take a mysqldump immediately before a deploy runs `prisma migrate deploy`.
|
||||||
|
*
|
||||||
|
* Why it is done THIS way and not with a plain `mysqldump` on the CI runner:
|
||||||
|
* a dump is only useful if a human can restore it, and the only restore path
|
||||||
|
* this platform has is the "Operaciones" admin screen, which lists and replays
|
||||||
|
* whatever `*.sql.gz` files sit in the API container's BACKUP_DIR volume
|
||||||
|
* (apps/api/src/ops/ops.service.ts — listBackups / RESTORE). A dump written on
|
||||||
|
* the runner would land nowhere anybody can reach. So we drive the dump INSIDE
|
||||||
|
* the still-running old API container, via Portainer's Docker API proxy: the
|
||||||
|
* container already has mysql-client baked in (docker/api.Dockerfile), and the
|
||||||
|
* file lands in the exact directory the restore UI reads.
|
||||||
|
*
|
||||||
|
* It must therefore run BEFORE the app stack is re-applied, while the previous
|
||||||
|
* container is still up.
|
||||||
|
*
|
||||||
|
* Required env:
|
||||||
|
* PORTAINER_URL https://<host>:9443
|
||||||
|
* PORTAINER_API_KEY Portainer access token
|
||||||
|
* PORTAINER_ENDPOINT_ID numeric endpoint id (galactus = 3)
|
||||||
|
* DATABASE_URL mysql://user:pass@host:port/db
|
||||||
|
* BACKUP_TAG label for the filename, e.g. the deployed tag
|
||||||
|
* Optional env:
|
||||||
|
* ALLOW_MISSING_CONTAINER=true exit 0 when no API container exists yet
|
||||||
|
* (first-ever deploy — nothing to back up)
|
||||||
|
* API_CONTAINER_LABEL=io.jorgecuadros.role=api
|
||||||
|
* EXEC_TIMEOUT_SECONDS=1800
|
||||||
|
*
|
||||||
|
* TLS: Portainer here uses a self-signed certificate. The caller is expected to
|
||||||
|
* set NODE_TLS_REJECT_UNAUTHORIZED=0 for this step; see the workflow. That
|
||||||
|
* disables verification for the whole process, so nothing else should run in it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
|
||||||
|
const API_KEY = required("PORTAINER_API_KEY");
|
||||||
|
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
|
||||||
|
const DATABASE_URL = required("DATABASE_URL");
|
||||||
|
const BACKUP_TAG = required("BACKUP_TAG");
|
||||||
|
|
||||||
|
const CONTAINER_LABEL =
|
||||||
|
process.env.API_CONTAINER_LABEL ?? "io.jorgecuadros.role=api";
|
||||||
|
const ALLOW_MISSING = process.env.ALLOW_MISSING_CONTAINER === "true";
|
||||||
|
const TIMEOUT_MS =
|
||||||
|
Number(process.env.EXEC_TIMEOUT_SECONDS ?? 1800) * 1000;
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) {
|
||||||
|
console.error(`missing required env: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
|
||||||
|
|
||||||
|
async function docker(path, init = {}) {
|
||||||
|
const res = await fetch(`${DOCKER}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": API_KEY,
|
||||||
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`docker ${path} -> ${res.status} ${text.slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-quote for `sh -c`, the same discipline ops.service.ts uses. */
|
||||||
|
function shq(value) {
|
||||||
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDbUrl(raw) {
|
||||||
|
const u = new URL(raw);
|
||||||
|
return {
|
||||||
|
host: u.hostname,
|
||||||
|
port: u.port || "3306",
|
||||||
|
user: decodeURIComponent(u.username),
|
||||||
|
password: decodeURIComponent(u.password),
|
||||||
|
database: u.pathname.replace(/^\//, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches ops.service.ts's own naming: ISO, colons and dots flattened. */
|
||||||
|
function timestamp() {
|
||||||
|
return new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[:.]/g, "-")
|
||||||
|
.replace("T", "_")
|
||||||
|
.slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ops.service.ts refuses to restore any name outside this character set, so a
|
||||||
|
* file we write with, say, a `+` in the tag would be permanently unrestorable
|
||||||
|
* through the UI. Sanitise before writing, not after.
|
||||||
|
*/
|
||||||
|
function safeTag(tag) {
|
||||||
|
return tag.replace(/[^A-Za-z0-9._-]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findApiContainer() {
|
||||||
|
const [key, value] = CONTAINER_LABEL.split("=");
|
||||||
|
const filters = encodeURIComponent(
|
||||||
|
JSON.stringify({ label: [`${key}=${value}`], status: ["running"] }),
|
||||||
|
);
|
||||||
|
const list = await docker(`/containers/json?filters=${filters}`);
|
||||||
|
return list.length ? list[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a command in the container and return its exit code. Detach:true keeps
|
||||||
|
* this to plain HTTP — a non-detached exec start hijacks the connection into a
|
||||||
|
* raw stream, which fetch cannot read. The cost is that we get no stdout, so
|
||||||
|
* every check below has to be expressed as an exit code.
|
||||||
|
*/
|
||||||
|
async function execInContainer(containerId, cmd, env = []) {
|
||||||
|
const created = await docker(`/containers/${containerId}/exec`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
AttachStdout: false,
|
||||||
|
AttachStderr: false,
|
||||||
|
Tty: false,
|
||||||
|
Env: env,
|
||||||
|
Cmd: ["sh", "-c", cmd],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await docker(`/exec/${created.Id}/start`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ Detach: true, Tty: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deadline = Date.now() + TIMEOUT_MS;
|
||||||
|
for (;;) {
|
||||||
|
const info = await docker(`/exec/${created.Id}/json`);
|
||||||
|
if (!info.Running) return info.ExitCode ?? 1;
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error(`exec timed out after ${TIMEOUT_MS / 1000}s`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const container = await findApiContainer();
|
||||||
|
if (!container) {
|
||||||
|
const message = `no running container matching label ${CONTAINER_LABEL}`;
|
||||||
|
if (ALLOW_MISSING) {
|
||||||
|
console.warn(`skipping pre-migrate backup: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`${message} — pass bootstrap=true only if this is the first deploy and ` +
|
||||||
|
`there is genuinely no data to lose`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = parseDbUrl(DATABASE_URL);
|
||||||
|
const file = `pre-migrate-${safeTag(BACKUP_TAG)}-${timestamp()}.sql.gz`;
|
||||||
|
const out = `/data/backups/${file}`;
|
||||||
|
|
||||||
|
console.log(`container : ${container.Id.slice(0, 12)}`);
|
||||||
|
console.log(`database : ${conn.user}@${conn.host}:${conn.port}/${conn.database}`);
|
||||||
|
console.log(`writing : ${out}`);
|
||||||
|
|
||||||
|
// Password via MYSQL_PWD in the exec env, never on the command line — argv is
|
||||||
|
// world-readable through `ps` inside the container.
|
||||||
|
const flags = `--host=${conn.host} --port=${conn.port} --user=${shq(conn.user)}`;
|
||||||
|
const dump =
|
||||||
|
`set -o pipefail; mysqldump ${flags} --single-transaction --routines ` +
|
||||||
|
`--triggers --no-tablespaces ${shq(conn.database)} | gzip -c > ${shq(out)}`;
|
||||||
|
|
||||||
|
const code = await execInContainer(container.Id, dump, [
|
||||||
|
`MYSQL_PWD=${conn.password}`,
|
||||||
|
]);
|
||||||
|
if (code !== 0) {
|
||||||
|
// Leave the truncated file behind for inspection but never let the deploy
|
||||||
|
// proceed believing it has a restore point.
|
||||||
|
throw new Error(`mysqldump exited ${code} — refusing to migrate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detached exec gives no stdout, so prove the artefact separately: non-empty
|
||||||
|
// file, and gzip that actually decompresses. A dump that fails mid-stream can
|
||||||
|
// still leave a plausible-looking file.
|
||||||
|
const verify = await execInContainer(
|
||||||
|
container.Id,
|
||||||
|
`test -s ${shq(out)} && gzip -t ${shq(out)}`,
|
||||||
|
);
|
||||||
|
if (verify !== 0) {
|
||||||
|
throw new Error(`backup ${file} is empty or corrupt (check exited ${verify})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`ok: ${file} written and verified in the API backup volume`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`pre-migrate backup FAILED: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Releasing, deploying, and changing the schema
|
||||||
|
|
||||||
|
How a version gets from this repo onto a server, and the one rule that keeps
|
||||||
|
rollbacks possible.
|
||||||
|
|
||||||
|
## The short version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm version:set 1.2.0 # stamp every package.json
|
||||||
|
git commit -am "chore(release): v1.2.0"
|
||||||
|
git tag v1.2.0 && git push origin master v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
That push triggers `.gitea/workflows/build.yml`, which builds **both** images in
|
||||||
|
one matrix run and publishes:
|
||||||
|
|
||||||
|
| tag pushed | image tags produced |
|
||||||
|
| --- | --- |
|
||||||
|
| `v1.2.0` | `1.2.0`, `1.2`, `sha-<short>` |
|
||||||
|
| push to `master` | `master`, `sha-<short>`, `latest` |
|
||||||
|
|
||||||
|
Then dispatch a deploy from the Actions tab:
|
||||||
|
|
||||||
|
- **galactus** (office server, standalone Docker) — *Deploy to galactus*
|
||||||
|
- **cubex** (3-node Swarm) — *Deploy to Portainer*
|
||||||
|
|
||||||
|
> **The `v` is not part of the image tag.** `docker/metadata-action`'s
|
||||||
|
> `{{version}}` strips it. Git tag `v1.2.0`, dispatch `1.2.0`. Dispatching
|
||||||
|
> `v1.2.0` deploys nothing that exists.
|
||||||
|
|
||||||
|
Because api and web are built from one matrix run, they cannot drift at build
|
||||||
|
time. They *can* drift at deploy time if a stack is applied with only one image
|
||||||
|
moved — the web footer shows both versions and flags a mismatch, and the deploy
|
||||||
|
workflow's last step fails if the API does not report the tag you dispatched.
|
||||||
|
|
||||||
|
## What a deploy actually does
|
||||||
|
|
||||||
|
1. **db + minio** — `scope: full` only. Idempotent; data lives on named volumes.
|
||||||
|
2. **Pre-migrate backup** — `deploy/scripts/pre-migrate-backup.mjs` runs
|
||||||
|
`mysqldump` *inside the still-running old API container*, via Portainer's
|
||||||
|
Docker API. The file lands in that container's `BACKUP_DIR` volume as
|
||||||
|
`pre-migrate-<tag>-<timestamp>.sql.gz`, which is exactly what the
|
||||||
|
**Operaciones** admin screen lists and can restore. A dump taken on the CI
|
||||||
|
runner would be unreachable by the only restore path the platform has.
|
||||||
|
3. **`prisma migrate deploy`** — as a workflow *step*, never the container
|
||||||
|
`CMD`. If it were the CMD, N replicas would race each other applying the
|
||||||
|
same migration.
|
||||||
|
4. **app** — the new api + web images.
|
||||||
|
5. **Verify** — `GET /version` on the running API must report the dispatched
|
||||||
|
tag.
|
||||||
|
|
||||||
|
Rollback is `tag: 1.1.9` re-dispatched. **That rolls back code only.** The
|
||||||
|
schema stays where it is. Which brings us to the rule.
|
||||||
|
|
||||||
|
## The rule: expand / contract
|
||||||
|
|
||||||
|
Prisma has no down-migrations. There is no `prisma migrate down`, and there
|
||||||
|
never will be. So a schema change that the *previous* release cannot tolerate
|
||||||
|
turns a 30-second rollback into a restore-from-backup outage.
|
||||||
|
|
||||||
|
**Every schema change must leave the previous release working.** Split anything
|
||||||
|
destructive across two releases:
|
||||||
|
|
||||||
|
| | Release N (expand) | Release N+1 (contract) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Rename a column | add the new column, write to both, read the old | drop the old column |
|
||||||
|
| Drop a column | stop reading and writing it in code | drop it |
|
||||||
|
| Add a required column | add it nullable (or with a default), backfill | make it `NOT NULL` |
|
||||||
|
| Split a table | create the new table, dual-write | stop writing the old, drop it |
|
||||||
|
| Add an enum value | add the value; old code must not choke on unknowns | start emitting it |
|
||||||
|
|
||||||
|
Ship N, let it soak, *then* ship N+1. If N has to be rolled back you just
|
||||||
|
re-dispatch the old tag — the expanded schema still satisfies it.
|
||||||
|
|
||||||
|
Restoring from the pre-migrate dump is the **emergency lever, not the routine
|
||||||
|
path**, and on galactus it is worse than it sounds: galactus is the replication
|
||||||
|
master, DDL replicates through the binlog, and restoring the master from a dump
|
||||||
|
diverges every replica. GTIDs will not line up and each replica needs a full
|
||||||
|
re-seed. Assume a restore is a multi-hour, whole-topology event.
|
||||||
|
|
||||||
|
## Migration history
|
||||||
|
|
||||||
|
`packages/database/prisma/migrations/0000_init/` is a **baseline**. It is the
|
||||||
|
full schema as it stood on 2026-07-30, generated with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma migrate diff --from-empty \
|
||||||
|
--to-schema-datamodel packages/database/prisma/schema.prisma --script
|
||||||
|
```
|
||||||
|
|
||||||
|
Until then the schema had only ever been applied with `prisma db push`, so no
|
||||||
|
history existed and the schema state was disconnected from the app version.
|
||||||
|
|
||||||
|
### One-time, on every database that already exists
|
||||||
|
|
||||||
|
`0000_init` describes tables those databases already have, so `migrate deploy`
|
||||||
|
would fail with **P3005 "the database schema is not empty"**. Mark it applied
|
||||||
|
instead of applying it — this writes a `_prisma_migrations` row and changes no
|
||||||
|
data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=<the database> npx prisma@5 migrate resolve \
|
||||||
|
--applied 0000_init --schema packages/database/prisma/schema.prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this once per database (prod, dev, any local copy). Verify first that the
|
||||||
|
live schema really does match the baseline — this should print an empty
|
||||||
|
migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma migrate diff --from-url "$DATABASE_URL" \
|
||||||
|
--to-schema-datamodel packages/database/prisma/schema.prisma --script
|
||||||
|
```
|
||||||
|
|
||||||
|
If it prints actual statements, the live database has drifted from
|
||||||
|
`schema.prisma`. Reconcile *before* baselining, or the first real migration
|
||||||
|
will fail against a schema Prisma believes it already knows.
|
||||||
|
|
||||||
|
### From here on
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# edit schema.prisma, then:
|
||||||
|
pnpm --filter @jorgecuadros/database exec prisma migrate dev --name add_foo
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the generated `migrations/<timestamp>_add_foo/` directory. `db push` is
|
||||||
|
now a local-scratch tool only — using it against a database with history
|
||||||
|
desynchronises it from `_prisma_migrations`.
|
||||||
|
|
||||||
|
## galactus vs cubex
|
||||||
|
|
||||||
|
`galactus` is standalone Docker (Portainer endpoint **3**), `cubex` is a 3-node
|
||||||
|
Swarm (endpoint **2**). They need different compose files because **plain
|
||||||
|
compose silently ignores Swarm's `deploy:` keys** rather than erroring:
|
||||||
|
|
||||||
|
| | Swarm (`deploy/*.stack.yml`) | standalone (`deploy/galactus/*.compose.yml`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| restart | `deploy.restart_policy` | `restart: unless-stopped` — **without this nothing comes back after a host reboot** |
|
||||||
|
| placement | `node.labels.jorgecuadros_db == true` | dropped, one host |
|
||||||
|
| ports | `{mode: ingress}` long syntax | `"3306:3306"` |
|
||||||
|
| `depends_on` | ignored by Swarm | honoured, with `condition: service_healthy` |
|
||||||
|
| volumes | named | named (unchanged — the pinning hazard was a Swarm problem) |
|
||||||
|
|
||||||
|
Keep the two sets in sync when either changes.
|
||||||
|
|
||||||
|
On both hosts, cross-stack traffic goes over the **host address**, not compose
|
||||||
|
service DNS: db, minio and app are three separate stacks, so three separate
|
||||||
|
networks. `DATABASE_URL` and `S3_ENDPOINT` name the host and its published
|
||||||
|
port. Do not "simplify" them to `mysql:3306`.
|
||||||
|
|
||||||
|
## Replication
|
||||||
|
|
||||||
|
galactus's MySQL is the **master**; every other MySQL in the estate is a
|
||||||
|
replica. Consequences that bite:
|
||||||
|
|
||||||
|
- `server-id` must be unique across the whole topology (prod `1`, cubex dev
|
||||||
|
`11`). A duplicate breaks replication silently.
|
||||||
|
- GTID is on from first boot, so replicas attach with `SOURCE_AUTO_POSITION=1`.
|
||||||
|
- `binlog_expire_logs_seconds` is raised to 60 days in the galactus compose file
|
||||||
|
(`MYSQL_BINLOG_EXPIRE_SECONDS`). MySQL 8.4 defaults to 30 days; a replica
|
||||||
|
offline longer than the retention needs a full re-seed.
|
||||||
|
|
||||||
|
Still open, and **not** handled by anything in this repo:
|
||||||
|
|
||||||
|
- No replication user with `REPLICATION SLAVE` granted exists yet.
|
||||||
|
- Nothing sets `read_only` / `super_read_only` on the replicas, so a stray write
|
||||||
|
to a replica will diverge it.
|
||||||
|
- The channel to the VPS crosses the public internet. It needs a tunnel or TLS —
|
||||||
|
do not publish raw 3306.
|
||||||
|
|
||||||
|
## Known caveats in the deploy path
|
||||||
|
|
||||||
|
- The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because
|
||||||
|
Portainer serves a self-signed certificate. It is scoped to that one step,
|
||||||
|
which talks to nothing but Portainer. Replacing the certificate and dropping
|
||||||
|
the flag is the real fix.
|
||||||
|
- The runner lives on cubex and must reach the target host's Portainer (9443)
|
||||||
|
**and** MySQL (3306). If it cannot reach 3306, run the migration by hand from
|
||||||
|
a host that can and dispatch with `skip_migrate: true`.
|
||||||
|
- `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.
|
||||||
+3
-1
@@ -12,7 +12,9 @@
|
|||||||
"build": "npm run build -ws --if-present",
|
"build": "npm run build -ws --if-present",
|
||||||
"prisma:generate": "npm run generate -w packages/database",
|
"prisma:generate": "npm run generate -w packages/database",
|
||||||
"prisma:migrate": "npm run migrate:dev -w packages/database",
|
"prisma:migrate": "npm run migrate:dev -w packages/database",
|
||||||
"prisma:studio": "npm run studio -w packages/database"
|
"prisma:deploy": "npm run migrate:deploy -w packages/database",
|
||||||
|
"prisma:studio": "npm run studio -w packages/database",
|
||||||
|
"version:set": "node scripts/set-version.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `customers` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`nameSource` VARCHAR(191) NULL,
|
||||||
|
`nameMissing` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`addressLine1` VARCHAR(191) NULL,
|
||||||
|
`addressLine2` VARCHAR(191) NULL,
|
||||||
|
`city` VARCHAR(191) NULL,
|
||||||
|
`state` VARCHAR(191) NULL,
|
||||||
|
`zipCode` VARCHAR(191) NULL,
|
||||||
|
`country` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`mobile` VARCHAR(191) NULL,
|
||||||
|
`fax` VARCHAR(191) NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`identificationType` VARCHAR(191) NULL,
|
||||||
|
`identificationNumber` VARCHAR(191) NULL,
|
||||||
|
`identificationExpiration` DATETIME(3) NULL,
|
||||||
|
`customerSince` DATETIME(3) NULL,
|
||||||
|
`status` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`minimumBalance` DECIMAL(12, 2) NULL,
|
||||||
|
`feeAmount` DECIMAL(12, 2) NULL,
|
||||||
|
`preferredCurrency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'USD',
|
||||||
|
`archivedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `customer_legacy_refs` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`sourceSystem` VARCHAR(191) NOT NULL,
|
||||||
|
`sourceTable` VARCHAR(191) NOT NULL,
|
||||||
|
`legacyId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `customer_legacy_refs_sourceSystem_sourceTable_legacyId_key`(`sourceSystem`, `sourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `insurance_providers` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `insurance_providers_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_types` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`shortDescription` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `policy_types_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policies` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`policyTypeId` VARCHAR(191) NULL,
|
||||||
|
`insuranceProviderId` VARCHAR(191) NULL,
|
||||||
|
`agentName` VARCHAR(191) NULL,
|
||||||
|
`policyDate` DATETIME(3) NULL,
|
||||||
|
`policyFrom` DATETIME(3) NULL,
|
||||||
|
`policyTo` DATETIME(3) NULL,
|
||||||
|
`coveragePeriodDays` INTEGER NULL DEFAULT 365,
|
||||||
|
`netPremium` DECIMAL(12, 2) NULL,
|
||||||
|
`policyFee` DECIMAL(12, 2) NULL,
|
||||||
|
`brokerFee` DECIMAL(12, 2) NULL,
|
||||||
|
`commission` DECIMAL(12, 2) NULL,
|
||||||
|
`total` DECIMAL(12, 2) NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
|
||||||
|
`observations` TEXT NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`coveragesJson` JSON NULL,
|
||||||
|
`endorsement` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`liquidated` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`liquidationNumber` VARCHAR(191) NULL,
|
||||||
|
`liquidationDate` DATETIME(3) NULL,
|
||||||
|
`archivedAt` DATETIME(3) NULL,
|
||||||
|
`legacySourceDb` VARCHAR(191) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `policies_policyNumber_idx`(`policyNumber`),
|
||||||
|
UNIQUE INDEX `policies_legacySourceDb_legacySourceTable_legacyId_key`(`legacySourceDb`, `legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `renewal_notices` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`generation` INTEGER NOT NULL,
|
||||||
|
`channel` ENUM('MAIL', 'EMAIL') NOT NULL DEFAULT 'MAIL',
|
||||||
|
`sentAt` DATETIME(3) NULL,
|
||||||
|
`sentById` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `renewal_notices_policyId_generation_key`(`policyId`, `generation`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_payment_installments` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`sequence` INTEGER NOT NULL,
|
||||||
|
`amount` DECIMAL(12, 2) NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
|
||||||
|
`dueDate` DATETIME(3) NULL,
|
||||||
|
`paidDate` DATETIME(3) NULL,
|
||||||
|
`checkNumber` VARCHAR(191) NULL,
|
||||||
|
`isCash` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `vehicles` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NULL,
|
||||||
|
`policyId` VARCHAR(191) NULL,
|
||||||
|
`make` VARCHAR(191) NULL,
|
||||||
|
`model` VARCHAR(191) NULL,
|
||||||
|
`modelYear` VARCHAR(191) NULL,
|
||||||
|
`bodyType` VARCHAR(191) NULL,
|
||||||
|
`engineNumber` VARCHAR(191) NULL,
|
||||||
|
`licensePlate` VARCHAR(191) NULL,
|
||||||
|
`vinNumber` VARCHAR(191) NULL,
|
||||||
|
`stateCode` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `insured_drivers` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`fullName` VARCHAR(191) NULL,
|
||||||
|
`birthDate` DATETIME(3) NULL,
|
||||||
|
`sex` VARCHAR(191) NULL,
|
||||||
|
`occupation` VARCHAR(191) NULL,
|
||||||
|
`licenseNumber` VARCHAR(191) NULL,
|
||||||
|
`licenseState` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_beneficiaries` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NULL,
|
||||||
|
`address` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `claims` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`claimType` VARCHAR(191) NULL,
|
||||||
|
`incidentDate` DATETIME(3) NULL,
|
||||||
|
`reportedDate` DATETIME(3) NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`adjusterId` VARCHAR(191) NULL,
|
||||||
|
`claimedAmount` DECIMAL(12, 2) NULL,
|
||||||
|
`settledAmount` DECIMAL(12, 2) NULL,
|
||||||
|
`settlementDate` DATETIME(3) NULL,
|
||||||
|
`checkNumber` VARCHAR(191) NULL,
|
||||||
|
`resolved` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`resolutionNotes` TEXT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `adjusters` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`company` VARCHAR(191) NULL,
|
||||||
|
`city` VARCHAR(191) NULL,
|
||||||
|
`name` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`beeper` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_documents` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`documentType` VARCHAR(191) NOT NULL,
|
||||||
|
`storageKey` VARCHAR(191) NOT NULL,
|
||||||
|
`originalColumn` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `properties` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NULL,
|
||||||
|
`addressLine1` VARCHAR(191) NULL,
|
||||||
|
`addressLine2` VARCHAR(191) NULL,
|
||||||
|
`phone1` VARCHAR(191) NULL,
|
||||||
|
`phone2` VARCHAR(191) NULL,
|
||||||
|
`phone3` VARCHAR(191) NULL,
|
||||||
|
`zone` VARCHAR(191) NULL,
|
||||||
|
`archivedAt` DATETIME(3) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `properties_legacySourceTable_legacyId_key`(`legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `property_services` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`propertyId` VARCHAR(191) NOT NULL,
|
||||||
|
`kind` ENUM('WATER', 'ELECTRIC', 'GAS', 'CABLE', 'PROPERTY_TAX', 'FEDERAL_ZONE', 'ALARM', 'OTHER') NOT NULL,
|
||||||
|
`accountNumber` VARCHAR(191) NULL,
|
||||||
|
`meterNumber` VARCHAR(191) NULL,
|
||||||
|
`route` VARCHAR(191) NULL,
|
||||||
|
`dueDay` VARCHAR(191) NULL,
|
||||||
|
`active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `service_documents` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`propertyId` VARCHAR(191) NOT NULL,
|
||||||
|
`documentType` VARCHAR(191) NOT NULL,
|
||||||
|
`storageKey` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `trust_accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`propertyId` VARCHAR(191) NOT NULL,
|
||||||
|
`bankName` VARCHAR(191) NULL,
|
||||||
|
`trustNumber` VARCHAR(191) NULL,
|
||||||
|
`bankFee` DECIMAL(12, 2) NULL,
|
||||||
|
`dueDate1` DATETIME(3) NULL,
|
||||||
|
`dueDate2` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `trust_accounts_propertyId_key`(`propertyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `type_transactions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`nameEn` VARCHAR(191) NOT NULL,
|
||||||
|
`nameEs` VARCHAR(191) NULL,
|
||||||
|
`isService` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `transactions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`domain` ENUM('UTILITY', 'INSURANCE', 'TRUST') NOT NULL,
|
||||||
|
`typeId` VARCHAR(191) NULL,
|
||||||
|
`transactionDate` DATETIME(3) NOT NULL,
|
||||||
|
`period` VARCHAR(191) NULL,
|
||||||
|
`reference` VARCHAR(191) NULL,
|
||||||
|
`amount` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
|
||||||
|
`exchangeRate` DECIMAL(10, 4) NULL,
|
||||||
|
`checkNumber` VARCHAR(191) NULL,
|
||||||
|
`message` TEXT NULL,
|
||||||
|
`outstanding` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`captureSource` ENUM('MANUAL', 'BATCH', 'OCR') NULL,
|
||||||
|
`captureRef` VARCHAR(191) NULL,
|
||||||
|
`voidedAt` DATETIME(3) NULL,
|
||||||
|
`voidedById` VARCHAR(191) NULL,
|
||||||
|
`legacySourceDb` VARCHAR(191) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `transactions_customerId_transactionDate_idx`(`customerId`, `transactionDate`),
|
||||||
|
INDEX `transactions_checkNumber_idx`(`checkNumber`),
|
||||||
|
INDEX `transactions_captureRef_idx`(`captureRef`),
|
||||||
|
UNIQUE INDEX `transactions_legacySourceDb_legacySourceTable_legacyId_key`(`legacySourceDb`, `legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `exchange_rates` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`rate` DECIMAL(10, 4) NOT NULL,
|
||||||
|
`effectiveDate` DATETIME(3) NOT NULL,
|
||||||
|
`effectiveHour` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `business_line_categories` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `business_line_categories_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `banks` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`country` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `banks_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `bank_accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`bankId` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL,
|
||||||
|
`businessLine` ENUM('UTILITY', 'INSURANCE', 'TRUST') NULL,
|
||||||
|
`active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `bank_transactions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`bankAccountId` VARCHAR(191) NOT NULL,
|
||||||
|
`transactionDate` DATETIME(3) NOT NULL,
|
||||||
|
`transactionType` VARCHAR(191) NULL,
|
||||||
|
`reference` VARCHAR(191) NULL,
|
||||||
|
`concept` VARCHAR(191) NULL,
|
||||||
|
`amount` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`categoryId` VARCHAR(191) NULL,
|
||||||
|
`cleared` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`transferred` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`amountInWords` VARCHAR(191) NULL,
|
||||||
|
`voidedAt` DATETIME(3) NULL,
|
||||||
|
`voidedById` VARCHAR(191) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
INDEX `bank_transactions_bankAccountId_transactionDate_idx`(`bankAccountId`, `transactionDate`),
|
||||||
|
UNIQUE INDEX `bank_transactions_legacySourceTable_legacyId_key`(`legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`passwordHash` VARCHAR(191) NOT NULL,
|
||||||
|
`role` ENUM('ADMIN', 'MANAGER', 'STAFF', 'VIEWER') NOT NULL DEFAULT 'STAFF',
|
||||||
|
`active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`uiScale` DOUBLE NOT NULL DEFAULT 1,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `users_email_key`(`email`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `activity_logs` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NULL,
|
||||||
|
`event` VARCHAR(191) NOT NULL,
|
||||||
|
`level` VARCHAR(191) NOT NULL,
|
||||||
|
`message` JSON NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `email_templates` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`subject` VARCHAR(191) NOT NULL,
|
||||||
|
`templateSource` TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `email_campaigns` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`campaignName` VARCHAR(191) NOT NULL,
|
||||||
|
`subject` VARCHAR(191) NULL,
|
||||||
|
`body` TEXT NULL,
|
||||||
|
`status` VARCHAR(191) NOT NULL DEFAULT 'in_progress',
|
||||||
|
`emailSentCount` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `email_log` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NULL,
|
||||||
|
`emailAddress` VARCHAR(191) NULL,
|
||||||
|
`emailType` VARCHAR(191) NULL,
|
||||||
|
`requestBody` TEXT NULL,
|
||||||
|
`responseBody` TEXT NULL,
|
||||||
|
`sentAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ops_jobs` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`kind` ENUM('BACKUP', 'RESTORE', 'REIMPORT', 'SYNC') NOT NULL,
|
||||||
|
`status` ENUM('RUNNING', 'SUCCESS', 'FAILED') NOT NULL DEFAULT 'RUNNING',
|
||||||
|
`log` LONGTEXT NOT NULL,
|
||||||
|
`params` JSON NULL,
|
||||||
|
`createdById` VARCHAR(191) NULL,
|
||||||
|
`startedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`finishedAt` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `ops_jobs_status_idx`(`status`),
|
||||||
|
INDEX `ops_jobs_startedAt_idx`(`startedAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `customer_legacy_refs` ADD CONSTRAINT `customer_legacy_refs_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policies` ADD CONSTRAINT `policies_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policies` ADD CONSTRAINT `policies_policyTypeId_fkey` FOREIGN KEY (`policyTypeId`) REFERENCES `policy_types`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policies` ADD CONSTRAINT `policies_insuranceProviderId_fkey` FOREIGN KEY (`insuranceProviderId`) REFERENCES `insurance_providers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `renewal_notices` ADD CONSTRAINT `renewal_notices_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policy_payment_installments` ADD CONSTRAINT `policy_payment_installments_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `vehicles` ADD CONSTRAINT `vehicles_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `vehicles` ADD CONSTRAINT `vehicles_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `insured_drivers` ADD CONSTRAINT `insured_drivers_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policy_beneficiaries` ADD CONSTRAINT `policy_beneficiaries_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `claims` ADD CONSTRAINT `claims_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `claims` ADD CONSTRAINT `claims_adjusterId_fkey` FOREIGN KEY (`adjusterId`) REFERENCES `adjusters`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policy_documents` ADD CONSTRAINT `policy_documents_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `properties` ADD CONSTRAINT `properties_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `properties` ADD CONSTRAINT `properties_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `property_services` ADD CONSTRAINT `property_services_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `service_documents` ADD CONSTRAINT `service_documents_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `trust_accounts` ADD CONSTRAINT `trust_accounts_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `transactions` ADD CONSTRAINT `transactions_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `transactions` ADD CONSTRAINT `transactions_typeId_fkey` FOREIGN KEY (`typeId`) REFERENCES `type_transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `bank_accounts` ADD CONSTRAINT `bank_accounts_bankId_fkey` FOREIGN KEY (`bankId`) REFERENCES `banks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `bank_transactions` ADD CONSTRAINT `bank_transactions_bankAccountId_fkey` FOREIGN KEY (`bankAccountId`) REFERENCES `bank_accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `bank_transactions` ADD CONSTRAINT `bank_transactions_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `business_line_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `activity_logs` ADD CONSTRAINT `activity_logs_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "mysql"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Stamp one version across every package.json in the workspace.
|
||||||
|
*
|
||||||
|
* The git tag is what actually drives the image tags (docker/metadata-action in
|
||||||
|
* .gitea/workflows/build.yml reads the tag, not any package.json). This script
|
||||||
|
* exists so the checked-in manifests stop lying: they all sat at 0.1.0 while
|
||||||
|
* real releases went out as v1.x, which makes a checkout impossible to place
|
||||||
|
* against a running container.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/set-version.mjs 1.2.0
|
||||||
|
* pnpm version:set 1.2.0
|
||||||
|
*
|
||||||
|
* Then, as one release commit:
|
||||||
|
* git commit -am "chore(release): v1.2.0"
|
||||||
|
* git tag v1.2.0 && git push origin master v1.2.0
|
||||||
|
*
|
||||||
|
* Note the tag carries the leading `v` but the deploy workflow's `tag` input
|
||||||
|
* does NOT — metadata-action's {{version}} strips it, so the published image is
|
||||||
|
* `1.2.0`. Dispatch `1.2.0`, tag `v1.2.0`.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
const MANIFESTS = [
|
||||||
|
"package.json",
|
||||||
|
"apps/api/package.json",
|
||||||
|
"apps/web/package.json",
|
||||||
|
"packages/database/package.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
const version = process.argv[2];
|
||||||
|
if (!version) {
|
||||||
|
console.error("usage: node scripts/set-version.mjs <x.y.z>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// Plain semver only — a leading `v` here would end up in the image tag and in
|
||||||
|
// every manifest, which is not what any consumer expects.
|
||||||
|
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||||
|
console.error(`invalid version: ${version} (expected x.y.z, no leading "v")`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rel of MANIFESTS) {
|
||||||
|
const file = join(REPO, rel);
|
||||||
|
const raw = readFileSync(file, "utf8");
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
const previous = pkg.version;
|
||||||
|
pkg.version = version;
|
||||||
|
// Match the 2-space + trailing-newline shape the files already have so the
|
||||||
|
// release commit is a one-line diff per manifest.
|
||||||
|
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
||||||
|
console.log(`${rel}: ${previous} -> ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nnext: git commit -am "chore(release): v${version}" && git tag v${version}`);
|
||||||
Reference in New Issue
Block a user