@strav/herald 1.0.0-alpha.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +48 -0
  2. package/package.json +43 -0
  3. package/src/drivers/gbp/gbp_client.ts +155 -0
  4. package/src/drivers/gbp/gbp_config.ts +48 -0
  5. package/src/drivers/gbp/gbp_driver.ts +246 -0
  6. package/src/drivers/gbp/gbp_provider.ts +35 -0
  7. package/src/drivers/gbp/index.ts +23 -0
  8. package/src/drivers/meta/index.ts +21 -0
  9. package/src/drivers/meta/meta_client.ts +103 -0
  10. package/src/drivers/meta/meta_config.ts +45 -0
  11. package/src/drivers/meta/meta_driver.ts +313 -0
  12. package/src/drivers/meta/meta_provider.ts +45 -0
  13. package/src/drivers/meta/meta_webhook_ops.ts +227 -0
  14. package/src/drivers/wordpress/index.ts +24 -0
  15. package/src/drivers/wordpress/wordpress_client.ts +185 -0
  16. package/src/drivers/wordpress/wordpress_config.ts +51 -0
  17. package/src/drivers/wordpress/wordpress_driver.ts +277 -0
  18. package/src/drivers/wordpress/wordpress_provider.ts +37 -0
  19. package/src/drivers/wordpress/wordpress_validate.ts +122 -0
  20. package/src/dto/post_status.ts +14 -0
  21. package/src/dto/publish_input.ts +38 -0
  22. package/src/dto/publish_result.ts +23 -0
  23. package/src/dto/publish_target.ts +15 -0
  24. package/src/errors.ts +122 -0
  25. package/src/herald_capabilities.ts +37 -0
  26. package/src/herald_config.ts +32 -0
  27. package/src/herald_driver.ts +86 -0
  28. package/src/herald_manager.ts +169 -0
  29. package/src/herald_provider.ts +44 -0
  30. package/src/index.ts +65 -0
  31. package/src/ledger/apply_publication_migration.ts +53 -0
  32. package/src/ledger/index.ts +11 -0
  33. package/src/ledger/publication.ts +30 -0
  34. package/src/ledger/publication_repository.ts +172 -0
  35. package/src/ledger/publication_schema.ts +70 -0
  36. package/src/onboarding/complete_channel_connect.ts +154 -0
  37. package/src/onboarding/connect_channel_callback.ts +138 -0
  38. package/src/onboarding/index.ts +26 -0
  39. package/src/onboarding/types.ts +67 -0
  40. package/src/tenanted/apply_tenanted_publication_migration.ts +46 -0
  41. package/src/tenanted/index.ts +18 -0
  42. package/src/tenanted/tenanted_publication.ts +28 -0
  43. package/src/tenanted/tenanted_publication_repository.ts +145 -0
  44. package/src/tenanted/tenanted_publication_schema.ts +35 -0
  45. package/src/webhook/apply_herald_webhook_event_migration.ts +42 -0
  46. package/src/webhook/herald_event.ts +139 -0
  47. package/src/webhook/herald_webhook.ts +149 -0
  48. package/src/webhook/herald_webhook_event.ts +25 -0
  49. package/src/webhook/herald_webhook_event_repository.ts +65 -0
  50. package/src/webhook/herald_webhook_event_schema.ts +37 -0
  51. package/src/webhook/herald_webhook_registry.ts +65 -0
  52. package/src/webhook/index.ts +24 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `TenantedPublicationRepository` — same surface as
3
+ * `PublicationRepository`, scoped to the tenanted schema. Callers
4
+ * MUST be inside a `TenantManager.withTenant(...)` scope; INSERTs
5
+ * rely on the session's `app.tenant_id` setting (RLS).
6
+ *
7
+ * Mirrors the non-tenanted Repository line-for-line — minor
8
+ * duplication is worth keeping both variants narrowly scoped + avoid
9
+ * runtime branching on a tenancy flag.
10
+ */
11
+
12
+ import { quoteIdent, Repository } from '@strav/database'
13
+ import { ulid } from '@strav/kernel'
14
+ import type { PublishResult } from '../dto/publish_result.ts'
15
+ import type { PublishTarget } from '../dto/publish_target.ts'
16
+ import { TenantedPublication } from './tenanted_publication.ts'
17
+ import { tenantedPublicationSchema } from './tenanted_publication_schema.ts'
18
+
19
+ export interface RecordInput {
20
+ sourceId: string
21
+ instanceName: string
22
+ target: PublishTarget
23
+ result: PublishResult
24
+ metadata?: Record<string, unknown>
25
+ }
26
+
27
+ export interface MarkPublishedInput {
28
+ providerPostId: string
29
+ url?: string
30
+ publishedAt?: Date
31
+ }
32
+
33
+ export class TenantedPublicationRepository extends Repository<TenantedPublication> {
34
+ static override readonly schema = tenantedPublicationSchema
35
+ static override readonly model = TenantedPublication
36
+
37
+ async record(input: RecordInput): Promise<TenantedPublication> {
38
+ const now = new Date()
39
+
40
+ if (input.result.providerPostId) {
41
+ const existing = await this.findByProviderPostId(
42
+ input.target.provider,
43
+ input.instanceName,
44
+ input.result.providerPostId,
45
+ )
46
+ if (existing) {
47
+ return this.update(existing, {
48
+ source_id: input.sourceId,
49
+ account_id: input.target.accountId,
50
+ url: input.result.url ?? null,
51
+ status: input.result.status,
52
+ published_at: input.result.status === 'published' ? now : existing.published_at,
53
+ error_message: null,
54
+ metadata: { ...existing.metadata, ...(input.metadata ?? {}) },
55
+ updated_at: now,
56
+ } as Partial<TenantedPublication>)
57
+ }
58
+ }
59
+
60
+ return this.create({
61
+ id: ulid(),
62
+ source_id: input.sourceId,
63
+ provider: input.target.provider,
64
+ instance_name: input.instanceName,
65
+ account_id: input.target.accountId,
66
+ provider_post_id: input.result.providerPostId || null,
67
+ url: input.result.url ?? null,
68
+ status: input.result.status,
69
+ published_at: input.result.status === 'published' ? now : null,
70
+ error_message: null,
71
+ metadata: input.metadata ?? {},
72
+ created_at: now,
73
+ updated_at: now,
74
+ } as Partial<TenantedPublication>)
75
+ }
76
+
77
+ async markPublished(id: string, input: MarkPublishedInput): Promise<void> {
78
+ const table = quoteIdent(tenantedPublicationSchema.name)
79
+ await this.db.execute(
80
+ `UPDATE ${table}
81
+ SET "provider_post_id" = $2,
82
+ "url" = $3,
83
+ "status" = 'published',
84
+ "published_at" = $4,
85
+ "error_message" = NULL,
86
+ "updated_at" = $5
87
+ WHERE "id" = $1`,
88
+ [
89
+ id,
90
+ input.providerPostId,
91
+ input.url ?? null,
92
+ input.publishedAt ?? new Date(),
93
+ new Date(),
94
+ ],
95
+ )
96
+ }
97
+
98
+ async markFailed(id: string, errorMessage: string): Promise<void> {
99
+ const table = quoteIdent(tenantedPublicationSchema.name)
100
+ await this.db.execute(
101
+ `UPDATE ${table}
102
+ SET "status" = 'failed',
103
+ "error_message" = $2,
104
+ "updated_at" = $3
105
+ WHERE "id" = $1`,
106
+ [id, errorMessage, new Date()],
107
+ )
108
+ }
109
+
110
+ async markDeleted(id: string): Promise<void> {
111
+ const table = quoteIdent(tenantedPublicationSchema.name)
112
+ await this.db.execute(
113
+ `UPDATE ${table}
114
+ SET "status" = 'deleted',
115
+ "updated_at" = $2
116
+ WHERE "id" = $1`,
117
+ [id, new Date()],
118
+ )
119
+ }
120
+
121
+ async findBySource(sourceId: string): Promise<TenantedPublication[]> {
122
+ const table = quoteIdent(tenantedPublicationSchema.name)
123
+ const rows = await this.db.query<Record<string, unknown>>(
124
+ `SELECT * FROM ${table} WHERE "source_id" = $1 ORDER BY "created_at"`,
125
+ [sourceId],
126
+ )
127
+ return Promise.all(rows.map((r) => this.hydrate(r)))
128
+ }
129
+
130
+ async findByProviderPostId(
131
+ provider: string,
132
+ instanceName: string,
133
+ providerPostId: string,
134
+ ): Promise<TenantedPublication | null> {
135
+ const table = quoteIdent(tenantedPublicationSchema.name)
136
+ const rows = await this.db.query<Record<string, unknown>>(
137
+ `SELECT * FROM ${table}
138
+ WHERE "provider" = $1 AND "instance_name" = $2 AND "provider_post_id" = $3
139
+ LIMIT 1`,
140
+ [provider, instanceName, providerPostId],
141
+ )
142
+ if (rows.length === 0) return null
143
+ return this.hydrate(rows[0]!)
144
+ }
145
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `tenantedPublicationSchema` — opt-in tenant-scoped variant of the
3
+ * publication ledger. Imported from `@strav/herald/tenanted` so apps
4
+ * that don't need multitenancy don't pay for it.
5
+ *
6
+ * Same columns as the default `publicationSchema`, with
7
+ * `tenanted: true` so `@strav/database` injects the `tenant_id` FK
8
+ * + RLS policy. Composite unique becomes
9
+ * `(tenant_id, provider, instance_name, provider_post_id)` — two
10
+ * tenants can each have a publication for the same provider id
11
+ * (rare in practice; collision-safe by construction).
12
+ */
13
+
14
+ import { Archetype, defineSchema } from '@strav/database'
15
+
16
+ export const tenantedPublicationSchema = defineSchema(
17
+ 'publication',
18
+ Archetype.Entity,
19
+ (t) => {
20
+ t.id()
21
+ t.string('source_id').max(64).notNull()
22
+ t.string('provider').max(64).notNull()
23
+ t.string('instance_name').max(64).notNull()
24
+ t.string('account_id').max(255).notNull()
25
+ t.string('provider_post_id').max(255).nullable()
26
+ t.string('url').max(2048).nullable()
27
+ t.string('status').max(16).notNull()
28
+ t.timestamp('published_at').nullable()
29
+ t.string('error_message').max(1024).nullable()
30
+ t.json('metadata').notNull().default({})
31
+ t.timestamp('created_at').notNull()
32
+ t.timestamp('updated_at').notNull()
33
+ },
34
+ { tenanted: true },
35
+ )
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `applyHeraldWebhookEventMigration` — DDL for the webhook dedup
3
+ * ledger plus the composite-unique index it relies on.
4
+ *
5
+ * Non-tenanted by construction: webhooks arrive before tenant
6
+ * context is known. Apps include this migration regardless of
7
+ * whether they use the tenanted publication ledger variant.
8
+ */
9
+
10
+ import {
11
+ emitCreateTable,
12
+ type DatabaseExecutor,
13
+ type SchemaRegistry,
14
+ } from '@strav/database'
15
+ import { heraldWebhookEventSchema } from './herald_webhook_event_schema.ts'
16
+
17
+ export interface ApplyHeraldWebhookEventMigrationOptions {
18
+ registry: SchemaRegistry
19
+ }
20
+
21
+ export async function applyHeraldWebhookEventMigration(
22
+ db: DatabaseExecutor,
23
+ options: ApplyHeraldWebhookEventMigrationOptions,
24
+ ): Promise<void> {
25
+ const { registry } = options
26
+
27
+ await db.execute(emitCreateTable(heraldWebhookEventSchema, { registry }).sql)
28
+
29
+ // Dedup contract — first INSERT for a `(provider, provider_event_id)`
30
+ // wins; replays return 200 without re-firing handlers.
31
+ await db.execute(
32
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_herald_webhook_event_dedup"
33
+ ON "${heraldWebhookEventSchema.name}" ("provider", "provider_event_id")`,
34
+ )
35
+
36
+ // Ops query: "stuck deliveries" — received but never processed.
37
+ await db.execute(
38
+ `CREATE INDEX IF NOT EXISTS "idx_herald_webhook_event_unprocessed"
39
+ ON "${heraldWebhookEventSchema.name}" ("received_at")
40
+ WHERE "processed_at" IS NULL`,
41
+ )
42
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Normalized webhook event types for `@strav/herald`.
3
+ *
4
+ * Drivers map their native event shapes (GBP `LOCAL_POST_REVIEW`,
5
+ * Meta `feed.comment`, WP REST custom hooks) onto this closed union.
6
+ * Apps register handlers by normalized type via
7
+ * `HeraldWebhookRegistry`; the native event is on `ctx.raw`.
8
+ *
9
+ * Two event families:
10
+ *
11
+ * - **post.** — async confirmation of a publish that initially
12
+ * returned `pending`. Apps reconcile by calling
13
+ * `PublicationRepository.markPublished` / `markFailed` /
14
+ * `markDeleted` from the handler.
15
+ *
16
+ * - **engagement.** — inbound interactions on a published post
17
+ * (comments, reactions, reviews). Apps surface these to
18
+ * operators, notify the owner, run moderation, etc.
19
+ *
20
+ * The `unknown` variant is for events the driver can't (or won't)
21
+ * map onto the closed union — the dedup row still records receipt
22
+ * so ops dashboards can spot novel events.
23
+ */
24
+
25
+ export type HeraldEventType =
26
+ // publish lifecycle (async confirmation)
27
+ | 'post.published'
28
+ | 'post.failed'
29
+ | 'post.deleted'
30
+ // engagement on a published post
31
+ | 'engagement.comment'
32
+ | 'engagement.review'
33
+ | 'engagement.reaction'
34
+ // catch-all
35
+ | 'unknown'
36
+
37
+ export interface HeraldWebhookEventBase {
38
+ /** Driver-assigned id; the dedup key. */
39
+ id: string
40
+ /** Normalized type. */
41
+ type: HeraldEventType
42
+ /**
43
+ * App-chosen instance name (the `:provider` route param, matches
44
+ * `herald.use(name)`). Drivers' `parse` sets this to the driver
45
+ * name (`'gbp'` / `'meta'`); the dispatcher overrides with the
46
+ * instance name.
47
+ */
48
+ provider: string
49
+ /**
50
+ * Provider-side account the event belongs to (FB Page id, GBP
51
+ * location id, WP site host). Matches `PublishTarget.accountId`.
52
+ * Lets apps reverse-lookup the tenant via their account → tenant
53
+ * map; webhooks themselves arrive without tenant context.
54
+ */
55
+ accountId: string
56
+ /** Provider-issued post id the event refers to, when applicable. */
57
+ providerPostId?: string
58
+ /** Best-known platform-side timestamp of the event. */
59
+ occurredAt?: Date
60
+ /** Native provider event payload. */
61
+ raw: unknown
62
+ }
63
+
64
+ export interface PostPublishedEvent extends HeraldWebhookEventBase {
65
+ type: 'post.published'
66
+ providerPostId: string
67
+ url?: string
68
+ }
69
+
70
+ export interface PostFailedEvent extends HeraldWebhookEventBase {
71
+ type: 'post.failed'
72
+ errorMessage: string
73
+ }
74
+
75
+ export interface PostDeletedEvent extends HeraldWebhookEventBase {
76
+ type: 'post.deleted'
77
+ providerPostId: string
78
+ }
79
+
80
+ export interface CommentEvent extends HeraldWebhookEventBase {
81
+ type: 'engagement.comment'
82
+ providerPostId: string
83
+ commentId: string
84
+ authorId?: string
85
+ authorName?: string
86
+ text?: string
87
+ parentCommentId?: string
88
+ }
89
+
90
+ export interface ReviewEvent extends HeraldWebhookEventBase {
91
+ type: 'engagement.review'
92
+ reviewId: string
93
+ rating?: number
94
+ text?: string
95
+ authorName?: string
96
+ }
97
+
98
+ export interface ReactionEvent extends HeraldWebhookEventBase {
99
+ type: 'engagement.reaction'
100
+ providerPostId: string
101
+ reactionType: string
102
+ authorId?: string
103
+ }
104
+
105
+ export interface UnknownEvent extends HeraldWebhookEventBase {
106
+ type: 'unknown'
107
+ }
108
+
109
+ export type HeraldWebhookEvent =
110
+ | PostPublishedEvent
111
+ | PostFailedEvent
112
+ | PostDeletedEvent
113
+ | CommentEvent
114
+ | ReviewEvent
115
+ | ReactionEvent
116
+ | UnknownEvent
117
+
118
+ // ─── Handler types ──────────────────────────────────────────────────
119
+
120
+ export interface WebhookHandlerContext {
121
+ event: HeraldWebhookEvent
122
+ /** Convenience shortcut for `event.id`. */
123
+ eventId: string
124
+ /** Convenience shortcut for `event.type`. */
125
+ eventType: HeraldEventType
126
+ /** Convenience shortcut for `event.provider`. */
127
+ provider: string
128
+ /** Convenience shortcut for `event.accountId`. */
129
+ accountId: string
130
+ /** Convenience shortcut for `event.raw`. */
131
+ raw: unknown
132
+ }
133
+
134
+ export type WebhookHandler = (ctx: WebhookHandlerContext) => void | Promise<void>
135
+
136
+ export interface WebhookHandlerFilter {
137
+ /** Restrict the handler to one provider instance. Omitted = fires for any provider that emits the type. */
138
+ provider?: string
139
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Provider-agnostic webhook route handler.
3
+ *
4
+ * Mount once per app:
5
+ *
6
+ * router.post('/webhooks/herald/:provider', heraldWebhook())
7
+ *
8
+ * Per request flow:
9
+ *
10
+ * 1. Read the `:provider` route param — picks the driver instance
11
+ * to verify against.
12
+ * 2. Read the raw body. Signature is computed over the bytes, so
13
+ * JSON-parsing first invalidates verification.
14
+ * 3. Resolve the signature header — drivers carry their own header
15
+ * name (`x-hub-signature-256` for Meta, `x-wp-signature` for
16
+ * WP, …); the handler reads them all.
17
+ * 4. `driver.webhook.verifySignature(rawBody, signature)` — 400 on
18
+ * missing / malformed; `false` on clean mismatch also returns
19
+ * 400 (provider retries).
20
+ * 5. `driver.webhook.parse(rawBody)` — driver normalizes every
21
+ * event in the batch into the `HeraldWebhookEvent` union.
22
+ * 6. Per event: idempotency claim against
23
+ * `herald_webhook_event(provider, provider_event_id)`. The
24
+ * first delivery wins; replays are skipped.
25
+ * 7. Dispatch matching handlers from `HeraldWebhookRegistry`.
26
+ * Handlers run in registration order. Throwing leaves
27
+ * `processed_at` NULL so dashboards surface stuck events; the
28
+ * route returns 500 so the provider retries the batch.
29
+ * 8. Mark `processed_at` on success.
30
+ */
31
+
32
+ import type { HttpContext } from '@strav/http'
33
+ import { UnknownProviderError, WebhookSignatureError } from '../errors.ts'
34
+ import { HeraldManager } from '../herald_manager.ts'
35
+ import type { HeraldWebhookEvent, WebhookHandlerContext } from './herald_event.ts'
36
+ import { HeraldWebhookEventRepository } from './herald_webhook_event_repository.ts'
37
+
38
+ export interface HeraldWebhookOptions {
39
+ /**
40
+ * Extra header names to probe for the provider signature. The
41
+ * built-in list covers the v1 drivers; apps with custom drivers
42
+ * append theirs.
43
+ */
44
+ signatureHeaders?: readonly string[]
45
+ }
46
+
47
+ const DEFAULT_SIGNATURE_HEADERS = [
48
+ // Meta (Facebook + Instagram)
49
+ 'x-hub-signature-256',
50
+ 'x-hub-signature',
51
+ // WordPress (custom plugin)
52
+ 'x-wp-signature',
53
+ // Google (X-Goog-Channel-Token + signature)
54
+ 'x-goog-signature',
55
+ // Generic fallback for custom drivers
56
+ 'webhook-signature',
57
+ ]
58
+
59
+ export function heraldWebhook(
60
+ options: HeraldWebhookOptions = {},
61
+ ): (ctx: HttpContext) => Promise<Response> {
62
+ const headerNames = options.signatureHeaders ?? DEFAULT_SIGNATURE_HEADERS
63
+
64
+ return async (ctx: HttpContext): Promise<Response> => {
65
+ const providerName = ctx.request.params['provider']
66
+ if (!providerName) {
67
+ return ctx.response.json(
68
+ { error: 'Missing :provider route param. Mount as `/webhooks/herald/:provider`.' },
69
+ { status: 400 },
70
+ )
71
+ }
72
+
73
+ const manager = ctx.container.resolve(HeraldManager)
74
+ let driver
75
+ try {
76
+ driver = manager.use(providerName)
77
+ } catch (cause) {
78
+ if (cause instanceof UnknownProviderError) {
79
+ return ctx.response.json({ error: cause.message }, { status: 404 })
80
+ }
81
+ throw cause
82
+ }
83
+
84
+ const signature = findSignatureHeader(ctx.request.headers, headerNames)
85
+ const rawBody = await ctx.request.raw.text()
86
+
87
+ let verified: boolean
88
+ try {
89
+ verified = driver.webhook.verifySignature(rawBody, signature)
90
+ } catch (cause) {
91
+ if (cause instanceof WebhookSignatureError) {
92
+ return ctx.response.json({ error: cause.message }, { status: 400 })
93
+ }
94
+ throw cause
95
+ }
96
+ if (!verified) {
97
+ return ctx.response.json({ error: 'Webhook signature verification failed.' }, { status: 400 })
98
+ }
99
+
100
+ const events = driver.webhook.parse(rawBody) as HeraldWebhookEvent[]
101
+ const repo = ctx.container.resolve(HeraldWebhookEventRepository)
102
+ const results: Array<{ id: string; type: string; duplicate: boolean }> = []
103
+
104
+ for (const event of events) {
105
+ // Honour instance-name routing — drivers stamp `provider` with
106
+ // their own name (`'meta'`); the route param is the instance
107
+ // name apps configured under (`'meta-marketing'`). Handlers
108
+ // resolve against the instance name.
109
+ const routed: HeraldWebhookEvent = { ...event, provider: providerName }
110
+ const claimed = await repo.claim(providerName, routed.id, routed.type)
111
+ if (!claimed) {
112
+ results.push({ id: routed.id, type: routed.type, duplicate: true })
113
+ continue
114
+ }
115
+ await dispatch(manager, routed)
116
+ await repo.markProcessed(providerName, routed.id)
117
+ results.push({ id: routed.id, type: routed.type, duplicate: false })
118
+ }
119
+
120
+ return ctx.response.json({ received: true, events: results })
121
+ }
122
+ }
123
+
124
+ async function dispatch(
125
+ manager: HeraldManager,
126
+ event: HeraldWebhookEvent,
127
+ ): Promise<void> {
128
+ const handlers = manager.webhookRegistry.resolve(event.type, event.provider)
129
+ if (handlers.length === 0) return
130
+ const ctx: WebhookHandlerContext = {
131
+ event,
132
+ eventId: event.id,
133
+ eventType: event.type,
134
+ provider: event.provider,
135
+ accountId: event.accountId,
136
+ raw: event.raw,
137
+ }
138
+ for (const handler of handlers) {
139
+ await handler(ctx)
140
+ }
141
+ }
142
+
143
+ function findSignatureHeader(headers: Headers, names: readonly string[]): string | null {
144
+ for (const name of names) {
145
+ const value = headers.get(name)
146
+ if (value) return value
147
+ }
148
+ return null
149
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `HeraldWebhookEventRecord` — typed row of the dedup ledger.
3
+ *
4
+ * Apps rarely touch this directly. Operator dashboards use it to list
5
+ * recent events, surface processing latencies, or flag stuck
6
+ * deliveries (rows where `processed_at` is still NULL after dispatch
7
+ * should have completed).
8
+ *
9
+ * Named `…Record` (not `…Event`) to avoid colliding with the
10
+ * normalized `HeraldWebhookEvent` union in `herald_event.ts`.
11
+ */
12
+
13
+ import { Model } from '@strav/database'
14
+ import { heraldWebhookEventSchema } from './herald_webhook_event_schema.ts'
15
+
16
+ export class HeraldWebhookEventRecord extends Model {
17
+ static override readonly schema = heraldWebhookEventSchema
18
+
19
+ id!: string
20
+ provider!: string
21
+ provider_event_id!: string
22
+ event_type!: string
23
+ received_at!: Date
24
+ processed_at!: Date | null
25
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `HeraldWebhookEventRepository` — data access for the webhook
3
+ * dedup ledger.
4
+ *
5
+ * Two custom helpers on top of the generic CRUD surface:
6
+ *
7
+ * - `claim(provider, eventId, type)` — atomic INSERT ... ON
8
+ * CONFLICT DO NOTHING RETURNING *. Returns the row on win,
9
+ * `null` when another delivery already recorded the
10
+ * `(provider, provider_event_id)` pair.
11
+ *
12
+ * - `markProcessed(provider, eventId)` — bumps `processed_at` to
13
+ * NOW(). Observability-only; not part of the dedup decision.
14
+ * Rows with `received_at` set but `processed_at` NULL surface
15
+ * stuck deliveries (handler threw / process crashed
16
+ * mid-dispatch).
17
+ */
18
+
19
+ import { quoteIdent, Repository } from '@strav/database'
20
+ import { ulid } from '@strav/kernel'
21
+ import { HeraldWebhookEventRecord } from './herald_webhook_event.ts'
22
+ import { heraldWebhookEventSchema } from './herald_webhook_event_schema.ts'
23
+
24
+ export class HeraldWebhookEventRepository extends Repository<HeraldWebhookEventRecord> {
25
+ static override readonly schema = heraldWebhookEventSchema
26
+ static override readonly model = HeraldWebhookEventRecord
27
+
28
+ /**
29
+ * Atomically record receipt of an event. Returns the inserted row
30
+ * when this call won the race, `null` when the
31
+ * `(provider, provider_event_id)` pair was already recorded.
32
+ */
33
+ async claim(
34
+ provider: string,
35
+ providerEventId: string,
36
+ eventType: string,
37
+ ): Promise<HeraldWebhookEventRecord | null> {
38
+ const table = quoteIdent(heraldWebhookEventSchema.name)
39
+ const sql = `
40
+ INSERT INTO ${table}
41
+ ("id", "provider", "provider_event_id", "event_type", "received_at")
42
+ VALUES ($1, $2, $3, $4, NOW())
43
+ ON CONFLICT ("provider", "provider_event_id") DO NOTHING
44
+ RETURNING *
45
+ `
46
+ const rows = await this.db.query<Record<string, unknown>>(sql, [
47
+ ulid(),
48
+ provider,
49
+ providerEventId,
50
+ eventType,
51
+ ])
52
+ if (rows.length === 0) return null
53
+ return this.hydrate(rows[0]!)
54
+ }
55
+
56
+ /** Bump `processed_at` to NOW(). No-op when the row doesn't exist. */
57
+ async markProcessed(provider: string, providerEventId: string): Promise<void> {
58
+ const table = quoteIdent(heraldWebhookEventSchema.name)
59
+ await this.db.execute(
60
+ `UPDATE ${table} SET "processed_at" = NOW()
61
+ WHERE "provider" = $1 AND "provider_event_id" = $2`,
62
+ [provider, providerEventId],
63
+ )
64
+ }
65
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `heraldWebhookEventSchema` — system-wide dedup ledger for inbound
3
+ * webhooks from every configured Herald provider.
4
+ *
5
+ * On every delivery (after signature verification + parse), the
6
+ * framework does:
7
+ *
8
+ * INSERT INTO herald_webhook_event (...) ON CONFLICT DO NOTHING
9
+ *
10
+ * The first delivery wins the INSERT and fires user handlers;
11
+ * subsequent deliveries (provider retries, concurrent webhook
12
+ * workers) see the conflict and return 200 without re-firing.
13
+ *
14
+ * **Why NOT tenanted:** webhooks arrive without tenant context.
15
+ * The dedup INSERT must run before any payload inspection (it gates
16
+ * handler dispatch); tenant routing — when needed — happens at the
17
+ * handler layer once the app has resolved `accountId → tenantId`.
18
+ *
19
+ * Composite unique `(provider, provider_event_id)` lives in the
20
+ * migration; different providers may emit colliding id formats so
21
+ * the pair is the actual uniqueness contract.
22
+ */
23
+
24
+ import { Archetype, defineSchema } from '@strav/database'
25
+
26
+ export const heraldWebhookEventSchema = defineSchema(
27
+ 'herald_webhook_event',
28
+ Archetype.Event,
29
+ (t) => {
30
+ t.id()
31
+ t.string('provider').max(64).notNull()
32
+ t.string('provider_event_id').max(255).notNull()
33
+ t.string('event_type').max(128).notNull()
34
+ t.timestamp('received_at').notNull()
35
+ t.timestamp('processed_at').nullable()
36
+ },
37
+ )