v4: a dump or playlist going public notifies its followers — a private dump notified no one when posted, and nothing told its followers when it was made public: the poster's followers never learnt of it, nor did the followers of the public playlists holding it. Likewise, the dumps added to a playlist while it was private were never notified to its followers. updateDump and replaceFileDump now call notifyIfWentPublic, which notifies the poster's followers (user_dump_posted) and, through the new notifyPlaylistFollowersNowPublic, the followers of each public playlist holding the dump (playlist_dump_added). updatePlaylist calls the same function when a playlist goes public, for each public dump in it, from the bottom up. A follower hears of a dump once both it and the playlist are public; the source keys stop a dump made private and public again from notifying twice. 256 backend tests pass, and deno check, deno fmt and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import { isValidHttpUrl } from "../remote/fetch.ts";
|
|||||||
import { broadcastDumpChange } from "./ws-service.ts";
|
import { broadcastDumpChange } from "./ws-service.ts";
|
||||||
import {
|
import {
|
||||||
notifyMentions,
|
notifyMentions,
|
||||||
|
notifyPlaylistFollowersNowPublic,
|
||||||
notifyUserFollowersNewDump,
|
notifyUserFollowersNewDump,
|
||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
import { changeSlug, newSlug, resolveSlug } from "./slug-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
|
* 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
|
* 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
|
* 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
|
* 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
|
* `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
|
* @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
|
* 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]);
|
attachCategoryIds([updatedDump]);
|
||||||
}
|
}
|
||||||
broadcastDumpChange(dump, updatedDump);
|
broadcastDumpChange(dump, updatedDump);
|
||||||
|
notifyIfWentPublic(dump, updatedDump);
|
||||||
if (updatedDump.comment) {
|
if (updatedDump.comment) {
|
||||||
if (!updatedDump.isPrivate) {
|
if (!updatedDump.isPrivate) {
|
||||||
notifyMentions(
|
notifyMentions(
|
||||||
@@ -555,6 +571,7 @@ export async function updateDump(
|
|||||||
}
|
}
|
||||||
|
|
||||||
broadcastDumpChange(dump, updatedDump);
|
broadcastDumpChange(dump, updatedDump);
|
||||||
|
notifyIfWentPublic(dump, updatedDump);
|
||||||
if (updatedDump.comment) {
|
if (updatedDump.comment) {
|
||||||
if (!updatedDump.isPrivate) {
|
if (!updatedDump.isPrivate) {
|
||||||
notifyMentions(
|
notifyMentions(
|
||||||
@@ -577,8 +594,8 @@ export async function updateDump(
|
|||||||
* `isPrivate` left out stays as it is. Visibility is taken here so that a new
|
* `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
|
* 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
|
* {@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
|
* the database update fails, and clients and followers are told as
|
||||||
* them.
|
* {@link updateDump} tells them.
|
||||||
*
|
*
|
||||||
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a
|
* @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,
|
* 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,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
broadcastDumpChange(dump, updatedDump);
|
broadcastDumpChange(dump, updatedDump);
|
||||||
|
notifyIfWentPublic(dump, updatedDump);
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,9 @@ export function notifyPlaylistOwnerNewFollower(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifies everyone following `dumperId` that they posted a dump. The dump
|
* 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(
|
export function notifyUserFollowersNewDump(
|
||||||
dumperId: string,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
notifyMentions,
|
notifyMentions,
|
||||||
notifyPlaylistFollowersNewDump,
|
notifyPlaylistFollowersNewDump,
|
||||||
|
notifyPlaylistFollowersNowPublic,
|
||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
||||||
import { linkAttachments } from "./attachment-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
|
* 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
|
* 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
|
* playlist is public, it notifies, in the owner's name, the users the
|
||||||
* description mentions. It links the description's attachments and
|
* description mentions. When it goes public, its followers are notified of
|
||||||
* broadcasts `playlist_updated`.
|
* 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
|
* @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
|
* 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);
|
linkAttachments(newDescription, "playlist", playlist.id);
|
||||||
}
|
}
|
||||||
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
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 () => {
|
test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async () => {
|
||||||
const owner = await createUser();
|
const owner = await createUser();
|
||||||
const { id } = insertDump({
|
const { id } = insertDump({
|
||||||
|
|||||||
@@ -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 () => {
|
test("playlist covers: upload and serve", async () => {
|
||||||
const owner = await createUser();
|
const owner = await createUser();
|
||||||
const other = await createUser();
|
const other = await createUser();
|
||||||
|
|||||||
Reference in New Issue
Block a user