feat(deploy): prisma migration history, /version, galactus standalone deploy
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m2s

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:
2026-07-30 11:41:12 -07:00
co-authored by Claude Opus 5
parent 9ba5d2d09a
commit 4ee7ec71f0
18 changed files with 1677 additions and 8 deletions
+228
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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",
};
}
} }
+32
View File
@@ -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;
+7 -1
View File
@@ -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
+49 -1
View File
@@ -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 &amp; 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>
); );
} }
+12
View File
@@ -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");
} }
+42
View File
@@ -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:
+7
View File
@@ -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}
+206
View File
@@ -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);
});
+182
View File
@@ -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
View File
@@ -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"
+60
View File
@@ -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}`);