diff --git a/apps/api/docs/media-library.md b/apps/api/docs/media-library.md index 8dd190d9..a10120bd 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. 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. + 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..7fdf7b56 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,39 @@ 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; + +/** + * 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 resolveImageMedia({ + teamId, + url, + alt, +}: { + teamId: string; + url?: string | null; + alt?: string; +}): 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; + return { url: row?.url ?? url ?? null, row }; +} 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..00b06ac8 100644 --- a/apps/api/src/web/queries.ts +++ b/apps/api/src/web/queries.ts @@ -5,6 +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 { resolveImageMedia } from "../media/email-content"; +import { replaceMediaReferencesForResource } from "../media/queries"; import { getTeamSettingsRow, type TeamSettings } from "../settings/general/queries"; import { archiveUrl, issueUrl } from "./urls"; @@ -246,7 +248,14 @@ export async function updateWebPublishing( if (input.seoDescription !== undefined) { values.webSeoDescription = input.seoDescription || null; } - if (input.ogImage !== undefined) values.webOgImage = input.ogImage || 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) { + 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) values.webCanonicalUrl = input.canonicalUrl || null; @@ -264,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 9e2ed4d8..ecbeee2b 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,114 @@ 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/i/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("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); + const [issue] = await tdb + .update(sequences) + .set({ status: "draft" }) + .where(eq(sequences.id, sent.id)) + .returning(); + await updateWebPublishing(issue, { + ogImage: "https://cdn.test/i/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()