From 8cff8e6b0326f0c974c15027811af61ed1f6105b Mon Sep 17 00:00:00 2001 From: Rajat Date: Mon, 2 Nov 2026 13:54:14 +0530 Subject: [PATCH 1/4] feat(web): upload a broadcast's social image instead of pasting a URL The Web page dialog of a broadcast now has a Social image control that reuses the email image picker (Upload, Your Media, Unsplash) in place of the "Social image URL" field. An uploaded MediaLit image is sealed on save, added to the media library and tracked with a new BROADCAST_WEB_PAGE media reference, so it can't be deleted from the library while the page uses it. Replacing or removing the image releases the reference but keeps the media; deleting the broadcast releases it and cleans up orphaned media. Other URLs, including ones sent through REST or MCP, are stored as they are. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/docs/media-library.md | 17 ++++- apps/api/src/mcp/tools/schemas.ts | 2 +- apps/api/src/media/email-content.ts | 32 +++++++- apps/api/src/media/queries.ts | 6 +- apps/api/src/media/routes.ts | 3 +- apps/api/src/sequences/queries.ts | 10 ++- apps/api/src/web/queries.ts | 13 +++- apps/api/src/web/web.test.ts | 76 ++++++++++++++++++- apps/web/app/(dashboard)/media/page.tsx | 8 +- .../dashboard/broadcast-web-dialog.tsx | 54 ++++++++++--- .../dashboard/email-image-upload-dialog.tsx | 6 +- apps/web/lib/api.ts | 2 +- packages/api-contract/src/schemas/media.ts | 2 +- packages/api-contract/src/schemas/web.ts | 7 +- 14 files changed, 213 insertions(+), 25 deletions(-) diff --git a/apps/api/docs/media-library.md b/apps/api/docs/media-library.md index 8dd190d9..d52f7f49 100644 --- a/apps/api/docs/media-library.md +++ b/apps/api/docs/media-library.md @@ -150,7 +150,10 @@ interface Media { Media references record where a media asset is used. ```ts -type MediaReferenceResourceType = "TEMPLATE" | "SEQUENCE_EMAIL"; +type MediaReferenceResourceType = + | "TEMPLATE" + | "SEQUENCE_EMAIL" + | "BROADCAST_WEB_PAGE"; interface MediaReference { id: string; @@ -176,6 +179,8 @@ For `SEQUENCE_EMAIL`, `resourceInternalId` should be `sequence_emails.id` and `r Broadcast email content is stored as a `sequence_emails` row under a `sequences` row with `type = 'broadcast'`, so broadcast usage should be represented as `SEQUENCE_EMAIL` with parent sequence context. +For `BROADCAST_WEB_PAGE`, `resourceInternalId` should be `sequences.id` and `resourcePublicId` should be `sequences.sequenceId`. It records that a broadcast's public web page uses the image as its social (Open Graph) image, and it is kept apart from `SEQUENCE_EMAIL` because saving the email body replaces that resource's references. + References should be recalculated when email content is saved. They should be removed when the owning template, broadcast, sequence, or sequence email is deleted. ## Email Content Contract @@ -268,6 +273,16 @@ This can be implemented either as a diff or by deleting all existing references Unsplash and external image URLs are ignored by media reference reconciliation because SendLit does not own or clean up those assets. +### Social image of a broadcast's web page + +The Web page dialog of a broadcast lets the user upload a social image or pick one from the media library, through the same picker as the email image block. The API treats `ogImage` like an email image: + +1. A MediaLit URL is sealed (once), registered in the media library, and stored as the sealed URL. +2. The broadcast's `BROADCAST_WEB_PAGE` reference is replaced with a reference to that media, so the image can't be deleted from the media library while the page uses it. +3. Any other URL (including one entered through the REST API or MCP) is stored as it is, and the reference is cleared. +4. Replacing or removing the image releases the reference but never deletes the media. +5. Deleting the broadcast releases the reference and runs `deleteOrphanedMedia` like it does for its emails. + Editing content never deletes media: an image removed from an email or template stays in the media library, because sent versions of that email may still show it and the user may add it back. Users delete unused images from the Media page. On deleting a template, broadcast, sequence, or sequence email (#157): diff --git a/apps/api/src/mcp/tools/schemas.ts b/apps/api/src/mcp/tools/schemas.ts index 96be22df..2e21f283 100644 --- a/apps/api/src/mcp/tools/schemas.ts +++ b/apps/api/src/mcp/tools/schemas.ts @@ -86,7 +86,7 @@ export const mediaListSchema = z.object({ }); const mediaReferenceSchema = z.object({ - resourceType: z.enum(["TEMPLATE", "SEQUENCE_EMAIL"]), + resourceType: z.enum(["TEMPLATE", "SEQUENCE_EMAIL", "BROADCAST_WEB_PAGE"]), resourcePublicId: z.string(), parentResourcePublicId: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), diff --git a/apps/api/src/media/email-content.ts b/apps/api/src/media/email-content.ts index 95197bb6..a70bedca 100644 --- a/apps/api/src/media/email-content.ts +++ b/apps/api/src/media/email-content.ts @@ -96,7 +96,7 @@ function updateImageSrc(content: EmailContent | null, mediaLitId: string, src: s } } -async function ensureUploadedMedia({ +export async function ensureUploadedMedia({ teamId, mediaLitId, alt, @@ -170,3 +170,33 @@ export async function syncEmailContentMediaReferences({ return reconciledContent; } + +/** + * Registers a single image (for example a broadcast's social image) as the + * only media of a resource. An uploaded MediaLit file is sealed, added to the + * media library and referenced, so it can't be deleted while in use; any + * other URL is kept as it is and the resource references no media. Returns + * the URL to store, which is the sealed one for MediaLit files. + */ +export async function syncImageMediaReference({ + teamId, + url, + alt, + resource, +}: { + teamId: string; + url?: string | null; + alt?: string; + resource: MediaReferenceResource; +}): Promise { + const mediaLitId = url ? extractMediaLitIdFromUrl(url) : undefined; + const row = mediaLitId + ? await ensureUploadedMedia({ teamId, mediaLitId, alt }) + : null; + await replaceMediaReferencesForResource({ + teamId, + resource, + mediaRows: row ? [row] : [], + }); + return row?.url ?? url ?? null; +} diff --git a/apps/api/src/media/queries.ts b/apps/api/src/media/queries.ts index 9e83462c..90510c63 100644 --- a/apps/api/src/media/queries.ts +++ b/apps/api/src/media/queries.ts @@ -7,7 +7,11 @@ import { deleteMedia as deleteMediaFile } from "./service"; export type Media = typeof media.$inferSelect; export type MediaReference = typeof mediaReferences.$inferSelect; -export type MediaReferenceResourceType = "TEMPLATE" | "SEQUENCE_EMAIL"; +export type MediaReferenceResourceType = + | "TEMPLATE" + | "SEQUENCE_EMAIL" + /** A broadcast's web page, which shows the media as its social image. */ + | "BROADCAST_WEB_PAGE"; export interface MediaReferenceResource { resourceType: MediaReferenceResourceType; diff --git a/apps/api/src/media/routes.ts b/apps/api/src/media/routes.ts index 3f1a9688..b4cc2f01 100644 --- a/apps/api/src/media/routes.ts +++ b/apps/api/src/media/routes.ts @@ -11,6 +11,7 @@ import { getMediaByMediaId, listMedia, listMediaReferences, + type MediaReferenceResourceType, updateMediaMetadata, } from "./queries"; import { getMediaUploadSignature } from "./service"; @@ -91,7 +92,7 @@ const impl = s.router(contract.media, { body: { items: serializeDates( references.map((reference) => ({ - resourceType: reference.resourceType as "TEMPLATE" | "SEQUENCE_EMAIL", + resourceType: reference.resourceType as MediaReferenceResourceType, resourcePublicId: reference.resourcePublicId, parentResourcePublicId: reference.parentResourcePublicId, createdAt: reference.createdAt, diff --git a/apps/api/src/sequences/queries.ts b/apps/api/src/sequences/queries.ts index ac186d04..37cb9bd3 100644 --- a/apps/api/src/sequences/queries.ts +++ b/apps/api/src/sequences/queries.ts @@ -853,7 +853,15 @@ export async function deleteSequence({ throw new Error(deleteBlockerOf(current) ?? "sequence_active"); } - const mediaIds = await releaseEmailMedia(teamId, emailIds, sentIds); + const mediaIds = [ + ...(await releaseEmailMedia(teamId, emailIds, sentIds)), + // The web page goes with the broadcast, so its social image is released too. + ...(await deleteMediaReferencesForResource({ + teamId, + resourceType: "BROADCAST_WEB_PAGE", + resourceInternalId: sequence.id, + })), + ]; const media = await deleteOrphanedMedia(teamId, mediaIds); captureEvent({ diff --git a/apps/api/src/web/queries.ts b/apps/api/src/web/queries.ts index 751d6ef8..1a8033d8 100644 --- a/apps/api/src/web/queries.ts +++ b/apps/api/src/web/queries.ts @@ -5,6 +5,7 @@ import { db } from "../db/client"; import { sequenceEmails, sequences, settings, signupForms, teams } from "../db/schema"; import { createForm } from "../forms/queries"; import { renderCampaignHtml } from "../mail/campaign"; +import { syncImageMediaReference } from "../media/email-content"; import { getTeamSettingsRow, type TeamSettings } from "../settings/general/queries"; import { archiveUrl, issueUrl } from "./urls"; @@ -246,7 +247,17 @@ export async function updateWebPublishing( if (input.seoDescription !== undefined) { values.webSeoDescription = input.seoDescription || null; } - if (input.ogImage !== undefined) values.webOgImage = input.ogImage || null; + if (input.ogImage !== undefined) { + values.webOgImage = await syncImageMediaReference({ + teamId: sequence.teamId, + url: input.ogImage, + resource: { + resourceType: "BROADCAST_WEB_PAGE", + resourceInternalId: sequence.id, + resourcePublicId: sequence.sequenceId, + }, + }); + } if (input.noindex !== undefined) values.webNoindex = input.noindex; if (input.canonicalUrl !== undefined) values.webCanonicalUrl = input.canonicalUrl || null; diff --git a/apps/api/src/web/web.test.ts b/apps/api/src/web/web.test.ts index 9e2ed4d8..77faf9c3 100644 --- a/apps/api/src/web/web.test.ts +++ b/apps/api/src/web/web.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; import express from "express"; mock.module("../db/client", async () => { @@ -6,9 +6,21 @@ mock.module("../db/client", async () => { return { db: await makeTestDb() }; }); +mock.module("../media/service", () => ({ + sealMedia: vi.fn((mediaId: string) => + Promise.resolve({ mediaId, file: `https://cdn.test/p/${mediaId}/main.webp` }), + ), + deleteMedia: vi.fn(), +})); +mock.module("../media/deletion-queue", () => ({ queueMediaLitDeletion: vi.fn() })); + const { eq } = await import("drizzle-orm"); const { db } = await import("../db/client"); -const { sequenceEmails, sequences, signupForms } = await import("../db/schema"); +const { media, mediaReferences, sequenceEmails, sequences, signupForms } = await import( + "../db/schema" +); +const { deleteMedia, sealMedia } = await import("../media/service"); +const { deleteSequence } = await import("../sequences/queries"); const { seedTeamAndContact, truncateAll } = await import("../test/db"); const { emailContent, seedSequence } = await import("../test/fixtures"); const { requestApp } = await import("../test/http"); @@ -137,6 +149,66 @@ describe("web publishing", () => { }); }); + it("seals an uploaded social image into the media library and keeps it in use", async () => { + const { team } = await setup(); + const issue = await seedBroadcast(team.id, "With image"); + + const first = await updateWebPublishing(issue, { + ogImage: "https://cdn.test/draft/og-upload/main.png", + }); + expect(sealMedia).toHaveBeenCalledWith("og-upload"); + expect(first.ogImage).toBe("https://cdn.test/p/og-upload/main.webp"); + const [row] = await tdb.select().from(media).where(eq(media.teamId, team.id)); + expect(row.mediaLitId).toBe("og-upload"); + const refs = await tdb.select().from(mediaReferences); + expect(refs).toMatchObject([ + { + mediaId: row.id, + resourceType: "BROADCAST_WEB_PAGE", + resourcePublicId: issue.sequenceId, + }, + ]); + + // Choosing it again from the library doesn't seal it twice. + (sealMedia as unknown as ReturnType).mockClear(); + await updateWebPublishing(issue, { ogImage: first.ogImage }); + expect(sealMedia).not.toHaveBeenCalled(); + expect(await tdb.select().from(mediaReferences)).toHaveLength(1); + + // An external URL stays as it is and releases the library image, which + // stays in the library. + const external = await updateWebPublishing(issue, { + ogImage: "https://example.com/og.png", + }); + expect(external.ogImage).toBe("https://example.com/og.png"); + expect(await tdb.select().from(mediaReferences)).toHaveLength(0); + expect(await tdb.select().from(media)).toHaveLength(1); + + const cleared = await updateWebPublishing(issue, { ogImage: null }); + expect(cleared.ogImage).toBeNull(); + }); + + it("releases the social image when the broadcast is deleted", async () => { + const { team } = await setup(); + const sent = await seedBroadcast(team.id, "Draft with image", false); + const [issue] = await tdb + .update(sequences) + .set({ status: "draft" }) + .where(eq(sequences.id, sent.id)) + .returning(); + await updateWebPublishing(issue, { + ogImage: "https://cdn.test/draft/og-deleted/main.png", + }); + expect(await tdb.select().from(media)).toHaveLength(1); + + expect( + await deleteSequence({ teamId: team.id, sequenceId: issue.sequenceId }), + ).toBe(true); + expect(await tdb.select().from(mediaReferences)).toHaveLength(0); + expect(await tdb.select().from(media)).toHaveLength(0); + expect(deleteMedia).toHaveBeenCalledWith("og-deleted"); + }); + it("lists only published issues that were sent, newest first", async () => { const { team } = await setup(); const sent = await seedBroadcast(team.id, "Sent issue"); diff --git a/apps/web/app/(dashboard)/media/page.tsx b/apps/web/app/(dashboard)/media/page.tsx index 62979a3d..3a218031 100644 --- a/apps/web/app/(dashboard)/media/page.tsx +++ b/apps/web/app/(dashboard)/media/page.tsx @@ -34,6 +34,12 @@ type MediaWithReferences = Media & { references: MediaReference[] }; const PAGE_SIZE = 50; +const REFERENCE_LABELS: Record = { + TEMPLATE: "Template", + SEQUENCE_EMAIL: "Email", + BROADCAST_WEB_PAGE: "Web page of broadcast", +}; + export default function MediaPage() { useSetBreadcrumb([{ label: "Media" }]); const [items, setItems] = useState(null); @@ -319,7 +325,7 @@ function MediaDetailsDialog({ key={`${reference.resourceType}:${reference.resourcePublicId}`} className="truncate" > - {reference.resourceType === "TEMPLATE" ? "Template" : "Email"}{" "} + {REFERENCE_LABELS[reference.resourceType]}{" "} {reference.parentResourcePublicId ? `${reference.parentResourcePublicId} / ` : ""} diff --git a/apps/web/components/dashboard/broadcast-web-dialog.tsx b/apps/web/components/dashboard/broadcast-web-dialog.tsx index c14645d7..949453f5 100644 --- a/apps/web/components/dashboard/broadcast-web-dialog.tsx +++ b/apps/web/components/dashboard/broadcast-web-dialog.tsx @@ -1,8 +1,9 @@ "use client"; -import { ExternalLink, Globe } from "lucide-react"; +import { ExternalLink, Globe, ImagePlus, Trash2 } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { EmailImageUploadDialog } from "@/components/dashboard/email-image-upload-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/codelit/button"; import { @@ -24,6 +25,8 @@ import { } from "@/lib/api"; import { ApiError } from "@/lib/api-client"; +const SOCIAL_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]; + const ERRORS: Record = { slug_taken: "Another issue already uses this URL slug.", }; @@ -37,6 +40,7 @@ export function BroadcastWebDialog({ sequenceId }: { sequenceId: string }) { const [seoTitle, setSeoTitle] = useState(""); const [seoDescription, setSeoDescription] = useState(""); const [ogImage, setOgImage] = useState(""); + const [pickingImage, setPickingImage] = useState(false); const [canonicalUrl, setCanonicalUrl] = useState(""); const [noindex, setNoindex] = useState(false); const [saving, setSaving] = useState(false); @@ -164,14 +168,46 @@ export function BroadcastWebDialog({ sequenceId }: { sequenceId: string }) { />
- - setOgImage(event.target.value)} - placeholder="https://…" - /> + + {ogImage && ( +
+ {/* biome-ignore lint/performance/noImgElement: MediaLit URLs from arbitrary hosts; next/image would need remotePatterns/loader config */} + Social preview +
+ )} +
+ setOgImage(image.src)} + > + + + {ogImage && ( + + )} +
+

+ Shown in link previews on social networks and chat apps. Uploads also + appear in your media library. +

diff --git a/apps/web/components/dashboard/email-image-upload-dialog.tsx b/apps/web/components/dashboard/email-image-upload-dialog.tsx index f64ea728..aed938e6 100644 --- a/apps/web/components/dashboard/email-image-upload-dialog.tsx +++ b/apps/web/components/dashboard/email-image-upload-dialog.tsx @@ -42,6 +42,7 @@ interface UnsplashPhoto { interface EmailImageUploadDialogProps { acceptedMimeTypes?: string[]; + description?: string; disabled?: boolean; onSelect: (image: SelectedImage) => void; open: boolean; @@ -55,6 +56,7 @@ function getUploadedImageSrc(media: Record) { export function EmailImageUploadDialog({ acceptedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"], + description = "Upload or choose an image for this email.", disabled = false, onSelect, open, @@ -288,9 +290,7 @@ export function EmailImageUploadDialog({ Select Image - - Upload or choose an image for this email. - + {description} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index fe597b37..7695fa98 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -133,7 +133,7 @@ export interface Media { } export interface MediaReference { - resourceType: "TEMPLATE" | "SEQUENCE_EMAIL"; + resourceType: "TEMPLATE" | "SEQUENCE_EMAIL" | "BROADCAST_WEB_PAGE"; resourcePublicId: string; parentResourcePublicId?: string | null; createdAt?: string | null; diff --git a/packages/api-contract/src/schemas/media.ts b/packages/api-contract/src/schemas/media.ts index e1231328..3f5fb18d 100644 --- a/packages/api-contract/src/schemas/media.ts +++ b/packages/api-contract/src/schemas/media.ts @@ -17,7 +17,7 @@ export const mediaSchema = z.object({ }); export const mediaReferenceSchema = z.object({ - resourceType: z.enum(["TEMPLATE", "SEQUENCE_EMAIL"]), + resourceType: z.enum(["TEMPLATE", "SEQUENCE_EMAIL", "BROADCAST_WEB_PAGE"]), resourcePublicId: z.string(), parentResourcePublicId: z.string().nullable().optional(), createdAt: z.string().or(z.date()).nullable().optional(), diff --git a/packages/api-contract/src/schemas/web.ts b/packages/api-contract/src/schemas/web.ts index 6efd496e..3a58fe72 100644 --- a/packages/api-contract/src/schemas/web.ts +++ b/packages/api-contract/src/schemas/web.ts @@ -45,7 +45,12 @@ export const updateWebPublishingBodySchema = z.object({ .describe("URL slug; defaults to one made from the title"), seoTitle: z.string().trim().max(200).nullable().optional(), seoDescription: z.string().trim().max(500).nullable().optional(), - ogImage: httpUrl.nullable().optional().describe("Image for social previews"), + ogImage: httpUrl + .nullable() + .optional() + .describe( + "Image for social previews. An image uploaded to MediaLit is added to the media library and can't be deleted while the page uses it", + ), noindex: z.boolean().optional().describe("Ask search engines not to index the page"), canonicalUrl: httpUrl .nullable() From 5befd4b9285e7a540edd441d544fc61442d2459f Mon Sep 17 00:00:00 2001 From: Rajat Date: Mon, 2 Nov 2026 22:44:32 +0530 Subject: [PATCH 2/4] fix(web): keep social image references safe and repair MCP tests - Only treat `/i//main.` and `/p//main.` URLs as MediaLit uploads, so an external image that merely looks like one is kept as is. - Seal first, but replace the media reference only after the broadcast is saved, so a failed update (for example a slug race) keeps the previous image referenced. - Back the MCP server and parity tests with a real PGlite db: Better Auth queries the db at import time, and a `{}` stub caused unhandled errors that failed CI. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/docs/media-library.md | 6 ++-- apps/api/src/mcp/parity.test.ts | 5 ++- apps/api/src/mcp/server.test.ts | 5 ++- apps/api/src/media/email-content.ts | 38 ++++++++++++--------- apps/api/src/web/queries.ts | 29 ++++++++++------ apps/api/src/web/web.test.ts | 52 +++++++++++++++++++++++++++-- 6 files changed, 102 insertions(+), 33 deletions(-) diff --git a/apps/api/docs/media-library.md b/apps/api/docs/media-library.md index d52f7f49..a10120bd 100644 --- a/apps/api/docs/media-library.md +++ b/apps/api/docs/media-library.md @@ -277,9 +277,9 @@ Unsplash and external image URLs are ignored by media reference reconciliation b The Web page dialog of a broadcast lets the user upload a social image or pick one from the media library, through the same picker as the email image block. The API treats `ogImage` like an email image: -1. A MediaLit URL is sealed (once), registered in the media library, and stored as the sealed URL. -2. The broadcast's `BROADCAST_WEB_PAGE` reference is replaced with a reference to that media, so the image can't be deleted from the media library while the page uses it. -3. Any other URL (including one entered through the REST API or MCP) is stored as it is, and the reference is cleared. +1. An uploaded MediaLit URL (`/i//main.` as a draft, `/p//main.` once sealed) is sealed (once), registered in the media library, and stored as the sealed URL. +2. Once the broadcast is saved, its `BROADCAST_WEB_PAGE` reference is replaced with a reference to that media, so the image can't be deleted from the media library while the page uses it. If the save fails, such as when another broadcast takes the slug in the meantime, the previous image stays referenced. +3. Any other URL (including one entered through the REST API or MCP, even if its path ends in `//main.`) is stored as it is, and the reference is cleared. 4. Replacing or removing the image releases the reference but never deletes the media. 5. Deleting the broadcast releases the reference and runs `deleteOrphanedMedia` like it does for its emails. diff --git a/apps/api/src/mcp/parity.test.ts b/apps/api/src/mcp/parity.test.ts index c922d279..23de614c 100644 --- a/apps/api/src/mcp/parity.test.ts +++ b/apps/api/src/mcp/parity.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, mock } from "bun:test"; import { validateParityManifest } from "@codelitdev/mcp-server-kit"; import { contract, mcpParityManifest } from "@sendlit/api-contract"; -mock.module("../db/client", () => ({ db: {}, pool: {} })); +mock.module("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb(), pool: {} }; +}); const { createSendLitMcp } = await import("./server"); diff --git a/apps/api/src/mcp/server.test.ts b/apps/api/src/mcp/server.test.ts index 71b4c6f8..bac746cf 100644 --- a/apps/api/src/mcp/server.test.ts +++ b/apps/api/src/mcp/server.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, mock } from "bun:test"; import { mcpParityManifest } from "@sendlit/api-contract"; import { z } from "zod"; -mock.module("../db/client", () => ({ db: {}, pool: {} })); +mock.module("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb(), pool: {} }; +}); const { sendLitMcpTools } = await import("./server"); const { authorizeMcpOperation, listMcpOperationScopes, MCP_SCOPES } = await import( diff --git a/apps/api/src/media/email-content.ts b/apps/api/src/media/email-content.ts index a70bedca..7fdf7b56 100644 --- a/apps/api/src/media/email-content.ts +++ b/apps/api/src/media/email-content.ts @@ -171,32 +171,38 @@ export async function syncEmailContentMediaReferences({ return reconciledContent; } +/** An uploaded MediaLit file: `/i//main.` while a draft and + * `/p//main.` once sealed. Stricter than `extractMediaLitIdFromUrl`, + * because a client can send any URL here and an external image such as + * `https://cdn.example.com/assets/main.jpg` must be kept, not sealed. */ +const MEDIALIT_UPLOAD_PATH = /\/[ip]\/([^/]+)\/main\.[^/]+$/i; + /** - * Registers a single image (for example a broadcast's social image) as the - * only media of a resource. An uploaded MediaLit file is sealed, added to the - * media library and referenced, so it can't be deleted while in use; any - * other URL is kept as it is and the resource references no media. Returns - * the URL to store, which is the sealed one for MediaLit files. + * Seals a single image (for example a broadcast's social image) into the + * media library when it is an uploaded MediaLit file, and returns the URL to + * store, which is the sealed one, with the media row to reference. Any other + * URL is returned as it is, with no row. Run `replaceMediaReferencesForResource` + * with the row once the resource itself is saved. */ -export async function syncImageMediaReference({ +export async function resolveImageMedia({ teamId, url, alt, - resource, }: { teamId: string; url?: string | null; alt?: string; - resource: MediaReferenceResource; -}): Promise { - const mediaLitId = url ? extractMediaLitIdFromUrl(url) : undefined; +}): Promise<{ url: string | null; row: Media | null }> { + let mediaLitId: string | undefined; + try { + mediaLitId = url + ? MEDIALIT_UPLOAD_PATH.exec(new URL(url).pathname)?.[1] + : undefined; + } catch { + // Not a valid URL, so not an upload. + } const row = mediaLitId ? await ensureUploadedMedia({ teamId, mediaLitId, alt }) : null; - await replaceMediaReferencesForResource({ - teamId, - resource, - mediaRows: row ? [row] : [], - }); - return row?.url ?? url ?? null; + return { url: row?.url ?? url ?? null, row }; } diff --git a/apps/api/src/web/queries.ts b/apps/api/src/web/queries.ts index 1a8033d8..00b06ac8 100644 --- a/apps/api/src/web/queries.ts +++ b/apps/api/src/web/queries.ts @@ -5,7 +5,8 @@ import { db } from "../db/client"; import { sequenceEmails, sequences, settings, signupForms, teams } from "../db/schema"; import { createForm } from "../forms/queries"; import { renderCampaignHtml } from "../mail/campaign"; -import { syncImageMediaReference } from "../media/email-content"; +import { resolveImageMedia } from "../media/email-content"; +import { replaceMediaReferencesForResource } from "../media/queries"; import { getTeamSettingsRow, type TeamSettings } from "../settings/general/queries"; import { archiveUrl, issueUrl } from "./urls"; @@ -247,16 +248,13 @@ export async function updateWebPublishing( if (input.seoDescription !== undefined) { values.webSeoDescription = input.seoDescription || null; } + // The upload is sealed now, but the page only references it once it is + // saved, so a failed update (such as a slug taken in the meantime) leaves + // the old image in use. + let ogImage: Awaited> | undefined; if (input.ogImage !== undefined) { - values.webOgImage = await syncImageMediaReference({ - teamId: sequence.teamId, - url: input.ogImage, - resource: { - resourceType: "BROADCAST_WEB_PAGE", - resourceInternalId: sequence.id, - resourcePublicId: sequence.sequenceId, - }, - }); + ogImage = await resolveImageMedia({ teamId: sequence.teamId, url: input.ogImage }); + values.webOgImage = ogImage.url; } if (input.noindex !== undefined) values.webNoindex = input.noindex; if (input.canonicalUrl !== undefined) @@ -275,6 +273,17 @@ export async function updateWebPublishing( .set(values) .where(eq(sequences.id, sequence.id)) .returning(); + if (ogImage) { + await replaceMediaReferencesForResource({ + teamId: sequence.teamId, + resource: { + resourceType: "BROADCAST_WEB_PAGE", + resourceInternalId: sequence.id, + resourcePublicId: sequence.sequenceId, + }, + mediaRows: ogImage.row ? [ogImage.row] : [], + }); + } return toWebPublishing(row); } catch (error: any) { if (error?.code === "23505" || error?.cause?.code === "23505") { diff --git a/apps/api/src/web/web.test.ts b/apps/api/src/web/web.test.ts index 77faf9c3..ecbeee2b 100644 --- a/apps/api/src/web/web.test.ts +++ b/apps/api/src/web/web.test.ts @@ -154,7 +154,7 @@ describe("web publishing", () => { const issue = await seedBroadcast(team.id, "With image"); const first = await updateWebPublishing(issue, { - ogImage: "https://cdn.test/draft/og-upload/main.png", + ogImage: "https://cdn.test/i/og-upload/main.png", }); expect(sealMedia).toHaveBeenCalledWith("og-upload"); expect(first.ogImage).toBe("https://cdn.test/p/og-upload/main.webp"); @@ -188,6 +188,54 @@ describe("web publishing", () => { expect(cleared.ogImage).toBeNull(); }); + it("keeps an external image that only looks like a MediaLit path", async () => { + const { team } = await setup(); + const issue = await seedBroadcast(team.id, "External image"); + (sealMedia as unknown as ReturnType).mockClear(); + + const saved = await updateWebPublishing(issue, { + ogImage: "https://cdn.example.com/assets/main.jpg", + }); + + expect(saved.ogImage).toBe("https://cdn.example.com/assets/main.jpg"); + expect(sealMedia).not.toHaveBeenCalled(); + expect(await tdb.select().from(mediaReferences)).toHaveLength(0); + }); + + it("keeps the current social image in use when the update loses a slug race", async () => { + const { team } = await setup(); + const one = await seedBroadcast(team.id, "Issue One"); + const two = await seedBroadcast(team.id, "Issue Two"); + const kept = await updateWebPublishing(two, { + ogImage: "https://cdn.test/i/og-kept/main.png", + }); + + // Another request takes the slug after this one checked it and while the + // new image is being sealed. + (sealMedia as unknown as ReturnType).mockImplementationOnce( + async (mediaId: string) => { + await tdb + .update(sequences) + .set({ webSlug: "raced" }) + .where(eq(sequences.id, one.id)); + return { mediaId, file: `https://cdn.test/p/${mediaId}/main.webp` }; + }, + ); + await expect( + updateWebPublishing(two, { + slug: "raced", + ogImage: "https://cdn.test/i/og-new/main.png", + }), + ).rejects.toMatchObject({ status: 409 }); + + const [row] = await tdb.select().from(sequences).where(eq(sequences.id, two.id)); + expect(row.webOgImage).toBe(kept.ogImage); + const refs = await tdb.select().from(mediaReferences); + expect(refs).toHaveLength(1); + const [inUse] = await tdb.select().from(media).where(eq(media.id, refs[0].mediaId)); + expect(inUse.mediaLitId).toBe("og-kept"); + }); + it("releases the social image when the broadcast is deleted", async () => { const { team } = await setup(); const sent = await seedBroadcast(team.id, "Draft with image", false); @@ -197,7 +245,7 @@ describe("web publishing", () => { .where(eq(sequences.id, sent.id)) .returning(); await updateWebPublishing(issue, { - ogImage: "https://cdn.test/draft/og-deleted/main.png", + ogImage: "https://cdn.test/i/og-deleted/main.png", }); expect(await tdb.select().from(media)).toHaveLength(1); From 1a63901354a85576c68e2e1e6e77709ac3bd9a42 Mon Sep 17 00:00:00 2001 From: Rajat Date: Mon, 2 Nov 2026 23:44:14 +0530 Subject: [PATCH 3/4] test(automation): stop the early-send test depending on today's date The test enrols at a fixed 2026-11-02T09:00Z, but seeds stamp `updatedAt` with the real clock. Once the real date passed that moment the sequence looked edited after enrolment and the early-send check stayed silent. Pin the timestamps before the enrolment. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/automation/sequence-journey.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/api/src/automation/sequence-journey.test.ts b/apps/api/src/automation/sequence-journey.test.ts index 23e9ede2..6e9e02d0 100644 --- a/apps/api/src/automation/sequence-journey.test.ts +++ b/apps/api/src/automation/sequence-journey.test.ts @@ -22,7 +22,9 @@ const captureEvent = vi.fn(); mock.module("../observability", () => ({ ...observability, captureEvent })); const { db } = await import("../db/client"); -const { ongoingSequences, rules } = await import("../db/schema"); +const { ongoingSequences, rules, sequenceEmails, sequences } = await import( + "../db/schema" +); const { EventType } = await import("../config/constants"); const { sendMail } = await import("../mail/send"); const { seedTeamAndContact, truncateAll } = await import("../test/db"); @@ -146,6 +148,12 @@ describe("a contact's journey through a multi-day sequence", () => { ]); // What the old enrollment did: due straight away. await tdb.update(ongoingSequences).set({ nextEmailScheduledTime: start }); + // The seeds are stamped with the real clock. Without this the sequence + // looks edited after the contact entered, which silences the check once + // the real date passes `start`. + const before = new Date(start - DAY); + await tdb.update(sequences).set({ updatedAt: before }); + await tdb.update(sequenceEmails).set({ updatedAt: before }); expect(await pollAt(start + 60_000)).toEqual(["Day 13"]); const [event] = earlySendEvents(); From 73cb2cb18524bcbdcfa799a49f8ad52d80e753ce Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 3 Nov 2026 00:19:00 +0530 Subject: [PATCH 4/4] test(mcp): keep the parity test off PGlite so test:conformance finishes Running parity.test.ts with a real PGlite next to platform-conformance.test.ts in one process (as `test:conformance` does) hangs. The parity test only lists tools, so give Better Auth an inert query builder instead. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/mcp/parity.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/api/src/mcp/parity.test.ts b/apps/api/src/mcp/parity.test.ts index 23de614c..8699379c 100644 --- a/apps/api/src/mcp/parity.test.ts +++ b/apps/api/src/mcp/parity.test.ts @@ -2,10 +2,16 @@ import { describe, expect, it, mock } from "bun:test"; import { validateParityManifest } from "@codelitdev/mcp-server-kit"; import { contract, mcpParityManifest } from "@sendlit/api-contract"; -mock.module("../db/client", async () => { - const { makeTestDb } = await import("../test/db.js"); - return { db: await makeTestDb(), pool: {} }; +// Better Auth queries the db while it loads, and this test only lists tools. +// An inert query builder (every call returns it, awaiting gives no rows) lets +// that finish without a second PGlite, which hangs `test:conformance` when it +// shares a process with platform-conformance.test.ts. +const inertQuery: unknown = new Proxy(() => undefined, { + get: (_target, key) => + key === "then" ? (resolve: (rows: unknown[]) => void) => resolve([]) : inertQuery, + apply: () => inertQuery, }); +mock.module("../db/client", () => ({ db: inertQuery, pool: {} })); const { createSendLitMcp } = await import("./server");