Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/api/docs/media-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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/<id>/main.<ext>` as a draft, `/p/<id>/main.<ext>` 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 `/<name>/main.<ext>`) 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):
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
38 changes: 37 additions & 1 deletion apps/api/src/media/email-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function updateImageSrc(content: EmailContent | null, mediaLitId: string, src: s
}
}

async function ensureUploadedMedia({
export async function ensureUploadedMedia({
teamId,
mediaLitId,
alt,
Expand Down Expand Up @@ -170,3 +170,39 @@ export async function syncEmailContentMediaReferences({

return reconciledContent;
}

/** An uploaded MediaLit file: `/i/<id>/main.<ext>` while a draft and
* `/p/<id>/main.<ext>` 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 };
}
6 changes: 5 additions & 1 deletion apps/api/src/media/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/media/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getMediaByMediaId,
listMedia,
listMediaReferences,
type MediaReferenceResourceType,
updateMediaMetadata,
} from "./queries";
import { getMediaUploadSignature } from "./service";
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/sequences/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
22 changes: 21 additions & 1 deletion apps/api/src/web/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<ReturnType<typeof resolveImageMedia>> | 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;
Expand All @@ -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") {
Expand Down
124 changes: 122 additions & 2 deletions apps/api/src/web/web.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
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 () => {
const { makeTestDb } = await import("../test/db.js");
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");
Expand Down Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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");
Expand Down
8 changes: 7 additions & 1 deletion apps/web/app/(dashboard)/media/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type MediaWithReferences = Media & { references: MediaReference[] };

const PAGE_SIZE = 50;

const REFERENCE_LABELS: Record<MediaReference["resourceType"], string> = {
TEMPLATE: "Template",
SEQUENCE_EMAIL: "Email",
BROADCAST_WEB_PAGE: "Web page of broadcast",
};

export default function MediaPage() {
useSetBreadcrumb([{ label: "Media" }]);
const [items, setItems] = useState<MediaWithReferences[] | null>(null);
Expand Down Expand Up @@ -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} / `
: ""}
Expand Down
Loading
Loading