diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index d29799cc..3b578757 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -34,6 +34,7 @@ import { isValidHttpUrl } from "../remote/fetch.ts"; import { broadcastDumpChange } from "./ws-service.ts"; import { notifyMentions, + notifyPlaylistFollowersNowPublic, notifyUserFollowersNewDump, } from "./notification-service.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts"; @@ -91,6 +92,19 @@ function titleFromUrl(url: string): string { } } +/** + * Notifies the followers of a dump that went public, `before` being private + * and `after` public: the poster's, as if it were just posted, and those of + * each public playlist holding it, as if it were just added. A private dump's + * followers were never told of it, so an edit that makes it public is when + * they learn of it. + */ +function notifyIfWentPublic(before: Dump, after: Dump): void { + if (!before.isPrivate || after.isPrivate) return; + notifyUserFollowersNewDump(after.userId, after.id, after.title); + notifyPlaylistFollowersNowPublic({ dumpId: after.id }); +} + /** * Posts a link as `userId`, for `POST /api/dumps`, with its preview fetched * from the page. A public dump is broadcast as `dump_created` and notified to @@ -427,7 +441,8 @@ export function findDumpsByUrl( * which `exempt` (a moderator) frees from its rate limit; a new title leaves * the slug as it is. Clients get the dump as `dump_updated`, or a * `dump_deleted` when it turns private and a `dump_created` when it turns - * public. Anyone @mentioned in the note is notified. + * public. Anyone @mentioned in the note is notified, and when it turns public, + * its followers are as when it is posted ({@link notifyIfWentPublic}). * * @throws {APIException} 404 when there is no such dump, 400 when a new URL * isn't a public http(s) URL, a category doesn't exist or the slug isn't @@ -484,6 +499,7 @@ export async function updateDump( attachCategoryIds([updatedDump]); } broadcastDumpChange(dump, updatedDump); + notifyIfWentPublic(dump, updatedDump); if (updatedDump.comment) { if (!updatedDump.isPrivate) { notifyMentions( @@ -555,6 +571,7 @@ export async function updateDump( } broadcastDumpChange(dump, updatedDump); + notifyIfWentPublic(dump, updatedDump); if (updatedDump.comment) { if (!updatedDump.isPrivate) { notifyMentions( @@ -577,8 +594,8 @@ export async function updateDump( * `isPrivate` left out stays as it is. Visibility is taken here so that a new * file and a change of visibility land together. The slug changes as in * {@link updateDump}, before the file is written. The old file is put back if - * the database update fails, and clients are told as {@link updateDump} tells - * them. + * the database update fails, and clients and followers are told as + * {@link updateDump} tells them. * * @throws {APIException} 404 when there is no such dump, 400 when it isn't a * file dump, the title or comment is too long, the file's type isn't allowed, @@ -683,6 +700,7 @@ export async function replaceFileDump( updatedAt: now, }; broadcastDumpChange(dump, updatedDump); + notifyIfWentPublic(dump, updatedDump); return updatedDump; } diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 943fdfec..87497b84 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -230,7 +230,9 @@ export function notifyPlaylistOwnerNewFollower( /** * Notifies everyone following `dumperId` that they posted a dump. The dump - * service calls it for a public dump only, when it is created. + * service calls it for a public dump only, when it is created or goes public. + * A follower is told of a dump once, however often it goes private and public + * again. */ export function notifyUserFollowersNewDump( dumperId: string, @@ -473,3 +475,46 @@ export function notifyPlaylistFollowersNewDump( } }); } + +/** + * Notifies the followers of each playlist of the dumps in it that became + * visible to them: a playlist's followers are told of a dump once both are + * public. The dump service calls it with `dumpId` when a dump goes public, for + * each public playlist holding it; the playlist service with `playlistId` + * when a playlist goes public, for each public dump in it, from the bottom of + * the playlist up. A dump the followers were told of before either went + * private isn't notified again. Adding a dump to a public playlist calls + * {@link notifyPlaylistFollowersNewDump} directly. + */ +export function notifyPlaylistFollowersNowPublic( + of: { dumpId: string } | { playlistId: string }, +): void { + const [column, id] = "dumpId" in of + ? ["pd.dump_id", of.dumpId] + : ["pd.playlist_id", of.playlistId]; + const rows = db.prepare( + `SELECT p.id AS playlist_id, p.title AS playlist_title, + d.id AS dump_id, d.title AS dump_title + FROM playlist_dumps pd + JOIN playlists p ON p.id = pd.playlist_id + JOIN dumps d ON d.id = pd.dump_id + WHERE ${column} = ? AND p.is_public = 1 AND d.is_private = 0 + ORDER BY pd.position DESC;`, + ).all(id) as { + playlist_id: string; + playlist_title: string; + dump_id: string; + dump_title: string; + }[]; + + transaction(() => { + for (const row of rows) { + notifyPlaylistFollowersNewDump( + row.playlist_id, + row.playlist_title, + row.dump_id, + row.dump_title, + ); + } + }); +} diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index f7133559..d7fbec1c 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -33,6 +33,7 @@ import { import { notifyMentions, notifyPlaylistFollowersNewDump, + notifyPlaylistFollowersNowPublic, } from "./notification-service.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts"; import { linkAttachments } from "./attachment-service.ts"; @@ -207,8 +208,9 @@ export function listPlaylistsByUser( * moderator is exempt from its rate limit; a new title leaves the slug as it * is. `canModerate` lets someone other than the owner edit it. If the * playlist is public, it notifies, in the owner's name, the users the - * description mentions. It links the description's attachments and - * broadcasts `playlist_updated`. + * description mentions. When it goes public, its followers are notified of + * its public dumps they weren't yet told of. It links the description's + * attachments and broadcasts `playlist_updated`. * * @throws {APIException} 404 when there is no such playlist, 403 when the user * is neither its owner nor a moderator, 400 when the slug isn't valid, 409 @@ -270,6 +272,10 @@ export function updatePlaylist( linkAttachments(newDescription, "playlist", playlist.id); } broadcastPlaylistUpdated(updated, playlist.isPublic); + // Its followers weren't told of the dumps added while it was private. + if (!playlist.isPublic && newIsPublic) { + notifyPlaylistFollowersNowPublic({ playlistId: playlist.id }); + } return updated; } diff --git a/api/test/routes/dumps.test.ts b/api/test/routes/dumps.test.ts index 6de41436..d25b0df7 100644 --- a/api/test/routes/dumps.test.ts +++ b/api/test/routes/dumps.test.ts @@ -601,6 +601,46 @@ test("PUT /api/dumps/:id: going private hides the dump from everyone else", asyn assertEquals((await get(`/api/dumps/${id}`)).status, 200); }); +test("PUT /api/dumps/:id: going public notifies the poster's and its playlists' followers, once", async () => { + const owner = await createUser(); + const fan = await createUser(); + const listener = await createUser(); + db.prepare( + `INSERT INTO follows (id, follower_id, followed_user_id, created_at) VALUES (?, ?, ?, ?);`, + ).run(crypto.randomUUID(), fan.id, owner.id, new Date().toISOString()); + const mix = insertPlaylist({ userId: owner.id, title: "Mix" }); + const hidden = insertPlaylist({ + userId: owner.id, + title: "Hidden", + isPublic: false, + }); + await put(`/api/me/follow/playlists/${mix.id}`, { auth: listener.auth }); + const { id } = insertDump({ userId: owner.id, isPrivate: true }); + for (const p of [mix, hidden]) { + await post(`/api/playlists/${p.id}/dumps/${id}`, { auth: owner.auth }); + } + db.exec(`DELETE FROM notifications;`); // the follow notified the owner + const setPrivate = (isPrivate: boolean) => + put(`/api/dumps/${id}`, { auth: owner.auth, json: { isPrivate } }); + + await setPrivate(false); + const notes = db.prepare( + `SELECT user_id, type, data FROM notifications ORDER BY type;`, + ).all() as { user_id: string; type: string; data: string }[]; + assertEquals( + notes.map((n) => [n.user_id, n.type, JSON.parse(n.data).playlistId]), + [ + [listener.id, "playlist_dump_added", mix.id], + [fan.id, "user_dump_posted", undefined], + ], + ); + // Neither going private and public again nor an edit notifies twice. + await setPrivate(true); + await setPrivate(false); + await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "New" } }); + assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 2); +}); + test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async () => { const owner = await createUser(); const { id } = insertDump({ diff --git a/api/test/routes/playlists.test.ts b/api/test/routes/playlists.test.ts index 05b9cc42..a40b0e39 100644 --- a/api/test/routes/playlists.test.ts +++ b/api/test/routes/playlists.test.ts @@ -320,6 +320,33 @@ test("adding to a followed public playlist notifies followers, private dumps exc }); }); +test("a playlist going public notifies its followers of the public dumps added meanwhile", async () => { + const owner = await createUser(); + const follower = await createUser(); + const { id } = insertPlaylist({ userId: owner.id, title: "Mix" }); + await put(`/api/me/follow/playlists/${id}`, { auth: follower.auth }); + const before = insertDump({ userId: owner.id, title: "Before" }); + await post(`/api/playlists/${id}/dumps/${before.id}`, { auth: owner.auth }); + db.prepare(`DELETE FROM notifications WHERE user_id = ?;`).run(owner.id); + const setPublic = (isPublic: boolean) => + put(`/api/playlists/${id}`, { auth: owner.auth, json: { isPublic } }); + + await setPublic(false); + const during = insertDump({ userId: owner.id, title: "During" }); + const priv = insertDump({ userId: owner.id, isPrivate: true }); + for (const d of [during, priv]) { + await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth }); + } + assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 1); + await setPublic(true); + const titles = (db.prepare( + `SELECT data FROM notifications WHERE user_id = ? ORDER BY created_at;`, + ).all(follower.id) as { data: string }[]).map((n) => + JSON.parse(n.data).dumpTitle + ); + assertEquals(titles, ["Before", "During"]); +}); + test("playlist covers: upload and serve", async () => { const owner = await createUser(); const other = await createUser();