ballrush-core 0.13.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ export declare class Event {
14
14
  private gameMode;
15
15
  private reportMessageId?;
16
16
  private status;
17
+ private excludeFromStats;
17
18
  private constructor();
18
19
  static create(params: {
19
20
  eventId: number;
@@ -30,6 +31,7 @@ export declare class Event {
30
31
  isRatingApplied?: boolean;
31
32
  status?: EventStatus;
32
33
  gameMode?: GameMode;
34
+ excludeFromStats?: boolean;
33
35
  }): Event;
34
36
  getEventId(): number;
35
37
  getGroupId(): number;
@@ -44,6 +46,7 @@ export declare class Event {
44
46
  getCreatedBy(): number;
45
47
  getStatus(): EventStatus;
46
48
  getIsRatingApplied(): boolean;
49
+ getExcludeFromStats(): boolean;
47
50
  getGameMode(): GameMode;
48
51
  canChangeGameMode(): boolean;
49
52
  setGameMode(mode: GameMode): void;
@@ -54,6 +57,7 @@ export declare class Event {
54
57
  replaceTeams(teams: Team[]): void;
55
58
  replaceMatches(matches: Match[]): void;
56
59
  setRatingApplied(mark: boolean): void;
60
+ setExcludeFromStats(value: boolean): void;
57
61
  setStatus(status: EventStatus): void;
58
62
  isActive(): boolean;
59
63
  isInProgress(): boolean;
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Event = void 0;
4
4
  const types_1 = require("../types");
5
5
  class Event {
6
- constructor(eventId, groupId, messageId, templateId, date, participants, teams, matches, createdBy, isRatingApplied, seasonId, gameMode, reportMessageId, status = "active") {
6
+ constructor(eventId, groupId, messageId, templateId, date, participants, teams, matches, createdBy, isRatingApplied, seasonId, gameMode, reportMessageId, status = "active", excludeFromStats = false) {
7
7
  this.eventId = eventId;
8
8
  this.groupId = groupId;
9
9
  this.messageId = messageId;
@@ -18,12 +18,13 @@ class Event {
18
18
  this.gameMode = gameMode;
19
19
  this.reportMessageId = reportMessageId;
20
20
  this.status = status;
21
+ this.excludeFromStats = excludeFromStats;
21
22
  }
22
23
  static create(params) {
23
24
  if (!params.seasonId) {
24
25
  throw new Error("Event must have a season ID");
25
26
  }
26
- return new Event(params.eventId, params.groupId, params.messageId ?? 0, params.templateId, params.date, params.participants ?? [], params.teams ?? [], params.matches ?? [], params.createdBy, params.isRatingApplied ?? false, params.seasonId, params.gameMode ?? types_1.DEFAULT_GAME_MODE, params.reportMessageId, params.status ?? "active");
27
+ return new Event(params.eventId, params.groupId, params.messageId ?? 0, params.templateId, params.date, params.participants ?? [], params.teams ?? [], params.matches ?? [], params.createdBy, params.isRatingApplied ?? false, params.seasonId, params.gameMode ?? types_1.DEFAULT_GAME_MODE, params.reportMessageId, params.status ?? "active", params.excludeFromStats ?? false);
27
28
  }
28
29
  getEventId() { return this.eventId; }
29
30
  getGroupId() { return this.groupId; }
@@ -38,6 +39,7 @@ class Event {
38
39
  getCreatedBy() { return this.createdBy; }
39
40
  getStatus() { return this.status; }
40
41
  getIsRatingApplied() { return this.isRatingApplied; }
42
+ getExcludeFromStats() { return this.excludeFromStats; }
41
43
  getGameMode() { return this.gameMode; }
42
44
  canChangeGameMode() { return this.matches.length === 0; }
43
45
  setGameMode(mode) {
@@ -67,6 +69,9 @@ class Event {
67
69
  setRatingApplied(mark) {
68
70
  this.isRatingApplied = mark;
69
71
  }
72
+ setExcludeFromStats(value) {
73
+ this.excludeFromStats = value;
74
+ }
70
75
  setStatus(status) {
71
76
  this.status = status;
72
77
  }
@@ -13,6 +13,7 @@ export interface EventDoc {
13
13
  seasonId: string;
14
14
  status: EventStatus;
15
15
  isRatingApplied: boolean;
16
+ excludeFromStats?: boolean;
16
17
  gameMode: GameMode;
17
18
  reportMessageId?: number;
18
19
  }
@@ -57,6 +57,7 @@ function createEventSchema() {
57
57
  teams: { type: [TeamSchema], default: [] },
58
58
  matches: { type: [MatchSchema], default: [] },
59
59
  isRatingApplied: { type: Boolean },
60
+ excludeFromStats: { type: Boolean, default: false },
60
61
  reportMessageId: { type: Number },
61
62
  gameMode: {
62
63
  type: String,
@@ -15,6 +15,7 @@ export type EventWrite = {
15
15
  isRatingApplied?: boolean;
16
16
  reportMessageId?: number;
17
17
  gameMode?: GameMode;
18
+ excludeFromStats?: boolean;
18
19
  };
19
20
  export interface EventsRepository {
20
21
  create(eventData: Event): Promise<Event>;
@@ -14,10 +14,19 @@ export type GroupWrite = {
14
14
  language: LanguageType;
15
15
  isPublic: boolean;
16
16
  };
17
+ export type GroupFieldsUpdate = Partial<Pick<GroupWrite, "groupName" | "timezone" | "isMessageLast" | "language" | "isPublic">>;
17
18
  export interface GroupsRepository {
18
19
  create(groupData: Group): Promise<Group>;
19
20
  findById(groupId: number): Promise<Group | null>;
20
21
  update(groupId: number, updates: Group): Promise<Group | null>;
22
+ /**
23
+ * Partial $set update for non-participant scalar fields.
24
+ * Use this instead of update() whenever you only need to change metadata
25
+ * (timezone, language, isMessageLast, etc.) — update() rewrites participants
26
+ * from the in-memory cache and can clobber rating data written by other
27
+ * processes between cache load and save.
28
+ */
29
+ updateFields(groupId: number, fields: GroupFieldsUpdate): Promise<void>;
21
30
  findAll(): Promise<Group[]>;
22
31
  delete(groupId: number): Promise<void>;
23
32
  }
@@ -1,5 +1,6 @@
1
1
  export * from "./mongo/user.repository";
2
2
  export * from "./mongo/group.repository";
3
+ export * from "./mongo/group-repo.events";
3
4
  export * from "./mongo/event.repository";
4
5
  export * from "./mongo/event-template.repository";
5
6
  export * from "./mongo/stat-group.repository";
@@ -16,6 +16,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./mongo/user.repository"), exports);
18
18
  __exportStar(require("./mongo/group.repository"), exports);
19
+ __exportStar(require("./mongo/group-repo.events"), exports);
19
20
  __exportStar(require("./mongo/event.repository"), exports);
20
21
  __exportStar(require("./mongo/event-template.repository"), exports);
21
22
  __exportStar(require("./mongo/stat-group.repository"), exports);
@@ -20,6 +20,7 @@ function toDomain(doc) {
20
20
  reportMessageId: doc.reportMessageId,
21
21
  status: doc.status,
22
22
  gameMode: doc.gameMode ?? types_1.DEFAULT_GAME_MODE,
23
+ excludeFromStats: doc.excludeFromStats ?? false,
23
24
  });
24
25
  }
25
26
  function toDoc(event) {
@@ -38,5 +39,6 @@ function toDoc(event) {
38
39
  isRatingApplied: event.getIsRatingApplied(),
39
40
  reportMessageId: event.getReportMessageId(),
40
41
  gameMode: event.getGameMode(),
42
+ excludeFromStats: event.getExcludeFromStats(),
41
43
  };
42
44
  }
@@ -0,0 +1,42 @@
1
+ import { EventEmitter } from "events";
2
+ export type SuspiciousGroupUpdateReason = "rating_regression" | "history_truncated";
3
+ export interface SuspiciousGroupUpdate {
4
+ groupId: number;
5
+ reason: SuspiciousGroupUpdateReason;
6
+ /** Sum of all rating points across all participants — before this update */
7
+ sumBefore: number;
8
+ /** Sum of all rating points across all participants — after this update would be applied */
9
+ sumAfter: number;
10
+ /** sumAfter - sumBefore (negative = regression) */
11
+ delta: number;
12
+ /** Total ratingHistory entries in the document — before */
13
+ historyCountBefore: number;
14
+ /** Total ratingHistory entries — after */
15
+ historyCountAfter: number;
16
+ /** Top 10 individual losses (userId, delta), sorted by largest loss first */
17
+ topLosses: Array<{
18
+ userId: number;
19
+ deltaSum: number;
20
+ deltaHistoryLen: number;
21
+ }>;
22
+ /** First N lines of the call stack pointing at the caller */
23
+ callerStack: string;
24
+ /** true if the repository refused to apply the write because of regression guard */
25
+ refused: boolean;
26
+ /** ISO timestamp of when the event fired */
27
+ timestamp: string;
28
+ }
29
+ declare class GroupRepoEvents extends EventEmitter {
30
+ emitSuspicious(ev: SuspiciousGroupUpdate): void;
31
+ }
32
+ /**
33
+ * Singleton emitter the MongoGroupsRepository fires events on whenever it
34
+ * detects something unusual (e.g. a write that would drop total rating
35
+ * significantly, or shorten history). Wire a listener in the app's bootstrap
36
+ * to forward these to Telegram, Slack, etc.
37
+ *
38
+ * import { groupRepoEvents } from "ballrush-core/repositories";
39
+ * groupRepoEvents.on("suspicious", (ev) => { ... });
40
+ */
41
+ export declare const groupRepoEvents: GroupRepoEvents;
42
+ export {};
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.groupRepoEvents = void 0;
4
+ const events_1 = require("events");
5
+ class GroupRepoEvents extends events_1.EventEmitter {
6
+ emitSuspicious(ev) {
7
+ this.emit("suspicious", ev);
8
+ }
9
+ }
10
+ /**
11
+ * Singleton emitter the MongoGroupsRepository fires events on whenever it
12
+ * detects something unusual (e.g. a write that would drop total rating
13
+ * significantly, or shorten history). Wire a listener in the app's bootstrap
14
+ * to forward these to Telegram, Slack, etc.
15
+ *
16
+ * import { groupRepoEvents } from "ballrush-core/repositories";
17
+ * groupRepoEvents.on("suspicious", (ev) => { ... });
18
+ */
19
+ exports.groupRepoEvents = new GroupRepoEvents();
@@ -1,5 +1,5 @@
1
1
  import type { Model } from "mongoose";
2
- import type { GroupsRepository } from "../../ports/groups.repository";
2
+ import type { GroupsRepository, GroupFieldsUpdate } from "../../ports/groups.repository";
3
3
  import type { GroupDoc } from "../../mongo";
4
4
  import { Group } from "../../domain/group";
5
5
  export declare class MongoGroupsRepository implements GroupsRepository {
@@ -8,6 +8,7 @@ export declare class MongoGroupsRepository implements GroupsRepository {
8
8
  create(group: Group): Promise<Group>;
9
9
  findById(groupId: number): Promise<Group | null>;
10
10
  update(groupId: number, updates: Group): Promise<Group | null>;
11
+ updateFields(groupId: number, fields: GroupFieldsUpdate): Promise<void>;
11
12
  findAll(): Promise<Group[]>;
12
13
  delete(groupId: number): Promise<void>;
13
14
  }
@@ -3,6 +3,71 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MongoGroupsRepository = void 0;
4
4
  const group_mapper_1 = require("./group.mapper");
5
5
  const errors_1 = require("../../errors");
6
+ const group_repo_events_1 = require("./group-repo.events");
7
+ // Regression guard thresholds. Tunable via env:
8
+ // GROUPS_REGRESSION_GUARD=off — disable refusal (still logs + emits event)
9
+ // GROUPS_REGRESSION_ABS_DROP — absolute sum drop that counts as suspicious (default 50)
10
+ // GROUPS_REGRESSION_PCT_DROP — fractional drop (0..1) that counts as suspicious (default 0.05)
11
+ function getRegressionGuardConfig() {
12
+ const enabled = (process.env.GROUPS_REGRESSION_GUARD ?? "on") !== "off";
13
+ const absDrop = Number(process.env.GROUPS_REGRESSION_ABS_DROP ?? 50);
14
+ const pctDrop = Number(process.env.GROUPS_REGRESSION_PCT_DROP ?? 0.05);
15
+ return { enabled, absDrop, pctDrop };
16
+ }
17
+ function sumOfHistory(participants) {
18
+ if (!participants)
19
+ return 0;
20
+ let total = 0;
21
+ for (const p of participants) {
22
+ for (const r of p.ratingHistory ?? [])
23
+ total += r.value ?? 0;
24
+ }
25
+ return total;
26
+ }
27
+ function countHistory(participants) {
28
+ if (!participants)
29
+ return 0;
30
+ let total = 0;
31
+ for (const p of participants)
32
+ total += (p.ratingHistory ?? []).length;
33
+ return total;
34
+ }
35
+ function computeTopLosses(before, after, limit = 10) {
36
+ const beforeBy = new Map();
37
+ for (const p of before)
38
+ beforeBy.set(p.userId, p);
39
+ const losses = [];
40
+ for (const a of after) {
41
+ const b = beforeBy.get(a.userId);
42
+ if (!b)
43
+ continue;
44
+ const bSum = (b.ratingHistory ?? []).reduce((s, r) => s + (r.value ?? 0), 0);
45
+ const aSum = (a.ratingHistory ?? []).reduce((s, r) => s + (r.value ?? 0), 0);
46
+ const deltaSum = aSum - bSum;
47
+ const deltaHistoryLen = (a.ratingHistory ?? []).length - (b.ratingHistory ?? []).length;
48
+ if (deltaSum < 0 || deltaHistoryLen < 0) {
49
+ losses.push({ userId: a.userId, deltaSum: Math.round(deltaSum * 100) / 100, deltaHistoryLen });
50
+ }
51
+ }
52
+ // also detect participants that disappeared entirely
53
+ const afterIds = new Set(after.map(p => p.userId));
54
+ for (const b of before) {
55
+ if (!afterIds.has(b.userId)) {
56
+ const bSum = (b.ratingHistory ?? []).reduce((s, r) => s + (r.value ?? 0), 0);
57
+ losses.push({
58
+ userId: b.userId,
59
+ deltaSum: Math.round(-bSum * 100) / 100,
60
+ deltaHistoryLen: -(b.ratingHistory ?? []).length,
61
+ });
62
+ }
63
+ }
64
+ losses.sort((x, y) => x.deltaSum - y.deltaSum);
65
+ return losses.slice(0, limit);
66
+ }
67
+ function shortStack(depth = 8) {
68
+ const raw = new Error().stack ?? "";
69
+ return raw.split("\n").slice(2, 2 + depth).join("\n");
70
+ }
6
71
  class MongoGroupsRepository {
7
72
  constructor(GroupModel) {
8
73
  this.GroupModel = GroupModel;
@@ -58,6 +123,59 @@ class MongoGroupsRepository {
58
123
  throw new errors_1.ValidationError('Group', 'updates', updates, 'updates object is required');
59
124
  }
60
125
  const write = (0, group_mapper_1.toDoc)(updates);
126
+ // --- Observability: log every full-document write of a group ---
127
+ // We log enough context to find the culprit after the fact if the
128
+ // historical Pattaya-style regression ever happens again.
129
+ const callerStack = shortStack();
130
+ const sumAfter = sumOfHistory(write.participants);
131
+ const historyCountAfter = countHistory(write.participants);
132
+ // Read current state ONCE for regression guard + alert payload.
133
+ const currentDoc = await this.GroupModel.findOne({ groupId }).lean();
134
+ const sumBefore = sumOfHistory(currentDoc?.participants ?? []);
135
+ const historyCountBefore = countHistory(currentDoc?.participants ?? []);
136
+ const delta = sumAfter - sumBefore;
137
+ const historyDelta = historyCountAfter - historyCountBefore;
138
+ console.log(`[GROUPS_UPDATE] groupId=${groupId} participants=${write.participants.length} ` +
139
+ `sumBefore=${sumBefore.toFixed(2)} sumAfter=${sumAfter.toFixed(2)} delta=${delta.toFixed(2)} ` +
140
+ `histBefore=${historyCountBefore} histAfter=${historyCountAfter} histDelta=${historyDelta}\n` +
141
+ `[GROUPS_UPDATE_STACK]\n${callerStack}`);
142
+ // --- Regression guard ---
143
+ const guard = getRegressionGuardConfig();
144
+ const absThreshold = Math.max(guard.absDrop, Math.abs(sumBefore) * guard.pctDrop);
145
+ const isRegression = currentDoc !== null && (delta < -absThreshold || // big rating drop
146
+ historyDelta < 0 // history shrank
147
+ );
148
+ if (isRegression) {
149
+ const topLosses = computeTopLosses((currentDoc?.participants ?? []), write.participants);
150
+ const event = {
151
+ groupId,
152
+ reason: (delta < -absThreshold ? "rating_regression" : "history_truncated"),
153
+ sumBefore: Math.round(sumBefore * 100) / 100,
154
+ sumAfter: Math.round(sumAfter * 100) / 100,
155
+ delta: Math.round(delta * 100) / 100,
156
+ historyCountBefore,
157
+ historyCountAfter,
158
+ topLosses,
159
+ callerStack,
160
+ refused: guard.enabled,
161
+ timestamp: new Date().toISOString(),
162
+ };
163
+ console.error(`[GROUPS_REGRESSION] groupId=${groupId} reason=${event.reason} ` +
164
+ `delta=${event.delta} histDelta=${historyDelta} refused=${event.refused}\n` +
165
+ `topLosses=${JSON.stringify(event.topLosses)}\n` +
166
+ `[GROUPS_REGRESSION_STACK]\n${callerStack}`);
167
+ // Fire-and-forget event for any listener (e.g. Telegram alerter in the bot).
168
+ try {
169
+ group_repo_events_1.groupRepoEvents.emitSuspicious(event);
170
+ }
171
+ catch { /* never fail update because of a listener */ }
172
+ if (guard.enabled) {
173
+ // Refuse the write — return null so callers can detect failure.
174
+ // To bypass intentionally (e.g. legit reset by admin), run with:
175
+ // GROUPS_REGRESSION_GUARD=off
176
+ return null;
177
+ }
178
+ }
61
179
  const doc = await this.GroupModel.findOneAndUpdate({ groupId }, { $set: write }, { new: true }).lean();
62
180
  return doc ? (0, group_mapper_1.toDomain)(doc) : null;
63
181
  }
@@ -68,6 +186,22 @@ class MongoGroupsRepository {
68
186
  throw new errors_1.QueryError('update', 'Group', groupId, error);
69
187
  }
70
188
  }
189
+ async updateFields(groupId, fields) {
190
+ try {
191
+ if (!groupId || groupId === 0) {
192
+ throw new errors_1.ValidationError('Group', 'groupId', groupId, 'must be a non-zero number');
193
+ }
194
+ if (!fields || Object.keys(fields).length === 0)
195
+ return;
196
+ await this.GroupModel.updateOne({ groupId }, { $set: fields });
197
+ }
198
+ catch (error) {
199
+ if (error instanceof errors_1.ValidationError) {
200
+ throw error;
201
+ }
202
+ throw new errors_1.QueryError('updateFields', 'Group', groupId, error);
203
+ }
204
+ }
71
205
  async findAll() {
72
206
  try {
73
207
  const docs = await this.GroupModel.find().lean();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ballrush-core",
3
- "version": "0.13.0",
3
+ "version": "1.3.2",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",