diff --git a/db/migrations/20260511200000_notification_orphans_restored_kind.sql b/db/migrations/20260511200000_notification_orphans_restored_kind.sql
new file mode 100644
index 000000000..867a793ad
--- /dev/null
+++ b/db/migrations/20260511200000_notification_orphans_restored_kind.sql
@@ -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'
+ )
+);
diff --git a/typescript/client/src/components/game/folder/FolderMinimap.tsx b/typescript/client/src/components/game/folder/FolderMinimap.tsx
index a48c9c42c..03c130ff8 100644
--- a/typescript/client/src/components/game/folder/FolderMinimap.tsx
+++ b/typescript/client/src/components/game/folder/FolderMinimap.tsx
@@ -125,13 +125,11 @@ function FolderMinimapMain({
// Switchboard tabs / unmount can leave portalled Bootstrap tooltips in
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 (
diff --git a/typescript/client/src/components/layout/misc/QuickTooltip.tsx b/typescript/client/src/components/layout/misc/QuickTooltip.tsx
index 0e8841fad..621d1e1a1 100644
--- a/typescript/client/src/components/layout/misc/QuickTooltip.tsx
+++ b/typescript/client/src/components/layout/misc/QuickTooltip.tsx
@@ -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}
>
diff --git a/typescript/client/src/components/notifications/NotificationRow.tsx b/typescript/client/src/components/notifications/NotificationRow.tsx
index 5ecc741b1..1932c9ac5 100644
--- a/typescript/client/src/components/notifications/NotificationRow.tsx
+++ b/typescript/client/src/components/notifications/NotificationRow.tsx
@@ -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`;
}
}
diff --git a/typescript/common/src/lib/schemas.ts b/typescript/common/src/lib/schemas.ts
index 8e6a02ec1..1bca15d4e 100644
--- a/typescript/common/src/lib/schemas.ts
+++ b/typescript/common/src/lib/schemas.ts
@@ -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);
diff --git a/typescript/common/src/types/notifications.ts b/typescript/common/src/types/notifications.ts
index a5ecca013..75165be60 100644
--- a/typescript/common/src/types/notifications.ts
+++ b/typescript/common/src/types/notifications.ts
@@ -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;
type: "SITE_ANNOUNCEMENT"; // Emitted as a site announcement
diff --git a/typescript/server/src/lib/notifications/notifications.test.ts b/typescript/server/src/lib/notifications/notifications.test.ts
index ecfbb8728..024d5c967 100644
--- a/typescript/server/src/lib/notifications/notifications.test.ts
+++ b/typescript/server/src/lib/notifications/notifications.test.ts
@@ -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", () => {
diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts
index c0903332d..5f81873e3 100644
--- a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts
+++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts
@@ -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();
+
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 };
}