Adopt pressly/goose for schema migrations
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled

Replaces the previous hand-rolled MigrateWithLock + GORM AutoMigrate path,
which had two compounding problems:
- AutoMigrate ran on every pod startup (~5 min over the transatlantic
  link) even when no schema changes had landed
- pg_advisory_lock is session-scoped, which silently fails through
  Neon's pgbouncer transaction-mode pooler — turns out this is a
  known and documented limitation that bites golang-migrate too

Goose was chosen over golang-migrate (the other heavyweight) because:
- Goose wraps each migration file in a transaction by default, so a
  failure rolls back cleanly instead of leaving a "dirty" version
  state requiring manual force-reset (golang-migrate's known
  weakness, per its own issue tracker — see #1001 + Atlas's writeup)
- Goose's locking is opt-in. We don't opt in: migrations run as a
  single Kubernetes Job, which IS the singleton process. No advisory
  lock needed at all.

Layout:
- migrations/000001_init.sql — schema-only pg_dump of the live Neon
  DB at adoption, stripped of psql-only directives that block goose's
  bookkeeping insert. Pre-goose hand-numbered migrations 002-022 had
  their effects folded into this baseline; deleted from the live tree
  but preserved in git history at 58e6997.
- Dockerfile installs `goose v3.22.1` at build time and copies the
  binary into the api image. The migrate Job reuses the api image with
  command=goose, so no separate image to build/push/version.
- deploy-k3s/manifests/migrate/job.yaml: a one-shot Job that strips
  the -pooler segment from DB_HOST (advisory lock won't survive
  pgbouncer transaction-mode), runs `goose up`, exits.
- deploy-k3s/scripts/03-deploy.sh: deletes any prior Job, applies the
  fresh one, `kubectl wait --for=condition=complete --timeout=10m`,
  then proceeds with api/worker rollout. Job failure aborts the deploy
  before any new app pod sees a stale schema.
- internal/database/database.go::RequireSchemaApplied checks
  goose_db_version on startup. api/worker refuse to boot if the
  table is missing or its latest row has is_applied=false — the
  fail-fast for "operator forgot to run migrate."
- Makefile: migrate-up / migrate-down / migrate-status / migrate-new
  for local workflow.

Production DB was bootstrapped manually:
  $ goose -dir migrations postgres "$DSN" version  # creates table
  $ psql ... -c "INSERT INTO goose_db_version (version_id, is_applied, tstamp) VALUES (1, true, NOW());"

Smoke test against fresh Postgres locally: 50 user tables created in
284ms via `goose up`, version_id=1 + is_applied=t recorded.

Verified the local goose CLI talks to prod successfully:
  $ goose ... status
  Applied At                  Migration
  =======================================
  Mon Apr 27 03:43:55 2026 -- 000001_init.sql

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-26 22:46:36 -05:00
parent d96f317d20
commit 12b2f9d43b
53 changed files with 3716 additions and 968 deletions
+1
View File
@@ -42,3 +42,4 @@ push_certs/
# Vendor (if not using go modules) # Vendor (if not using go modules)
# vendor/ # vendor/
/migrate
+9
View File
@@ -49,6 +49,12 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o /
# Build the worker binary # Build the worker binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o /app/worker ./cmd/worker RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o /app/worker ./cmd/worker
# Install goose CLI for production migrations. Pinned to a specific version
# so an upstream behavioural change can't break a deploy unannounced.
# Bumping is a deliberate, reviewable diff.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
go install github.com/pressly/goose/v3/cmd/goose@v3.22.1
# Base runtime stage for Go services # Base runtime stage for Go services
FROM alpine:3.19 AS go-base FROM alpine:3.19 AS go-base
@@ -64,6 +70,9 @@ WORKDIR /app
# Copy all binaries from builder # Copy all binaries from builder
COPY --from=builder /app/api /app/api COPY --from=builder /app/api /app/api
COPY --from=builder /app/worker /app/worker COPY --from=builder /app/worker /app/worker
# goose is the migration runner — same image is reused as the migrate Job
# entrypoint via `command: ["/usr/local/bin/goose", ...]`.
COPY --from=builder /go/bin/goose /usr/local/bin/goose
# Copy templates directory # Copy templates directory
COPY --from=builder /app/templates /app/templates COPY --from=builder /app/templates /app/templates
+26 -5
View File
@@ -89,15 +89,36 @@ docker-build-prod:
docker build --target worker -t $${REGISTRY:-ghcr.io/treytartt}/honeydue-worker:$${TAG:-latest} . docker build --target worker -t $${REGISTRY:-ghcr.io/treytartt}/honeydue-worker:$${TAG:-latest} .
docker build --target admin -t $${REGISTRY:-ghcr.io/treytartt}/honeydue-admin:$${TAG:-latest} . docker build --target admin -t $${REGISTRY:-ghcr.io/treytartt}/honeydue-admin:$${TAG:-latest} .
# Database migrations # Database migrations (goose)
#
# DATABASE_URL must point at the *direct* (non-pooler) Neon endpoint —
# goose's session-scoped advisory lock won't survive PgBouncer transaction
# mode. Example:
# export DATABASE_URL='host=ep-floral-truth-amttbc5a.c-5.us-east-1.aws.neon.tech \
# user=neondb_owner password=... dbname=honeyDue sslmode=require'
#
# Bootstrap (one-time, when adopting goose against an existing DB):
# make migrate-status # creates goose_db_version
# psql ... -c "INSERT INTO goose_db_version (version_id, is_applied, tstamp) VALUES (1, true, NOW());"
#
# Day-to-day:
# make migrate-status # show what's pending
# make migrate-up # apply pending migrations
# make migrate-down # roll back the latest migration
# make migrate-new name=add_some_column # scaffold a new SQL migration
migrate-up: migrate-up:
migrate -path migrations -database "$(DATABASE_URL)" up goose -dir migrations postgres "$(DATABASE_URL)" up
migrate-down: migrate-down:
migrate -path migrations -database "$(DATABASE_URL)" down goose -dir migrations postgres "$(DATABASE_URL)" down
migrate-create: migrate-status:
migrate create -ext sql -dir migrations -seq $(name) goose -dir migrations postgres "$(DATABASE_URL)" status
migrate-new:
@if [ -z "$(name)" ]; then echo "usage: make migrate-new name=<short_name>"; exit 1; fi
goose -dir migrations create $(name) sql
# Encrypt existing uploads at rest (run after setting STORAGE_ENCRYPTION_KEY) # Encrypt existing uploads at rest (run after setting STORAGE_ENCRYPTION_KEY)
migrate-encrypt: migrate-encrypt:
+8 -5
View File
@@ -87,11 +87,14 @@ func main() {
log.Error().Err(dbErr).Msg("Failed to connect to database - API will start but database operations will fail") log.Error().Err(dbErr).Msg("Failed to connect to database - API will start but database operations will fail")
} else { } else {
defer database.Close() defer database.Close()
// Run database migrations only if connected. // Migrations are managed out-of-band by golang-migrate (see
// MigrateWithLock serialises parallel replica starts via a Postgres // cmd/migrate and deploy-k3s/manifests/migrate/job.yaml) so the api
// advisory lock so concurrent AutoMigrate calls don't race on DDL. // no longer runs AutoMigrate at startup. Instead we verify the
if err := database.MigrateWithLock(); err != nil { // schema is at the expected version and refuse to start if not —
log.Error().Err(err).Msg("Failed to run database migrations") // this catches the "operator forgot to run migrate" footgun loudly,
// at boot, instead of with mysterious runtime errors.
if err := database.RequireSchemaApplied(); err != nil {
log.Fatal().Err(err).Msg("Schema precondition failed — run `kubectl -n honeydue create job --from=cronjob/honeydue-migrate` (or `make migrate-up` locally) and retry")
} }
} }
+75
View File
@@ -0,0 +1,75 @@
# One-shot migration Job. Runs goose against Neon's *direct* (non-pooler)
# endpoint, applies any pending migrations from /app/migrations (baked into
# the api image), exits.
#
# 03-deploy.sh deletes any prior Job, applies this one, waits for completion
# with `kubectl wait --for=condition=complete`, and rolls api/worker only
# after the Job succeeds. A Job failure aborts the whole deploy.
#
# We reuse the api image rather than build a separate one — the api Dockerfile
# already installs the goose CLI to /usr/local/bin/goose and copies the
# migrations directory to /app/migrations.
apiVersion: batch/v1
kind: Job
metadata:
name: honeydue-migrate
namespace: honeydue
labels:
app.kubernetes.io/name: migrate
app.kubernetes.io/part-of: honeydue
spec:
backoffLimit: 0 # fail fast — no silent retries on a bad migration
ttlSecondsAfterFinished: 86400 # keep finished Job for 24h so logs are inspectable
template:
metadata:
labels:
app.kubernetes.io/name: migrate
app.kubernetes.io/part-of: honeydue
spec:
restartPolicy: Never
imagePullSecrets:
- name: ghcr-credentials
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: goose
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api
command: ["/bin/sh", "-c"]
# DB_HOST in the ConfigMap points at the -pooler endpoint for runtime.
# goose's session-scoped advisory lock can't survive PgBouncer
# transaction-mode, so we strip the -pooler segment for migrations.
# `set -e` so any sub-command failure exits non-zero.
args:
- |
set -e
DIRECT_HOST=$(echo "$DB_HOST" | sed 's/-pooler\.\(.*\)$/.\1/')
echo "[migrate] running goose up against $DIRECT_HOST"
exec /usr/local/bin/goose \
-dir /app/migrations \
postgres "host=$DIRECT_HOST port=$DB_PORT user=$POSTGRES_USER password=$POSTGRES_PASSWORD dbname=$POSTGRES_DB sslmode=$DB_SSLMODE" \
up
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
envFrom:
- configMapRef:
name: honeydue-config
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: POSTGRES_PASSWORD
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
+17
View File
@@ -149,6 +149,23 @@ kubectl apply -f "${MANIFESTS}/namespace.yaml"
kubectl apply -f "${MANIFESTS}/redis/" kubectl apply -f "${MANIFESTS}/redis/"
kubectl apply -f "${MANIFESTS}/ingress/" kubectl apply -f "${MANIFESTS}/ingress/"
# --- Run migrations BEFORE rolling api/worker ---
#
# goose-based migration Job. We delete any prior Job (Jobs are immutable —
# applying a duplicate name otherwise fails), apply a fresh one with the new
# api image (which includes /usr/local/bin/goose and /app/migrations), and
# block until it succeeds. A failure aborts the deploy before any new app
# pod sees a stale schema.
log "Running database migrations (goose Job)..."
kubectl delete job honeydue-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/migrate/job.yaml" | kubectl apply -f -
if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=10m job/honeydue-migrate; then
warn "migration Job failed — see logs:"
kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true
die "migrations did not complete cleanly; aborting deploy"
fi
log "Migrations applied; proceeding with api/worker rollout"
# Apply deployments with image substitution # Apply deployments with image substitution
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f - sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f -
kubectl apply -f "${MANIFESTS}/api/service.yaml" kubectl apply -f "${MANIFESTS}/api/service.yaml"
+88 -44
View File
@@ -150,66 +150,110 @@ the default 25/10. If we hit connection errors in prod, adjust.
## Schema management ## Schema management
### GORM AutoMigrate ### goose
On startup, the Go API's `cmd/api/main.go` calls We use [pressly/goose](https://github.com/pressly/goose) (pinned in the
`database.MigrateWithLock()` which: api `Dockerfile` to v3.22.1) for schema migrations. Why goose specifically:
1. Opens a dedicated Postgres connection - Each migration file runs inside its own transaction by default —
2. `SELECT pg_advisory_lock(1751412071)` — acquires a session-level partial-failure recovery is built in (no "dirty" state to manually
advisory lock on a hardcoded key unstick like golang-migrate).
3. Calls `db.AutoMigrate(&models.*{})` for every GORM model - Locking is opt-in. We *don't* opt in. Migrations run as a single
4. `SELECT pg_advisory_unlock(...)` via deferred function Kubernetes Job — that's the singleton process. No advisory-lock vs
5. Close the connection PgBouncer-transaction-mode foot-gun.
- Plain SQL files. No DSL, no library integration in our Go code.
The advisory lock serializes migrations across replicas: when 3 api See `docs/deployment/19-postmortem-swarm.md` (Schema Versioning section)
pods start simultaneously, one acquires the lock and migrates; the for the AutoMigrate-with-advisory-lock approach this replaced and why.
others block on the lock. Once the first finishes (≤2s for already-
migrated schema, up to 90s on first cold boot), the next acquires and
sees the schema is current (no-op migrate).
### Why an advisory lock ### Migration files
Without it, concurrent `CREATE TABLE IF NOT EXISTS ...` statements from Live under `migrations/`, named `<NNNNNN>_<short_name>.sql`. Each file
multiple replicas would race — Postgres usually handles it, but GORM's has both the up and down migration in one file, separated by goose
AutoMigrate also alters tables (adds columns, indexes) which can deadlock markers:
under concurrency.
The advisory lock pattern (also used by Rails + Django + Alembic) is the ```sql
canonical solution. -- +goose Up
CREATE TABLE example (id bigint PRIMARY KEY);
### The lock key -- +goose Down
DROP TABLE example;
```
`1751412071` is a hardcoded integer in `internal/database/database.go`. Multi-statement constructs (`CREATE FUNCTION`, `DO $$ BEGIN ... END $$`)
Arbitrary but unique — as long as nothing else in the Postgres instance need `-- +goose StatementBegin` / `-- +goose StatementEnd` wrappers
uses the same advisory lock key, no conflicts. because goose splits on semicolons by default.
### First-boot behavior `migrations/000001_init.sql` is the baseline — captures every
table/index/sequence as it existed when goose was adopted, generated
via `pg_dump --schema-only --no-owner --no-privileges`. The pre-goose
hand-numbered migrations (002-022 in git history at commit
58e6997) had their effects folded into this baseline; they're gone
from the live tree but remain in git for archaeology.
On a **fresh database** (new Neon project), the first api pod runs ### Production migration flow
through every model's `CREATE TABLE` statement. This is ~50 tables for
honeyDue and takes ~90 seconds.
On a **warm database** (tables already exist), AutoMigrate is fast — `deploy-k3s/scripts/03-deploy.sh` runs migrations as part of every
typically under 2 seconds. It still runs (GORM checks every model deploy, **before** the api/worker rollout starts:
against the schema) but finds no work to do.
### Where this bit us ```
1. kubectl delete job honeydue-migrate (idempotent)
2. kubectl apply -f manifests/migrate/job.yaml (with current api image)
3. kubectl wait --for=condition=complete --timeout=10m job/honeydue-migrate
4. (only if Job succeeded) kubectl apply -f manifests/api/...
```
With 3 api pods starting simultaneously and migrations taking 90s first The Job uses the api image — we install the goose CLI binary at
time, the lock queue for the last replica is ~180s. We needed a `/usr/local/bin/goose` during the api Dockerfile build, so any pod that
startupProbe grace of 240s to cover this without false restart loops. can run api can run goose. No separate image to build/push.
See Chapter 7 §startupProbe and Chapter 19 §MigrateWithLock.
### Downside: no schema versioning The Job's `command` runs `goose ... up` against the **direct**
(non-pooler) Neon endpoint. Goose's session-scoped advisory lock can't
survive PgBouncer transaction-mode pooling, so the Job script strips
the `-pooler` segment from `DB_HOST` before connecting. The api/worker
runtime continues to use the pooler endpoint for everything else; only
this one Job needs the direct connection.
AutoMigrate can only *add* — new tables, new columns, new indexes. It ### Schema-version precondition
won't drop columns, rename them, or change types destructively. For
those we'd need raw SQL migrations (a tool like `golang-migrate` or
`dbmate`).
Today: we accept that schema changes are additive-only. When we need `internal/database/database.go::RequireSchemaApplied()` runs at api and
destructive changes, we'd hand-write them. worker startup. It queries `goose_db_version` for the highest applied
version and refuses to start if the table is missing or the latest row
is `is_applied=false`. This catches "operator forgot to run migrate" as
a clear boot error instead of a mysterious runtime "relation does not
exist" later.
### Local migration workflow
```bash
# Set the direct-endpoint DSN once
export DATABASE_URL='host=ep-floral-truth-amttbc5a.c-5.us-east-1.aws.neon.tech \
user=neondb_owner password=$PG_PASSWORD dbname=honeyDue sslmode=require'
make migrate-status # what's pending
make migrate-up # apply
make migrate-down # roll back the latest
make migrate-new name=add_widget_col # scaffold a new SQL file
```
Each new migration file goes through code review like any other code
change. The deploy-script Job applies it on the next deploy.
### Bootstrap (one-time, when the prod DB already had a schema)
Bootstrapping a goose-managed DB whose schema already exists requires
seeding `goose_db_version` so goose treats version 1 as already-applied:
```bash
# Once. After this, future migrations append normally.
goose -dir migrations postgres "$DATABASE_URL" version # creates the table
psql "$DATABASE_URL" -c \
"INSERT INTO goose_db_version (version_id, is_applied, tstamp) VALUES (1, true, NOW());"
```
This was done for honeyDue's prod Neon project at the time of goose
adoption — no need to repeat unless we set up a fresh DB from a
schema dump.
## What's in the database ## What's in the database
+2 -2
View File
@@ -68,7 +68,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -96,7 +96,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect
+4 -3
View File
@@ -20,8 +20,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -137,8 +137,9 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+30 -41
View File
@@ -19,11 +19,6 @@ import (
"github.com/uptrace/opentelemetry-go-extra/otelgorm" "github.com/uptrace/opentelemetry-go-extra/otelgorm"
) )
// migrationAdvisoryLockKey is the pg_advisory_lock key that serializes
// Migrate() across API replicas booting in parallel. Value is arbitrary but
// stable ("hdmg" as bytes = honeydue migration).
const migrationAdvisoryLockKey int64 = 0x68646d67
// zerologGormWriter adapts zerolog for GORM's logger interface // zerologGormWriter adapts zerolog for GORM's logger interface
type zerologGormWriter struct{} type zerologGormWriter struct{}
@@ -189,52 +184,46 @@ func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
} }
} }
// MigrateWithLock runs Migrate() under a Postgres session-level advisory lock // RequireSchemaApplied verifies that goose's version table exists and has
// so that multiple API replicas booting in parallel don't race on AutoMigrate. // at least one applied entry. This is the fail-fast that runs at api/worker
// On non-Postgres dialects (sqlite in tests) it falls through to Migrate(). // boot: if the operator forgot to run the migrate Job, the pod refuses to
func MigrateWithLock() error { // start with a clear error instead of throwing mysterious "relation does
// not exist" errors deep in a request handler.
//
// On non-Postgres dialects (sqlite in tests) this is a no-op — tests use
// AutoMigrate via testutil.SetupTestDB to create a fresh schema per run.
// goose isn't involved in the test path.
func RequireSchemaApplied() error {
if db == nil { if db == nil {
return fmt.Errorf("database not initialised") return fmt.Errorf("database not initialised")
} }
if db.Dialector.Name() != "postgres" { if db.Dialector.Name() != "postgres" {
return Migrate() return nil
} }
sqlDB, err := db.DB() // goose_db_version stores one row per applied migration, not a single
// "current version" row — so we look for the highest version_id with
// is_applied=true. ORDER BY id DESC LIMIT 1 also catches the case where
// the table exists but is empty (no rows returned, scan leaves Version
// at zero).
type migrationRow struct {
VersionID int64 `gorm:"column:version_id"`
IsApplied bool `gorm:"column:is_applied"`
}
var row migrationRow
err := db.Raw(`SELECT version_id, is_applied FROM goose_db_version ORDER BY id DESC LIMIT 1`).Scan(&row).Error
if err != nil { if err != nil {
return fmt.Errorf("get underlying sql.DB: %w", err) return fmt.Errorf("goose_db_version check failed (run the migrate Job to bootstrap): %w", err)
} }
if !row.IsApplied {
// Give ourselves up to 5 min to acquire the lock — long enough for a return fmt.Errorf("goose_db_version latest row is_applied=false at version=%d — last migration was rolled back or aborted; investigate before starting", row.VersionID)
// slow migration on a peer replica, short enough to fail fast if Postgres
// is hung.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
conn, err := sqlDB.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire dedicated migration connection: %w", err)
} }
defer conn.Close() if row.VersionID < 1 {
return fmt.Errorf("goose_db_version is empty — run goose up (or seed a row marking version 1 as applied if the schema already exists)")
log.Info().Int64("lock_key", migrationAdvisoryLockKey).Msg("Acquiring migration advisory lock...")
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationAdvisoryLockKey); err != nil {
return fmt.Errorf("pg_advisory_lock: %w", err)
} }
log.Info().Msg("Migration advisory lock acquired") log.Info().Int64("schema_version", row.VersionID).Msg("Schema precondition satisfied")
return nil
defer func() {
// Unlock with a fresh context — the outer ctx may have expired.
unlockCtx, unlockCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer unlockCancel()
if _, err := conn.ExecContext(unlockCtx, "SELECT pg_advisory_unlock($1)", migrationAdvisoryLockKey); err != nil {
log.Warn().Err(err).Msg("Failed to release migration advisory lock (session close will also release)")
} else {
log.Info().Msg("Migration advisory lock released")
}
}()
return Migrate()
} }
// Migrate runs database migrations for all models // Migrate runs database migrations for all models
File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
-- Rollback GoAdmin tables
DROP TABLE IF EXISTS goadmin_role_menu;
DROP TABLE IF EXISTS goadmin_role_permissions;
DROP TABLE IF EXISTS goadmin_user_permissions;
DROP TABLE IF EXISTS goadmin_role_users;
DROP TABLE IF EXISTS goadmin_operation_log;
DROP TABLE IF EXISTS goadmin_site;
DROP TABLE IF EXISTS goadmin_menu;
DROP TABLE IF EXISTS goadmin_permissions;
DROP TABLE IF EXISTS goadmin_roles;
DROP TABLE IF EXISTS goadmin_users;
DROP TABLE IF EXISTS goadmin_session;
-185
View File
@@ -1,185 +0,0 @@
-- GoAdmin required tables for PostgreSQL
-- This migration creates all tables needed by GoAdmin
-- Session storage table
CREATE TABLE IF NOT EXISTS goadmin_session (
id SERIAL PRIMARY KEY,
sid VARCHAR(50) NOT NULL DEFAULT '',
"values" TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_session_sid ON goadmin_session(sid);
-- Users table for admin authentication
CREATE TABLE IF NOT EXISTS goadmin_users (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL DEFAULT '',
password VARCHAR(100) NOT NULL DEFAULT '',
name VARCHAR(100) NOT NULL DEFAULT '',
avatar VARCHAR(255) DEFAULT '',
remember_token VARCHAR(100) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_goadmin_users_username ON goadmin_users(username);
-- Roles table
CREATE TABLE IF NOT EXISTS goadmin_roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL DEFAULT '',
slug VARCHAR(50) NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_goadmin_roles_slug ON goadmin_roles(slug);
-- Permissions table
CREATE TABLE IF NOT EXISTS goadmin_permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL DEFAULT '',
slug VARCHAR(50) NOT NULL DEFAULT '',
http_method VARCHAR(255) DEFAULT '',
http_path TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_goadmin_permissions_slug ON goadmin_permissions(slug);
-- Role-User relationship table
CREATE TABLE IF NOT EXISTS goadmin_role_users (
id SERIAL PRIMARY KEY,
role_id INT NOT NULL,
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_role_users_role_id ON goadmin_role_users(role_id);
CREATE INDEX IF NOT EXISTS idx_goadmin_role_users_user_id ON goadmin_role_users(user_id);
-- User-Permission relationship table
CREATE TABLE IF NOT EXISTS goadmin_user_permissions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_user_permissions_user_id ON goadmin_user_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_goadmin_user_permissions_permission_id ON goadmin_user_permissions(permission_id);
-- Role-Permission relationship table
CREATE TABLE IF NOT EXISTS goadmin_role_permissions (
id SERIAL PRIMARY KEY,
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_role_permissions_role_id ON goadmin_role_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_goadmin_role_permissions_permission_id ON goadmin_role_permissions(permission_id);
-- Menu table for admin sidebar
CREATE TABLE IF NOT EXISTS goadmin_menu (
id SERIAL PRIMARY KEY,
parent_id INT NOT NULL DEFAULT 0,
type INT NOT NULL DEFAULT 0,
"order" INT NOT NULL DEFAULT 0,
title VARCHAR(50) NOT NULL DEFAULT '',
icon VARCHAR(50) NOT NULL DEFAULT '',
uri VARCHAR(3000) NOT NULL DEFAULT '',
header VARCHAR(150) DEFAULT '',
plugin_name VARCHAR(150) NOT NULL DEFAULT '',
uuid VARCHAR(150) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_menu_parent_id ON goadmin_menu(parent_id);
-- Role-Menu relationship table
CREATE TABLE IF NOT EXISTS goadmin_role_menu (
id SERIAL PRIMARY KEY,
role_id INT NOT NULL,
menu_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_role_menu_role_id ON goadmin_role_menu(role_id);
CREATE INDEX IF NOT EXISTS idx_goadmin_role_menu_menu_id ON goadmin_role_menu(menu_id);
-- Operation log table for audit trail
CREATE TABLE IF NOT EXISTS goadmin_operation_log (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
path VARCHAR(255) NOT NULL DEFAULT '',
method VARCHAR(10) NOT NULL DEFAULT '',
ip VARCHAR(15) NOT NULL DEFAULT '',
input TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_operation_log_user_id ON goadmin_operation_log(user_id);
-- Site configuration table
CREATE TABLE IF NOT EXISTS goadmin_site (
id SERIAL PRIMARY KEY,
key VARCHAR(100) NOT NULL DEFAULT '',
value TEXT NOT NULL,
description VARCHAR(3000) DEFAULT '',
state INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_goadmin_site_key ON goadmin_site(key);
-- Insert default admin user (password: admin)
-- Password is bcrypt hash of 'admin'
INSERT INTO goadmin_users (username, password, name, avatar)
VALUES ('admin', '$2a$10$sRv1E1XmGXS5HgU7VK3bNOQRZLGDON0.2xvMlz.bKcIzI3pAF1T3y', 'Administrator', '')
ON CONFLICT DO NOTHING;
-- Insert default roles
INSERT INTO goadmin_roles (name, slug) VALUES ('Administrator', 'administrator') ON CONFLICT DO NOTHING;
INSERT INTO goadmin_roles (name, slug) VALUES ('Operator', 'operator') ON CONFLICT DO NOTHING;
-- Insert default permissions
INSERT INTO goadmin_permissions (name, slug, http_method, http_path)
VALUES ('All permissions', '*', '', '*') ON CONFLICT DO NOTHING;
INSERT INTO goadmin_permissions (name, slug, http_method, http_path)
VALUES ('Dashboard', 'dashboard', 'GET', '/') ON CONFLICT DO NOTHING;
-- Assign admin user to administrator role
INSERT INTO goadmin_role_users (role_id, user_id)
SELECT r.id, u.id FROM goadmin_roles r, goadmin_users u
WHERE r.slug = 'administrator' AND u.username = 'admin'
ON CONFLICT DO NOTHING;
-- Assign all permissions to administrator role
INSERT INTO goadmin_role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM goadmin_roles r, goadmin_permissions p
WHERE r.slug = 'administrator' AND p.slug = '*'
ON CONFLICT DO NOTHING;
-- Insert default menu items
INSERT INTO goadmin_menu (parent_id, type, "order", title, icon, uri, plugin_name) VALUES
(0, 1, 1, 'Dashboard', 'fa-bar-chart', '/', ''),
(0, 1, 2, 'Admin', 'fa-tasks', '', ''),
(2, 1, 1, 'Users', 'fa-users', '/info/goadmin_users', ''),
(2, 1, 2, 'Roles', 'fa-user', '/info/goadmin_roles', ''),
(2, 1, 3, 'Permissions', 'fa-ban', '/info/goadmin_permissions', ''),
(2, 1, 4, 'Menu', 'fa-bars', '/menu', ''),
(2, 1, 5, 'Operation Log', 'fa-history', '/info/goadmin_operation_log', ''),
(0, 1, 3, 'HoneyDue', 'fa-home', '', ''),
(8, 1, 1, 'Users', 'fa-user', '/info/users', ''),
(8, 1, 2, 'Residences', 'fa-building', '/info/residences', ''),
(8, 1, 3, 'Tasks', 'fa-tasks', '/info/tasks', ''),
(8, 1, 4, 'Contractors', 'fa-wrench', '/info/contractors', ''),
(8, 1, 5, 'Documents', 'fa-file', '/info/documents', ''),
(8, 1, 6, 'Notifications', 'fa-bell', '/info/notifications', '')
ON CONFLICT DO NOTHING;
-- Assign all menus to administrator role
INSERT INTO goadmin_role_menu (role_id, menu_id)
SELECT r.id, m.id FROM goadmin_roles r, goadmin_menu m
WHERE r.slug = 'administrator'
ON CONFLICT DO NOTHING;
@@ -1,4 +0,0 @@
-- Revert: Make residence_id required again
-- WARNING: This will fail if there are contractors with NULL residence_id
ALTER TABLE task_contractor ALTER COLUMN residence_id SET NOT NULL;
@@ -1,4 +0,0 @@
-- Make residence_id optional for contractors
-- Allows contractors to be personal (no residence) or shared (with residence)
ALTER TABLE task_contractor ALTER COLUMN residence_id DROP NOT NULL;
@@ -1,2 +0,0 @@
-- Remove is_free column from subscription_usersubscription table
ALTER TABLE subscription_usersubscription DROP COLUMN IF EXISTS is_free;
@@ -1,3 +0,0 @@
-- Add is_free column to subscription_usersubscription table
-- When true, user bypasses all limitations regardless of global settings
ALTER TABLE subscription_usersubscription ADD COLUMN IF NOT EXISTS is_free BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1,45 +0,0 @@
-- Rollback: Restore status_id foreign key from in_progress boolean
-- Step 1: Recreate the task_taskstatus table
CREATE TABLE IF NOT EXISTS task_taskstatus (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
name VARCHAR(20) NOT NULL,
description TEXT,
color VARCHAR(7),
display_order INTEGER NOT NULL DEFAULT 0
);
-- Step 2: Seed the status lookup data
INSERT INTO task_taskstatus (name, description, color, display_order) VALUES
('Pending', 'Task is waiting to be started', '#808080', 1),
('In Progress', 'Task is currently being worked on', '#3498db', 2),
('Completed', 'Task has been finished', '#27ae60', 3),
('On Hold', 'Task is temporarily paused', '#f39c12', 4),
('Cancelled', 'Task has been cancelled', '#e74c3c', 5)
ON CONFLICT DO NOTHING;
-- Step 3: Add status_id column back
ALTER TABLE task_task ADD COLUMN IF NOT EXISTS status_id INTEGER;
-- Step 4: Migrate data - set status_id based on in_progress flag
-- Set to "In Progress" status if in_progress is true, otherwise "Pending"
UPDATE task_task
SET status_id = (
CASE
WHEN in_progress = true THEN (SELECT id FROM task_taskstatus WHERE name = 'In Progress' LIMIT 1)
ELSE (SELECT id FROM task_taskstatus WHERE name = 'Pending' LIMIT 1)
END
);
-- Step 5: Add foreign key constraint
ALTER TABLE task_task ADD CONSTRAINT fk_task_task_status
FOREIGN KEY (status_id) REFERENCES task_taskstatus(id);
-- Step 6: Drop the in_progress column
ALTER TABLE task_task DROP COLUMN IF EXISTS in_progress;
-- Step 7: Drop the index
DROP INDEX IF EXISTS idx_task_task_in_progress;
@@ -1,44 +0,0 @@
-- Migration: Replace status_id foreign key with in_progress boolean
-- This simplifies the task model since status was only used to determine if a task is "In Progress"
-- Step 1: Add in_progress boolean column with default false
ALTER TABLE task_task ADD COLUMN IF NOT EXISTS in_progress BOOLEAN NOT NULL DEFAULT false;
-- Step 2: Create index on in_progress for query performance
CREATE INDEX IF NOT EXISTS idx_task_task_in_progress ON task_task(in_progress);
-- Step 3: Migrate existing data - set in_progress = true for tasks with "In Progress" status
UPDATE task_task
SET in_progress = true
WHERE status_id IN (
SELECT id FROM task_taskstatus WHERE LOWER(name) = 'in progress'
);
-- Step 4: Drop the foreign key constraint on status_id (if it exists)
-- PostgreSQL syntax - the constraint name might vary
DO $$
BEGIN
-- Try to drop the constraint if it exists
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_task_task_status'
AND table_name = 'task_task'
) THEN
ALTER TABLE task_task DROP CONSTRAINT fk_task_task_status;
END IF;
-- Also try the gorm auto-generated constraint name
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'task_task_status_id_fkey'
AND table_name = 'task_task'
) THEN
ALTER TABLE task_task DROP CONSTRAINT task_task_status_id_fkey;
END IF;
END $$;
-- Step 5: Drop the status_id column
ALTER TABLE task_task DROP COLUMN IF EXISTS status_id;
-- Step 6: Drop the task_taskstatus table
DROP TABLE IF EXISTS task_taskstatus;
@@ -1,23 +0,0 @@
-- Rollback performance optimization indexes
-- Migration: 006_performance_indexes
DROP INDEX IF EXISTS idx_user_email_lower;
DROP INDEX IF EXISTS idx_user_username_lower;
DROP INDEX IF EXISTS idx_admin_email_lower;
DROP INDEX IF EXISTS idx_task_residence_status;
DROP INDEX IF EXISTS idx_task_residence_active;
DROP INDEX IF EXISTS idx_task_next_due_date;
DROP INDEX IF EXISTS idx_notification_user_read;
DROP INDEX IF EXISTS idx_notification_user_sent;
DROP INDEX IF EXISTS idx_notification_task;
DROP INDEX IF EXISTS idx_document_residence_active_type;
DROP INDEX IF EXISTS idx_document_expiry_active;
DROP INDEX IF EXISTS idx_contractor_created_by;
DROP INDEX IF EXISTS idx_completion_task;
DROP INDEX IF EXISTS idx_completion_completed_by;
DROP INDEX IF EXISTS idx_residence_member_user;
DROP INDEX IF EXISTS idx_share_code_active;
-75
View File
@@ -1,75 +0,0 @@
-- Performance optimization indexes
-- Migration: 006_performance_indexes
-- =====================================================
-- CRITICAL: Case-insensitive indexes for auth lookups
-- =====================================================
-- These eliminate full table scans on login/registration
CREATE INDEX IF NOT EXISTS idx_user_email_lower ON auth_user (LOWER(email));
CREATE INDEX IF NOT EXISTS idx_user_username_lower ON auth_user (LOWER(username));
CREATE INDEX IF NOT EXISTS idx_admin_email_lower ON admin_users (LOWER(email));
-- =====================================================
-- HIGH PRIORITY: Composite indexes for common queries
-- =====================================================
-- Tasks: Most common query pattern is by residence with status filters
CREATE INDEX IF NOT EXISTS idx_task_residence_status
ON task_task (residence_id, is_cancelled, is_archived);
-- Tasks: For kanban board queries (active tasks by residence)
CREATE INDEX IF NOT EXISTS idx_task_residence_active
ON task_task (residence_id, is_archived, in_progress)
WHERE is_cancelled = false;
-- Tasks: For overdue queries (next_due_date lookups)
CREATE INDEX IF NOT EXISTS idx_task_next_due_date
ON task_task (next_due_date)
WHERE is_cancelled = false AND is_archived = false AND next_due_date IS NOT NULL;
-- Notifications: Queried constantly for unread count
CREATE INDEX IF NOT EXISTS idx_notification_user_read
ON notifications_notification (user_id, read);
-- Notifications: For pending notification worker
CREATE INDEX IF NOT EXISTS idx_notification_user_sent
ON notifications_notification (user_id, sent);
-- Notifications: Task-based lookups
CREATE INDEX IF NOT EXISTS idx_notification_task
ON notifications_notification (task_id)
WHERE task_id IS NOT NULL;
-- Documents: Warranty expiry queries
CREATE INDEX IF NOT EXISTS idx_document_residence_active_type
ON task_document (residence_id, is_active, document_type);
-- Documents: Expiring warranties lookup
CREATE INDEX IF NOT EXISTS idx_document_expiry_active
ON task_document (expiry_date, is_active)
WHERE document_type = 'warranty' AND is_active = true;
-- =====================================================
-- MEDIUM PRIORITY: Foreign key indexes
-- =====================================================
-- Contractor: Query by creator (user's personal contractors)
CREATE INDEX IF NOT EXISTS idx_contractor_created_by
ON task_contractor (created_by_id);
-- Task completions: Query by task
CREATE INDEX IF NOT EXISTS idx_completion_task
ON task_taskcompletion (task_id);
-- Task completions: Query by user who completed
CREATE INDEX IF NOT EXISTS idx_completion_completed_by
ON task_taskcompletion (completed_by_id);
-- Residence members: Query by user
CREATE INDEX IF NOT EXISTS idx_residence_member_user
ON residence_residencemember (user_id);
-- Share codes: Active code lookups
CREATE INDEX IF NOT EXISTS idx_share_code_active
ON residence_residencesharecode (code, is_active)
WHERE is_active = true;
@@ -1,5 +0,0 @@
-- Rollback custom_interval_days column
-- Migration: 007_custom_interval_days
ALTER TABLE task_task
DROP COLUMN IF EXISTS custom_interval_days;
@@ -1,8 +0,0 @@
-- Add custom_interval_days for custom frequency tasks
-- Migration: 007_custom_interval_days
ALTER TABLE task_task
ADD COLUMN IF NOT EXISTS custom_interval_days INTEGER;
-- Add comment for documentation
COMMENT ON COLUMN task_task.custom_interval_days IS 'For Custom frequency tasks, the user-specified number of days between occurrences';
@@ -1,8 +0,0 @@
-- Rollback additional performance optimization indexes
-- Migration: 008_additional_performance_indexes
DROP INDEX IF EXISTS idx_task_kanban_composite;
DROP INDEX IF EXISTS idx_completion_task_date;
DROP INDEX IF EXISTS idx_sharecode_code_active;
DROP INDEX IF EXISTS idx_residence_users_user_residence;
DROP INDEX IF EXISTS idx_task_in_progress;
@@ -1,47 +0,0 @@
-- Additional performance optimization indexes
-- Migration: 008_additional_performance_indexes
-- =====================================================
-- KANBAN QUERY OPTIMIZATION
-- =====================================================
-- Composite index for kanban board queries
-- Covers: WHERE residence_id IN ? AND is_archived = false
-- with ordering by due_date, next_due_date
CREATE INDEX IF NOT EXISTS idx_task_kanban_composite
ON task_task (residence_id, is_archived, is_cancelled, next_due_date, due_date)
WHERE is_archived = false;
-- =====================================================
-- COMPLETION QUERY OPTIMIZATION
-- =====================================================
-- Ordering index for completion queries (most recent first)
CREATE INDEX IF NOT EXISTS idx_completion_task_date
ON task_taskcompletion (task_id, completed_at DESC);
-- =====================================================
-- SHARE CODE OPTIMIZATION
-- =====================================================
-- Unique index for active share code lookups
CREATE UNIQUE INDEX IF NOT EXISTS idx_sharecode_code_active
ON residence_residencesharecode (code)
WHERE is_active = true;
-- =====================================================
-- RESIDENCE USER ACCESS OPTIMIZATION
-- =====================================================
-- Index for residence user membership queries (used by FindResidenceIDsByUser)
CREATE INDEX IF NOT EXISTS idx_residence_users_user_residence
ON residence_residence_users (user_id, residence_id);
-- =====================================================
-- TASK IN_PROGRESS QUERIES
-- =====================================================
-- Index for in_progress task queries (kanban "In Progress" column)
CREATE INDEX IF NOT EXISTS idx_task_in_progress
ON task_task (residence_id, in_progress)
WHERE in_progress = true AND is_cancelled = false AND is_archived = false;
@@ -1,10 +0,0 @@
-- Rollback: Recreate removed indexes
-- Recreate share code index (from migration 006)
CREATE INDEX IF NOT EXISTS idx_share_code_active
ON residence_residencesharecode (residence_id)
WHERE is_active = true;
-- Recreate notification user+sent index (from migration 006)
CREATE INDEX IF NOT EXISTS idx_notification_user_sent
ON notifications_notification (user_id, sent);
@@ -1,15 +0,0 @@
-- Migration: 009_remove_redundant_indexes
-- Description: Remove indexes that are redundant or unused
-- Remove redundant share code index
-- idx_share_code_active is superseded by unique idx_sharecode_code_active (migration 008)
-- Both filter WHERE is_active = true, but 008's unique index on (code) is more restrictive
DROP INDEX IF EXISTS idx_share_code_active;
-- Remove unused composite notification index
-- idx_notification_user_sent on (user_id, sent) is never used:
-- - GetPendingNotifications() only filters on sent, not user_id
-- - FindByUser() only filters on user_id, not sent (uses idx_notification_user_created_at)
-- - No queries combine user_id AND sent together
-- The leading column (user_id) queries already use idx_notification_user_created_at
DROP INDEX IF EXISTS idx_notification_user_sent;
@@ -1,5 +0,0 @@
-- Rollback: Smart Notification Reminder System
DROP INDEX IF EXISTS idx_reminderlog_sent_at;
DROP INDEX IF EXISTS idx_reminderlog_task_user_date;
DROP TABLE IF EXISTS task_reminderlog;
@@ -1,24 +0,0 @@
-- Smart Notification Reminder System
-- Tracks which reminders have been sent to prevent duplicates
CREATE TABLE task_reminderlog (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES task_task(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE,
due_date DATE NOT NULL, -- Which occurrence this is for
reminder_stage VARCHAR(20) NOT NULL, -- e.g., "reminder_30d", "reminder_7d", "day_of", "overdue_1", "overdue_4"
sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
notification_id INTEGER REFERENCES notifications_notification(id) ON DELETE SET NULL,
-- Prevent duplicate reminders for same task/user/date/stage
UNIQUE(task_id, user_id, due_date, reminder_stage)
);
-- Index for quick lookup when checking if reminder was already sent
CREATE INDEX idx_reminderlog_task_user_date ON task_reminderlog(task_id, user_id, due_date);
-- Index for cleanup job (delete old logs)
CREATE INDEX idx_reminderlog_sent_at ON task_reminderlog(sent_at);
COMMENT ON TABLE task_reminderlog IS 'Tracks sent task reminders to prevent duplicate notifications';
COMMENT ON COLUMN task_reminderlog.reminder_stage IS 'Stage of reminder: reminder_30d, reminder_14d, reminder_7d, reminder_3d, reminder_1d, day_of, overdue_N';
@@ -1,3 +0,0 @@
-- Remove timezone column from notification preferences
ALTER TABLE notifications_notificationpreference
DROP COLUMN IF EXISTS timezone;
@@ -1,7 +0,0 @@
-- Add timezone column to notification preferences
-- Stores IANA timezone name (e.g., "America/Los_Angeles") for background job calculations
ALTER TABLE notifications_notificationpreference
ADD COLUMN timezone VARCHAR(50);
-- Add comment for documentation
COMMENT ON COLUMN notifications_notificationpreference.timezone IS 'IANA timezone name for daily digest calculations (e.g., America/Los_Angeles)';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS webhook_event_log;
-9
View File
@@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS webhook_event_log (
id SERIAL PRIMARY KEY,
event_id VARCHAR(255) NOT NULL,
provider VARCHAR(20) NOT NULL,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW(),
payload_hash VARCHAR(64),
UNIQUE(provider, event_id)
);
@@ -1,5 +0,0 @@
ALTER TABLE task_task DROP CONSTRAINT IF EXISTS chk_task_not_cancelled_and_archived;
ALTER TABLE subscriptions_usersubscription DROP CONSTRAINT IF EXISTS chk_subscription_tier;
ALTER TABLE notifications_notification DROP CONSTRAINT IF EXISTS chk_notification_sent_consistency;
ALTER TABLE subscriptions_usersubscription DROP CONSTRAINT IF EXISTS uq_subscription_user;
ALTER TABLE notifications_notificationpreference DROP CONSTRAINT IF EXISTS uq_notif_pref_user;
@@ -1,31 +0,0 @@
-- Prevent task from being both cancelled and archived simultaneously
ALTER TABLE task_task ADD CONSTRAINT chk_task_not_cancelled_and_archived
CHECK (NOT (is_cancelled = true AND is_archived = true));
-- Subscription tier must be valid
ALTER TABLE subscriptions_usersubscription ADD CONSTRAINT chk_subscription_tier
CHECK (tier IN ('free', 'pro'));
-- Notification: sent_at must be set if sent is true
ALTER TABLE notifications_notification ADD CONSTRAINT chk_notification_sent_consistency
CHECK ((sent = false) OR (sent = true AND sent_at IS NOT NULL));
-- One subscription per user
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_subscription_user'
) THEN
ALTER TABLE subscriptions_usersubscription ADD CONSTRAINT uq_subscription_user UNIQUE (user_id);
END IF;
END $$;
-- One notification preference per user
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_notif_pref_user'
) THEN
ALTER TABLE notifications_notificationpreference ADD CONSTRAINT uq_notif_pref_user UNIQUE (user_id);
END IF;
END $$;
@@ -1 +0,0 @@
ALTER TABLE task_task DROP COLUMN IF EXISTS version;
@@ -1 +0,0 @@
ALTER TABLE task_task ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1;
-3
View File
@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_task_kanban_query;
DROP INDEX IF EXISTS idx_notification_user_unread;
DROP INDEX IF EXISTS idx_document_residence_active;
-14
View File
@@ -1,14 +0,0 @@
-- Kanban: composite index for active task queries by residence with due date ordering
CREATE INDEX IF NOT EXISTS idx_task_kanban_query
ON task_task (residence_id, is_cancelled, is_archived, next_due_date, due_date)
WHERE is_cancelled = false AND is_archived = false;
-- Notifications: index for unread count (hot query)
CREATE INDEX IF NOT EXISTS idx_notification_user_unread
ON notifications_notification (user_id, read)
WHERE read = false;
-- Documents: residence + active filter
CREATE INDEX IF NOT EXISTS idx_document_residence_active
ON documents_document (residence_id, is_active)
WHERE is_active = true;
@@ -1,2 +0,0 @@
-- No-op: the created column is part of Django's original schema and should not
-- be removed.
@@ -1,6 +0,0 @@
-- Ensure created column exists on user_authtoken (Django already creates it,
-- but this migration guarantees it for fresh Go-only deployments).
ALTER TABLE user_authtoken ADD COLUMN IF NOT EXISTS created TIMESTAMP WITH TIME ZONE DEFAULT NOW();
-- Backfill any rows that may have a NULL created timestamp.
UPDATE user_authtoken SET created = NOW() WHERE created IS NULL;
-40
View File
@@ -1,40 +0,0 @@
-- Rollback: 017_fk_indexes
-- Drop all FK indexes added in the up migration.
-- auth / user tables
DROP INDEX IF EXISTS idx_authtoken_user_id;
DROP INDEX IF EXISTS idx_userprofile_user_id;
DROP INDEX IF EXISTS idx_confirmationcode_user_id;
DROP INDEX IF EXISTS idx_passwordresetcode_user_id;
DROP INDEX IF EXISTS idx_applesocialauth_user_id;
DROP INDEX IF EXISTS idx_googlesocialauth_user_id;
-- push notification device tables
DROP INDEX IF EXISTS idx_apnsdevice_user_id;
DROP INDEX IF EXISTS idx_gcmdevice_user_id;
-- notification tables
DROP INDEX IF EXISTS idx_notificationpreference_user_id;
-- subscription tables
DROP INDEX IF EXISTS idx_subscription_user_id;
-- residence tables
DROP INDEX IF EXISTS idx_residence_owner_id;
DROP INDEX IF EXISTS idx_sharecode_residence_id;
DROP INDEX IF EXISTS idx_sharecode_created_by_id;
-- task tables
DROP INDEX IF EXISTS idx_task_created_by_id;
DROP INDEX IF EXISTS idx_task_assigned_to_id;
DROP INDEX IF EXISTS idx_task_category_id;
DROP INDEX IF EXISTS idx_task_priority_id;
DROP INDEX IF EXISTS idx_task_frequency_id;
DROP INDEX IF EXISTS idx_task_contractor_id;
DROP INDEX IF EXISTS idx_task_parent_task_id;
DROP INDEX IF EXISTS idx_completionimage_completion_id;
DROP INDEX IF EXISTS idx_document_created_by_id;
DROP INDEX IF EXISTS idx_document_task_id;
DROP INDEX IF EXISTS idx_documentimage_document_id;
DROP INDEX IF EXISTS idx_contractor_residence_id;
DROP INDEX IF EXISTS idx_reminderlog_notification_id;
-131
View File
@@ -1,131 +0,0 @@
-- Migration: 017_fk_indexes
-- Add indexes on all foreign key columns that are not already covered by existing indexes.
-- Uses CREATE INDEX IF NOT EXISTS to be idempotent (safe to re-run).
-- =====================================================
-- auth / user tables
-- =====================================================
-- user_authtoken: user_id (unique FK, but ensure index exists)
CREATE UNIQUE INDEX IF NOT EXISTS idx_authtoken_user_id
ON user_authtoken (user_id);
-- user_userprofile: user_id (unique FK)
CREATE UNIQUE INDEX IF NOT EXISTS idx_userprofile_user_id
ON user_userprofile (user_id);
-- user_confirmationcode: user_id
CREATE INDEX IF NOT EXISTS idx_confirmationcode_user_id
ON user_confirmationcode (user_id);
-- user_passwordresetcode: user_id
CREATE INDEX IF NOT EXISTS idx_passwordresetcode_user_id
ON user_passwordresetcode (user_id);
-- user_applesocialauth: user_id (unique FK)
CREATE UNIQUE INDEX IF NOT EXISTS idx_applesocialauth_user_id
ON user_applesocialauth (user_id);
-- user_googlesocialauth: user_id (unique FK)
CREATE UNIQUE INDEX IF NOT EXISTS idx_googlesocialauth_user_id
ON user_googlesocialauth (user_id);
-- =====================================================
-- push notification device tables
-- =====================================================
-- push_notifications_apnsdevice: user_id
CREATE INDEX IF NOT EXISTS idx_apnsdevice_user_id
ON push_notifications_apnsdevice (user_id);
-- push_notifications_gcmdevice: user_id
CREATE INDEX IF NOT EXISTS idx_gcmdevice_user_id
ON push_notifications_gcmdevice (user_id);
-- =====================================================
-- notification tables
-- =====================================================
-- notifications_notificationpreference: user_id (unique FK)
CREATE UNIQUE INDEX IF NOT EXISTS idx_notificationpreference_user_id
ON notifications_notificationpreference (user_id);
-- =====================================================
-- subscription tables
-- =====================================================
-- subscription_usersubscription: user_id (unique FK)
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscription_user_id
ON subscription_usersubscription (user_id);
-- =====================================================
-- residence tables
-- =====================================================
-- residence_residence: owner_id
CREATE INDEX IF NOT EXISTS idx_residence_owner_id
ON residence_residence (owner_id);
-- residence_residencesharecode: residence_id (may already exist from model index tag via GORM)
CREATE INDEX IF NOT EXISTS idx_sharecode_residence_id
ON residence_residencesharecode (residence_id);
-- residence_residencesharecode: created_by_id
CREATE INDEX IF NOT EXISTS idx_sharecode_created_by_id
ON residence_residencesharecode (created_by_id);
-- =====================================================
-- task tables
-- =====================================================
-- task_task: created_by_id
CREATE INDEX IF NOT EXISTS idx_task_created_by_id
ON task_task (created_by_id);
-- task_task: assigned_to_id
CREATE INDEX IF NOT EXISTS idx_task_assigned_to_id
ON task_task (assigned_to_id);
-- task_task: category_id
CREATE INDEX IF NOT EXISTS idx_task_category_id
ON task_task (category_id);
-- task_task: priority_id
CREATE INDEX IF NOT EXISTS idx_task_priority_id
ON task_task (priority_id);
-- task_task: frequency_id
CREATE INDEX IF NOT EXISTS idx_task_frequency_id
ON task_task (frequency_id);
-- task_task: contractor_id
CREATE INDEX IF NOT EXISTS idx_task_contractor_id
ON task_task (contractor_id);
-- task_task: parent_task_id
CREATE INDEX IF NOT EXISTS idx_task_parent_task_id
ON task_task (parent_task_id);
-- task_taskcompletionimage: completion_id
CREATE INDEX IF NOT EXISTS idx_completionimage_completion_id
ON task_taskcompletionimage (completion_id);
-- task_document: created_by_id
CREATE INDEX IF NOT EXISTS idx_document_created_by_id
ON task_document (created_by_id);
-- task_document: task_id
CREATE INDEX IF NOT EXISTS idx_document_task_id
ON task_document (task_id);
-- task_documentimage: document_id
CREATE INDEX IF NOT EXISTS idx_documentimage_document_id
ON task_documentimage (document_id);
-- task_contractor: residence_id
CREATE INDEX IF NOT EXISTS idx_contractor_residence_id
ON task_contractor (residence_id);
-- task_reminderlog: notification_id
CREATE INDEX IF NOT EXISTS idx_reminderlog_notification_id
ON task_reminderlog (notification_id);
-2
View File
@@ -1,2 +0,0 @@
-- Rollback: 018_audit_log
DROP TABLE IF EXISTS audit_log;
-16
View File
@@ -1,16 +0,0 @@
-- Migration: 018_audit_log
-- Create audit_log table for tracking security-relevant events (login, register, etc.)
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INTEGER,
event_type VARCHAR(50) NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
details JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
CREATE INDEX idx_audit_log_event_type ON audit_log(event_type);
CREATE INDEX idx_audit_log_created_at ON audit_log(created_at);
@@ -1,18 +0,0 @@
-- Migration: 019_residence_home_profile (rollback)
-- Remove home profile fields from residence
ALTER TABLE residence_residence
DROP COLUMN IF EXISTS heating_type,
DROP COLUMN IF EXISTS cooling_type,
DROP COLUMN IF EXISTS water_heater_type,
DROP COLUMN IF EXISTS roof_type,
DROP COLUMN IF EXISTS has_pool,
DROP COLUMN IF EXISTS has_sprinkler_system,
DROP COLUMN IF EXISTS has_septic,
DROP COLUMN IF EXISTS has_fireplace,
DROP COLUMN IF EXISTS has_garage,
DROP COLUMN IF EXISTS has_basement,
DROP COLUMN IF EXISTS has_attic,
DROP COLUMN IF EXISTS exterior_type,
DROP COLUMN IF EXISTS flooring_primary,
DROP COLUMN IF EXISTS landscaping_type;
@@ -1,18 +0,0 @@
-- Migration: 019_residence_home_profile
-- Add home profile fields to residence for smart onboarding task suggestions
ALTER TABLE residence_residence
ADD COLUMN IF NOT EXISTS heating_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS cooling_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS water_heater_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS roof_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS has_pool BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS has_sprinkler_system BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS has_septic BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS has_fireplace BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS has_garage BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS has_basement BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS has_attic BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS exterior_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS flooring_primary VARCHAR(50),
ADD COLUMN IF NOT EXISTS landscaping_type VARCHAR(50);
@@ -1,4 +0,0 @@
-- Migration: 020_template_conditions (rollback)
-- Remove conditions column from task templates
ALTER TABLE task_tasktemplate DROP COLUMN IF EXISTS conditions;
@@ -1,4 +0,0 @@
-- Migration: 020_template_conditions
-- Add conditions column to task templates for residence-aware suggestions
ALTER TABLE task_tasktemplate ADD COLUMN IF NOT EXISTS conditions JSONB DEFAULT '{}';
-2
View File
@@ -1,2 +0,0 @@
DROP INDEX IF EXISTS idx_task_task_task_template_id;
ALTER TABLE task_task DROP COLUMN IF EXISTS task_template_id;
-13
View File
@@ -1,13 +0,0 @@
-- Add a backlink from task_task to task_tasktemplate so that tasks created from
-- a template (e.g. onboarding suggestions or the template catalog) can be
-- reported on and filtered. Nullable — user-created custom tasks remain unset.
ALTER TABLE task_task
ADD COLUMN IF NOT EXISTS task_template_id BIGINT NULL;
CREATE INDEX IF NOT EXISTS idx_task_task_task_template_id
ON task_task (task_template_id);
-- Deferred FK — not enforced at the DB level because task_tasktemplate rows
-- may be renamed/retired; application code is the source of truth for the
-- relationship and already tolerates nil.
@@ -1,12 +0,0 @@
-- Recreates the legacy task_tasktemplate_regions join table. Data is not
-- restored — if a rollback needs the prior associations they have to be
-- reseeded from the task template conditions JSON.
CREATE TABLE IF NOT EXISTS task_tasktemplate_regions (
task_template_id BIGINT NOT NULL,
climate_region_id BIGINT NOT NULL,
PRIMARY KEY (task_template_id, climate_region_id)
);
CREATE INDEX IF NOT EXISTS idx_task_tasktemplate_regions_region
ON task_tasktemplate_regions (climate_region_id);
@@ -1,5 +0,0 @@
-- Drop the legacy many-to-many join table task_tasktemplate_regions.
-- Climate-region affinity now lives in task_tasktemplate.conditions->'climate_region_id'
-- and is scored by SuggestionService alongside the other home-profile conditions.
DROP TABLE IF EXISTS task_tasktemplate_regions;