@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.
- package/package.json +1 -1
- package/src/server/jobs.ts +87 -0
package/package.json
CHANGED
package/src/server/jobs.ts
CHANGED
|
@@ -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
|
+
}
|