mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-08 22:59:52 +03:00
feat: notify users when orphans resolve
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user