@happyvertical/smrt-social 0.30.0
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/AGENTS.md +18 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +94 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/collections/index.d.ts +5 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/collections/oauth-state-collection.d.ts +9 -0
- package/dist/collections/oauth-state-collection.d.ts.map +1 -0
- package/dist/collections/social-account-collection.d.ts +9 -0
- package/dist/collections/social-account-collection.d.ts.map +1 -0
- package/dist/collections/social-post-analytics-snapshot-collection.d.ts +18 -0
- package/dist/collections/social-post-analytics-snapshot-collection.d.ts.map +1 -0
- package/dist/collections/social-post-collection.d.ts +32 -0
- package/dist/collections/social-post-collection.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +824 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +3363 -0
- package/dist/oauth-state.d.ts +127 -0
- package/dist/oauth-state.d.ts.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +106 -0
- package/dist/server.js.map +1 -0
- package/dist/smrt-knowledge.json +1398 -0
- package/dist/social-account.d.ts +270 -0
- package/dist/social-account.d.ts.map +1 -0
- package/dist/social-post-analytics-snapshot.d.ts +40 -0
- package/dist/social-post-analytics-snapshot.d.ts.map +1 -0
- package/dist/social-post.d.ts +258 -0
- package/dist/social-post.d.ts.map +1 -0
- package/dist/svelte/components/SocialAccountSettings.svelte +243 -0
- package/dist/svelte/components/SocialAccountSettings.svelte.d.ts +15 -0
- package/dist/svelte/components/SocialAccountSettings.svelte.d.ts.map +1 -0
- package/dist/svelte/i18n.d.ts +9 -0
- package/dist/svelte/i18n.d.ts.map +1 -0
- package/dist/svelte/i18n.js +15 -0
- package/dist/svelte/index.d.ts +6 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +2 -0
- package/dist/svelte/types.d.ts +16 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +1 -0
- package/package.json +88 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
import { ObjectRegistry, smrt, SmrtObject, SmrtCollection, foreignKey, crossPackageRef, field } from "@happyvertical/smrt-core";
|
|
2
|
+
import { tenantId, TenantScoped, getCurrentTenant, withTenant } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { SecretService } from "@happyvertical/smrt-secrets";
|
|
5
|
+
import { getSocialPlatformSetup } from "./server.js";
|
|
6
|
+
import { VideoContent } from "@happyvertical/smrt-video";
|
|
7
|
+
ObjectRegistry.registerPackageManifest(
|
|
8
|
+
new URL("./manifest.json", import.meta.url)
|
|
9
|
+
);
|
|
10
|
+
var __defProp$3 = Object.defineProperty;
|
|
11
|
+
var __getOwnPropDesc$3 = Object.getOwnPropertyDescriptor;
|
|
12
|
+
var __decorateClass$3 = (decorators, target, key, kind) => {
|
|
13
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$3(target, key) : target;
|
|
14
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
15
|
+
if (decorator = decorators[i])
|
|
16
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
17
|
+
if (kind && result) __defProp$3(target, key, result);
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
let OAuthState = class extends SmrtObject {
|
|
21
|
+
tenantId = null;
|
|
22
|
+
/**
|
|
23
|
+
* Platform being connected
|
|
24
|
+
*/
|
|
25
|
+
platform = "youtube";
|
|
26
|
+
/**
|
|
27
|
+
* CSRF state token
|
|
28
|
+
* This is sent to the OAuth provider and verified on callback
|
|
29
|
+
*/
|
|
30
|
+
state = "";
|
|
31
|
+
/**
|
|
32
|
+
* PKCE code verifier
|
|
33
|
+
* Required for platforms using PKCE (YouTube, etc.)
|
|
34
|
+
*/
|
|
35
|
+
codeVerifier = null;
|
|
36
|
+
/**
|
|
37
|
+
* Redirect URI used in the OAuth request
|
|
38
|
+
* Must match on callback for verification
|
|
39
|
+
*/
|
|
40
|
+
redirectUri = "";
|
|
41
|
+
/**
|
|
42
|
+
* Requested OAuth scopes
|
|
43
|
+
*/
|
|
44
|
+
scopes = [];
|
|
45
|
+
/**
|
|
46
|
+
* When this state expires
|
|
47
|
+
* States should be short-lived (10 minutes typical)
|
|
48
|
+
*/
|
|
49
|
+
expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
super(options);
|
|
52
|
+
if (options.platform !== void 0) this.platform = options.platform;
|
|
53
|
+
if (options.state !== void 0) this.state = options.state;
|
|
54
|
+
if (options.codeVerifier !== void 0)
|
|
55
|
+
this.codeVerifier = options.codeVerifier;
|
|
56
|
+
if (options.redirectUri !== void 0)
|
|
57
|
+
this.redirectUri = options.redirectUri;
|
|
58
|
+
if (options.scopes !== void 0) this.scopes = options.scopes;
|
|
59
|
+
if (options.expiresAt !== void 0) this.expiresAt = options.expiresAt;
|
|
60
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Check if the state has expired
|
|
64
|
+
*/
|
|
65
|
+
get isExpired() {
|
|
66
|
+
return Date.now() >= this.expiresAt.getTime();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if the state is still valid
|
|
70
|
+
*/
|
|
71
|
+
get isValid() {
|
|
72
|
+
return !this.isExpired && this.state.length > 0;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Verify a callback state matches this record
|
|
76
|
+
*/
|
|
77
|
+
verifyState(callbackState) {
|
|
78
|
+
return this.isValid && this.state === callbackState;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Generate a new random state token
|
|
82
|
+
*/
|
|
83
|
+
static generateState() {
|
|
84
|
+
return crypto.randomUUID();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Generate a PKCE code verifier
|
|
88
|
+
* Returns a 43-128 character random string
|
|
89
|
+
*/
|
|
90
|
+
static generateCodeVerifier() {
|
|
91
|
+
const array = new Uint8Array(32);
|
|
92
|
+
crypto.getRandomValues(array);
|
|
93
|
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
94
|
+
""
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Generate PKCE code challenge from verifier (S256 method)
|
|
99
|
+
* Note: This requires async crypto operations
|
|
100
|
+
*/
|
|
101
|
+
static async generateCodeChallenge(verifier) {
|
|
102
|
+
const encoder = new TextEncoder();
|
|
103
|
+
const data = encoder.encode(verifier);
|
|
104
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
105
|
+
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
|
|
106
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
__decorateClass$3([
|
|
110
|
+
tenantId({ nullable: true })
|
|
111
|
+
], OAuthState.prototype, "tenantId", 2);
|
|
112
|
+
OAuthState = __decorateClass$3([
|
|
113
|
+
TenantScoped({ mode: "optional" }),
|
|
114
|
+
smrt({
|
|
115
|
+
tableStrategy: "sti",
|
|
116
|
+
api: {
|
|
117
|
+
include: ["list", "get", "create", "delete"]
|
|
118
|
+
},
|
|
119
|
+
mcp: false,
|
|
120
|
+
cli: true
|
|
121
|
+
})
|
|
122
|
+
], OAuthState);
|
|
123
|
+
class OAuthStateCollection extends SmrtCollection {
|
|
124
|
+
static _itemClass = OAuthState;
|
|
125
|
+
async findByState(state) {
|
|
126
|
+
return this.get({ state });
|
|
127
|
+
}
|
|
128
|
+
async findExpired(now = /* @__PURE__ */ new Date()) {
|
|
129
|
+
return this.list({
|
|
130
|
+
where: { "expiresAt <=": now },
|
|
131
|
+
orderBy: "expiresAt ASC"
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async deleteExpired(now = /* @__PURE__ */ new Date()) {
|
|
135
|
+
const expired = await this.findExpired(now);
|
|
136
|
+
await Promise.all(expired.map((state) => state.delete()));
|
|
137
|
+
return expired.length;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
var __defProp$2 = Object.defineProperty;
|
|
141
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
142
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
143
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
144
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
145
|
+
if (decorator = decorators[i])
|
|
146
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
147
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
148
|
+
return result;
|
|
149
|
+
};
|
|
150
|
+
let SocialAccount = class extends SmrtObject {
|
|
151
|
+
tenantId = null;
|
|
152
|
+
/**
|
|
153
|
+
* Human-readable name for the account
|
|
154
|
+
*/
|
|
155
|
+
name = "";
|
|
156
|
+
/**
|
|
157
|
+
* Social platform type
|
|
158
|
+
*/
|
|
159
|
+
platform = "youtube";
|
|
160
|
+
/**
|
|
161
|
+
* Platform-specific user ID
|
|
162
|
+
*/
|
|
163
|
+
platformUserId = null;
|
|
164
|
+
/**
|
|
165
|
+
* Platform username/handle
|
|
166
|
+
*/
|
|
167
|
+
platformUsername = null;
|
|
168
|
+
/**
|
|
169
|
+
* Profile URL on the platform
|
|
170
|
+
*/
|
|
171
|
+
platformUrl = null;
|
|
172
|
+
/**
|
|
173
|
+
* Deprecated raw OAuth access token.
|
|
174
|
+
* Prefer credentialSecretId/accessTokenSecretName.
|
|
175
|
+
*/
|
|
176
|
+
accessToken = null;
|
|
177
|
+
/**
|
|
178
|
+
* Deprecated raw OAuth refresh token.
|
|
179
|
+
* Prefer credentialSecretId/refreshTokenSecretName.
|
|
180
|
+
*/
|
|
181
|
+
refreshToken = null;
|
|
182
|
+
/**
|
|
183
|
+
* Secret name containing the complete platform credential payload.
|
|
184
|
+
*/
|
|
185
|
+
credentialSecretId = null;
|
|
186
|
+
/**
|
|
187
|
+
* Secret name containing only the access token.
|
|
188
|
+
*/
|
|
189
|
+
accessTokenSecretName = null;
|
|
190
|
+
/**
|
|
191
|
+
* Secret name containing only the refresh token.
|
|
192
|
+
*/
|
|
193
|
+
refreshTokenSecretName = null;
|
|
194
|
+
/**
|
|
195
|
+
* Token expiration time
|
|
196
|
+
*/
|
|
197
|
+
tokenExpiresAt = null;
|
|
198
|
+
/**
|
|
199
|
+
* Whether the account is active
|
|
200
|
+
*/
|
|
201
|
+
isActive = true;
|
|
202
|
+
/**
|
|
203
|
+
* Default hashtags to add to posts
|
|
204
|
+
*/
|
|
205
|
+
defaultHashtags = [];
|
|
206
|
+
/**
|
|
207
|
+
* Granted OAuth scopes or platform permissions.
|
|
208
|
+
*/
|
|
209
|
+
scopes = [];
|
|
210
|
+
/**
|
|
211
|
+
* Required permissions that still need app review or user grant.
|
|
212
|
+
*/
|
|
213
|
+
missingPermissions = [];
|
|
214
|
+
/**
|
|
215
|
+
* How to handle links in posts
|
|
216
|
+
* - description: Include link in post body/description
|
|
217
|
+
* - reply: Post link as a reply (better for X algorithm)
|
|
218
|
+
* - none: Don't include link
|
|
219
|
+
*/
|
|
220
|
+
linkBehavior = "description";
|
|
221
|
+
/**
|
|
222
|
+
* Safety mode for publish operations.
|
|
223
|
+
*/
|
|
224
|
+
publishMode = "dry_run";
|
|
225
|
+
/**
|
|
226
|
+
* Separate latch required before public publishing is allowed.
|
|
227
|
+
*/
|
|
228
|
+
publicPublishingAllowed = false;
|
|
229
|
+
/**
|
|
230
|
+
* Account connection status
|
|
231
|
+
*/
|
|
232
|
+
status = "connected";
|
|
233
|
+
/**
|
|
234
|
+
* Error message if status is 'error'
|
|
235
|
+
*/
|
|
236
|
+
errorMessage = null;
|
|
237
|
+
constructor(options = {}) {
|
|
238
|
+
super(options);
|
|
239
|
+
if (options.name !== void 0) this.name = options.name;
|
|
240
|
+
if (options.platform !== void 0) this.platform = options.platform;
|
|
241
|
+
if (options.platformUserId !== void 0)
|
|
242
|
+
this.platformUserId = options.platformUserId;
|
|
243
|
+
if (options.platformUsername !== void 0)
|
|
244
|
+
this.platformUsername = options.platformUsername;
|
|
245
|
+
if (options.platformUrl !== void 0)
|
|
246
|
+
this.platformUrl = options.platformUrl;
|
|
247
|
+
if (options.accessToken !== void 0)
|
|
248
|
+
this.accessToken = options.accessToken;
|
|
249
|
+
if (options.refreshToken !== void 0)
|
|
250
|
+
this.refreshToken = options.refreshToken;
|
|
251
|
+
if (options.credentialSecretId !== void 0)
|
|
252
|
+
this.credentialSecretId = options.credentialSecretId;
|
|
253
|
+
if (options.accessTokenSecretName !== void 0)
|
|
254
|
+
this.accessTokenSecretName = options.accessTokenSecretName;
|
|
255
|
+
if (options.refreshTokenSecretName !== void 0)
|
|
256
|
+
this.refreshTokenSecretName = options.refreshTokenSecretName;
|
|
257
|
+
if (options.tokenExpiresAt !== void 0)
|
|
258
|
+
this.tokenExpiresAt = options.tokenExpiresAt;
|
|
259
|
+
if (options.isActive !== void 0) this.isActive = options.isActive;
|
|
260
|
+
if (options.defaultHashtags !== void 0)
|
|
261
|
+
this.defaultHashtags = options.defaultHashtags;
|
|
262
|
+
if (options.scopes !== void 0) this.scopes = options.scopes;
|
|
263
|
+
if (options.missingPermissions !== void 0)
|
|
264
|
+
this.missingPermissions = options.missingPermissions;
|
|
265
|
+
if (options.linkBehavior !== void 0)
|
|
266
|
+
this.linkBehavior = options.linkBehavior;
|
|
267
|
+
if (options.publishMode !== void 0)
|
|
268
|
+
this.publishMode = options.publishMode;
|
|
269
|
+
if (options.publicPublishingAllowed !== void 0)
|
|
270
|
+
this.publicPublishingAllowed = options.publicPublishingAllowed;
|
|
271
|
+
if (options.status !== void 0) this.status = options.status;
|
|
272
|
+
if (options.errorMessage !== void 0)
|
|
273
|
+
this.errorMessage = options.errorMessage;
|
|
274
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
275
|
+
if (options.platform !== void 0) {
|
|
276
|
+
const setup = getSocialPlatformSetup(this.platform);
|
|
277
|
+
if (options.linkBehavior === void 0) {
|
|
278
|
+
this.linkBehavior = setup.defaultLinkBehavior;
|
|
279
|
+
}
|
|
280
|
+
if (options.publishMode === void 0) {
|
|
281
|
+
this.publishMode = setup.defaultPublishMode;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Social accounts need a slug identity that is scoped by tenant and platform.
|
|
287
|
+
* A newsroom may connect `@localnews` on X, YouTube, Threads, and Facebook;
|
|
288
|
+
* the generic name-derived slug would make those accounts overwrite each
|
|
289
|
+
* other through SMRT's slug/context upsert identity.
|
|
290
|
+
*/
|
|
291
|
+
async getSlug() {
|
|
292
|
+
if (!this.slug) {
|
|
293
|
+
const identity = this.platformUserId || this.platformUsername || this.name || this.id;
|
|
294
|
+
if (!identity) {
|
|
295
|
+
return this.slug;
|
|
296
|
+
}
|
|
297
|
+
const source = [
|
|
298
|
+
this.tenantId ? `tenant-${this.tenantId}` : "no-tenant",
|
|
299
|
+
this.platform,
|
|
300
|
+
identity
|
|
301
|
+
].filter(Boolean).join("-");
|
|
302
|
+
this.slug = source.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
303
|
+
}
|
|
304
|
+
return this.slug;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Check if the token is expired or will expire soon
|
|
308
|
+
*/
|
|
309
|
+
get isTokenExpired() {
|
|
310
|
+
if (!this.tokenExpiresAt) return false;
|
|
311
|
+
const expiryBuffer = 5 * 60 * 1e3;
|
|
312
|
+
return Date.now() >= this.tokenExpiresAt.getTime() - expiryBuffer;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Check if the account needs attention (expired or error)
|
|
316
|
+
*/
|
|
317
|
+
get needsAttention() {
|
|
318
|
+
return this.status !== "connected" || this.isTokenExpired || this.missingPermissions.length > 0 || this.publishMode === "public" && !this.publicPublishingAllowed;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Check if the account is ready for publishing
|
|
322
|
+
*/
|
|
323
|
+
get isReady() {
|
|
324
|
+
return this.isActive && this.status === "connected" && this.hasCredentials && this.missingPermissions.length === 0 && !this.isTokenExpired && (this.publishMode !== "public" || this.publicPublishingAllowed);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Effective publish mode after applying the public-publishing latch.
|
|
328
|
+
*/
|
|
329
|
+
get effectivePublishMode() {
|
|
330
|
+
if (this.publishMode === "public" && !this.publicPublishingAllowed) {
|
|
331
|
+
return "dry_run";
|
|
332
|
+
}
|
|
333
|
+
return this.publishMode;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Check whether any usable credential reference exists.
|
|
337
|
+
*/
|
|
338
|
+
get hasCredentials() {
|
|
339
|
+
return Boolean(
|
|
340
|
+
this.credentialSecretId || this.accessTokenSecretName || this.accessToken
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Store all platform credentials in smrt-secrets as a single JSON payload.
|
|
345
|
+
*/
|
|
346
|
+
async setCredentials(credentials, options = {}) {
|
|
347
|
+
if (!this.id) {
|
|
348
|
+
this.id = randomUUID();
|
|
349
|
+
}
|
|
350
|
+
const secretName = this.credentialSecretId ?? `social-account-${this.id}`;
|
|
351
|
+
const tenantId2 = this.requireCredentialTenantId(
|
|
352
|
+
"store social account credentials"
|
|
353
|
+
);
|
|
354
|
+
const secretService = await SecretService.create({ db: this.db });
|
|
355
|
+
await secretService.storeForTenant(
|
|
356
|
+
tenantId2,
|
|
357
|
+
secretName,
|
|
358
|
+
JSON.stringify(credentials),
|
|
359
|
+
{
|
|
360
|
+
description: options.description ?? `Credentials for ${this.name}`,
|
|
361
|
+
category: options.category ?? "social"
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
this.credentialSecretId = secretName;
|
|
365
|
+
await this.withCredentialTenantContext(async () => {
|
|
366
|
+
await this.save();
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Retrieve platform credentials from smrt-secrets, falling back to deprecated fields.
|
|
371
|
+
*/
|
|
372
|
+
async getCredentials() {
|
|
373
|
+
if (!this.credentialSecretId) {
|
|
374
|
+
if (this.accessTokenSecretName || this.refreshTokenSecretName) {
|
|
375
|
+
const tenantId3 = this.requireCredentialTenantId(
|
|
376
|
+
"retrieve social account credentials"
|
|
377
|
+
);
|
|
378
|
+
const secretService2 = await SecretService.create({ db: this.db });
|
|
379
|
+
const credentials = {};
|
|
380
|
+
if (this.accessTokenSecretName) {
|
|
381
|
+
const secret2 = await secretService2.retrieveForTenant(
|
|
382
|
+
tenantId3,
|
|
383
|
+
this.accessTokenSecretName
|
|
384
|
+
);
|
|
385
|
+
credentials.accessToken = secret2.value;
|
|
386
|
+
}
|
|
387
|
+
if (this.refreshTokenSecretName) {
|
|
388
|
+
const secret2 = await secretService2.retrieveForTenant(
|
|
389
|
+
tenantId3,
|
|
390
|
+
this.refreshTokenSecretName
|
|
391
|
+
);
|
|
392
|
+
credentials.refreshToken = secret2.value;
|
|
393
|
+
}
|
|
394
|
+
return credentials;
|
|
395
|
+
}
|
|
396
|
+
if (!this.accessToken && !this.refreshToken) return null;
|
|
397
|
+
return {
|
|
398
|
+
accessToken: this.accessToken,
|
|
399
|
+
refreshToken: this.refreshToken
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const credentialSecretId = this.credentialSecretId;
|
|
403
|
+
const tenantId2 = this.requireCredentialTenantId(
|
|
404
|
+
"retrieve social account credentials"
|
|
405
|
+
);
|
|
406
|
+
const secretService = await SecretService.create({ db: this.db });
|
|
407
|
+
const secret = await secretService.retrieveForTenant(
|
|
408
|
+
tenantId2,
|
|
409
|
+
credentialSecretId
|
|
410
|
+
);
|
|
411
|
+
return JSON.parse(secret.value);
|
|
412
|
+
}
|
|
413
|
+
async withCredentialTenantContext(fn) {
|
|
414
|
+
const tenantId2 = this.getCredentialTenantId();
|
|
415
|
+
const currentTenantId = getCurrentTenant()?.tenantId;
|
|
416
|
+
if (!tenantId2 || currentTenantId === tenantId2) {
|
|
417
|
+
return fn();
|
|
418
|
+
}
|
|
419
|
+
return withTenant({ tenantId: tenantId2 }, fn);
|
|
420
|
+
}
|
|
421
|
+
getCredentialTenantId() {
|
|
422
|
+
if (this.tenantId) {
|
|
423
|
+
return this.tenantId;
|
|
424
|
+
}
|
|
425
|
+
return getCurrentTenant()?.tenantId ?? null;
|
|
426
|
+
}
|
|
427
|
+
requireCredentialTenantId(action) {
|
|
428
|
+
const tenantId2 = this.getCredentialTenantId();
|
|
429
|
+
if (!tenantId2) {
|
|
430
|
+
throw new Error(`Tenant context required to ${action}.`);
|
|
431
|
+
}
|
|
432
|
+
return tenantId2;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
__decorateClass$2([
|
|
436
|
+
tenantId({ nullable: true })
|
|
437
|
+
], SocialAccount.prototype, "tenantId", 2);
|
|
438
|
+
SocialAccount = __decorateClass$2([
|
|
439
|
+
TenantScoped({ mode: "optional" }),
|
|
440
|
+
smrt({
|
|
441
|
+
tableStrategy: "sti",
|
|
442
|
+
api: {
|
|
443
|
+
include: ["list", "get", "create", "update", "delete"]
|
|
444
|
+
},
|
|
445
|
+
mcp: {
|
|
446
|
+
include: ["list", "get"]
|
|
447
|
+
},
|
|
448
|
+
cli: true
|
|
449
|
+
})
|
|
450
|
+
], SocialAccount);
|
|
451
|
+
class SocialAccountCollection extends SmrtCollection {
|
|
452
|
+
static _itemClass = SocialAccount;
|
|
453
|
+
async findActive(platform) {
|
|
454
|
+
const accounts = await this.list({
|
|
455
|
+
where: {
|
|
456
|
+
isActive: true,
|
|
457
|
+
...platform ? { platform } : {}
|
|
458
|
+
},
|
|
459
|
+
orderBy: "name ASC"
|
|
460
|
+
});
|
|
461
|
+
return accounts;
|
|
462
|
+
}
|
|
463
|
+
async findReady(platform) {
|
|
464
|
+
const accounts = await this.findActive(platform);
|
|
465
|
+
return accounts.filter((account) => account.isReady);
|
|
466
|
+
}
|
|
467
|
+
async findNeedsAttention() {
|
|
468
|
+
const accounts = await this.list({
|
|
469
|
+
where: { isActive: true },
|
|
470
|
+
orderBy: "name ASC"
|
|
471
|
+
});
|
|
472
|
+
return accounts.filter((account) => account.needsAttention);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
var __defProp$1 = Object.defineProperty;
|
|
476
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
477
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
478
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
479
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
480
|
+
if (decorator = decorators[i])
|
|
481
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
482
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
483
|
+
return result;
|
|
484
|
+
};
|
|
485
|
+
let SocialPost = class extends SmrtObject {
|
|
486
|
+
tenantId = null;
|
|
487
|
+
socialAccountId = null;
|
|
488
|
+
videoContentId = null;
|
|
489
|
+
contentId = null;
|
|
490
|
+
/**
|
|
491
|
+
* High-level post type
|
|
492
|
+
*/
|
|
493
|
+
postType = "text";
|
|
494
|
+
/**
|
|
495
|
+
* Public media URL for platforms that require URL media publishing
|
|
496
|
+
*/
|
|
497
|
+
mediaUrl = null;
|
|
498
|
+
/**
|
|
499
|
+
* Post title (for platforms that support it)
|
|
500
|
+
*/
|
|
501
|
+
title = null;
|
|
502
|
+
/**
|
|
503
|
+
* Post description/caption
|
|
504
|
+
*/
|
|
505
|
+
description = "";
|
|
506
|
+
/**
|
|
507
|
+
* Hashtags to include
|
|
508
|
+
*/
|
|
509
|
+
hashtags = [];
|
|
510
|
+
/**
|
|
511
|
+
* Link URL to include in the post
|
|
512
|
+
*/
|
|
513
|
+
linkUrl = null;
|
|
514
|
+
/**
|
|
515
|
+
* Platform-specific post ID (set after publishing)
|
|
516
|
+
*/
|
|
517
|
+
platformPostId = null;
|
|
518
|
+
/**
|
|
519
|
+
* Public post URL (set after publishing)
|
|
520
|
+
*/
|
|
521
|
+
platformUrl = null;
|
|
522
|
+
/**
|
|
523
|
+
* Scheduled publish time
|
|
524
|
+
* If set, post will be scheduled instead of published immediately
|
|
525
|
+
*/
|
|
526
|
+
scheduledAt = null;
|
|
527
|
+
/**
|
|
528
|
+
* Actual publish time
|
|
529
|
+
*/
|
|
530
|
+
publishedAt = null;
|
|
531
|
+
/**
|
|
532
|
+
* Post status
|
|
533
|
+
* - draft: Not yet submitted for publishing
|
|
534
|
+
* - pending_approval: Awaiting editorial approval
|
|
535
|
+
* - approved: Approved and ready to publish
|
|
536
|
+
* - scheduled: Queued for future publishing
|
|
537
|
+
* - publishing: Currently being published
|
|
538
|
+
* - dry_run: Payload was validated without remote write
|
|
539
|
+
* - staged: Non-public platform object/container was created
|
|
540
|
+
* - published: Successfully published
|
|
541
|
+
* - failed: Publishing failed
|
|
542
|
+
* - cancelled: Publishing was cancelled
|
|
543
|
+
*/
|
|
544
|
+
status = "draft";
|
|
545
|
+
/**
|
|
546
|
+
* Error message if status is 'failed'
|
|
547
|
+
*/
|
|
548
|
+
errorMessage = null;
|
|
549
|
+
/**
|
|
550
|
+
* Engagement analytics
|
|
551
|
+
*/
|
|
552
|
+
analytics = {};
|
|
553
|
+
/**
|
|
554
|
+
* When analytics were last synced
|
|
555
|
+
*/
|
|
556
|
+
analyticsLastSyncedAt = null;
|
|
557
|
+
constructor(options = {}) {
|
|
558
|
+
super(options);
|
|
559
|
+
if (options.socialAccountId !== void 0)
|
|
560
|
+
this.socialAccountId = options.socialAccountId;
|
|
561
|
+
if (options.videoContentId !== void 0)
|
|
562
|
+
this.videoContentId = options.videoContentId;
|
|
563
|
+
if (options.contentId !== void 0) this.contentId = options.contentId;
|
|
564
|
+
if (options.postType !== void 0) this.postType = options.postType;
|
|
565
|
+
if (options.mediaUrl !== void 0) this.mediaUrl = options.mediaUrl;
|
|
566
|
+
if (options.title !== void 0) this.title = options.title;
|
|
567
|
+
if (options.description !== void 0)
|
|
568
|
+
this.description = options.description;
|
|
569
|
+
if (options.hashtags !== void 0) this.hashtags = options.hashtags;
|
|
570
|
+
if (options.linkUrl !== void 0) this.linkUrl = options.linkUrl;
|
|
571
|
+
if (options.platformPostId !== void 0)
|
|
572
|
+
this.platformPostId = options.platformPostId;
|
|
573
|
+
if (options.platformUrl !== void 0)
|
|
574
|
+
this.platformUrl = options.platformUrl;
|
|
575
|
+
if (options.scheduledAt !== void 0)
|
|
576
|
+
this.scheduledAt = options.scheduledAt;
|
|
577
|
+
if (options.publishedAt !== void 0)
|
|
578
|
+
this.publishedAt = options.publishedAt;
|
|
579
|
+
if (options.status !== void 0) this.status = options.status;
|
|
580
|
+
if (options.errorMessage !== void 0)
|
|
581
|
+
this.errorMessage = options.errorMessage;
|
|
582
|
+
if (options.analytics !== void 0) this.analytics = options.analytics;
|
|
583
|
+
if (options.analyticsLastSyncedAt !== void 0)
|
|
584
|
+
this.analyticsLastSyncedAt = options.analyticsLastSyncedAt;
|
|
585
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Check if the post is scheduled for future publishing
|
|
589
|
+
*/
|
|
590
|
+
get isScheduled() {
|
|
591
|
+
return this.status === "scheduled" && this.scheduledAt !== null;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Check if the post has been published
|
|
595
|
+
*/
|
|
596
|
+
get isPublished() {
|
|
597
|
+
return this.status === "published";
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Check if the post can be edited (draft or failed)
|
|
601
|
+
*/
|
|
602
|
+
get isEditable() {
|
|
603
|
+
return this.status === "draft" || this.status === "pending_approval" || this.status === "failed";
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Check if the post is due for publishing now.
|
|
607
|
+
*/
|
|
608
|
+
get isDueForPublish() {
|
|
609
|
+
if (this.status !== "approved" && this.status !== "scheduled") {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
return !this.scheduledAt || this.scheduledAt.getTime() <= Date.now();
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Get formatted hashtag string
|
|
616
|
+
*/
|
|
617
|
+
get hashtagString() {
|
|
618
|
+
return this.hashtags.map((tag) => tag.startsWith("#") ? tag : `#${tag}`).join(" ");
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Get the full post text with hashtags
|
|
622
|
+
*/
|
|
623
|
+
get fullText() {
|
|
624
|
+
const parts = [this.description];
|
|
625
|
+
if (this.hashtags.length > 0) {
|
|
626
|
+
parts.push(this.hashtagString);
|
|
627
|
+
}
|
|
628
|
+
return parts.join("\n\n");
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
__decorateClass$1([
|
|
632
|
+
tenantId({ nullable: true })
|
|
633
|
+
], SocialPost.prototype, "tenantId", 2);
|
|
634
|
+
__decorateClass$1([
|
|
635
|
+
foreignKey(() => SocialAccount)
|
|
636
|
+
], SocialPost.prototype, "socialAccountId", 2);
|
|
637
|
+
__decorateClass$1([
|
|
638
|
+
foreignKey(() => VideoContent)
|
|
639
|
+
], SocialPost.prototype, "videoContentId", 2);
|
|
640
|
+
__decorateClass$1([
|
|
641
|
+
crossPackageRef("@happyvertical/smrt-content:Content")
|
|
642
|
+
], SocialPost.prototype, "contentId", 2);
|
|
643
|
+
SocialPost = __decorateClass$1([
|
|
644
|
+
TenantScoped({ mode: "optional" }),
|
|
645
|
+
smrt({
|
|
646
|
+
tableStrategy: "sti",
|
|
647
|
+
api: {
|
|
648
|
+
include: ["list", "get", "create", "update", "delete"]
|
|
649
|
+
},
|
|
650
|
+
mcp: {
|
|
651
|
+
include: ["list", "get"]
|
|
652
|
+
},
|
|
653
|
+
cli: true
|
|
654
|
+
})
|
|
655
|
+
], SocialPost);
|
|
656
|
+
var __defProp = Object.defineProperty;
|
|
657
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
658
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
659
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
660
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
661
|
+
if (decorator = decorators[i])
|
|
662
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
663
|
+
if (kind && result) __defProp(target, key, result);
|
|
664
|
+
return result;
|
|
665
|
+
};
|
|
666
|
+
let SocialPostAnalyticsSnapshot = class extends SmrtObject {
|
|
667
|
+
tenantId = null;
|
|
668
|
+
socialPostId = null;
|
|
669
|
+
platform = null;
|
|
670
|
+
analytics = {};
|
|
671
|
+
raw = null;
|
|
672
|
+
capturedAt = /* @__PURE__ */ new Date();
|
|
673
|
+
constructor(options = {}) {
|
|
674
|
+
super(options);
|
|
675
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
676
|
+
if (options.socialPostId !== void 0)
|
|
677
|
+
this.socialPostId = options.socialPostId;
|
|
678
|
+
if (options.platform !== void 0) this.platform = options.platform;
|
|
679
|
+
if (options.analytics !== void 0) this.analytics = options.analytics;
|
|
680
|
+
if (options.raw !== void 0) this.raw = options.raw;
|
|
681
|
+
if (options.capturedAt !== void 0) this.capturedAt = options.capturedAt;
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
__decorateClass([
|
|
685
|
+
tenantId({ nullable: true })
|
|
686
|
+
], SocialPostAnalyticsSnapshot.prototype, "tenantId", 2);
|
|
687
|
+
__decorateClass([
|
|
688
|
+
foreignKey(() => SocialPost)
|
|
689
|
+
], SocialPostAnalyticsSnapshot.prototype, "socialPostId", 2);
|
|
690
|
+
__decorateClass([
|
|
691
|
+
field({ required: true })
|
|
692
|
+
], SocialPostAnalyticsSnapshot.prototype, "platform", 2);
|
|
693
|
+
__decorateClass([
|
|
694
|
+
field({ type: "json", nullable: true })
|
|
695
|
+
], SocialPostAnalyticsSnapshot.prototype, "raw", 2);
|
|
696
|
+
SocialPostAnalyticsSnapshot = __decorateClass([
|
|
697
|
+
TenantScoped({ mode: "optional" }),
|
|
698
|
+
smrt({
|
|
699
|
+
tableStrategy: "sti",
|
|
700
|
+
api: {
|
|
701
|
+
include: ["list", "get", "create", "delete"]
|
|
702
|
+
},
|
|
703
|
+
mcp: {
|
|
704
|
+
include: ["list", "get"]
|
|
705
|
+
},
|
|
706
|
+
cli: true
|
|
707
|
+
})
|
|
708
|
+
], SocialPostAnalyticsSnapshot);
|
|
709
|
+
function isRawAnalyticsPayload(value) {
|
|
710
|
+
return value === null || Array.isArray(value) || typeof value === "object" && value !== null;
|
|
711
|
+
}
|
|
712
|
+
class SocialPostAnalyticsSnapshotCollection extends SmrtCollection {
|
|
713
|
+
static _itemClass = SocialPostAnalyticsSnapshot;
|
|
714
|
+
async recordSnapshot(options) {
|
|
715
|
+
const snapshot = await this.create({
|
|
716
|
+
socialPostId: options.socialPostId,
|
|
717
|
+
platform: options.platform,
|
|
718
|
+
analytics: options.metrics,
|
|
719
|
+
raw: options.raw ?? (isRawAnalyticsPayload(options.metrics.raw) ? options.metrics.raw : null),
|
|
720
|
+
capturedAt: options.capturedAt ?? /* @__PURE__ */ new Date(),
|
|
721
|
+
tenantId: options.tenantId
|
|
722
|
+
});
|
|
723
|
+
return snapshot;
|
|
724
|
+
}
|
|
725
|
+
async findForPost(socialPostId) {
|
|
726
|
+
return this.list({
|
|
727
|
+
where: { socialPostId },
|
|
728
|
+
orderBy: "capturedAt DESC"
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
async findLatestForPost(socialPostId) {
|
|
732
|
+
const snapshots = await this.findForPost(socialPostId);
|
|
733
|
+
return snapshots[0] ?? null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
class SocialPostCollection extends SmrtCollection {
|
|
737
|
+
static _itemClass = SocialPost;
|
|
738
|
+
async createDraft(options) {
|
|
739
|
+
return this.create({
|
|
740
|
+
...options,
|
|
741
|
+
postType: options.postType ?? (options.videoContentId || options.mediaUrl ? "video" : options.linkUrl ? "link" : "text"),
|
|
742
|
+
status: options.scheduledAt ? "scheduled" : "draft"
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
async findByStatus(status) {
|
|
746
|
+
return this.list({ where: { status }, orderBy: "scheduledAt ASC" });
|
|
747
|
+
}
|
|
748
|
+
async findForAccount(socialAccountId) {
|
|
749
|
+
return this.list({
|
|
750
|
+
where: { socialAccountId },
|
|
751
|
+
orderBy: "created_at DESC"
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
async findDueForPublish(now = /* @__PURE__ */ new Date()) {
|
|
755
|
+
const [approved, scheduled] = await Promise.all([
|
|
756
|
+
this.list({
|
|
757
|
+
where: { status: "approved" },
|
|
758
|
+
orderBy: "scheduledAt ASC"
|
|
759
|
+
}),
|
|
760
|
+
this.list({
|
|
761
|
+
where: { status: "scheduled", "scheduledAt <=": now },
|
|
762
|
+
orderBy: "scheduledAt ASC"
|
|
763
|
+
})
|
|
764
|
+
]);
|
|
765
|
+
return [
|
|
766
|
+
...approved.filter((post) => post.isDueForPublish),
|
|
767
|
+
...scheduled
|
|
768
|
+
].sort(
|
|
769
|
+
(a, b) => (a.scheduledAt?.getTime() ?? 0) - (b.scheduledAt?.getTime() ?? 0)
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
async recordPublishSuccess(post, data) {
|
|
773
|
+
const status = data.status ?? "published";
|
|
774
|
+
post.platformPostId = data.platformPostId;
|
|
775
|
+
post.platformUrl = data.platformUrl;
|
|
776
|
+
if (status === "published") {
|
|
777
|
+
post.publishedAt = data.publishedAt ?? post.publishedAt ?? /* @__PURE__ */ new Date();
|
|
778
|
+
}
|
|
779
|
+
post.status = status;
|
|
780
|
+
post.errorMessage = null;
|
|
781
|
+
await post.save();
|
|
782
|
+
return post;
|
|
783
|
+
}
|
|
784
|
+
async recordPublishFailure(post, error) {
|
|
785
|
+
post.status = "failed";
|
|
786
|
+
post.errorMessage = formatPublishError(error);
|
|
787
|
+
await post.save();
|
|
788
|
+
return post;
|
|
789
|
+
}
|
|
790
|
+
async updateLatestAnalytics(post, analytics, syncedAt = /* @__PURE__ */ new Date()) {
|
|
791
|
+
post.analytics = {
|
|
792
|
+
...analytics,
|
|
793
|
+
lastUpdated: analytics.lastUpdated ?? syncedAt
|
|
794
|
+
};
|
|
795
|
+
post.analyticsLastSyncedAt = syncedAt;
|
|
796
|
+
await post.save();
|
|
797
|
+
return post;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function formatPublishError(error) {
|
|
801
|
+
if (typeof error === "string") {
|
|
802
|
+
return error;
|
|
803
|
+
}
|
|
804
|
+
const details = [error.message];
|
|
805
|
+
const code = error.code;
|
|
806
|
+
if (code !== void 0) {
|
|
807
|
+
details.push(`code=${String(code)}`);
|
|
808
|
+
}
|
|
809
|
+
if (error.stack) {
|
|
810
|
+
details.push(error.stack);
|
|
811
|
+
}
|
|
812
|
+
return details.join("\n");
|
|
813
|
+
}
|
|
814
|
+
export {
|
|
815
|
+
OAuthState,
|
|
816
|
+
OAuthStateCollection,
|
|
817
|
+
SocialAccount,
|
|
818
|
+
SocialAccountCollection,
|
|
819
|
+
SocialPost,
|
|
820
|
+
SocialPostAnalyticsSnapshot,
|
|
821
|
+
SocialPostAnalyticsSnapshotCollection,
|
|
822
|
+
SocialPostCollection
|
|
823
|
+
};
|
|
824
|
+
//# sourceMappingURL=index.js.map
|