feat: notify users when orphans resolve

This commit is contained in:
zk
2026-05-11 18:03:08 +00:00
parent e9071ce48c
commit 1884a0ca7d
8 changed files with 74 additions and 14 deletions
@@ -0,0 +1,12 @@
-- Allow ORPHANS_RESTORED inbox notifications (payload matches tachi-common NotificationBody).
ALTER TABLE notification DROP CONSTRAINT IF EXISTS notification_kind_check;
ALTER TABLE notification ADD CONSTRAINT notification_kind_check CHECK (
kind IN (
'rivaled_by',
'quest_changed',
'site_announcement',
'orphans_restored'
)
);
@@ -125,13 +125,11 @@ function FolderMinimapMain({
// Switchboard tabs / unmount can leave portalled Bootstrap tooltips in <body> while React
// state still has show=true; drop those nodes so nothing sticks.
useLayoutEffect(() => {
return () => {
useLayoutEffect(() => () => {
document.querySelectorAll(".tooltip-folder-minimap").forEach((el) => {
el.remove();
});
};
}, [enumMetric]);
}, [enumMetric]);
return (
<div className="row">
@@ -14,11 +14,11 @@ export default function QuickTooltip({
}: {
children: JSX.Element;
delay?: number | { hide?: number; show?: number };
/** When false, the tooltip hides as soon as the pointer leaves the trigger (overlay is not hoverable). */
keepOpenWhenHoveringTooltip?: boolean;
max?: boolean;
style?: CSSProperties;
tooltipClassName?: string;
/** When false, the tooltip hides as soon as the pointer leaves the trigger (overlay is not hoverable). */
keepOpenWhenHoveringTooltip?: boolean;
tooltipContent: React.ReactChild | undefined;
wide?: boolean;
}) {
@@ -88,14 +88,10 @@ export default function QuickTooltip({
className={overlayClass}
id={nanoid()}
onMouseEnter={
keepOpenWhenHoveringTooltip
? () => setMousedOver(true)
: undefined
keepOpenWhenHoveringTooltip ? () => setMousedOver(true) : undefined
}
onMouseLeave={
keepOpenWhenHoveringTooltip
? () => setMousedOver(false)
: undefined
keepOpenWhenHoveringTooltip ? () => setMousedOver(false) : undefined
}
style={style}
>
@@ -43,5 +43,7 @@ function NotifToURL(notif: NotificationDocument) {
return `/u/${notif.body.content.userID}/games/${notif.body.content.game}`;
case "SITE_ANNOUNCEMENT":
return null;
case "ORPHANS_RESTORED":
return `/u/${notif.sentTo}/orphans`;
}
}
+8 -1
View File
@@ -823,7 +823,7 @@ const PRE_SCHEMAS = {
sentAt: p.isPositive,
read: "boolean",
body: {
type: p.isIn("RIVALED_BY", "QUEST_CHANGED"),
type: p.isIn("RIVALED_BY", "QUEST_CHANGED", "SITE_ANNOUNCEMENT", "ORPHANS_RESTORED"),
content: (self, parent) => {
const type = parent.type as NotificationBody["type"];
@@ -850,6 +850,13 @@ const PRE_SCHEMAS = {
subSchema = {};
break;
}
case "ORPHANS_RESTORED": {
subSchema = {
scoreCount: p.isPositiveNonZeroInteger,
};
break;
}
}
const err = p(self, subSchema);
@@ -25,6 +25,12 @@ export type NotificationBody =
};
type: "RIVALED_BY"; // Emitted when the user is rivalled by someone.
}
| {
content: {
scoreCount: integer;
};
type: "ORPHANS_RESTORED"; // Emitted after orphaned scores were successfully imported.
}
| {
content: Record<string, never>;
type: "SITE_ANNOUNCEMENT"; // Emitted as a site announcement
@@ -31,6 +31,25 @@ describe("SendNotification", () => {
content: { questID: "q1", game: "iidx-sp" },
});
});
it("inserts ORPHANS_RESTORED with scoreCount payload", async () => {
await SendNotification("3 orphan scores were restored", userId, {
type: "ORPHANS_RESTORED",
content: { scoreCount: 3 },
});
const row = await DB.selectFrom("notification")
.select(SELECT_NOTIFICATION)
.where("notification.sent_to", "=", userId)
.where("notification.kind", "=", "orphans_restored")
.executeTakeFirstOrThrow();
expect(row.title).toBe("3 orphan scores were restored");
expect(row.payload).toEqual({
type: "ORPHANS_RESTORED",
content: { scoreCount: 3 },
});
});
});
describe("BulkSendNotification", () => {
@@ -6,10 +6,10 @@ import type {
ImportTypeDataMap,
OrphanScoreDocument,
} from "#lib/score-import/import-types/common/types";
import type { GameGroup, ImportTypes, integer } from "tachi-common";
import type { OrphanScore as PgOrphanScoreRow } from "tachi-db";
import { SELECT_ORPHAN_SCORE } from "#lib/db-formats/orphan-score";
import { SendNotification } from "#lib/notifications/notifications";
import { Converters } from "#lib/score-import/import-types/converters";
import DB from "#services/pg/db";
import { GetBlacklist } from "#utils/queries/blacklist";
@@ -17,6 +17,7 @@ import { GetUserWithID } from "#utils/user";
import { ExpectedErr } from "bliss";
import fjsh from "fast-json-stable-hash";
import { sql } from "kysely";
import { type GameGroup, GetGameGroupConfig, type ImportTypes, type integer } from "tachi-common";
import { type ConverterFailure, IsConverterFailure } from "../common/converter-failures";
import { HandlePostImportSteps } from "../score-importing/score-import-main";
@@ -427,6 +428,8 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger
let removed = 0;
let processed = 0;
const restoredScoreCountByUser = new Map<integer, integer>();
for (const or of orphans) {
// We have to await like this to avoid mid-air race conditions,
// where two orphans attempt to deorphan to the same scoreID
@@ -445,6 +448,12 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger
failed++;
} else {
success++;
if (!GetGameGroupConfig(or.game).dynamicContent) {
restoredScoreCountByUser.set(
or.userID,
(restoredScoreCountByUser.get(or.userID) ?? 0) + 1,
);
}
}
} catch (err) {
log.error({ orphanID: or.orphanID, err }, `Failed to reprocess orphan.`);
@@ -452,5 +461,16 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger
}
}
await Promise.all(
[...restoredScoreCountByUser.entries()].map(([userID, scoreCount]) => {
const title = `We've added new song data to the site, and ${scoreCount} of your scores have now been resolved to your profile.`;
return SendNotification(title, userID, {
type: "ORPHANS_RESTORED",
content: { scoreCount },
});
}),
);
return { processed, removed, failed, success };
}