@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,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
+ }