@tribe-nest/forge 2.1.0 → 2.2.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server/jobs.ts +87 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -370,3 +370,90 @@ export async function aggregateAppCollection(
370
370
  if (!res.ok) throw new Error(`aggregateAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
371
371
  return res.json();
372
372
  }
373
+
374
+ /**
375
+ * Upload a file and get back a hosted URL, in two steps.
376
+ *
377
+ * The two steps are not a formality. The bytes go to a STAGING key that does not
378
+ * survive: `finalizeAppUpload` moves the file to its real home, and anything
379
+ * never finalized is deleted within the hour. So the URL you can actually use is
380
+ * the one finalize returns — a staged URL will stop working, and the upload will
381
+ * not be counted against the profile's storage.
382
+ *
383
+ * const up = await createAppUpload(ctx, cfg, "cover.png", bytes.byteLength)
384
+ * await fetch(up.uploadUrl, { method: "PUT", headers: up.requiredHeaders, body: bytes })
385
+ * const media = await finalizeAppUpload(ctx, cfg, up.uploadId) // media.url is yours
386
+ *
387
+ * `size` is a hint so an over-quota upload fails before the transfer rather than
388
+ * after it; the size recorded is whatever storage reports once the bytes land.
389
+ * The file name decides the type, and script-capable types (html, svg) are
390
+ * refused.
391
+ */
392
+ export async function createAppUpload(
393
+ ctx: JobContext,
394
+ cfg: AppJobsConfig,
395
+ fileName: string,
396
+ size?: number,
397
+ ): Promise<{
398
+ uploadId: string;
399
+ uploadUrl: string;
400
+ requiredHeaders: Record<string, string>;
401
+ method: "PUT";
402
+ expiresAt: string;
403
+ }> {
404
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/uploads`, {
405
+ method: "POST",
406
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
407
+ body: JSON.stringify({ fileName, size }),
408
+ });
409
+ if (!res.ok) throw new Error(`createAppUpload failed: ${res.status} ${await res.text().catch(() => "")}`);
410
+ return res.json();
411
+ }
412
+
413
+ /**
414
+ * Complete an upload started with {@link createAppUpload} and get the media row.
415
+ *
416
+ * Safe to retry — finalizing twice returns the same media rather than storing the
417
+ * file twice. Takes no size: the platform reads it back from storage.
418
+ */
419
+ export async function finalizeAppUpload(
420
+ ctx: JobContext,
421
+ cfg: AppJobsConfig,
422
+ uploadId: string,
423
+ ): Promise<{ id: string; url: string; size: string; type: string; name: string }> {
424
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/uploads/${uploadId}/finalize`, {
425
+ method: "POST",
426
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
427
+ body: "{}",
428
+ });
429
+ if (!res.ok) throw new Error(`finalizeAppUpload failed: ${res.status} ${await res.text().catch(() => "")}`);
430
+ return res.json();
431
+ }
432
+
433
+ /**
434
+ * Send an email as this app.
435
+ *
436
+ * The From resolves to the app's own sending identity when the owner configured
437
+ * one, otherwise the profile's — so mail is attributed to the creator, not to
438
+ * TribeNest, without the app holding any mail credentials.
439
+ *
440
+ * await sendAppEmail(ctx, cfg, { to: user.email, subject: "Booked", html })
441
+ *
442
+ * Bounded on purpose: at most 50 recipients per call, and a daily per-app
443
+ * recipient budget, because the send is charged to the owner's allocation. For a
444
+ * campaign, use the platform's messaging tools instead — they handle consent and
445
+ * unsubscribes, which this deliberately does not.
446
+ */
447
+ export async function sendAppEmail(
448
+ ctx: JobContext,
449
+ cfg: AppJobsConfig,
450
+ msg: { to: string | string[]; subject: string; html: string; replyTo?: string },
451
+ ): Promise<{ sent: number }> {
452
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/emails`, {
453
+ method: "POST",
454
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
455
+ body: JSON.stringify(msg),
456
+ });
457
+ if (!res.ok) throw new Error(`sendAppEmail failed: ${res.status} ${await res.text().catch(() => "")}`);
458
+ return res.json();
459
+ }