@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.
- package/README.md +48 -0
- package/package.json +43 -0
- package/src/drivers/gbp/gbp_client.ts +155 -0
- package/src/drivers/gbp/gbp_config.ts +48 -0
- package/src/drivers/gbp/gbp_driver.ts +246 -0
- package/src/drivers/gbp/gbp_provider.ts +35 -0
- package/src/drivers/gbp/index.ts +23 -0
- package/src/drivers/meta/index.ts +21 -0
- package/src/drivers/meta/meta_client.ts +103 -0
- package/src/drivers/meta/meta_config.ts +45 -0
- package/src/drivers/meta/meta_driver.ts +313 -0
- package/src/drivers/meta/meta_provider.ts +45 -0
- package/src/drivers/meta/meta_webhook_ops.ts +227 -0
- package/src/drivers/wordpress/index.ts +24 -0
- package/src/drivers/wordpress/wordpress_client.ts +185 -0
- package/src/drivers/wordpress/wordpress_config.ts +51 -0
- package/src/drivers/wordpress/wordpress_driver.ts +277 -0
- package/src/drivers/wordpress/wordpress_provider.ts +37 -0
- package/src/drivers/wordpress/wordpress_validate.ts +122 -0
- package/src/dto/post_status.ts +14 -0
- package/src/dto/publish_input.ts +38 -0
- package/src/dto/publish_result.ts +23 -0
- package/src/dto/publish_target.ts +15 -0
- package/src/errors.ts +122 -0
- package/src/herald_capabilities.ts +37 -0
- package/src/herald_config.ts +32 -0
- package/src/herald_driver.ts +86 -0
- package/src/herald_manager.ts +169 -0
- package/src/herald_provider.ts +44 -0
- package/src/index.ts +65 -0
- package/src/ledger/apply_publication_migration.ts +53 -0
- package/src/ledger/index.ts +11 -0
- package/src/ledger/publication.ts +30 -0
- package/src/ledger/publication_repository.ts +172 -0
- package/src/ledger/publication_schema.ts +70 -0
- package/src/onboarding/complete_channel_connect.ts +154 -0
- package/src/onboarding/connect_channel_callback.ts +138 -0
- package/src/onboarding/index.ts +26 -0
- package/src/onboarding/types.ts +67 -0
- package/src/tenanted/apply_tenanted_publication_migration.ts +46 -0
- package/src/tenanted/index.ts +18 -0
- package/src/tenanted/tenanted_publication.ts +28 -0
- package/src/tenanted/tenanted_publication_repository.ts +145 -0
- package/src/tenanted/tenanted_publication_schema.ts +35 -0
- package/src/webhook/apply_herald_webhook_event_migration.ts +42 -0
- package/src/webhook/herald_event.ts +139 -0
- package/src/webhook/herald_webhook.ts +149 -0
- package/src/webhook/herald_webhook_event.ts +25 -0
- package/src/webhook/herald_webhook_event_repository.ts +65 -0
- package/src/webhook/herald_webhook_event_schema.ts +37 -0
- package/src/webhook/herald_webhook_registry.ts +65 -0
- package/src/webhook/index.ts +24 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@strav/herald` — runtime config shapes.
|
|
3
|
+
*
|
|
4
|
+
* `HeraldConfig` is the `config.herald` shape apps declare in
|
|
5
|
+
* `config/herald.ts`. Multi-provider by default — a single Albastr
|
|
6
|
+
* tenant might publish to one GBP location, one Facebook Page, one
|
|
7
|
+
* Instagram account, and one WordPress site all in parallel.
|
|
8
|
+
* `providers` is keyed by app-chosen instance name; each entry carries
|
|
9
|
+
* a `driver` discriminator the framework resolves to a concrete
|
|
10
|
+
* `HeraldDriver`.
|
|
11
|
+
*
|
|
12
|
+
* `ProviderConfig` is free-form so each adapter owns its own config
|
|
13
|
+
* shape (`MetaProviderConfig` etc.) without forcing the core to know
|
|
14
|
+
* every vendor field.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface HeraldConfig {
|
|
18
|
+
/** Key into `providers`. The default routing target for unqualified `herald.*` calls. */
|
|
19
|
+
default: string
|
|
20
|
+
providers: Record<string, ProviderConfig>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProviderConfig {
|
|
24
|
+
/**
|
|
25
|
+
* Driver identifier — must match a name registered via
|
|
26
|
+
* `manager.extend(name, factory)` (typically by an adapter
|
|
27
|
+
* ServiceProvider).
|
|
28
|
+
*/
|
|
29
|
+
driver: string
|
|
30
|
+
/** Driver-specific fields — see each adapter's `*Config`. */
|
|
31
|
+
[key: string]: unknown
|
|
32
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HeraldDriver` — the driver contract every adapter implements.
|
|
3
|
+
*
|
|
4
|
+
* One driver represents a configured provider instance
|
|
5
|
+
* (`config.herald.providers['meta']`). The manager holds one driver
|
|
6
|
+
* per configured name and routes publish / update / delete / webhook
|
|
7
|
+
* calls into it.
|
|
8
|
+
*
|
|
9
|
+
* Methods drivers don't support throw `ProviderUnsupportedError`
|
|
10
|
+
* synchronously. The driver's `capabilities` set declares the
|
|
11
|
+
* supported surfaces — apps that branch on capability avoid the throw
|
|
12
|
+
* by checking first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PostStatus } from './dto/post_status.ts'
|
|
16
|
+
import type { PublishInput } from './dto/publish_input.ts'
|
|
17
|
+
import type { PublishResult } from './dto/publish_result.ts'
|
|
18
|
+
import type { PublishTarget } from './dto/publish_target.ts'
|
|
19
|
+
import type { HeraldCapability } from './herald_capabilities.ts'
|
|
20
|
+
import type { HeraldWebhookEvent } from './webhook/herald_event.ts'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Inbound webhook verification + parsing. Drivers that lack a webhook
|
|
24
|
+
* surface (rare for a publishing target — engagement comes back as
|
|
25
|
+
* comments / reviews) declare neither `webhook.signature` nor
|
|
26
|
+
* `webhook.parse` capabilities and throw `ProviderUnsupportedError`
|
|
27
|
+
* from these methods.
|
|
28
|
+
*/
|
|
29
|
+
export interface WebhookOps {
|
|
30
|
+
/**
|
|
31
|
+
* Verify the provider signature against the raw request body.
|
|
32
|
+
* Returns `true` when the signature matches. Drivers throw
|
|
33
|
+
* `WebhookSignatureError` only when the header is missing / malformed;
|
|
34
|
+
* a clean mismatch returns `false` so the route can decide how to
|
|
35
|
+
* respond.
|
|
36
|
+
*/
|
|
37
|
+
verifySignature(rawBody: string, signature: string | null | undefined): boolean
|
|
38
|
+
/**
|
|
39
|
+
* Parse a verified raw body into the framework's normalized
|
|
40
|
+
* `HeraldWebhookEvent` union. Drivers translate every event variant
|
|
41
|
+
* they can recognise; unknown shapes map to
|
|
42
|
+
* `{ type: 'unknown', raw, … }`. One delivery often carries
|
|
43
|
+
* multiple events (Meta batches feed updates) — return them in
|
|
44
|
+
* order; the dispatcher dedupes per-event by `event.id`.
|
|
45
|
+
*/
|
|
46
|
+
parse(rawBody: string): HeraldWebhookEvent[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HeraldDriver {
|
|
50
|
+
/** Driver identifier — matches the `driver:` discriminator in `ProviderConfig`. */
|
|
51
|
+
readonly name: string
|
|
52
|
+
/** App-chosen instance name (`config.herald.providers[name]`). */
|
|
53
|
+
readonly instanceName: string
|
|
54
|
+
/** Declared feature set. Apps check this to branch around `ProviderUnsupportedError`. */
|
|
55
|
+
readonly capabilities: ReadonlySet<HeraldCapability>
|
|
56
|
+
|
|
57
|
+
/** Publish a post to the target account. */
|
|
58
|
+
publish(target: PublishTarget, input: PublishInput): Promise<PublishResult>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Update an existing post on the platform. Drivers that don't
|
|
62
|
+
* support edits (GBP Posts, IG feed posts) throw
|
|
63
|
+
* `ProviderUnsupportedError`.
|
|
64
|
+
*/
|
|
65
|
+
update?(
|
|
66
|
+
target: PublishTarget,
|
|
67
|
+
providerPostId: string,
|
|
68
|
+
input: PublishInput,
|
|
69
|
+
): Promise<PublishResult>
|
|
70
|
+
|
|
71
|
+
/** Delete a post from the platform. */
|
|
72
|
+
delete?(target: PublishTarget, providerPostId: string): Promise<void>
|
|
73
|
+
|
|
74
|
+
/** Read the platform-side status of a previously published post. */
|
|
75
|
+
get?(target: PublishTarget, providerPostId: string): Promise<PostStatus>
|
|
76
|
+
|
|
77
|
+
readonly webhook: WebhookOps
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Factory the manager invokes for each configured provider. */
|
|
81
|
+
export type HeraldDriverFactory = (config: {
|
|
82
|
+
/** App-chosen instance name (`'meta'`, `'meta-marketing'`, …). */
|
|
83
|
+
instanceName: string
|
|
84
|
+
/** Provider-config object with `driver:` + driver-specific fields. */
|
|
85
|
+
config: Record<string, unknown> & { driver: string }
|
|
86
|
+
}) => HeraldDriver
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HeraldManager` — the facade apps use for micro-publishing
|
|
3
|
+
* workflows.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the manager-pattern shared with `@strav/payment`,
|
|
6
|
+
* `@strav/instant`, and `@strav/notification`:
|
|
7
|
+
*
|
|
8
|
+
* - **Drivers.** Apps declare providers in
|
|
9
|
+
* `config.herald.providers`. The manager constructs each driver
|
|
10
|
+
* lazily on first `use(name)` + memoizes. Adapter packages
|
|
11
|
+
* register their factories via `manager.extend(name, factory)`.
|
|
12
|
+
*
|
|
13
|
+
* - **Default routing.** `manager.publish(target, input)` looks
|
|
14
|
+
* up the driver named by `target.provider` (not the
|
|
15
|
+
* `config.default`); the default only kicks in for
|
|
16
|
+
* unqualified `use()` calls in tests / one-offs.
|
|
17
|
+
*
|
|
18
|
+
* - **Webhooks.** `manager.verify(provider, rawBody, sig)` and
|
|
19
|
+
* `manager.parseWebhook(provider, rawBody)` delegate into the
|
|
20
|
+
* driver's `WebhookOps`. Apps wire these into their HTTP route
|
|
21
|
+
* (one route per provider). Slice 3 adds `manager.webhook` — a
|
|
22
|
+
* `HeraldWebhookRegistry` for typed-handler dispatch with
|
|
23
|
+
* idempotency + ledger persistence.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { PostStatus } from './dto/post_status.ts'
|
|
27
|
+
import type { PublishInput } from './dto/publish_input.ts'
|
|
28
|
+
import type { PublishResult } from './dto/publish_result.ts'
|
|
29
|
+
import type { PublishTarget } from './dto/publish_target.ts'
|
|
30
|
+
import { HeraldConfigError, ProviderUnsupportedError, UnknownProviderError } from './errors.ts'
|
|
31
|
+
import type { HeraldConfig, ProviderConfig } from './herald_config.ts'
|
|
32
|
+
import type { HeraldDriver, HeraldDriverFactory } from './herald_driver.ts'
|
|
33
|
+
import type {
|
|
34
|
+
HeraldEventType,
|
|
35
|
+
HeraldWebhookEvent,
|
|
36
|
+
WebhookHandler,
|
|
37
|
+
WebhookHandlerFilter,
|
|
38
|
+
} from './webhook/herald_event.ts'
|
|
39
|
+
import { HeraldWebhookRegistry } from './webhook/herald_webhook_registry.ts'
|
|
40
|
+
|
|
41
|
+
export interface HeraldManagerOptions {
|
|
42
|
+
config: HeraldConfig
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class HeraldManager {
|
|
46
|
+
readonly config: HeraldConfig
|
|
47
|
+
readonly webhookRegistry: HeraldWebhookRegistry = new HeraldWebhookRegistry()
|
|
48
|
+
|
|
49
|
+
private readonly drivers = new Map<string, HeraldDriver>()
|
|
50
|
+
private readonly extensions = new Map<string, HeraldDriverFactory>()
|
|
51
|
+
|
|
52
|
+
constructor(options: HeraldManagerOptions) {
|
|
53
|
+
const { config } = options
|
|
54
|
+
if (!config.providers[config.default]) {
|
|
55
|
+
throw new HeraldConfigError(
|
|
56
|
+
`HeraldManager: default provider "${config.default}" is not configured.`,
|
|
57
|
+
{
|
|
58
|
+
context: {
|
|
59
|
+
default: config.default,
|
|
60
|
+
available: Object.keys(config.providers),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
this.config = config
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Driver routing ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** Resolve a driver by app-chosen instance name (or the default when omitted). */
|
|
71
|
+
use(name?: string): HeraldDriver {
|
|
72
|
+
const key = name ?? this.config.default
|
|
73
|
+
const cached = this.drivers.get(key)
|
|
74
|
+
if (cached) return cached
|
|
75
|
+
|
|
76
|
+
const cfg = this.config.providers[key]
|
|
77
|
+
if (!cfg) {
|
|
78
|
+
throw new UnknownProviderError(key, Object.keys(this.config.providers))
|
|
79
|
+
}
|
|
80
|
+
const ext = this.extensions.get(cfg.driver)
|
|
81
|
+
if (!ext) {
|
|
82
|
+
throw new HeraldConfigError(
|
|
83
|
+
`HeraldManager: unknown driver "${cfg.driver}" for provider "${key}". Register it via \`manager.extend("${cfg.driver}", factory)\` or install the matching adapter package.`,
|
|
84
|
+
{ context: { driver: cfg.driver, available: [...this.extensions.keys()] } },
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const driver = ext({
|
|
88
|
+
instanceName: key,
|
|
89
|
+
config: cfg as ProviderConfig & { driver: string },
|
|
90
|
+
})
|
|
91
|
+
this.drivers.set(key, driver)
|
|
92
|
+
return driver
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Register a driver factory. Adapter packages call this from their
|
|
97
|
+
* ServiceProvider's `register()` step.
|
|
98
|
+
*/
|
|
99
|
+
extend(driverName: string, factory: HeraldDriverFactory): void {
|
|
100
|
+
this.extensions.set(driverName, factory)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Hand-wire a driver instance under an app-chosen name (tests / one-offs). */
|
|
104
|
+
useDriver(instanceName: string, driver: HeraldDriver): void {
|
|
105
|
+
this.drivers.set(instanceName, driver)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Convenience delegates ───────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
publish(target: PublishTarget, input: PublishInput): Promise<PublishResult> {
|
|
111
|
+
return this.use(target.provider).publish(target, input)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
update(
|
|
115
|
+
target: PublishTarget,
|
|
116
|
+
providerPostId: string,
|
|
117
|
+
input: PublishInput,
|
|
118
|
+
): Promise<PublishResult> {
|
|
119
|
+
const driver = this.use(target.provider)
|
|
120
|
+
if (!driver.update) throw new ProviderUnsupportedError(driver.name, 'update')
|
|
121
|
+
return driver.update(target, providerPostId, input)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
delete(target: PublishTarget, providerPostId: string): Promise<void> {
|
|
125
|
+
const driver = this.use(target.provider)
|
|
126
|
+
if (!driver.delete) throw new ProviderUnsupportedError(driver.name, 'delete')
|
|
127
|
+
return driver.delete(target, providerPostId)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get(target: PublishTarget, providerPostId: string): Promise<PostStatus> {
|
|
131
|
+
const driver = this.use(target.provider)
|
|
132
|
+
if (!driver.get) throw new ProviderUnsupportedError(driver.name, 'get')
|
|
133
|
+
return driver.get(target, providerPostId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Webhook routing ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** Verify a webhook signature using the named provider's driver. */
|
|
139
|
+
verify(provider: string, rawBody: string, signature: string | null | undefined): boolean {
|
|
140
|
+
return this.use(provider).webhook.verifySignature(rawBody, signature)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Parse a verified raw webhook body into the framework's normalized event union. */
|
|
144
|
+
parseWebhook(provider: string, rawBody: string): HeraldWebhookEvent[] {
|
|
145
|
+
return this.use(provider).webhook.parse(rawBody)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Register a handler for a normalized webhook event type.
|
|
150
|
+
* Sugar over `manager.webhookRegistry.on(...)`.
|
|
151
|
+
*/
|
|
152
|
+
onWebhookEvent(eventType: HeraldEventType, handler: WebhookHandler): void
|
|
153
|
+
onWebhookEvent(
|
|
154
|
+
eventType: HeraldEventType,
|
|
155
|
+
filter: WebhookHandlerFilter,
|
|
156
|
+
handler: WebhookHandler,
|
|
157
|
+
): void
|
|
158
|
+
onWebhookEvent(
|
|
159
|
+
eventType: HeraldEventType,
|
|
160
|
+
filterOrHandler: WebhookHandlerFilter | WebhookHandler,
|
|
161
|
+
maybeHandler?: WebhookHandler,
|
|
162
|
+
): void {
|
|
163
|
+
if (typeof filterOrHandler === 'function') {
|
|
164
|
+
this.webhookRegistry.on(eventType, filterOrHandler)
|
|
165
|
+
} else {
|
|
166
|
+
this.webhookRegistry.on(eventType, filterOrHandler, maybeHandler!)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HeraldProvider` — `ServiceProvider` that wires `HeraldManager` into
|
|
3
|
+
* the container from `config.herald`.
|
|
4
|
+
*
|
|
5
|
+
* Adapter packages register their drivers separately via their own
|
|
6
|
+
* ServiceProvider (e.g. `MetaHeraldProvider`). Apps list the adapter
|
|
7
|
+
* providers AFTER `HeraldProvider` in `bootstrap/providers.ts` —
|
|
8
|
+
* `register()` runs in declaration order, then `boot()` runs in the
|
|
9
|
+
* same order. Adapter `register()` calls `manager.extend(driver,
|
|
10
|
+
* factory)`; this provider's `boot()` eagerly resolves so config
|
|
11
|
+
* errors surface at startup.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
|
|
15
|
+
import { HeraldConfigError } from './errors.ts'
|
|
16
|
+
import type { HeraldConfig } from './herald_config.ts'
|
|
17
|
+
import { HeraldManager } from './herald_manager.ts'
|
|
18
|
+
|
|
19
|
+
export class HeraldProvider extends ServiceProvider {
|
|
20
|
+
override readonly name = 'herald'
|
|
21
|
+
override readonly dependencies = ['config']
|
|
22
|
+
|
|
23
|
+
override register(app: Application): void {
|
|
24
|
+
app.singleton(HeraldManager, (c) => {
|
|
25
|
+
const raw = c.resolve(ConfigRepository).get('herald') as HeraldConfig | undefined
|
|
26
|
+
if (!raw) {
|
|
27
|
+
throw new HeraldConfigError(
|
|
28
|
+
'HeraldProvider: `config.herald` is missing. Add `config/herald.ts` with at least one provider.',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
if (!raw.providers || Object.keys(raw.providers).length === 0) {
|
|
32
|
+
throw new HeraldConfigError(
|
|
33
|
+
'HeraldProvider: `config.herald.providers` is empty. Configure at least one provider.',
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return new HeraldManager({ config: raw })
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override boot(app: Application): void {
|
|
41
|
+
// Force-resolve so config errors surface at boot, not on first publish().
|
|
42
|
+
app.resolve(HeraldManager)
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Public API of `@strav/herald`.
|
|
2
|
+
//
|
|
3
|
+
// V1: provider-agnostic micro-publishing abstraction — normalized
|
|
4
|
+
// `PublishInput` + `PublishResult` + multi-provider routing. Drivers
|
|
5
|
+
// (WordPress, Meta, Google Business Profile) ship as subpath imports
|
|
6
|
+
// in follow-up slices: `@strav/herald/wordpress`, `@strav/herald/meta`,
|
|
7
|
+
// `@strav/herald/gbp`. X (Twitter), TikTok, LinkedIn, Threads come
|
|
8
|
+
// later.
|
|
9
|
+
|
|
10
|
+
export type { PostStatus } from './dto/post_status.ts'
|
|
11
|
+
export type { PublishAttachment, PublishInput } from './dto/publish_input.ts'
|
|
12
|
+
export type { PublishResult } from './dto/publish_result.ts'
|
|
13
|
+
export type { PublishTarget } from './dto/publish_target.ts'
|
|
14
|
+
export {
|
|
15
|
+
HeraldConfigError,
|
|
16
|
+
HeraldError,
|
|
17
|
+
HeraldProviderError,
|
|
18
|
+
ProviderUnsupportedError,
|
|
19
|
+
UnknownProviderError,
|
|
20
|
+
WebhookSignatureError,
|
|
21
|
+
} from './errors.ts'
|
|
22
|
+
export type { HeraldCapability } from './herald_capabilities.ts'
|
|
23
|
+
export type { HeraldConfig, ProviderConfig } from './herald_config.ts'
|
|
24
|
+
export type {
|
|
25
|
+
HeraldDriver,
|
|
26
|
+
HeraldDriverFactory,
|
|
27
|
+
WebhookOps,
|
|
28
|
+
} from './herald_driver.ts'
|
|
29
|
+
export {
|
|
30
|
+
HeraldManager,
|
|
31
|
+
type HeraldManagerOptions,
|
|
32
|
+
} from './herald_manager.ts'
|
|
33
|
+
export { HeraldProvider } from './herald_provider.ts'
|
|
34
|
+
export {
|
|
35
|
+
applyPublicationMigration,
|
|
36
|
+
type ApplyPublicationMigrationOptions,
|
|
37
|
+
type MarkPublishedInput,
|
|
38
|
+
Publication,
|
|
39
|
+
PublicationRepository,
|
|
40
|
+
type RecordInput,
|
|
41
|
+
publicationSchema,
|
|
42
|
+
} from './ledger/index.ts'
|
|
43
|
+
export {
|
|
44
|
+
applyHeraldWebhookEventMigration,
|
|
45
|
+
type ApplyHeraldWebhookEventMigrationOptions,
|
|
46
|
+
type CommentEvent,
|
|
47
|
+
type HeraldEventType,
|
|
48
|
+
type HeraldWebhookEvent,
|
|
49
|
+
type HeraldWebhookEventBase,
|
|
50
|
+
HeraldWebhookEventRecord,
|
|
51
|
+
HeraldWebhookEventRepository,
|
|
52
|
+
heraldWebhookEventSchema,
|
|
53
|
+
heraldWebhook,
|
|
54
|
+
type HeraldWebhookOptions,
|
|
55
|
+
HeraldWebhookRegistry,
|
|
56
|
+
type PostDeletedEvent,
|
|
57
|
+
type PostFailedEvent,
|
|
58
|
+
type PostPublishedEvent,
|
|
59
|
+
type ReactionEvent,
|
|
60
|
+
type ReviewEvent,
|
|
61
|
+
type UnknownEvent,
|
|
62
|
+
type WebhookHandler,
|
|
63
|
+
type WebhookHandlerContext,
|
|
64
|
+
type WebhookHandlerFilter,
|
|
65
|
+
} from './webhook/index.ts'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyPublicationMigration` — emit DDL for the `publication` table
|
|
3
|
+
* plus its composite unique constraint and lookup indexes.
|
|
4
|
+
*
|
|
5
|
+
* Non-tenanted by default (framework policy: multitenancy is opt-in).
|
|
6
|
+
* Apps that need per-tenant scoping use
|
|
7
|
+
* `applyTenantedPublicationMigration` from `@strav/herald/tenanted`
|
|
8
|
+
* instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
emitCreateTable,
|
|
13
|
+
type DatabaseExecutor,
|
|
14
|
+
type SchemaRegistry,
|
|
15
|
+
} from '@strav/database'
|
|
16
|
+
import { publicationSchema } from './publication_schema.ts'
|
|
17
|
+
|
|
18
|
+
export interface ApplyPublicationMigrationOptions {
|
|
19
|
+
/** Required for `emitCreateTable` to resolve relations. */
|
|
20
|
+
registry: SchemaRegistry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function applyPublicationMigration(
|
|
24
|
+
db: DatabaseExecutor,
|
|
25
|
+
options: ApplyPublicationMigrationOptions,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const { registry } = options
|
|
28
|
+
|
|
29
|
+
await db.execute(emitCreateTable(publicationSchema, { registry }).sql)
|
|
30
|
+
|
|
31
|
+
// Provider-post uniqueness — one row per platform-side post.
|
|
32
|
+
// Webhook reconciliation (`findByProviderPostId`) leans on this.
|
|
33
|
+
// Partial index: `provider_post_id` is nullable while a publish
|
|
34
|
+
// sits in `pending`; the unique constraint only applies once an id
|
|
35
|
+
// has been assigned.
|
|
36
|
+
await db.execute(
|
|
37
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_publication_provider_post"
|
|
38
|
+
ON "${publicationSchema.name}" ("provider", "instance_name", "provider_post_id")
|
|
39
|
+
WHERE "provider_post_id" IS NOT NULL`,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// "All channels for source" lookup — per-post status UI.
|
|
43
|
+
await db.execute(
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS "idx_publication_source"
|
|
45
|
+
ON "${publicationSchema.name}" ("source_id")`,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// Operational query: "all pending / failed publishes for a provider".
|
|
49
|
+
await db.execute(
|
|
50
|
+
`CREATE INDEX IF NOT EXISTS "idx_publication_provider_status"
|
|
51
|
+
ON "${publicationSchema.name}" ("provider", "status")`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
applyPublicationMigration,
|
|
3
|
+
type ApplyPublicationMigrationOptions,
|
|
4
|
+
} from './apply_publication_migration.ts'
|
|
5
|
+
export { Publication } from './publication.ts'
|
|
6
|
+
export {
|
|
7
|
+
type MarkPublishedInput,
|
|
8
|
+
PublicationRepository,
|
|
9
|
+
type RecordInput,
|
|
10
|
+
} from './publication_repository.ts'
|
|
11
|
+
export { publicationSchema } from './publication_schema.ts'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Publication` — typed row of the published-post ledger.
|
|
3
|
+
*
|
|
4
|
+
* No `@encrypt` columns: this ledger holds public-facing references
|
|
5
|
+
* (provider post id, URL, status), not credentials. Credentials live
|
|
6
|
+
* in `@strav/social`'s `social_account` table and are loaded on
|
|
7
|
+
* demand by the drivers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Model } from '@strav/database'
|
|
11
|
+
import type { PostStatus } from '../dto/post_status.ts'
|
|
12
|
+
import { publicationSchema } from './publication_schema.ts'
|
|
13
|
+
|
|
14
|
+
export class Publication extends Model {
|
|
15
|
+
static override readonly schema = publicationSchema
|
|
16
|
+
|
|
17
|
+
id!: string
|
|
18
|
+
source_id!: string
|
|
19
|
+
provider!: string
|
|
20
|
+
instance_name!: string
|
|
21
|
+
account_id!: string
|
|
22
|
+
provider_post_id!: string | null
|
|
23
|
+
url!: string | null
|
|
24
|
+
status!: PostStatus
|
|
25
|
+
published_at!: Date | null
|
|
26
|
+
error_message!: string | null
|
|
27
|
+
metadata!: Record<string, unknown>
|
|
28
|
+
created_at!: Date
|
|
29
|
+
updated_at!: Date
|
|
30
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PublicationRepository` — domain helpers on top of the generic CRUD
|
|
3
|
+
* surface. The methods apps actually use:
|
|
4
|
+
*
|
|
5
|
+
* - `record({ sourceId, target, result, instanceName })` — upsert
|
|
6
|
+
* by `(provider, instance_name, provider_post_id)`. Called
|
|
7
|
+
* immediately after a successful `herald.publish(...)`. When
|
|
8
|
+
* status is `pending` (no `provider_post_id` yet), inserts a
|
|
9
|
+
* fresh row; webhook reconciliation later updates it via
|
|
10
|
+
* `markPublished` / `markFailed`.
|
|
11
|
+
*
|
|
12
|
+
* - `markPublished(id, { providerPostId, url, publishedAt })` —
|
|
13
|
+
* flip status from `pending` to `published`. Called from
|
|
14
|
+
* webhook handlers when the platform confirms.
|
|
15
|
+
*
|
|
16
|
+
* - `markFailed(id, errorMessage)` — flip status to `failed`.
|
|
17
|
+
*
|
|
18
|
+
* - `markDeleted(id)` — flip status to `deleted` after a successful
|
|
19
|
+
* `herald.delete(...)`.
|
|
20
|
+
*
|
|
21
|
+
* - `findBySource(sourceId)` — list every channel one app-side
|
|
22
|
+
* post fanned out to. Apps render per-post status UIs from this.
|
|
23
|
+
*
|
|
24
|
+
* - `findByProviderPostId(provider, instanceName, providerPostId)` —
|
|
25
|
+
* reverse lookup for webhook reconciliation: "this engagement
|
|
26
|
+
* event names provider X post Y, which row does it belong to?"
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { quoteIdent, Repository } from '@strav/database'
|
|
30
|
+
import { ulid } from '@strav/kernel'
|
|
31
|
+
import type { PublishResult } from '../dto/publish_result.ts'
|
|
32
|
+
import type { PublishTarget } from '../dto/publish_target.ts'
|
|
33
|
+
import { Publication } from './publication.ts'
|
|
34
|
+
import { publicationSchema } from './publication_schema.ts'
|
|
35
|
+
|
|
36
|
+
export interface RecordInput {
|
|
37
|
+
/** App-side post / draft id this publication belongs to. */
|
|
38
|
+
sourceId: string
|
|
39
|
+
/** `config.herald.providers[name]` — usually `target.provider`. */
|
|
40
|
+
instanceName: string
|
|
41
|
+
target: PublishTarget
|
|
42
|
+
result: PublishResult
|
|
43
|
+
/** Optional driver extras to persist alongside the canonical fields. */
|
|
44
|
+
metadata?: Record<string, unknown>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MarkPublishedInput {
|
|
48
|
+
providerPostId: string
|
|
49
|
+
url?: string
|
|
50
|
+
publishedAt?: Date
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class PublicationRepository extends Repository<Publication> {
|
|
54
|
+
static override readonly schema = publicationSchema
|
|
55
|
+
static override readonly model = Publication
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Upsert a publication row from a `PublishResult`. Re-recording the
|
|
59
|
+
* same `provider_post_id` (e.g. on webhook reconciliation) updates
|
|
60
|
+
* the existing row in place rather than inserting a duplicate.
|
|
61
|
+
*/
|
|
62
|
+
async record(input: RecordInput): Promise<Publication> {
|
|
63
|
+
const now = new Date()
|
|
64
|
+
|
|
65
|
+
if (input.result.providerPostId) {
|
|
66
|
+
const existing = await this.findByProviderPostId(
|
|
67
|
+
input.target.provider,
|
|
68
|
+
input.instanceName,
|
|
69
|
+
input.result.providerPostId,
|
|
70
|
+
)
|
|
71
|
+
if (existing) {
|
|
72
|
+
return this.update(existing, {
|
|
73
|
+
source_id: input.sourceId,
|
|
74
|
+
account_id: input.target.accountId,
|
|
75
|
+
url: input.result.url ?? null,
|
|
76
|
+
status: input.result.status,
|
|
77
|
+
published_at: input.result.status === 'published' ? now : existing.published_at,
|
|
78
|
+
error_message: null,
|
|
79
|
+
metadata: { ...existing.metadata, ...(input.metadata ?? {}) },
|
|
80
|
+
updated_at: now,
|
|
81
|
+
} as Partial<Publication>)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return this.create({
|
|
86
|
+
id: ulid(),
|
|
87
|
+
source_id: input.sourceId,
|
|
88
|
+
provider: input.target.provider,
|
|
89
|
+
instance_name: input.instanceName,
|
|
90
|
+
account_id: input.target.accountId,
|
|
91
|
+
provider_post_id: input.result.providerPostId || null,
|
|
92
|
+
url: input.result.url ?? null,
|
|
93
|
+
status: input.result.status,
|
|
94
|
+
published_at: input.result.status === 'published' ? now : null,
|
|
95
|
+
error_message: null,
|
|
96
|
+
metadata: input.metadata ?? {},
|
|
97
|
+
created_at: now,
|
|
98
|
+
updated_at: now,
|
|
99
|
+
} as Partial<Publication>)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async markPublished(id: string, input: MarkPublishedInput): Promise<void> {
|
|
103
|
+
const table = quoteIdent(publicationSchema.name)
|
|
104
|
+
await this.db.execute(
|
|
105
|
+
`UPDATE ${table}
|
|
106
|
+
SET "provider_post_id" = $2,
|
|
107
|
+
"url" = $3,
|
|
108
|
+
"status" = 'published',
|
|
109
|
+
"published_at" = $4,
|
|
110
|
+
"error_message" = NULL,
|
|
111
|
+
"updated_at" = $5
|
|
112
|
+
WHERE "id" = $1`,
|
|
113
|
+
[
|
|
114
|
+
id,
|
|
115
|
+
input.providerPostId,
|
|
116
|
+
input.url ?? null,
|
|
117
|
+
input.publishedAt ?? new Date(),
|
|
118
|
+
new Date(),
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async markFailed(id: string, errorMessage: string): Promise<void> {
|
|
124
|
+
const table = quoteIdent(publicationSchema.name)
|
|
125
|
+
await this.db.execute(
|
|
126
|
+
`UPDATE ${table}
|
|
127
|
+
SET "status" = 'failed',
|
|
128
|
+
"error_message" = $2,
|
|
129
|
+
"updated_at" = $3
|
|
130
|
+
WHERE "id" = $1`,
|
|
131
|
+
[id, errorMessage, new Date()],
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async markDeleted(id: string): Promise<void> {
|
|
136
|
+
const table = quoteIdent(publicationSchema.name)
|
|
137
|
+
await this.db.execute(
|
|
138
|
+
`UPDATE ${table}
|
|
139
|
+
SET "status" = 'deleted',
|
|
140
|
+
"updated_at" = $2
|
|
141
|
+
WHERE "id" = $1`,
|
|
142
|
+
[id, new Date()],
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every channel one app-side post fanned out to. */
|
|
147
|
+
async findBySource(sourceId: string): Promise<Publication[]> {
|
|
148
|
+
const table = quoteIdent(publicationSchema.name)
|
|
149
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
150
|
+
`SELECT * FROM ${table} WHERE "source_id" = $1 ORDER BY "created_at"`,
|
|
151
|
+
[sourceId],
|
|
152
|
+
)
|
|
153
|
+
return Promise.all(rows.map((r) => this.hydrate(r)))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Reverse lookup for webhook reconciliation. */
|
|
157
|
+
async findByProviderPostId(
|
|
158
|
+
provider: string,
|
|
159
|
+
instanceName: string,
|
|
160
|
+
providerPostId: string,
|
|
161
|
+
): Promise<Publication | null> {
|
|
162
|
+
const table = quoteIdent(publicationSchema.name)
|
|
163
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
164
|
+
`SELECT * FROM ${table}
|
|
165
|
+
WHERE "provider" = $1 AND "instance_name" = $2 AND "provider_post_id" = $3
|
|
166
|
+
LIMIT 1`,
|
|
167
|
+
[provider, instanceName, providerPostId],
|
|
168
|
+
)
|
|
169
|
+
if (rows.length === 0) return null
|
|
170
|
+
return this.hydrate(rows[0]!)
|
|
171
|
+
}
|
|
172
|
+
}
|