Compare commits
11 Commits
mai/knuth/
...
mai/hermes
| Author | SHA1 | Date | |
|---|---|---|---|
| 18d2e743ba | |||
| 5e17de6e07 | |||
| 0e1f62e375 | |||
| cca5e72c57 | |||
| 4d923562f5 | |||
| c70914c2a0 | |||
| 016ac2532a | |||
| c901293c9c | |||
| 0b1653c2bf | |||
| 191d8e7268 | |||
| cb44b3b8cc |
242
.gitea/workflows/test.yaml
Normal file
242
.gitea/workflows/test.yaml
Normal file
@@ -0,0 +1,242 @@
|
||||
# Paliad CI gate (t-paliad-282 / m/paliad#114).
|
||||
#
|
||||
# Single workflow, two purposes:
|
||||
#
|
||||
# - On every push: gate tier — build + unit + migration smoke. Red gate
|
||||
# means no further work and (on main) no deploy.
|
||||
# - On push to main with gate green: deploy step — calls the Dokploy
|
||||
# compose-deploy API for paliad's compose Zx147ycurfYagKRl_Zzyo, then
|
||||
# polls /health/ready until the new container reports 200.
|
||||
#
|
||||
# The deploy step REPLACES the previous Gitea-push → Dokploy webhook path
|
||||
# (per m's Q11.4 pick: soft-launch with both alive for ~1 week, then
|
||||
# disable the Dokploy auto-deploy toggle). Soft-launch leaves Dokploy's
|
||||
# autoDeploy=true intact today — the workflow's deploy step is additive
|
||||
# and idempotent (Dokploy's deploy is itself idempotent).
|
||||
#
|
||||
# Catches the three failure classes from 2026-05-25:
|
||||
#
|
||||
# - brunel slot collision (~13:20) — TestMigrations_NoDuplicateSlot,
|
||||
# pure unit, no DB needed.
|
||||
# - hermes dropped-col refs (~16:05) — TestBootSmoke, applies all NEW
|
||||
# migrations (those not in the snapshot) end-to-end against a
|
||||
# scratch DB restored from internal/db/testdata/prod-snapshot.sql.
|
||||
# - mig 129 42501 ownership (~14:56→) — TestMigrations_EndToEndAsAppRole,
|
||||
# applies new migrations as the prod-shaped `postgres` role (which
|
||||
# is NOT a superuser on supabase/postgres — same shape as
|
||||
# youpc-supabase prod, see internal/db/testdata/README.md).
|
||||
#
|
||||
# Snapshot approach: dump paliad schema + applied_migrations rows from
|
||||
# prod, commit them. CI restores → ApplyMigrations sees existing migs as
|
||||
# applied, only runs NEW migs (the ones this PR adds). This sidesteps the
|
||||
# fresh-DB idempotence requirement on historical migrations (some of
|
||||
# which use raw COMMIT or pre-installed extensions and can't be replayed
|
||||
# from scratch). To refresh: `make refresh-snapshot`.
|
||||
#
|
||||
# Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md (cronus inventor
|
||||
# shift, t-paliad-282).
|
||||
|
||||
name: Paliad CI gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'mai/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
BUN_VERSION: '1.2'
|
||||
|
||||
jobs:
|
||||
# Gate job 1 — pure build. Catches go/bun build breakage that local
|
||||
# `go build` would catch but which a worker might have skipped before
|
||||
# pushing. Fast (~60 s) so a red here surfaces immediately.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: bun install + build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
# Gate job 2 — Go test suite + migration smoke against snapshot-restored
|
||||
# scratch DB.
|
||||
#
|
||||
# The Postgres service container uses the same supabase/postgres image
|
||||
# as youpc-supabase prod. The CI scratch DB starts empty; a setup step
|
||||
# installs pg_trgm + restores the snapshot. After restore, paliad
|
||||
# schema is at HEAD-of-snapshot and applied_migrations covers every
|
||||
# migration up to (and including) the snapshot's max version.
|
||||
#
|
||||
# ApplyMigrations called in TestBootSmoke / TestMigrations_EndToEndAsAppRole
|
||||
# sees the snapshot's applied set, finds whatever NEW migrations this
|
||||
# PR added on top, and applies only those. The role-split smoke runs as
|
||||
# `postgres` (which is NOT a superuser on supabase/postgres, matching
|
||||
# the prod role topology) — any new migration that needs supabase_admin
|
||||
# privilege fails here as it would in prod.
|
||||
test-go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
# supabase/postgres baked-in auth schema + supabase role topology
|
||||
# matches youpc-supabase prod. `postgres` here is NOT a superuser
|
||||
# (verified live: \du postgres shows "Create role, Create DB,
|
||||
# Replication, Bypass RLS" — no Superuser). This is the prod-shaped
|
||||
# role the deploy uses.
|
||||
postgres:
|
||||
image: supabase/postgres:15.8.1.060
|
||||
env:
|
||||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: paliad_scratch
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install postgresql-client
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq postgresql-client
|
||||
|
||||
# Snapshot restore. Two prep steps as supabase_admin (the actual
|
||||
# superuser): GRANT CREATE so the `postgres` role can later create
|
||||
# schemas if a new mig needs it; install pg_trgm so the snapshot's
|
||||
# trigram indexes restore. Snapshot itself loads as `postgres`.
|
||||
- name: Provision + restore snapshot
|
||||
env:
|
||||
PGPASSWORD: ci
|
||||
run: |
|
||||
set -euo pipefail
|
||||
psql -h localhost -U supabase_admin -d paliad_scratch -v ON_ERROR_STOP=1 \
|
||||
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
|
||||
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1 \
|
||||
-f internal/db/testdata/prod-snapshot.sql
|
||||
|
||||
# Pre-flight: catches brunel slot collision in seconds, no DB
|
||||
# contact (still useful even though the test-go job has Postgres
|
||||
# running, because the failure mode is independent).
|
||||
- name: Migration coordination check
|
||||
run: go test -count=1 -run TestMigrations_NoDuplicateSlot ./internal/db/
|
||||
|
||||
# Role-split end-to-end apply. Connects as `postgres` (NOT a
|
||||
# superuser on supabase/postgres) and runs ApplyMigrations against
|
||||
# the snapshot-restored DB. Existing migs are skipped (already in
|
||||
# applied_migrations); NEW migs in this PR apply here. If a new
|
||||
# migration assumes supabase_admin privilege, fails with the same
|
||||
# 42501 error class that took paliad.de offline on 2026-05-25.
|
||||
- name: Migration end-to-end (deploy role)
|
||||
env:
|
||||
TEST_APP_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
|
||||
run: go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
|
||||
|
||||
# Boot smoke. Confirms ApplyMigrations succeeds + applied set
|
||||
# matches on-disk set + /healthz returns 200 + /health/ready
|
||||
# returns 200 (the live-pool variant via TestHealthReady_Live).
|
||||
- name: Boot smoke + readiness
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
|
||||
run: go test -count=1 -run 'TestBootSmoke|TestHealthReady_Live' ./cmd/server/
|
||||
|
||||
# Full Go test suite WITHOUT TEST_DATABASE_URL so live-DB service
|
||||
# tests skip (same shape as a developer laptop without a scratch
|
||||
# DB). Live-DB tests in internal/services/* will be activated by a
|
||||
# follow-up shift once the snapshot is verified stable across
|
||||
# multiple PRs — they need investigation against supabase/postgres
|
||||
# 15.8 (parameter type inference differs subtly from youpc-supabase).
|
||||
- name: go test ./... (pure + skip-on-no-DB)
|
||||
run: go test -count=1 ./internal/... ./cmd/...
|
||||
|
||||
# Deploy step. Only runs on push to main and only after both gate jobs
|
||||
# are green. Calls Dokploy's compose.deploy with the paliad compose ID
|
||||
# (Zx147ycurfYagKRl_Zzyo) and polls /health/ready until it returns 200
|
||||
# or times out.
|
||||
#
|
||||
# Skipped on PR / feature branch pushes — those run the gate tier as
|
||||
# a status check but don't trigger a prod deploy. Dokploy's existing
|
||||
# autoDeploy=true webhook continues to fire during the soft-launch
|
||||
# window (per Q11.4); it can be disabled in the Dokploy UI once this
|
||||
# workflow has gated ≥5 successful green deploys.
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, test-go]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Trigger Dokploy compose deploy
|
||||
env:
|
||||
DOKPLOY_KEY: ${{ secrets.DOKPLOY_TOKEN }}
|
||||
DOKPLOY_API: http://100.99.98.201:3000/api/trpc
|
||||
COMPOSE_ID: Zx147ycurfYagKRl_Zzyo
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DOKPLOY_KEY:-}" ]; then
|
||||
echo "ERROR: DOKPLOY_TOKEN secret is not configured."
|
||||
echo " Set the secret in Gitea repo settings before this step can deploy."
|
||||
exit 2
|
||||
fi
|
||||
echo "==> POST compose.deploy"
|
||||
curl -sS --connect-timeout 5 --max-time 30 \
|
||||
-X POST \
|
||||
-H "x-api-key: $DOKPLOY_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"json\":{\"composeId\":\"$COMPOSE_ID\"}}" \
|
||||
"$DOKPLOY_API/compose.deploy"
|
||||
echo
|
||||
|
||||
- name: Wait for /health/ready
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "==> polling https://paliad.de/health/ready"
|
||||
# Up to 5 minutes (60 × 5 s) — paliad's cold-start is normally
|
||||
# ≤30 s; the longer budget covers slow image pulls + migration
|
||||
# apply.
|
||||
for i in $(seq 1 60); do
|
||||
status=$(curl -sS --connect-timeout 3 --max-time 5 \
|
||||
-o /dev/null -w '%{http_code}' \
|
||||
https://paliad.de/health/ready || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "ready after ${i} poll(s)"
|
||||
exit 0
|
||||
fi
|
||||
echo " [$i/60] status=$status — sleeping 5s"
|
||||
sleep 5
|
||||
done
|
||||
echo "ERROR: /health/ready did not return 200 within 5 minutes."
|
||||
echo " The deploy fired but the new container is not serving."
|
||||
echo " Investigate: ssh mlake 'docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1'"
|
||||
exit 1
|
||||
72
Makefile
72
Makefile
@@ -21,18 +21,24 @@
|
||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||
# files.
|
||||
|
||||
.PHONY: help verify-migrations verify-mig test test-go
|
||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot
|
||||
|
||||
help:
|
||||
@echo "Paliad — developer targets"
|
||||
@echo ""
|
||||
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
|
||||
@echo " verify-mig Alias for verify-migrations"
|
||||
@echo " verify-mig-app End-to-end migration smoke as non-superuser role"
|
||||
@echo " (needs TEST_APP_DATABASE_URL — t-paliad-282 / m/paliad#114)"
|
||||
@echo " test Short test pass — covers gate tier"
|
||||
@echo " test-go Full Go suite with race detector"
|
||||
@echo " test-frontend Frontend bun:test suite"
|
||||
@echo ""
|
||||
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||
@echo ""
|
||||
@echo "Set TEST_APP_DATABASE_URL to enable the role-split smoke. Example:"
|
||||
@echo " export TEST_APP_DATABASE_URL=postgres://paliad_app:...@localhost:5432/paliad_scratch"
|
||||
|
||||
# Gate target — the test that would have caught mig 098 / mig 099 before
|
||||
# deploy. Combines:
|
||||
@@ -71,3 +77,67 @@ test:
|
||||
# (full suite, not per-PR).
|
||||
test-go:
|
||||
go test -race ./...
|
||||
|
||||
# Frontend bun:test suite. Runs the 4 existing pure-TS tests today; will
|
||||
# grow as mendel's Slice 3 (frontend test infill) lands.
|
||||
test-frontend:
|
||||
cd frontend && bun test
|
||||
|
||||
# Role-split end-to-end migration smoke — the catch for the mig 129 42501
|
||||
# ownership class (m/paliad#114). Runs ApplyMigrations as a non-superuser
|
||||
# role against TEST_APP_DATABASE_URL. Fails the build if any migration
|
||||
# assumes more privilege than the deploy role has.
|
||||
#
|
||||
# Developer setup (local):
|
||||
# psql -c "CREATE ROLE paliad_app LOGIN PASSWORD 'ci' NOSUPERUSER;"
|
||||
# psql -c "CREATE DATABASE paliad_scratch OWNER paliad_app;"
|
||||
# export TEST_APP_DATABASE_URL=postgres://paliad_app:ci@localhost:5432/paliad_scratch
|
||||
verify-mig-app:
|
||||
@if [ -z "$$TEST_APP_DATABASE_URL" ]; then \
|
||||
echo "ERROR: TEST_APP_DATABASE_URL is not set."; \
|
||||
echo " The role-split migration smoke cannot run without a non-superuser scratch DB."; \
|
||||
echo " See Makefile comments above this target for setup."; \
|
||||
exit 2; \
|
||||
fi
|
||||
go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
|
||||
|
||||
# Refresh the prod schema snapshot used by CI's migration smoke
|
||||
# (t-paliad-282 / m/paliad#114). Connects to youpc-supabase prod, dumps
|
||||
# the paliad schema + applied_migrations rows, strips rows beyond the
|
||||
# current branch's max on-disk version, and writes
|
||||
# internal/db/testdata/prod-snapshot.sql.
|
||||
#
|
||||
# When to refresh:
|
||||
# - After merging a PR that added a new migration to main.
|
||||
# - When CI's migration smoke starts spuriously failing because the
|
||||
# snapshot's applied set diverges from on-disk by more than this
|
||||
# branch's worth of new migs.
|
||||
#
|
||||
# Requires PALIAD_PROD_DATABASE_URL env var (a Postgres URL with
|
||||
# pg_dump rights on youpc-supabase). Example:
|
||||
# export PALIAD_PROD_DATABASE_URL='postgres://postgres:PW@100.99.98.201:11833/postgres'
|
||||
refresh-snapshot:
|
||||
@if [ -z "$$PALIAD_PROD_DATABASE_URL" ]; then \
|
||||
echo "ERROR: PALIAD_PROD_DATABASE_URL is not set."; \
|
||||
echo " Refresh requires read access to youpc-supabase prod."; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> dumping paliad schema (no owner, no privs)..."
|
||||
@pg_dump --schema-only --schema=paliad --no-owner --no-privileges \
|
||||
--no-publications --no-subscriptions \
|
||||
"$$PALIAD_PROD_DATABASE_URL" > internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@echo "==> appending applied_migrations rows..."
|
||||
@pg_dump --data-only --table=paliad.applied_migrations \
|
||||
--no-owner --no-privileges \
|
||||
"$$PALIAD_PROD_DATABASE_URL" >> internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@echo "==> stripping pg16 \\restrict / \\unrestrict commands for pg15 compat..."
|
||||
@sed -i.bak '/^\\restrict /d; /^\\unrestrict /d' internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@rm -f internal/db/testdata/prod-snapshot.sql.tmp.bak
|
||||
@echo "==> stripping applied_migrations rows beyond branch's max on-disk version..."
|
||||
@MAX_VER=$$(ls internal/db/migrations/*.up.sql | xargs -I{} basename {} | sed 's/_.*//' | sort -n | tail -1); \
|
||||
awk -v max=$$MAX_VER ' \
|
||||
/^[0-9]+\t/ { split($$0, a, "\t"); if (a[1]+0 > max) next; } \
|
||||
{ print } \
|
||||
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
|
||||
@rm internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@wc -l internal/db/testdata/prod-snapshot.sql
|
||||
|
||||
@@ -165,6 +165,7 @@ func main() {
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Pool: pool,
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
|
||||
@@ -98,6 +98,51 @@ func TestBootSmoke(t *testing.T) {
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
|
||||
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
|
||||
}
|
||||
|
||||
// (4) Readiness probe. With a nil Services bundle the endpoint MUST
|
||||
// report 503 — that's the contract documented in handlers/handlers.go.
|
||||
// A separate svc-with-Pool case is exercised in TestHealthReady (live).
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/health/ready", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("GET /health/ready (nil svc): status=%d; want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthReady_Live asserts the readiness probe answers 200 when the
|
||||
// pool is reachable, 503 when it isn't. Requires TEST_DATABASE_URL.
|
||||
//
|
||||
// Why a separate test: TestBootSmoke runs Register with svc=nil to keep
|
||||
// its setup minimal; the pool-reachable path needs the pool wired in
|
||||
// through svc.Pool. Two tests, two assertions, no entanglement.
|
||||
func TestHealthReady_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live readiness probe")
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||
}
|
||||
pool, err := db.OpenPool(url)
|
||||
if err != nil {
|
||||
t.Fatalf("open pool: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
|
||||
handlers.Register(mux, authClient, "", &handlers.Services{Pool: pool})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health/ready", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET /health/ready (live pool): status=%d, body=%q; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ready" {
|
||||
t.Errorf("GET /health/ready (live pool): body=%q; want \"ready\"", body)
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
|
||||
|
||||
181
docs/cicd-runner-setup-2026-05-25.md
Normal file
181
docs/cicd-runner-setup-2026-05-25.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# CI/CD runner setup — paliad
|
||||
|
||||
**Companion to:** `docs/design-cicd-pre-deploy-gate-2026-05-25.md` (Slice A, t-paliad-282 / m/paliad#114)
|
||||
**Date:** 2026-05-25
|
||||
**Audience:** mlake / mriver admin (m or head)
|
||||
|
||||
Slice A's `.gitea/workflows/test.yaml` requires (a) at least one online Gitea Actions runner and (b) a Dokploy API token wired as a repo secret. Both are one-time setup actions that paliad's source tree cannot perform itself — they live on infra-side. This doc lists them so the workflow can go green on its first run.
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-flight: what already exists
|
||||
|
||||
Verified live (2026-05-25 cronus inventor shift):
|
||||
|
||||
- Gitea 1.24.4 on `mgit.msbls.de`, `has_actions: true` on `m/paliad`.
|
||||
- `/api/v1/admin/actions/runners` reports **2 runners** registered. They are likely the shared runners used by `m/mGreen` and `m/mGeo` (both have `.gitea/workflows/deploy.yml` with `runs-on: self-hosted`).
|
||||
- `m/paliad/actions/tasks` reports `total_count=0` — paliad has never run a workflow yet.
|
||||
|
||||
The existing runners may already be capable of running paliad's workflow without further setup. The verification step (§3) below tells you whether they are.
|
||||
|
||||
---
|
||||
|
||||
## 1. Runner placement decision (m's Q11.1)
|
||||
|
||||
m's pick: **mriver**.
|
||||
|
||||
Rationale: mriver hosts the mai worker fleet but workers spend most of their time waiting on Anthropic. mlake's Dokploy + Swarm workload is more contended. A new runner on mriver adds the least pressure to either box.
|
||||
|
||||
If mriver is offline or saturated when CI first fires, fall back to the existing mlake-side runners (they're already registered; no provisioning needed).
|
||||
|
||||
---
|
||||
|
||||
## 2. One-time setup (admin steps)
|
||||
|
||||
### 2.1 Register a new Gitea Actions runner on mriver
|
||||
|
||||
```bash
|
||||
# On mriver, as m:
|
||||
# 1. Download the act_runner binary (matching Gitea 1.24.x)
|
||||
curl -L -o /usr/local/bin/act_runner \
|
||||
https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-linux-amd64
|
||||
chmod +x /usr/local/bin/act_runner
|
||||
|
||||
# 2. Get a runner registration token. In the Gitea UI:
|
||||
# /admin → Actions → Runners → "Create new Runner"
|
||||
# (or org-scope: /m/paliad/settings/actions/runners)
|
||||
# Copy the token.
|
||||
|
||||
# 3. Register
|
||||
mkdir -p ~/act_runner && cd ~/act_runner
|
||||
act_runner register --no-interactive \
|
||||
--instance https://mgit.msbls.de \
|
||||
--token <REGISTRATION_TOKEN> \
|
||||
--name mriver-paliad-1 \
|
||||
--labels ubuntu-latest:docker://node:20-bookworm
|
||||
|
||||
# 4. Run as a systemd unit (preferred) or as a session daemon
|
||||
# Systemd unit example: /etc/systemd/system/act_runner.service
|
||||
# [Unit]
|
||||
# Description=Gitea Actions runner
|
||||
# After=network.target
|
||||
# [Service]
|
||||
# User=m
|
||||
# WorkingDirectory=/home/m/act_runner
|
||||
# ExecStart=/usr/local/bin/act_runner daemon
|
||||
# Restart=on-failure
|
||||
# [Install]
|
||||
# WantedBy=multi-user.target
|
||||
sudo systemctl enable --now act_runner
|
||||
sudo systemctl status act_runner
|
||||
```
|
||||
|
||||
**Why `ubuntu-latest:docker://node:20-bookworm` for the label?** Gitea Actions' `runs-on: ubuntu-latest` resolves via the runner's label map. Mapping it to a Docker image gives the workflow a sandbox with Docker available — required for our Postgres service container in `test.yaml`. mriver should have Docker (for `paliadin-shim`); if not, install it.
|
||||
|
||||
### 2.2 Register the Dokploy API token as a repo secret
|
||||
|
||||
The workflow's `deploy` job needs `secrets.DOKPLOY_TOKEN`. Use the existing project-wide Dokploy API key (the one stored in `~/.claude/skills/mai-dokploy/SKILL.md`).
|
||||
|
||||
In the Gitea UI:
|
||||
- Navigate to `https://mgit.msbls.de/m/paliad/settings/actions/secrets`
|
||||
- Click "Add secret"
|
||||
- Name: `DOKPLOY_TOKEN`
|
||||
- Value: `mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz`
|
||||
|
||||
Or via API (mAi identity):
|
||||
```bash
|
||||
curl --netrc-file ~/.netrc-mai -sS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
https://mgit.msbls.de/api/v1/repos/m/paliad/actions/secrets/DOKPLOY_TOKEN \
|
||||
-d '{"data":"mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz"}'
|
||||
```
|
||||
|
||||
(Requires repo-owner permission. If mAi lacks it, m runs it.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the runner sees the workflow
|
||||
|
||||
After (2.1) + (2.2):
|
||||
|
||||
```bash
|
||||
# Push the Slice A branch (the one this doc lives on)
|
||||
git push origin mai/cronus/coder-cicd-slice-a
|
||||
|
||||
# Confirm the runner picked up the job
|
||||
curl --netrc-file ~/.netrc-mai -sS \
|
||||
"https://mgit.msbls.de/api/v1/repos/m/paliad/actions/tasks?limit=5" | jq '.'
|
||||
```
|
||||
|
||||
A new task per job should appear (build, test-go). If `total_count` stays 0, the runner labels don't match the workflow's `runs-on`. Re-register with `--labels ubuntu-latest` (no docker:// suffix) and the existing runners on mlake will pick it up via shell mode.
|
||||
|
||||
---
|
||||
|
||||
## 4. Soft-launch (m's Q11.4)
|
||||
|
||||
m's pick: **keep both Dokploy auto-deploy and the workflow's deploy step alive for ~1 week. After ≥5 successful green deploys via the workflow, disable Dokploy's autoDeploy in the Dokploy UI for the paliad compose.**
|
||||
|
||||
While both are live, every push to main fires:
|
||||
1. Dokploy webhook (existing path) → deploys immediately, no gate.
|
||||
2. Gitea workflow → on green, ALSO calls `compose.deploy`.
|
||||
|
||||
The second call is idempotent — if Dokploy already deployed the same commit, this is a no-op. The workflow's value during soft-launch is the **gate signal**: a red workflow on a green main = the bad migration shipped via the unguarded webhook and broke prod, and the workflow is shouting about it.
|
||||
|
||||
After confidence builds:
|
||||
1. In the Dokploy UI, navigate to the paliad compose → Settings.
|
||||
2. Toggle "Auto Deploy" off.
|
||||
3. Save.
|
||||
|
||||
From this point, the only path to deploy is the workflow's deploy job. Red workflow = no deploy.
|
||||
|
||||
---
|
||||
|
||||
## 5. What Slice A catches today — and what it doesn't
|
||||
|
||||
After this branch (`mai/cronus/coder-cicd-slice-a`) merges to main:
|
||||
|
||||
### Catches (active in CI)
|
||||
|
||||
- **Build breakage** — `go build`, `go vet`, `bun run build`. Red gate, no deploy.
|
||||
- **Slot collisions** — `TestMigrations_NoDuplicateSlot` runs without a DB. A PR adding migration N when version N already exists fails at gate time. This is the brunel-class catch (m/paliad#114 ~13:20 outage).
|
||||
- **New-migration shape errors (hermes class)** — `TestBootSmoke` runs `ApplyMigrations` against the snapshot-restored DB. New migs from this PR get applied for real; any column/relation/syntax error fails the gate before merge.
|
||||
- **New-migration ownership errors (mig 129 42501 class)** — `TestMigrations_EndToEndAsAppRole` runs `ApplyMigrations` connected as `postgres` (NON-superuser on `supabase/postgres:15.8.1.060`, same role topology as youpc-supabase prod). Any migration that assumes supabase_admin privilege fails with the same `42501 must be owner` error class that took paliad.de offline on 2026-05-25.
|
||||
- **Readiness probe regressions** — `TestHealthReady_Live` confirms `/health/ready` returns 200 against a live pool, 503 against a nil pool.
|
||||
- **Pure-Go test regressions** — `go test ./internal/... ./cmd/...` runs without `TEST_DATABASE_URL` (live-DB service tests skip the same way they do on a developer laptop without a scratch DB).
|
||||
|
||||
### Mechanism — the snapshot approach
|
||||
|
||||
CI's scratch DB starts from a `pg_dump` of youpc-supabase paliad schema +
|
||||
`paliad.applied_migrations` rows, committed to `internal/db/testdata/prod-snapshot.sql`. After restore, the scratch DB is at "paliad HEAD of snapshot" and `ApplyMigrations` sees only this PR's new migrations as pending.
|
||||
|
||||
This sidesteps the fresh-DB idempotence problem: several historical migrations (notably mig 037's missing `CREATE EXTENSION pg_trgm`, mig 051's inner `COMMIT;`) can't be replayed from scratch against `supabase/postgres:15.8.1.060`. The snapshot pins everything that's already applied in prod and lets CI focus on what's new — which is what we actually care about for outage prevention.
|
||||
|
||||
Snapshot refresh: `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL` set (see `internal/db/testdata/README.md`).
|
||||
|
||||
### Known gap — live-DB service tests don't run in CI
|
||||
|
||||
`internal/services/*_test.go` tests with `TEST_DATABASE_URL` set fail against `supabase/postgres:15.8.1.060` with `42P08 inconsistent types deduced for parameter` errors on some INSERT bind paths. The same tests pass against youpc-supabase prod. Cause is unconfirmed — likely subtle differences in type inference between the dockerized image and the prod cluster's configuration. CI today runs `go test ./...` without `TEST_DATABASE_URL` so these tests skip. Not blocking outage prevention; tracked as a follow-up for the post-Slice-A coder.
|
||||
|
||||
### Migration cleanup also bundled in this PR
|
||||
|
||||
Two surgical migration improvements that surfaced during snapshot debugging — kept here because they're small and harmless:
|
||||
|
||||
- **mig 024 + 027** — `ALTER INDEX` / `ALTER POLICY` exception handlers now catch `undefined_object` OR `undefined_table` OR `duplicate_object`. Old handler caught only `undefined_object`; Postgres raises `undefined_table` when the source object never existed and `duplicate_object` when the destination already exists. The expanded handler makes the migrations truly idempotent across the three plausible states: source-still-German (rename succeeds), already-renamed (catches duplicate_object), and fresh-DB-never-had-German (catches undefined_table).
|
||||
|
||||
Other migration history bugs (mig 037 missing pg_trgm, mig 051 inner COMMIT) are tracked as a separate cleanup task — not blocking, because the snapshot bypasses them.
|
||||
|
||||
### Verification checklist (after Slice A merges)
|
||||
|
||||
1. **Workflow green on its first PR run?** Check `/m/paliad/actions`. If not, fix before merging.
|
||||
2. **Dokploy `compose.deploy` call succeeds?** The workflow's `deploy` job logs the POST response. A successful response is a Dokploy job ID; a 4xx is an auth or compose-id problem.
|
||||
3. **`/health/ready` returns 200 within 5 minutes after a green deploy?** The workflow polls this. If it times out, the migration may have failed silently inside the new container — check `docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1` on mlake.
|
||||
4. **Reproduce the slot-collision catch locally:** rename `131_…up.sql` to `129_…` (duplicate slot) → workflow MUST fail at `Migration coordination check`. Revert before pushing.
|
||||
5. **Reproduce the role-split catch locally:** add a no-op migration `132_test_supersedes.up.sql` containing `REINDEX SYSTEM paliad_scratch;` (requires superuser). Workflow MUST fail at `Migration end-to-end (deploy role)`. Revert before pushing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Future polish (Slice D, m's Q4 R-pick)
|
||||
|
||||
`mai-test` post-merge shift: once Slice A is stable, wire a Gitea webhook on push-to-main that fires `/mai-test` as a follow-up shift. It runs the broader smoke + integration suite and posts results as a Gitea commit status. Not blocking; the gate doesn't depend on it.
|
||||
|
||||
Implementation belongs in `m/mAi` (the mai webhook handler), not in paliad. Out of scope for Slice A.
|
||||
126
frontend/src/client/filter-bar/compute-effective.test.ts
Normal file
126
frontend/src/client/filter-bar/compute-effective.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Unit tests for the FilterBar's computeEffective() overlay. These pin
|
||||
// the contract that any chip the user clicks ends up as a predicate the
|
||||
// server can see — the t-paliad-283 regression had four sources picking
|
||||
// up zero narrowing for /views/any because the bar's chip click didn't
|
||||
// produce a non-empty `filter.predicates` for that source.
|
||||
//
|
||||
// Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { computeEffective } from "./index";
|
||||
import type { FilterSpec, RenderSpec } from "../views/types";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
// Mirrors paliad.user_views row {slug: "any"} — the saved Custom View
|
||||
// that triggered the t-paliad-283 regression report.
|
||||
const ANY_VIEW_FILTER: FilterSpec = {
|
||||
version: 1,
|
||||
sources: ["deadline", "appointment", "project_event", "approval_request"],
|
||||
scope: { projects: { mode: "all_visible" } },
|
||||
time: { field: "auto", horizon: "past_30d" },
|
||||
};
|
||||
|
||||
const ANY_VIEW_RENDER: RenderSpec = {
|
||||
shape: "list",
|
||||
list: { sort: "date_asc", density: "comfortable" },
|
||||
};
|
||||
|
||||
describe("filter-bar/computeEffective — /views/any (all 4 sources)", () => {
|
||||
test("empty state leaves base spec intact (no overlays)", () => {
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toEqual([
|
||||
"deadline", "appointment", "project_event", "approval_request",
|
||||
]);
|
||||
expect(eff.filter.time).toEqual({ field: "auto", horizon: "past_30d" });
|
||||
// predicates may be {} (the bar zero-fills it) but never carries a
|
||||
// stray narrowing on any source — that would silently filter
|
||||
// results the user never asked to filter.
|
||||
for (const src of ANY_VIEW_FILTER.sources) {
|
||||
expect(eff.filter.predicates?.[src]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline_status chip narrows deadline predicate", () => {
|
||||
const state: BarState = { deadline_status: ["pending"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
});
|
||||
|
||||
test("appointment_type chip narrows appointment predicate", () => {
|
||||
const state: BarState = { appointment_type: ["hearing"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
});
|
||||
|
||||
test("approval_viewer_role chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_viewer_role: "any_visible" };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.viewer_role).toBe("any_visible");
|
||||
});
|
||||
|
||||
test("approval_status chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_status: ["pending", "approved"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending", "approved"]);
|
||||
});
|
||||
|
||||
test("approval_entity_type chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_entity_type: ["deadline"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.entity_types).toEqual(["deadline"]);
|
||||
});
|
||||
|
||||
test("project_event_kind chip narrows project_event predicate", () => {
|
||||
const state: BarState = { project_event_kind: ["deadline_created"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("time chip overrides base horizon", () => {
|
||||
const state: BarState = { time: { horizon: "past_7d" } };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.time.field).toBe("auto"); // preserved from base
|
||||
});
|
||||
|
||||
test("personal_only chip flips scope flag", () => {
|
||||
const state: BarState = { personal_only: true };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.scope.personal_only).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple chips combine into the same effective spec", () => {
|
||||
const state: BarState = {
|
||||
time: { horizon: "past_7d" },
|
||||
deadline_status: ["pending"],
|
||||
appointment_type: ["hearing"],
|
||||
approval_status: ["pending"],
|
||||
project_event_kind: ["deadline_created"],
|
||||
};
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("overlay does not mutate the caller's base filter", () => {
|
||||
const base: FilterSpec = JSON.parse(JSON.stringify(ANY_VIEW_FILTER));
|
||||
const state: BarState = { deadline_status: ["pending"], time: { horizon: "past_7d" } };
|
||||
computeEffective(base, ANY_VIEW_RENDER, state);
|
||||
// The bar deep-clones; the base must come back unchanged so a
|
||||
// second click doesn't compound the previous click's overlay.
|
||||
expect(base).toEqual(ANY_VIEW_FILTER);
|
||||
});
|
||||
|
||||
test("inbox-only axes do not affect a /views/any spec (no inbox axis exposed)", () => {
|
||||
// /views/any's axes don't include unread_only or inbox_focus, so
|
||||
// those keys never appear in state. Verify that even if they did,
|
||||
// the bar's overlay doesn't silently mutate sources or predicates
|
||||
// in a way that would break a 4-source Custom View.
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toHaveLength(4);
|
||||
expect(eff.filter.unread_only ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -439,9 +439,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.side.undefined": "Nicht festgelegt",
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
@@ -3543,9 +3544,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.side.undefined": "Undefined",
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
|
||||
@@ -497,7 +497,17 @@ async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide |
|
||||
function sideLabelI18n(s: Side): string {
|
||||
if (s === "claimant") return t("deadlines.side.claimant");
|
||||
if (s === "defendant") return t("deadlines.side.defendant");
|
||||
return t("deadlines.side.both");
|
||||
return t("deadlines.side.undefined");
|
||||
}
|
||||
|
||||
// syncSideHintVisibility shows the "pick a side" hint chip only while
|
||||
// currentSide is unset (m/paliad#120). When the user has picked
|
||||
// claimant / defendant the columns are already focused, so the prompt
|
||||
// would be misleading.
|
||||
function syncSideHintVisibility() {
|
||||
const hint = document.getElementById("side-hint");
|
||||
if (!hint) return;
|
||||
hint.style.display = currentSide === null ? "" : "none";
|
||||
}
|
||||
|
||||
// renderSideChip swaps the radio cluster for a read-only chip showing
|
||||
@@ -521,6 +531,9 @@ function showSideRadioCluster() {
|
||||
if (!cluster || !chip) return;
|
||||
cluster.style.display = "";
|
||||
chip.style.display = "none";
|
||||
// Cluster re-appears after override → re-evaluate hint visibility so
|
||||
// we don't leave a stale "pick a side" prompt above a checked radio.
|
||||
syncSideHintVisibility();
|
||||
}
|
||||
|
||||
// applySidePrefill takes a project's our_side, maps it to the side axis,
|
||||
@@ -606,6 +619,7 @@ function initPerspectiveControls() {
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
@@ -613,6 +627,7 @@ function initPerspectiveControls() {
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,13 @@ export interface FilterSpec {
|
||||
sources: DataSource[];
|
||||
scope: ScopeSpec;
|
||||
time: TimeSpec;
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
// Per-source narrowing. Flat shape — one entry per data source. The
|
||||
// Go side (internal/services/filter_spec.go: FilterSpec.Predicates)
|
||||
// mirrors this exactly; the previous Partial<Record<DataSource,
|
||||
// Predicates>> spelling was a latent contract bug (t-paliad-283)
|
||||
// where every chip click sent a single-nested shape the server
|
||||
// unmarshalled to no-op.
|
||||
predicates?: Predicates;
|
||||
// Inbox unread-only overlay (t-paliad-249). When true, the view
|
||||
// service drops project_event rows older than the caller's
|
||||
// users.inbox_seen_at cursor. Pending approval_requests always
|
||||
|
||||
@@ -1460,12 +1460,13 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.from_project"
|
||||
| "deadlines.side.hint"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.side.override"
|
||||
| "deadlines.side.undefined"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
|
||||
@@ -1917,7 +1917,11 @@ input[type="range"]::-moz-range-thumb {
|
||||
.fristen-row.is-active .fristen-row-num {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text, #111);
|
||||
/* Lime is high-luminance; foreground stays midnight in both themes via
|
||||
--color-accent-dark (light: midnight by default, dark: midnight
|
||||
explicit). Using --color-text here would flip to cream in dark mode
|
||||
and collapse contrast on lime. */
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled .fristen-row-num {
|
||||
@@ -3578,7 +3582,10 @@ input[type="range"]::-moz-range-thumb {
|
||||
.event-card-choices-option--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: var(--color-text);
|
||||
/* Foreground stays midnight in both themes — --color-text would flip
|
||||
to cream in dark mode and leave the active "Berufung durch …"
|
||||
chip unreadable on lime (m/paliad#123). */
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3711,6 +3718,22 @@ input[type="range"]::-moz-range-thumb {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* "Pick a side" hint that sits next to the side-radio cluster while
|
||||
currentSide is null (m/paliad#120). Both columns still render every
|
||||
rule in that state — the chip just nudges the user that picking a
|
||||
side focuses their column. Hidden by JS once a side is picked. */
|
||||
.side-radio-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.side-hint {
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
|
||||
resolves a project whose our_side is set: shows the inferred side
|
||||
with a small "Andere Seite wählen" override link that swaps the row
|
||||
@@ -7976,7 +7999,7 @@ dialog.modal::backdrop {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
color: var(--color-accent-dark);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -15906,7 +15929,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
color: var(--color-accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
@@ -16514,7 +16537,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.smart-timeline-anchor-submit {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
color: var(--color-text, #333);
|
||||
color: var(--color-accent-dark);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -17452,7 +17475,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.admin-rules-chip.active {
|
||||
background: var(--color-accent, #BFF355);
|
||||
border-color: var(--color-accent, #BFF355);
|
||||
color: var(--color-text, #000);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.admin-rules-pill {
|
||||
|
||||
@@ -190,9 +190,18 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Prompt shown while the user hasn't picked a side
|
||||
(m/paliad#120). Hidden by client when side is
|
||||
claimant or defendant. Both columns still
|
||||
render every rule in this state — picking a
|
||||
side just focuses the user's column. */}
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
{/* Auto-fill chip — populated by the client when a
|
||||
?project=<id> URL resolves a project with our_side
|
||||
|
||||
@@ -116,6 +116,57 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrations_NoDuplicateSlot is a free-standing pre-flight check that
|
||||
// scanEmbeddedMigrations refuses to walk a tree where two *.up.sql files
|
||||
// claim the same NNN slot. This is the brunel-slot-collision class of
|
||||
// outage (m/paliad#114, 2026-05-25 ~13:20): a worker writes a migration
|
||||
// at slot N while another shipped slot N from a separate branch, both
|
||||
// merge, both end up in the embed.FS, and the runner refuses to start.
|
||||
//
|
||||
// Catching this at CI time (no DB needed) lets the second PR fail before
|
||||
// it merges, instead of breaking prod at the next deploy. Pure unit test;
|
||||
// runs even on developer laptops that don't set TEST_DATABASE_URL.
|
||||
func TestMigrations_NoDuplicateSlot(t *testing.T) {
|
||||
if _, err := scanEmbeddedMigrations(); err != nil {
|
||||
t.Fatalf("scanEmbeddedMigrations: %v "+
|
||||
"(two migrations share the same NNN slot — coordinate with head "+
|
||||
"and rename one of them before merging)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrations_EndToEndAsAppRole applies every embedded migration in
|
||||
// numeric order against a scratch DB connected as a NON-SUPERUSER role.
|
||||
// This is the prod-shape smoke that the per-mig BEGIN/ROLLBACK dry-run
|
||||
// (TestMigrations_DryRun) cannot deliver: the dry-run runs each
|
||||
// statement in isolation and rolls back, so it cannot reproduce the
|
||||
// mig-129-class outage (m/paliad#114, 2026-05-25 ~14:56 — pq: must be
|
||||
// owner of table project_event_choices, SQLSTATE 42501) where a
|
||||
// migration assumes ownership the deploy role doesn't have.
|
||||
//
|
||||
// Requires TEST_APP_DATABASE_URL — a Postgres URL whose role is NOT a
|
||||
// superuser and does NOT own the `paliad` schema (m's Q11.2 pick:
|
||||
// generic two-role model, see docs/design-cicd-pre-deploy-gate-2026-05-25.md
|
||||
// §6.2(a)). The CI workflow creates the role + schema split before
|
||||
// invoking the test; a developer who wants to reproduce the gate locally
|
||||
// runs the same SQL preamble (see Makefile target `verify-migrations`).
|
||||
//
|
||||
// Skipped without TEST_APP_DATABASE_URL — keeps `go test ./...` green
|
||||
// on machines that haven't set up the role split.
|
||||
func TestMigrations_EndToEndAsAppRole(t *testing.T) {
|
||||
url := os.Getenv("TEST_APP_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_APP_DATABASE_URL not set — skipping role-split end-to-end migration smoke")
|
||||
}
|
||||
if err := ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("ApplyMigrations as app role failed: %v "+
|
||||
"(a migration assumes more privilege than the deploy role has — "+
|
||||
"common cases: ALTER TABLE on a schema-owner table, CREATE EXTENSION "+
|
||||
"without grants, SET ROLE without permission. Fix the migration to "+
|
||||
"work as the deploy role, or arrange for the schema to be owned by "+
|
||||
"the deploy role)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readAppliedVersions returns the set of versions present in
|
||||
// paliad.applied_migrations on the scratch DB. Missing table → empty set
|
||||
// (fresh-DB path; the table only exists after the runner has been called).
|
||||
|
||||
@@ -26,24 +26,24 @@ DO $$ BEGIN ALTER TABLE paliad.department_members RENAME COLUMN dezernat_id TO d
|
||||
-- Constraints (primary key + foreign keys + check). Renaming a pkey
|
||||
-- constraint also renames the underlying index of the same name.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Standalone indexes (non-pkey).
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS policies
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
@@ -63,27 +63,27 @@ ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_u
|
||||
-- 5. Rename constraints. Postgres auto-renames the underlying index for
|
||||
-- pkey/uniq constraints; standalone indexes are renamed in step 6.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. Rename non-pkey indexes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. Rename RLS policies.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. Audit table for partner-unit events. Mutations on partner_units +
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Reverses mig 133. Removes the 5 new rules:
|
||||
-- * upc.dmgs.cfi.interim
|
||||
-- * upc.dmgs.cfi.oral
|
||||
-- * upc.dmgs.cfi.decision
|
||||
-- * upc.dmgs.cfi.appeal_spawn
|
||||
-- * upc.pi.cfi.appeal_spawn
|
||||
--
|
||||
-- The audit_reason is required by the mig 079 trigger for DELETE;
|
||||
-- set_config at top supplies it.
|
||||
--
|
||||
-- Idempotent — if a rule is already missing the DELETE matches zero
|
||||
-- rows and the audit log records nothing extra.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133 (down): revert UPC Damages tree-end rows and UPC PI appeal-spawn (t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118)',
|
||||
true);
|
||||
|
||||
-- Delete the spawn rows first so the parent_id reference goes away
|
||||
-- before the parent decision row is removed.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'upc.pi.cfi.appeal_spawn')
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.interim',
|
||||
'upc.dmgs.cfi.oral',
|
||||
'upc.dmgs.cfi.decision')
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published';
|
||||
405
internal/db/migrations/133_upc_dmgs_pi_court_followup.up.sql
Normal file
405
internal/db/migrations/133_upc_dmgs_pi_court_followup.up.sql
Normal file
@@ -0,0 +1,405 @@
|
||||
-- t-paliad-285 (m/paliad#117) + t-paliad-286 (m/paliad#118) —
|
||||
-- post-submission court followup for UPC Damages and appeal route
|
||||
-- for UPC Provisional Measures.
|
||||
--
|
||||
-- m's 2026-05-25 report: the upc.dmgs.cfi proceeding stops at the
|
||||
-- last party submission (rejoin) — no interim conference, no oral
|
||||
-- hearing, no decision row, no appeal-spawn. The upc.pi.cfi
|
||||
-- proceeding has its decision row (`pi.order`) but no spawn into
|
||||
-- the appeal tree. Both gaps prevent the Verfahrensablauf timeline
|
||||
-- from rendering the court phase plus any downstream appeal sub-
|
||||
-- tree that atlas's #96 spawn-rendering mechanism is otherwise
|
||||
-- ready to surface.
|
||||
--
|
||||
-- Two sections in one migration (slot 133 — knuth on 132, paliadin
|
||||
-- coordinated):
|
||||
--
|
||||
-- A. UPC Damages tree-end rows (#117)
|
||||
-- A1 upc.dmgs.cfi.interim UPC RoP R.105 court-set hearing
|
||||
-- A2 upc.dmgs.cfi.oral UPC RoP R.118 / R.250 court-set hearing
|
||||
-- A3 upc.dmgs.cfi.decision UPC RoP R.118 / R.144 court-set decision
|
||||
-- A4 upc.dmgs.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- B. UPC Provisional Measures appeal route (#118)
|
||||
-- B1 upc.pi.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- Source citations:
|
||||
-- * docs/research-deadlines-completeness-2026-05-25.md
|
||||
-- — §2.1 (upc.dmgs.cfi has only 4 rules: R.131.2 / R.137.2 / R.139)
|
||||
-- — §D Damages table (R.144 tree-end row missing — listed
|
||||
-- in Tier 4 as "cosmetic", upgraded to Tier-0 by m's
|
||||
-- report once the wider follow-up gap was understood)
|
||||
-- * docs/audit-upc-rop-deadlines-2026-05-08.md §D row R.144,
|
||||
-- §F R.220.1(a) / R.224.1(a) (verified verbatim in youpc DB
|
||||
-- under law_type=UPCRoP).
|
||||
-- * UPC Rules of Procedure (consolidated):
|
||||
-- R.105 — Interim conference (court fixes after written
|
||||
-- procedure closes; same structural shape as the inf
|
||||
-- interim conference, already modelled as `upc.inf.cfi.interim`).
|
||||
-- R.118 — Decision after oral hearing; general rule for
|
||||
-- deciding panels.
|
||||
-- R.250 — Determination of damages decision; damages-
|
||||
-- specific decision rule (chains R.144 indication →
|
||||
-- damages award).
|
||||
-- R.144 — Final decision on damages quantum (tree-end
|
||||
-- anchor for §A3).
|
||||
-- R.220.1(a) — Appeal lies from any final decision /
|
||||
-- decision disposing of the case at first instance.
|
||||
-- A PI order under R.211 disposes of the urgent question
|
||||
-- and is therefore appealable on the main 2-month track
|
||||
-- (not the 15-day order track of R.220.1(c), which covers
|
||||
-- case-management and procedural orders requiring leave).
|
||||
-- Curie's §F table confirms the main-track wiring for
|
||||
-- decisions on merits / disposing orders.
|
||||
-- R.224.1(a) — Statement of Appeal within 2 months of
|
||||
-- service of the final decision; the deadline-notes text
|
||||
-- mirrors mig 095's inf.appeal_spawn / rev.appeal_spawn.
|
||||
-- R.224.2(a) — Statement of grounds within 4 months
|
||||
-- (separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding; already present as upc.apl.merits.grounds).
|
||||
--
|
||||
-- Shape decisions (mirroring mig 012 / mig 095 conventions):
|
||||
-- * Court-set rows (interim / oral / decision) carry
|
||||
-- primary_party='court', event_type='hearing'|'decision',
|
||||
-- duration_value=0, is_court_set=true, parent_id=NULL,
|
||||
-- concept_id reuses the shared concepts already wired for
|
||||
-- upc.inf.cfi (interim-conference / oral-hearing / decision).
|
||||
-- * Spawn rows carry primary_party='both', is_spawn=true,
|
||||
-- spawn_proceeding_type_id=11 (upc.apl.merits), spawn_label
|
||||
-- identical to the merits spawn already in production. The
|
||||
-- spawn row's parent_id is the spawning decision/order row
|
||||
-- (so the audit log carries the trigger link).
|
||||
-- * No condition_expr — m's F2.3 decision recorded in mig 095
|
||||
-- §3: "the appeal deadline should always be triggered by a
|
||||
-- decision … appeal is always a possibility." Visibility
|
||||
-- filtering on the frontend hides appeals on projects where
|
||||
-- no appeal is contemplated.
|
||||
-- * sequence_order numbering follows the inf convention
|
||||
-- (40=interim, 50=oral, 60=decision, 80=appeal_spawn) so the
|
||||
-- Verfahrensablauf timeline orders consistently across
|
||||
-- proceedings. For PI the existing pi.order sits at
|
||||
-- sequence_order=3; the appeal_spawn lands at 10 (clear of
|
||||
-- the writ phase, room for future court-phase rows).
|
||||
--
|
||||
-- Idempotency: every INSERT is gated by `WHERE NOT EXISTS (… same
|
||||
-- submission_code, proceeding_type_id, lifecycle_state)`. Re-apply
|
||||
-- against an already-migrated DB inserts zero rows and the audit
|
||||
-- log carries no duplicate entries.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on INSERT/UPDATE/DELETE without it.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133: t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118 — UPC Damages tree-end rows (interim conference R.105, oral hearing R.118/R.250, decision R.118/R.144, appeal-spawn R.220.1(a)) and UPC Provisional Measures appeal-spawn R.220.1(a); see docs/research-deadlines-completeness-2026-05-25.md §D and docs/audit-upc-rop-deadlines-2026-05-08.md §D/§F',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- A. UPC Damages — court-phase tree end (m/paliad#117)
|
||||
-- =============================================================================
|
||||
|
||||
-- A1. upc.dmgs.cfi.interim — Interim conference (UPC RoP R.105).
|
||||
-- Court-set hearing fixed by the judge-rapporteur once the
|
||||
-- written procedure closes. Identical shape to
|
||||
-- upc.inf.cfi.interim; reuses the shared interim-conference
|
||||
-- concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.interim',
|
||||
'Zwischenverfahren',
|
||||
'Interim Conference',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
'Termin vom Gericht bestimmt',
|
||||
'Date set by the court',
|
||||
40,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'e5071152-d408-4455-b644-9e79d86fd538'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.interim'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A2. upc.dmgs.cfi.oral — Oral hearing (UPC RoP R.118 / R.250).
|
||||
-- Court-set hearing after the interim conference / close of
|
||||
-- written procedure. Same shape as upc.inf.cfi.oral; reuses
|
||||
-- the shared oral-hearing concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.oral',
|
||||
'Mündliche Verhandlung',
|
||||
'Oral Hearing',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
50,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'd6e5b793-dcf1-4d83-81ff-34f42dbb3693'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.oral'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A3. upc.dmgs.cfi.decision — Damages decision (UPC RoP R.118 /
|
||||
-- R.144 / R.250). Court-set decision delivered after oral
|
||||
-- hearing; closes the §3.1 audit gap (R.144 tree-end). Same
|
||||
-- shape as upc.inf.cfi.decision; reuses the shared decision
|
||||
-- concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.decision',
|
||||
'Entscheidung',
|
||||
'Decision',
|
||||
NULL,
|
||||
'court',
|
||||
'decision',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
60,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'472fc32d-cc4f-4aa4-8ace-e422031812de'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A4. upc.dmgs.cfi.appeal_spawn — Appeal against damages decision
|
||||
-- (UPC RoP R.220.1(a), 2-month main track; grounds R.224.2(a)
|
||||
-- run as a separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding). Parent points at the freshly-inserted
|
||||
-- upc.dmgs.cfi.decision; the SELECT subquery resolves it
|
||||
-- after A3 lands. Same shape as the mig 095 inf.appeal_spawn.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
17,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'Berufung gegen Schadensentscheidung',
|
||||
'Appeal against damages decision',
|
||||
'Berufung gegen die Entscheidung über die Schadensbemessung (R.118 / R.144). Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der Schadensentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the damages decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
80,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- B. UPC Provisional Measures — appeal route (m/paliad#118)
|
||||
-- =============================================================================
|
||||
|
||||
-- B1. upc.pi.cfi.appeal_spawn — Appeal against PI order (UPC RoP
|
||||
-- R.220.1(a), 2-month main track). PI orders under R.211
|
||||
-- dispose of the urgent question and are appealable on the
|
||||
-- main 2-month track (R.220.1(a)/R.224.1(a)); the 15-day
|
||||
-- order track of R.220.1(c) is for case-management /
|
||||
-- procedural orders requiring leave and does not apply to
|
||||
-- PI dispositions. Parent points at the existing
|
||||
-- upc.pi.cfi.order (sequence_order=3) so the spawn fires
|
||||
-- once the order is anchored on a project's timeline.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
10,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.order'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.pi.cfi.appeal_spawn',
|
||||
'Berufung gegen Anordnung',
|
||||
'Appeal against PI order',
|
||||
'Berufung gegen die einstweilige Anordnung nach R.211. Eine PI-Anordnung erledigt die einstweilige Streitfrage und wird wie eine Endentscheidung im Hauptverfahren behandelt: statutarische Frist von 2 Monaten ab Zustellung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren). Die 15-Tage-Spur nach R.220.1(c) / R.220.2 gilt für Verfahrensanordnungen mit Zulassung und ist hier nicht einschlägig.',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der PI-Anordnung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the PI order lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
10,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Post-insert verification — raise if any expected row is missing
|
||||
-- (matches the mig 095 / 127 convention; protects against a future
|
||||
-- re-shape of the table that silently drops one of the WHERE NOT
|
||||
-- EXISTS predicates).
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(expected, ', ' ORDER BY expected)
|
||||
INTO v_missing
|
||||
FROM (VALUES
|
||||
('upc.dmgs.cfi.interim'),
|
||||
('upc.dmgs.cfi.oral'),
|
||||
('upc.dmgs.cfi.decision'),
|
||||
('upc.dmgs.cfi.appeal_spawn'),
|
||||
('upc.pi.cfi.appeal_spawn')
|
||||
) AS t(expected)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = t.expected
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_active = true);
|
||||
|
||||
IF v_missing IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: expected published rules missing after insert: %', v_missing;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 17
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.dmgs.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 10
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.pi.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
END $$;
|
||||
69
internal/db/testdata/README.md
vendored
Normal file
69
internal/db/testdata/README.md
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
# `internal/db/testdata/` — CI snapshot
|
||||
|
||||
## `prod-snapshot.sql`
|
||||
|
||||
Schema-only `pg_dump` of paliad's prod DB (youpc-supabase paliad schema)
|
||||
plus the rows of `paliad.applied_migrations` that match this branch's
|
||||
on-disk migration set.
|
||||
|
||||
**Purpose.** Lets CI's migration smoke (`.gitea/workflows/test.yaml`)
|
||||
restore a Postgres scratch DB to "paliad at HEAD-of-snapshot" without
|
||||
having to replay 131 migrations from scratch. ApplyMigrations on the
|
||||
restored DB sees the applied set and only runs whatever NEW migrations
|
||||
this PR adds — exactly the integration shape we want to test, and the
|
||||
same shape prod sees on every deploy.
|
||||
|
||||
**Why a snapshot at all.** Running ApplyMigrations from scratch against a
|
||||
fresh `supabase/postgres:15.8.1.060` surfaces multiple fresh-DB
|
||||
idempotence bugs in historical migrations (raw `COMMIT;` in mig 051,
|
||||
missing `CREATE EXTENSION pg_trgm` for mig 037, ALTER POLICY
|
||||
exception-handler gaps in mig 024/027 — the last is fixed in this PR).
|
||||
Fixing them all is a separate cleanup. The snapshot sidesteps them by
|
||||
starting CI from a state where every historical migration is already
|
||||
applied as it was in prod.
|
||||
|
||||
**Schema scope.** `--schema=paliad` only. Auth schema comes baked into
|
||||
`supabase/postgres`; CI's setup step installs `pg_trgm` before restoring.
|
||||
|
||||
**Ownership.** `--no-owner --no-privileges` keeps the dump portable
|
||||
across role topologies (CI's supabase_admin / postgres / authenticated /
|
||||
anon don't have to match prod's exact role layout). The role-split smoke
|
||||
relies on `postgres` being a non-superuser, which is true on
|
||||
supabase/postgres by default.
|
||||
|
||||
**Refresh.** Run `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL`
|
||||
set to a Postgres URL with `pg_dump` rights on youpc-supabase. The
|
||||
target appends data rows for `paliad.applied_migrations`, strips
|
||||
`\restrict` / `\unrestrict` commands (pg 16 dump → pg 15 restore), and
|
||||
filters out applied-migrations rows for versions beyond the branch's
|
||||
local max. The CI workflow consumes the resulting file verbatim.
|
||||
|
||||
**Verify a refresh.** Boot a local scratch:
|
||||
|
||||
```bash
|
||||
docker run -d --rm --name paliad-snap \
|
||||
-e POSTGRES_PASSWORD=ci -e POSTGRES_DB=paliad_scratch \
|
||||
-p 15433:5432 supabase/postgres:15.8.1.060
|
||||
sleep 5
|
||||
docker exec -e PGPASSWORD=ci paliad-snap psql -h localhost -U supabase_admin -d paliad_scratch \
|
||||
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
|
||||
cat internal/db/testdata/prod-snapshot.sql | docker exec -i -e PGPASSWORD=ci paliad-snap \
|
||||
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1
|
||||
TEST_DATABASE_URL="postgres://postgres:ci@localhost:15433/paliad_scratch?sslmode=disable" \
|
||||
TEST_APP_DATABASE_URL="postgres://postgres:ci@localhost:15433/paliad_scratch?sslmode=disable" \
|
||||
go test -count=1 -run 'TestMigrations|TestBootSmoke|TestHealthReady_Live' ./internal/db/ ./cmd/server/
|
||||
docker stop paliad-snap
|
||||
```
|
||||
|
||||
All four named tests must pass. If any fails after a refresh,
|
||||
investigate before merging — usually because a new migration was added
|
||||
to prod that this branch doesn't have on disk yet.
|
||||
|
||||
**Why is the snapshot not gzipped?** Small enough (~200 KB) that the
|
||||
diff stays human-readable in `git diff` reviews. If it crosses ~1 MB,
|
||||
gzip + decompress-on-restore in CI.
|
||||
|
||||
**Privacy.** Schema-only dump, no row data from any paliad table (except
|
||||
`paliad.applied_migrations`, which contains migration filenames +
|
||||
checksums — public info already in the repo).
|
||||
6278
internal/db/testdata/prod-snapshot.sql
vendored
Normal file
6278
internal/db/testdata/prod-snapshot.sql
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
@@ -50,6 +54,12 @@ func noCachePages(h http.Handler) http.Handler {
|
||||
// Services bundles the Phase B + C database-backed services. Pass nil if
|
||||
// DATABASE_URL was unset; the matter-management endpoints will return 503.
|
||||
type Services struct {
|
||||
// Pool is the raw connection pool. Held so the readiness probe
|
||||
// (/health/ready) can ping it without going through any individual
|
||||
// service. nil when DATABASE_URL was unset — in that case
|
||||
// /health/ready returns 503.
|
||||
Pool *sqlx.DB
|
||||
|
||||
Project *services.ProjectService
|
||||
Team *services.TeamService
|
||||
PartnerUnit *services.PartnerUnitService
|
||||
@@ -188,6 +198,38 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
// Readiness probe. Public, no auth. Distinct from /healthz: this
|
||||
// returns 200 only when the DB pool is reachable. Reaching Register
|
||||
// at all implies db.ApplyMigrations succeeded (cmd/server/main.go
|
||||
// calls it before constructing svc), so a 200 here means "migrations
|
||||
// applied AND pool responsive" — the contract Dokploy / Traefik should
|
||||
// gate on, not the bind-and-serve check that /healthz answers.
|
||||
//
|
||||
// Three outcomes:
|
||||
// - svc == nil OR svc.Pool == nil → 503 (DB-less knowledge-platform
|
||||
// deployments report not-ready so an external orchestrator can
|
||||
// distinguish them from a full prod boot).
|
||||
// - PingContext fails within 2 s → 503 (pool unreachable).
|
||||
// - PingContext succeeds → 200 "ready".
|
||||
//
|
||||
// Used by docker-compose.yml's healthcheck (Slice B) and by the
|
||||
// post-deploy verification step in .gitea/workflows/test.yaml.
|
||||
mux.HandleFunc("GET /health/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if svc == nil || svc.Pool == nil {
|
||||
http.Error(w, "db not configured\n", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := svc.Pool.PingContext(ctx); err != nil {
|
||||
http.Error(w, "db unreachable\n", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ready\n"))
|
||||
})
|
||||
|
||||
// API endpoints (JSON, public)
|
||||
mux.HandleFunc("POST /api/login", handleAPILogin)
|
||||
mux.HandleFunc("POST /api/register", handleAPIRegister)
|
||||
|
||||
@@ -51,13 +51,20 @@ const SpecVersion = 1
|
||||
// can't bury an in-flight approval, per the design doc §3 carve-out).
|
||||
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
|
||||
// it false and the spec is a no-op.
|
||||
//
|
||||
// Predicates is a flat per-source narrowing record: keys at the top
|
||||
// level are data sources ("deadline", "appointment", …) and values are
|
||||
// the per-source predicate structs directly. The shape on the wire and
|
||||
// the shape the frontend emits agree exactly — see t-paliad-283 for the
|
||||
// latent contract bug (Go used to wrap each entry in another Predicates
|
||||
// struct, so the frontend's overlay clicks parsed back as no-op).
|
||||
type FilterSpec struct {
|
||||
Version int `json:"version"`
|
||||
Sources []DataSource `json:"sources"`
|
||||
Scope ScopeSpec `json:"scope"`
|
||||
Time TimeSpec `json:"time"`
|
||||
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
|
||||
UnreadOnly bool `json:"unread_only,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Sources []DataSource `json:"sources"`
|
||||
Scope ScopeSpec `json:"scope"`
|
||||
Time TimeSpec `json:"time"`
|
||||
Predicates *Predicates `json:"predicates,omitempty"`
|
||||
UnreadOnly bool `json:"unread_only,omitempty"`
|
||||
}
|
||||
|
||||
// ScopeSpec narrows which projects contribute rows. Resolved at query
|
||||
@@ -147,7 +154,8 @@ const (
|
||||
)
|
||||
|
||||
// Predicates is the per-source narrowing payload. Empty fields mean
|
||||
// "no narrowing" — never "exclude all".
|
||||
// "no narrowing" — never "exclude all". One field per data source;
|
||||
// the wire shape is the same: `{"deadline": {...}, "appointment": {...}}`.
|
||||
type Predicates struct {
|
||||
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
|
||||
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
|
||||
@@ -305,14 +313,25 @@ func (s *FilterSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
for src, preds := range s.Predicates {
|
||||
if !isKnownSource(src) {
|
||||
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
|
||||
if s.Predicates != nil {
|
||||
// Reject predicates set on a source the spec doesn't list — we'd
|
||||
// silently drop the narrowing otherwise. Walk the set fields.
|
||||
type srcCheck struct {
|
||||
src DataSource
|
||||
present bool
|
||||
}
|
||||
if !seen[src] {
|
||||
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
|
||||
checks := []srcCheck{
|
||||
{SourceDeadline, s.Predicates.Deadline != nil},
|
||||
{SourceAppointment, s.Predicates.Appointment != nil},
|
||||
{SourceProjectEvent, s.Predicates.ProjectEvent != nil},
|
||||
{SourceApprovalRequest, s.Predicates.ApprovalRequest != nil},
|
||||
}
|
||||
if err := preds.validate(); err != nil {
|
||||
for _, c := range checks {
|
||||
if c.present && !seen[c.src] {
|
||||
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, c.src)
|
||||
}
|
||||
}
|
||||
if err := s.Predicates.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
125
internal/services/filter_spec_predicates_test.go
Normal file
125
internal/services/filter_spec_predicates_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// t-paliad-283 regression: the bar's chip clicks POST a `predicates`
|
||||
// payload shaped as `{<source>: <per-source>}`. The Go side previously
|
||||
// declared `Predicates map[DataSource]Predicates` — a doubled-nested
|
||||
// shape — which silently unmarshalled the bar's payload as no-op
|
||||
// narrowing. This test pins the wire shape so the contract can't drift
|
||||
// again.
|
||||
//
|
||||
// Run with `go test ./internal/services/`.
|
||||
|
||||
func TestFilterSpec_FlatPredicatesWireShape(t *testing.T) {
|
||||
// The shape every chip click in the FilterBar emits: predicates is
|
||||
// keyed by data source, value is the per-source predicate struct
|
||||
// directly. Doubled-nesting would unmarshal as empty Predicates.
|
||||
const wire = `{
|
||||
"version": 1,
|
||||
"sources": ["deadline", "appointment", "project_event", "approval_request"],
|
||||
"scope": {"projects": {"mode": "all_visible"}},
|
||||
"time": {"field": "auto", "horizon": "past_30d"},
|
||||
"predicates": {
|
||||
"deadline": {"status": ["pending"]},
|
||||
"appointment": {"appointment_types": ["hearing"]},
|
||||
"project_event": {"event_types": ["deadline_created"]},
|
||||
"approval_request": {"viewer_role": "any_visible", "status": ["pending"]}
|
||||
}
|
||||
}`
|
||||
|
||||
var spec FilterSpec
|
||||
if err := json.Unmarshal([]byte(wire), &spec); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if err := spec.Validate(); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if spec.Predicates == nil {
|
||||
t.Fatal("predicates must be non-nil after unmarshalling the bar's shape")
|
||||
}
|
||||
if spec.Predicates.Deadline == nil || len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "pending" {
|
||||
t.Errorf("deadline.status must round-trip, got %+v", spec.Predicates.Deadline)
|
||||
}
|
||||
if spec.Predicates.Appointment == nil || len(spec.Predicates.Appointment.AppointmentTypes) != 1 {
|
||||
t.Errorf("appointment.appointment_types must round-trip, got %+v", spec.Predicates.Appointment)
|
||||
}
|
||||
if spec.Predicates.ProjectEvent == nil || len(spec.Predicates.ProjectEvent.EventTypes) != 1 {
|
||||
t.Errorf("project_event.event_types must round-trip, got %+v", spec.Predicates.ProjectEvent)
|
||||
}
|
||||
if spec.Predicates.ApprovalRequest == nil || spec.Predicates.ApprovalRequest.ViewerRole != "any_visible" {
|
||||
t.Errorf("approval_request.viewer_role must round-trip, got %+v", spec.Predicates.ApprovalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped FilterSpec must marshal back to exactly the flat shape
|
||||
// the frontend declares in views/types.ts. Otherwise /api/views/system
|
||||
// (which serializes the InboxSystemView's Filter for the bar) returns a
|
||||
// shape the frontend can't consume without translation gymnastics.
|
||||
func TestFilterSpec_MarshalFlatPredicatesShape(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Parse back generically so the assertion is on the wire shape, not
|
||||
// on the Go type system that produced it.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
t.Fatalf("re-unmarshal: %v", err)
|
||||
}
|
||||
var preds map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw["predicates"], &preds); err != nil {
|
||||
t.Fatalf("predicates re-unmarshal: %v", err)
|
||||
}
|
||||
dl, ok := preds["deadline"]
|
||||
if !ok {
|
||||
t.Fatal("predicates.deadline missing — wire shape regressed")
|
||||
}
|
||||
var dlBody map[string]json.RawMessage
|
||||
if err := json.Unmarshal(dl, &dlBody); err != nil {
|
||||
t.Fatalf("deadline body unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := dlBody["status"]; !ok {
|
||||
t.Errorf("predicates.deadline.status must be a top-level field; doubled-nesting reappeared. Body: %s", string(dl))
|
||||
}
|
||||
if _, ok := dlBody["deadline"]; ok {
|
||||
t.Errorf("predicates.deadline must NOT wrap a nested deadline key — that's the t-paliad-283 bug. Body: %s", string(dl))
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end pin: the bar's payload after the user clicks
|
||||
// "Frist-Status: Erledigt" (completed) must produce a spec whose
|
||||
// runDeadlines branch narrows to completed deadlines. Without the
|
||||
// t-paliad-283 fix, the unmarshal silently produced an empty Predicates
|
||||
// and the SQL ran without the `status='completed'` clause.
|
||||
func TestFilterSpec_BarChipPayloadNarrowsDeadlineStatus(t *testing.T) {
|
||||
const barPayload = `{
|
||||
"version": 1,
|
||||
"sources": ["deadline"],
|
||||
"scope": {"projects": {"mode": "all_visible"}},
|
||||
"time": {"field": "auto", "horizon": "past_30d"},
|
||||
"predicates": {"deadline": {"status": ["completed"]}}
|
||||
}`
|
||||
var spec FilterSpec
|
||||
if err := json.Unmarshal([]byte(barPayload), &spec); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if spec.Predicates == nil || spec.Predicates.Deadline == nil {
|
||||
t.Fatal("deadline predicate must survive the round-trip")
|
||||
}
|
||||
if len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "completed" {
|
||||
t.Errorf("deadline.status must be [\"completed\"], got %+v", spec.Predicates.Deadline.Status)
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,8 @@ func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
|
||||
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("predicates on unselected source must reject, got %v", err)
|
||||
@@ -190,8 +190,8 @@ func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
|
||||
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"weird"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown deadline.status must reject, got %v", err)
|
||||
@@ -201,8 +201,8 @@ func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
|
||||
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = append(s.Sources, SourceAppointment)
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown appointment_type must reject, got %v", err)
|
||||
@@ -212,8 +212,8 @@ func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
|
||||
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceProjectEvent}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
|
||||
s.Predicates = &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown project_event kind must reject, got %v", err)
|
||||
@@ -223,8 +223,8 @@ func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
|
||||
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
|
||||
s.Predicates = &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown viewer_role must reject, got %v", err)
|
||||
@@ -234,8 +234,8 @@ func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
|
||||
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
|
||||
s.Predicates = &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown approval_request.status must reject, got %v", err)
|
||||
@@ -251,15 +251,15 @@ func TestFilterSpec_RoundTripJSON(t *testing.T) {
|
||||
PersonalOnly: false,
|
||||
},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{
|
||||
Status: []string{"pending"},
|
||||
ApprovalStatus: []string{"approved", "pending"},
|
||||
}},
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
},
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "approver_eligible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, err := MarshalFilterSpec(original)
|
||||
|
||||
@@ -66,8 +66,8 @@ func AgendaSystemView() SystemView {
|
||||
Sources: []DataSource{SourceDeadline, SourceAppointment},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
@@ -126,14 +126,14 @@ func InboxSystemView() SystemView {
|
||||
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
Predicates: &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "any_visible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
},
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: InboxProjectEventKinds,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
@@ -159,10 +159,10 @@ func InboxRequesterSystemView() SystemView {
|
||||
Sources: []DataSource{SourceApprovalRequest},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
Predicates: &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "self_requested",
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
|
||||
@@ -82,11 +82,10 @@ func TestInboxSystemView_RowActionInbox(t *testing.T) {
|
||||
|
||||
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
|
||||
sv := InboxSystemView()
|
||||
preds := sv.Filter.Predicates[SourceProjectEvent]
|
||||
if preds.ProjectEvent == nil {
|
||||
if sv.Filter.Predicates == nil || sv.Filter.Predicates.ProjectEvent == nil {
|
||||
t.Fatal("InboxSystemView must narrow project_event predicates")
|
||||
}
|
||||
got := preds.ProjectEvent.EventTypes
|
||||
got := sv.Filter.Predicates.ProjectEvent.EventTypes
|
||||
if len(got) != len(InboxProjectEventKinds) {
|
||||
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
|
||||
}
|
||||
|
||||
@@ -234,8 +234,8 @@ func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec
|
||||
uid := userID
|
||||
df.CreatedBy = &uid
|
||||
}
|
||||
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
|
||||
dp := preds.Deadline
|
||||
if spec.Predicates != nil && spec.Predicates.Deadline != nil {
|
||||
dp := spec.Predicates.Deadline
|
||||
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
|
||||
// If the spec asks for both pending+completed → no narrowing; if
|
||||
// only pending → DeadlineFilterPending; only completed → Completed.
|
||||
@@ -317,8 +317,8 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
|
||||
}
|
||||
af.From = bounds.from
|
||||
af.To = bounds.to
|
||||
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
|
||||
ap := preds.Appointment
|
||||
if spec.Predicates != nil && spec.Predicates.Appointment != nil {
|
||||
ap := spec.Predicates.Appointment
|
||||
// AppointmentListFilter takes a single Type today; narrow to first
|
||||
// listed value, fall back to all if multiple.
|
||||
if len(ap.AppointmentTypes) == 1 {
|
||||
@@ -482,21 +482,24 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
|
||||
// ApprovalService inbox queries. ViewerRole picks which underlying
|
||||
// query runs.
|
||||
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||
preds := spec.Predicates[SourceApprovalRequest]
|
||||
var ap *ApprovalRequestPredicates
|
||||
if spec.Predicates != nil {
|
||||
ap = spec.Predicates.ApprovalRequest
|
||||
}
|
||||
role := "approver_eligible"
|
||||
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
|
||||
role = preds.ApprovalRequest.ViewerRole
|
||||
if ap != nil && ap.ViewerRole != "" {
|
||||
role = ap.ViewerRole
|
||||
}
|
||||
|
||||
filter := InboxFilter{}
|
||||
if preds.ApprovalRequest != nil {
|
||||
if ap != nil {
|
||||
// InboxFilter takes a single status today. If the spec says
|
||||
// only one, narrow; if multiple, leave open.
|
||||
if len(preds.ApprovalRequest.Status) == 1 {
|
||||
filter.Status = preds.ApprovalRequest.Status[0]
|
||||
if len(ap.Status) == 1 {
|
||||
filter.Status = ap.Status[0]
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) == 1 {
|
||||
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
|
||||
if len(ap.EntityTypes) == 1 {
|
||||
filter.EntityType = ap.EntityTypes[0]
|
||||
}
|
||||
}
|
||||
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
|
||||
@@ -665,19 +668,18 @@ func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
|
||||
// approvalStatusMatches checks the entity-side approval_status filter.
|
||||
// Returns true when the row passes (no filter set → always true).
|
||||
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
|
||||
preds, ok := spec.Predicates[src]
|
||||
if !ok {
|
||||
if spec.Predicates == nil {
|
||||
return true
|
||||
}
|
||||
var allowed []string
|
||||
switch src {
|
||||
case SourceDeadline:
|
||||
if preds.Deadline != nil {
|
||||
allowed = preds.Deadline.ApprovalStatus
|
||||
if spec.Predicates.Deadline != nil {
|
||||
allowed = spec.Predicates.Deadline.ApprovalStatus
|
||||
}
|
||||
case SourceAppointment:
|
||||
if preds.Appointment != nil {
|
||||
allowed = preds.Appointment.ApprovalStatus
|
||||
if spec.Predicates.Appointment != nil {
|
||||
allowed = spec.Predicates.Appointment.ApprovalStatus
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
@@ -689,15 +691,15 @@ func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bo
|
||||
// allowedAppointmentTypes returns nil when the filter is open, otherwise
|
||||
// a set of legal appointment_type values.
|
||||
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceAppointment]
|
||||
if !ok || preds.Appointment == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.Appointment == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.Appointment.AppointmentTypes) <= 1 {
|
||||
ap := spec.Predicates.Appointment
|
||||
if len(ap.AppointmentTypes) <= 1 {
|
||||
return nil // single-value already pushed down via AppointmentListFilter.Type
|
||||
}
|
||||
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
|
||||
for _, t := range preds.Appointment.AppointmentTypes {
|
||||
out := make(map[string]bool, len(ap.AppointmentTypes))
|
||||
for _, t := range ap.AppointmentTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
@@ -712,13 +714,16 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
||||
// don't want both rows showing up side-by-side. The drop applies to
|
||||
// both the explicit caller list and the implicit "all kinds" path.
|
||||
func allowedProjectEventKinds(spec FilterSpec) []string {
|
||||
preds, ok := spec.Predicates[SourceProjectEvent]
|
||||
var pe *ProjectEventPredicates
|
||||
if spec.Predicates != nil {
|
||||
pe = spec.Predicates.ProjectEvent
|
||||
}
|
||||
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
|
||||
|
||||
var requested []string
|
||||
switch {
|
||||
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
|
||||
requested = preds.ProjectEvent.EventTypes
|
||||
case pe != nil && len(pe.EventTypes) > 0:
|
||||
requested = pe.EventTypes
|
||||
case dedupApprovals:
|
||||
// No explicit narrowing, but ApprovalRequest is in sources —
|
||||
// rebuild the implicit "all" list so we can subtract approvals.
|
||||
@@ -750,30 +755,30 @@ func isApprovalAuditKind(kind string) bool {
|
||||
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
|
||||
// already pushed into InboxFilter.Status").
|
||||
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.Status) <= 1 {
|
||||
ap := spec.Predicates.ApprovalRequest
|
||||
if len(ap.Status) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
|
||||
for _, s := range preds.ApprovalRequest.Status {
|
||||
out := make(map[string]bool, len(ap.Status))
|
||||
for _, s := range ap.Status {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
|
||||
ap := spec.Predicates.ApprovalRequest
|
||||
if len(ap.EntityTypes) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
|
||||
for _, t := range preds.ApprovalRequest.EntityTypes {
|
||||
out := make(map[string]bool, len(ap.EntityTypes))
|
||||
for _, t := range ap.EntityTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -13,8 +13,8 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
Predicates: &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: []string{
|
||||
"deadline_created",
|
||||
"deadline_approval_requested",
|
||||
@@ -22,7 +22,7 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
||||
"approval_decided",
|
||||
"note_created",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := allowedProjectEventKinds(spec)
|
||||
@@ -47,13 +47,13 @@ func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceProjectEvent},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
Predicates: &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: []string{
|
||||
"deadline_created",
|
||||
"deadline_approval_requested",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := allowedProjectEventKinds(spec)
|
||||
|
||||
Reference in New Issue
Block a user