Adopt pressly/goose for schema migrations
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:
@@ -42,3 +42,4 @@ push_certs/
|
||||
|
||||
# Vendor (if not using go modules)
|
||||
# vendor/
|
||||
/migrate
|
||||
|
||||
@@ -49,6 +49,12 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o /
|
||||
# Build the worker binary
|
||||
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
|
||||
FROM alpine:3.19 AS go-base
|
||||
|
||||
@@ -64,6 +70,9 @@ WORKDIR /app
|
||||
# Copy all binaries from builder
|
||||
COPY --from=builder /app/api /app/api
|
||||
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 --from=builder /app/templates /app/templates
|
||||
|
||||
@@ -89,15 +89,36 @@ docker-build-prod:
|
||||
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} .
|
||||
|
||||
# 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 -path migrations -database "$(DATABASE_URL)" up
|
||||
goose -dir migrations postgres "$(DATABASE_URL)" up
|
||||
|
||||
migrate-down:
|
||||
migrate -path migrations -database "$(DATABASE_URL)" down
|
||||
goose -dir migrations postgres "$(DATABASE_URL)" down
|
||||
|
||||
migrate-create:
|
||||
migrate create -ext sql -dir migrations -seq $(name)
|
||||
migrate-status:
|
||||
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)
|
||||
migrate-encrypt:
|
||||
|
||||
+8
-5
@@ -87,11 +87,14 @@ func main() {
|
||||
log.Error().Err(dbErr).Msg("Failed to connect to database - API will start but database operations will fail")
|
||||
} else {
|
||||
defer database.Close()
|
||||
// Run database migrations only if connected.
|
||||
// MigrateWithLock serialises parallel replica starts via a Postgres
|
||||
// advisory lock so concurrent AutoMigrate calls don't race on DDL.
|
||||
if err := database.MigrateWithLock(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to run database migrations")
|
||||
// Migrations are managed out-of-band by golang-migrate (see
|
||||
// cmd/migrate and deploy-k3s/manifests/migrate/job.yaml) so the api
|
||||
// no longer runs AutoMigrate at startup. Instead we verify the
|
||||
// schema is at the expected version and refuse to start if not —
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -149,6 +149,23 @@ kubectl apply -f "${MANIFESTS}/namespace.yaml"
|
||||
kubectl apply -f "${MANIFESTS}/redis/"
|
||||
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
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f -
|
||||
kubectl apply -f "${MANIFESTS}/api/service.yaml"
|
||||
|
||||
@@ -150,66 +150,110 @@ the default 25/10. If we hit connection errors in prod, adjust.
|
||||
|
||||
## Schema management
|
||||
|
||||
### GORM AutoMigrate
|
||||
### goose
|
||||
|
||||
On startup, the Go API's `cmd/api/main.go` calls
|
||||
`database.MigrateWithLock()` which:
|
||||
We use [pressly/goose](https://github.com/pressly/goose) (pinned in the
|
||||
api `Dockerfile` to v3.22.1) for schema migrations. Why goose specifically:
|
||||
|
||||
1. Opens a dedicated Postgres connection
|
||||
2. `SELECT pg_advisory_lock(1751412071)` — acquires a session-level
|
||||
advisory lock on a hardcoded key
|
||||
3. Calls `db.AutoMigrate(&models.*{})` for every GORM model
|
||||
4. `SELECT pg_advisory_unlock(...)` via deferred function
|
||||
5. Close the connection
|
||||
- Each migration file runs inside its own transaction by default —
|
||||
partial-failure recovery is built in (no "dirty" state to manually
|
||||
unstick like golang-migrate).
|
||||
- Locking is opt-in. We *don't* opt in. Migrations run as a single
|
||||
Kubernetes Job — that's the singleton process. No advisory-lock vs
|
||||
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
|
||||
pods start simultaneously, one acquires the lock and migrates; the
|
||||
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).
|
||||
See `docs/deployment/19-postmortem-swarm.md` (Schema Versioning section)
|
||||
for the AutoMigrate-with-advisory-lock approach this replaced and why.
|
||||
|
||||
### Why an advisory lock
|
||||
### Migration files
|
||||
|
||||
Without it, concurrent `CREATE TABLE IF NOT EXISTS ...` statements from
|
||||
multiple replicas would race — Postgres usually handles it, but GORM's
|
||||
AutoMigrate also alters tables (adds columns, indexes) which can deadlock
|
||||
under concurrency.
|
||||
Live under `migrations/`, named `<NNNNNN>_<short_name>.sql`. Each file
|
||||
has both the up and down migration in one file, separated by goose
|
||||
markers:
|
||||
|
||||
The advisory lock pattern (also used by Rails + Django + Alembic) is the
|
||||
canonical solution.
|
||||
```sql
|
||||
-- +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`.
|
||||
Arbitrary but unique — as long as nothing else in the Postgres instance
|
||||
uses the same advisory lock key, no conflicts.
|
||||
Multi-statement constructs (`CREATE FUNCTION`, `DO $$ BEGIN ... END $$`)
|
||||
need `-- +goose StatementBegin` / `-- +goose StatementEnd` wrappers
|
||||
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
|
||||
through every model's `CREATE TABLE` statement. This is ~50 tables for
|
||||
honeyDue and takes ~90 seconds.
|
||||
### Production migration flow
|
||||
|
||||
On a **warm database** (tables already exist), AutoMigrate is fast —
|
||||
typically under 2 seconds. It still runs (GORM checks every model
|
||||
against the schema) but finds no work to do.
|
||||
`deploy-k3s/scripts/03-deploy.sh` runs migrations as part of every
|
||||
deploy, **before** the api/worker rollout starts:
|
||||
|
||||
### 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
|
||||
time, the lock queue for the last replica is ~180s. We needed a
|
||||
startupProbe grace of 240s to cover this without false restart loops.
|
||||
See Chapter 7 §startupProbe and Chapter 19 §MigrateWithLock.
|
||||
The Job uses the api image — we install the goose CLI binary at
|
||||
`/usr/local/bin/goose` during the api Dockerfile build, so any pod that
|
||||
can run api can run goose. No separate image to build/push.
|
||||
|
||||
### 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
|
||||
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`).
|
||||
### Schema-version precondition
|
||||
|
||||
Today: we accept that schema changes are additive-only. When we need
|
||||
destructive changes, we'd hand-write them.
|
||||
`internal/database/database.go::RequireSchemaApplied()` runs at api and
|
||||
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
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ require (
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.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/felixge/httpsnoop v1.0.4 // 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-sqlite3 v2.0.3+incompatible // 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/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
|
||||
@@ -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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
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/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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.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/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
|
||||
@@ -19,11 +19,6 @@ import (
|
||||
"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
|
||||
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
|
||||
// so that multiple API replicas booting in parallel don't race on AutoMigrate.
|
||||
// On non-Postgres dialects (sqlite in tests) it falls through to Migrate().
|
||||
func MigrateWithLock() error {
|
||||
// RequireSchemaApplied verifies that goose's version table exists and has
|
||||
// at least one applied entry. This is the fail-fast that runs at api/worker
|
||||
// boot: if the operator forgot to run the migrate Job, the pod refuses to
|
||||
// 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 {
|
||||
return fmt.Errorf("database not initialised")
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// Give ourselves up to 5 min to acquire the lock — long enough for a
|
||||
// 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)
|
||||
if !row.IsApplied {
|
||||
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)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
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)
|
||||
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().Msg("Migration advisory lock acquired")
|
||||
|
||||
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()
|
||||
log.Info().Int64("schema_version", row.VersionID).Msg("Schema precondition satisfied")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Migrate runs database migrations for all models
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Rollback: 018_audit_log
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
@@ -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 '{}';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user