Skip to content

Commit c4b9267

Browse files
committed
fix: fix #17 fix #18 fix #20, ref #16
1 parent 69aa095 commit c4b9267

File tree

11 files changed

+227
-47
lines changed

11 files changed

+227
-47
lines changed

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# At least one webhook URL is required
2+
CENTRAL_WEBHOOK_NEW_SUBMISSION_URL=https://your.domain/webhook/new-submission
3+
CENTRAL_WEBHOOK_REVIEW_SUBMISSION_URL=https://your.domain/webhook/review-submission
4+
CENTRAL_WEBHOOK_UPDATE_ENTITY_URL=https://your.domain/webhook/update-entity
5+
6+
# Optional authentication header
7+
CENTRAL_WEBHOOK_API_KEY=
8+
9+
# Optional log level: INFO or DEBUG
10+
CENTRAL_WEBHOOK_LOG_LEVEL=INFO
11+
12+
# Optional image tags used by compose.webhook.yml
13+
CENTRAL_WEBHOOK_TAG=latest
14+
POSTGRES_MAJOR=14

.github/workflows/test.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,3 @@ jobs:
1818
tag_override: ci
1919
compose_file: compose.yml
2020
compose_service: webhook
21-
cache_extra_imgs: |
22-
"docker.io/postgis/postgis:17-3.5-alpine"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ go.work.sum
2222

2323
# env file
2424
.env
25+
.env.webhook

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /app/centralwebhook
1212

1313
# Run the tests in the container
1414
FROM build AS run-test-stage
15-
RUN go test -v ./...
15+
RUN go test -p 1 -v ./...
1616

1717

1818
# Add a non-root user to passwd file

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,60 @@ The `centralwebhook` tool is a simple CLI that installs or uninstalls database t
3737

3838
> [!NOTE]
3939
> **Using our helper images**: We provide PostgreSQL images with the `pgsql-http` extension pre-installed:
40-
> - `ghcr.io/hotosm/postgres:18-http` (based on vanilla PostgreSQL 18 images)
40+
> - `ghcr.io/hotosm/postgres:14-http` (recommended default for current ODK Central installs)
41+
> - `ghcr.io/hotosm/postgres:15-http`
42+
> - `ghcr.io/hotosm/postgres:16-http`
43+
> - `ghcr.io/hotosm/postgres:17-http`
44+
> - `ghcr.io/hotosm/postgres:18-http`
4145
>
4246
> These images are drop-in replacements for standard PostgreSQL images and simply add the extension.
4347
>
4448
> **Installing manually**: If you don't wish to use these images, you must install the `pgsql-http` extension yourself. The extension may require superuser privileges to install. If you cannot install it yourself, ask your database administrator.
4549
50+
## ODK Central Quick Start (Recommended)
51+
52+
For most users running a mostly-vanilla ODK Central setup, the easiest approach is to layer this repo's compose override on top of the official Central compose file.
53+
54+
1. In your ODK Central repo/environment, copy the example file and set webhook variables:
55+
56+
```bash
57+
cp /path/to/central-webhook/.env.example .env.webhook
58+
```
59+
60+
Then merge the values you need into your Central `.env` (or export them in your shell):
61+
62+
```dotenv
63+
# Required: provide at least one webhook URL
64+
CENTRAL_WEBHOOK_NEW_SUBMISSION_URL=https://your.domain/webhook/new-submission
65+
CENTRAL_WEBHOOK_REVIEW_SUBMISSION_URL=https://your.domain/webhook/review-submission
66+
CENTRAL_WEBHOOK_UPDATE_ENTITY_URL=https://your.domain/webhook/update-entity
67+
68+
# Optional
69+
CENTRAL_WEBHOOK_API_KEY=your-secret-api-key
70+
CENTRAL_WEBHOOK_LOG_LEVEL=INFO
71+
72+
# Optional image tags (defaults shown)
73+
CENTRAL_WEBHOOK_TAG=latest
74+
POSTGRES_MAJOR=14
75+
```
76+
77+
2. Start Central + webhook trigger installer:
78+
79+
```bash
80+
docker compose -f docker-compose.yml -f /path/to/central-webhook/compose.webhook.yml up -d
81+
```
82+
83+
3. Verify installation:
84+
85+
```bash
86+
docker compose logs webhook
87+
```
88+
89+
You should see a successful `install` message after Central and PostgreSQL are healthy.
90+
91+
> [!IMPORTANT]
92+
> Use the helper image major version that matches your Central database major version. Do not point a newer PostgreSQL container at an existing older data directory without a proper PostgreSQL upgrade process.
93+
4694
## Usage
4795

4896
The `centralwebhook` tool is a CLI that installs or uninstalls database triggers. After installation, the triggers run automatically whenever audit events occur in the database.
@@ -233,6 +281,10 @@ The tool installs PostgreSQL triggers on the `audits` table that:
233281

234282
The triggers run automatically after installation - no long-running service is needed.
235283

284+
For `submission.update` events, if `instanceId` is missing from audit details in your
285+
Central version, the trigger resolves it from `submission_defs` using
286+
`submissionDefId`/`submissionId`.
287+
236288
## Development
237289

238290
- This package uses the standard library and a Postgres driver.

compose.webhook.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ services:
1414

1515
# Override the image used to contain the `pgsql-http` extension and a healthcheck
1616
postgres14:
17-
image: "ghcr.io/hotosm/postgres:14-http"
17+
image: "ghcr.io/hotosm/postgres:${POSTGRES_MAJOR:-14}-http"
1818

1919
healthcheck:
2020
test: pg_isready -U ${DB_USER:-odk} || exit 1
@@ -25,9 +25,9 @@ services:
2525

2626
# The main webhook service
2727
webhook:
28-
image: "ghcr.io/hotosm/central-webhook:1.0.2"
28+
image: "ghcr.io/hotosm/central-webhook:${CENTRAL_WEBHOOK_TAG:-latest}"
2929
environment:
30-
CENTRAL_WEBHOOK_DB_URI: postgresql://odk:odk@postgres14:5432/odk?sslmode=disable
30+
CENTRAL_WEBHOOK_DB_URI: postgresql://${DB_USER:-odk}:${DB_PASSWORD:-odk}@postgres14:5432/${DB_NAME:-odk}?sslmode=disable
3131
CENTRAL_WEBHOOK_UPDATE_ENTITY_URL: ${CENTRAL_WEBHOOK_UPDATE_ENTITY_URL}
3232
CENTRAL_WEBHOOK_REVIEW_SUBMISSION_URL: ${CENTRAL_WEBHOOK_REVIEW_SUBMISSION_URL}
3333
CENTRAL_WEBHOOK_NEW_SUBMISSION_URL: ${CENTRAL_WEBHOOK_NEW_SUBMISSION_URL}

compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ services:
3131
extra_hosts:
3232
- host.docker.internal:host-gateway
3333
restart: "no"
34-
entrypoint: go test -timeout=2m -v ./...
34+
# Use -p 1 to avoid race condition when testing
35+
entrypoint: go test -p 1 -timeout=2m -v ./...
3536

3637
db:
3738
image: "ghcr.io/hotosm/postgres:18-http"

db/trigger.go

Lines changed: 86 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,6 @@ func CreateTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string,
4444
url := quoteSQLString(*opts.UpdateEntityURL)
4545
caseStatements += fmt.Sprintf(`
4646
WHEN 'entity.update.version' THEN
47-
DECLARE
48-
entity_data jsonb;
49-
entity_payload jsonb;
50-
BEGIN
5147
-- Deduplicate: only fire for the first audit row for this entity UUID
5248
IF EXISTS (
5349
SELECT 1
@@ -78,17 +74,16 @@ func CreateTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string,
7874
'data', entity_data
7975
);
8076
81-
PERFORM http((
77+
PERFORM (http((
8278
'POST',
8379
%s,
8480
http_headers(%s),
8581
'application/json',
8682
entity_payload::text
87-
)::http_request);
83+
)::http_request)).status;
8884
8985
-- Mark as processed
9086
NEW.details := NEW.details || '{"webhook_sent": true}'::jsonb;
91-
END;
9287
`, tableName, url, headersSQL)
9388
}
9489

@@ -99,16 +94,33 @@ func CreateTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string,
9994
url := quoteSQLString(*opts.NewSubmissionURL)
10095
caseStatements += fmt.Sprintf(`
10196
WHEN 'submission.create' THEN
102-
DECLARE
103-
submission_data jsonb;
104-
submission_payload jsonb;
105-
BEGIN
97+
resolved_instance_id := NEW.details->>'instanceId';
98+
IF (resolved_instance_id IS NULL OR resolved_instance_id = '')
99+
AND (NEW.details->>'submissionDefId') IS NOT NULL THEN
100+
SELECT submission_defs."instanceId"::text
101+
INTO resolved_instance_id
102+
FROM submission_defs
103+
WHERE submission_defs.id = (NEW.details->>'submissionDefId')::int;
104+
END IF;
105+
106+
IF resolved_instance_id IS NULL OR resolved_instance_id = '' THEN
107+
RAISE WARNING 'Instance ID missing for submission.create event';
108+
NEW.details := NEW.details || '{"webhook_sent": true}'::jsonb;
109+
RETURN NEW;
110+
END IF;
111+
106112
-- Deduplicate by instanceId
107113
IF EXISTS (
108114
SELECT 1
109115
FROM %s a
110116
WHERE a.action = 'submission.create'
111-
AND a.details->>'instanceId' = NEW.details->>'instanceId'
117+
AND (
118+
a.details->>'instanceId' = resolved_instance_id
119+
OR (
120+
(NEW.details->>'submissionDefId') IS NOT NULL
121+
AND a.details->>'submissionDefId' = NEW.details->>'submissionDefId'
122+
)
123+
)
112124
AND a.details ? 'webhook_sent'
113125
) THEN
114126
-- Already processed
@@ -129,21 +141,20 @@ func CreateTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string,
129141
130142
submission_payload := jsonb_build_object(
131143
'type', 'submission.create',
132-
'id', (NEW.details->>'instanceId'),
144+
'id', resolved_instance_id,
133145
'data', submission_data
134146
);
135147
136-
PERFORM http((
148+
PERFORM (http((
137149
'POST',
138150
%s,
139151
http_headers(%s),
140152
'application/json',
141153
submission_payload::text
142-
)::http_request);
154+
)::http_request)).status;
143155
144156
-- Mark as processed
145157
NEW.details := NEW.details || '{"webhook_sent": true}'::jsonb;
146-
END;
147158
`, tableName, url, headersSQL)
148159
}
149160

@@ -154,16 +165,50 @@ func CreateTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string,
154165
url := quoteSQLString(*opts.ReviewSubmissionURL)
155166
caseStatements += fmt.Sprintf(`
156167
WHEN 'submission.update' THEN
157-
DECLARE
158-
review_payload jsonb;
159-
BEGIN
168+
resolved_instance_id := NEW.details->>'instanceId';
169+
resolved_review_state := COALESCE(NEW.details->>'reviewState', '');
170+
171+
-- Fallback for Central audit rows that omit instanceId
172+
IF (resolved_instance_id IS NULL OR resolved_instance_id = '')
173+
AND (NEW.details->>'submissionDefId') IS NOT NULL THEN
174+
SELECT submission_defs."instanceId"::text
175+
INTO resolved_instance_id
176+
FROM submission_defs
177+
WHERE submission_defs.id = (NEW.details->>'submissionDefId')::int;
178+
END IF;
179+
IF (resolved_instance_id IS NULL OR resolved_instance_id = '')
180+
AND (NEW.details->>'submissionId') IS NOT NULL THEN
181+
SELECT submission_defs."instanceId"::text
182+
INTO resolved_instance_id
183+
FROM submission_defs
184+
WHERE submission_defs."submissionId" = (NEW.details->>'submissionId')::int
185+
ORDER BY submission_defs.id DESC
186+
LIMIT 1;
187+
END IF;
188+
189+
IF resolved_instance_id IS NULL OR resolved_instance_id = '' THEN
190+
RAISE WARNING 'Instance ID missing for submission.update event';
191+
NEW.details := NEW.details || '{"webhook_sent": true}'::jsonb;
192+
RETURN NEW;
193+
END IF;
194+
160195
-- Deduplicate by instanceId + reviewState
161196
IF EXISTS (
162197
SELECT 1
163198
FROM %s a
164199
WHERE a.action = 'submission.update'
165-
AND a.details->>'instanceId' = NEW.details->>'instanceId'
166-
AND a.details->>'reviewState' = NEW.details->>'reviewState'
200+
AND (
201+
a.details->>'instanceId' = resolved_instance_id
202+
OR (
203+
(NEW.details->>'submissionDefId') IS NOT NULL
204+
AND a.details->>'submissionDefId' = NEW.details->>'submissionDefId'
205+
)
206+
OR (
207+
(NEW.details->>'submissionId') IS NOT NULL
208+
AND a.details->>'submissionId' = NEW.details->>'submissionId'
209+
)
210+
)
211+
AND COALESCE(a.details->>'reviewState', '') = resolved_review_state
167212
AND a.details ? 'webhook_sent'
168213
) THEN
169214
-- Already processed
@@ -173,23 +218,22 @@ func CreateTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string,
173218
174219
review_payload := jsonb_build_object(
175220
'type', 'submission.update',
176-
'id', (NEW.details->>'instanceId'),
221+
'id', resolved_instance_id,
177222
'data', jsonb_build_object(
178223
'reviewState', NEW.details->>'reviewState'
179224
)
180225
);
181226
182-
PERFORM http((
227+
PERFORM (http((
183228
'POST',
184229
%s,
185230
http_headers(%s),
186231
'application/json',
187232
review_payload::text
188-
)::http_request);
233+
)::http_request)).status;
189234
190235
-- Mark as processed
191236
NEW.details := NEW.details || '{"webhook_sent": true}'::jsonb;
192-
END;
193237
`, tableName, url, headersSQL)
194238
}
195239

@@ -204,6 +248,13 @@ func CreateTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string,
204248
$$
205249
DECLARE
206250
action_type text;
251+
entity_data jsonb;
252+
entity_payload jsonb;
253+
submission_data jsonb;
254+
submission_payload jsonb;
255+
review_payload jsonb;
256+
resolved_instance_id text;
257+
resolved_review_state text;
207258
BEGIN
208259
action_type := NEW.action;
209260
@@ -358,6 +409,16 @@ func RemoveTrigger(ctx context.Context, dbPool *pgxpool.Pool, tableName string)
358409
}
359410
}
360411

412+
// Verify function removal. This catches rare cases where a DROP is accepted
413+
// due to a transient error but the function still exists.
414+
functionExists, err := checkFunctionExists(ctx, conn)
415+
if err != nil {
416+
return fmt.Errorf("failed to verify function removal: %w", err)
417+
}
418+
if functionExists {
419+
return fmt.Errorf("failed to remove function new_audit_log")
420+
}
421+
361422
return nil
362423
}
363424

0 commit comments

Comments
 (0)