ballrush-core 0.3.0 → 0.4.0

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.
@@ -1,4 +1,5 @@
1
- import { ParticipantGroup, PlayerPosition, LanguageType } from "../types";
1
+ import { GroupStatistics } from "../ports";
2
+ import { ParticipantGroup, PlayerPosition, LanguageType, RatingSource } from "../types";
2
3
  export declare class Group {
3
4
  private readonly groupId;
4
5
  private readonly groupName;
@@ -6,6 +7,8 @@ export declare class Group {
6
7
  private timezone;
7
8
  private isMessageLast;
8
9
  private language;
10
+ private isPublic;
11
+ private statistics;
9
12
  private constructor();
10
13
  static create(params: {
11
14
  groupId: number;
@@ -14,6 +17,8 @@ export declare class Group {
14
17
  timezone?: string;
15
18
  isMessageLast?: boolean;
16
19
  language?: LanguageType;
20
+ isPublic?: boolean;
21
+ statistics?: GroupStatistics;
17
22
  }): Group;
18
23
  getGroupId(): number;
19
24
  getGroupName(): string;
@@ -21,15 +26,18 @@ export declare class Group {
21
26
  getIsMessageLast(): boolean;
22
27
  getLanguage(): LanguageType;
23
28
  getParticipants(): ParticipantGroup[];
29
+ getIsPublic(): boolean;
30
+ getStatistics(): GroupStatistics;
24
31
  getAdmins(): ParticipantGroup[];
25
32
  getAdminIds(): number[];
26
33
  setTimezone(tz: string): void;
27
34
  setIsMessageLast(v: boolean): void;
28
35
  setLanguage(language: LanguageType): void;
36
+ setIsPublic(value: boolean): void;
29
37
  addAdmin(userId: number): void;
30
38
  removeAdmin(userId: number): void;
31
- addParticipant(userId: number, isAdmin?: boolean, initialRating?: number): boolean;
39
+ addNewParticipant(userId: number, isAdmin?: boolean, initialRating?: number): boolean;
32
40
  removeParticipant(userId: number): void;
33
41
  setPosition(userId: number, pos: PlayerPosition): void;
34
- addRatingPoint(userId: number, value: number, at?: Date): void;
42
+ addRatingPoint(userId: number, value: number, source: RatingSource): void;
35
43
  }
@@ -4,18 +4,20 @@ exports.Group = void 0;
4
4
  const types_1 = require("../types");
5
5
  const utils_1 = require("../utils");
6
6
  class Group {
7
- constructor(groupId, groupName, participants, timezone = "UTC+0", isMessageLast = false, language = "en") {
7
+ constructor(groupId, groupName, participants, timezone = "UTC+0", isMessageLast = false, language = "en", isPublic = false, statistics = { seasonsCount: 0, eventsCount: 0, matchesCount: 0, participantsCount: 0 }) {
8
8
  this.groupId = groupId;
9
9
  this.groupName = groupName;
10
10
  this.participants = participants;
11
11
  this.timezone = timezone;
12
12
  this.isMessageLast = isMessageLast;
13
13
  this.language = language;
14
+ this.isPublic = isPublic;
15
+ this.statistics = statistics;
14
16
  }
15
17
  static create(params) {
16
18
  if (!params.groupName)
17
19
  throw new Error("Group name is required");
18
- return new Group(params.groupId, params.groupName, (params.participants ?? []).map(p => ({ ...p, ratingHistory: [...p.ratingHistory] })), params.timezone ?? "UTC+0", params.isMessageLast ?? false, params.language ?? "en");
20
+ return new Group(params.groupId, params.groupName, (params.participants ?? []).map(p => ({ ...p, ratingHistory: [...p.ratingHistory] })), params.timezone ?? "UTC+0", params.isMessageLast ?? false, params.language ?? "en", params.isPublic ?? false, params.statistics ?? { seasonsCount: 0, eventsCount: 0, matchesCount: 0, participantsCount: 0 });
19
21
  }
20
22
  getGroupId() { return this.groupId; }
21
23
  getGroupName() { return this.groupName; }
@@ -23,6 +25,8 @@ class Group {
23
25
  getIsMessageLast() { return this.isMessageLast; }
24
26
  getLanguage() { return this.language; }
25
27
  getParticipants() { return this.participants; }
28
+ getIsPublic() { return this.isPublic; }
29
+ getStatistics() { return this.statistics; }
26
30
  getAdmins() {
27
31
  return this.participants.filter(p => p.isAdmin);
28
32
  }
@@ -38,6 +42,9 @@ class Group {
38
42
  setLanguage(language) {
39
43
  this.language = language;
40
44
  }
45
+ setIsPublic(value) {
46
+ this.isPublic = value;
47
+ }
41
48
  addAdmin(userId) {
42
49
  const p = this.participants.find(x => x.userId === userId);
43
50
  if (p) {
@@ -47,7 +54,7 @@ class Group {
47
54
  this.participants.push({
48
55
  userId,
49
56
  position: types_1.PlayerPosition.UN,
50
- ratingHistory: (0, utils_1.addRating)([], 0),
57
+ ratingHistory: (0, utils_1.addRating)([], 0, new Date(), types_1.RatingSource.SYSTEM),
51
58
  isAdmin: true,
52
59
  });
53
60
  }
@@ -57,13 +64,13 @@ class Group {
57
64
  if (p)
58
65
  p.isAdmin = false;
59
66
  }
60
- addParticipant(userId, isAdmin = false, initialRating = 0) {
67
+ addNewParticipant(userId, isAdmin = false, initialRating = 0) {
61
68
  if (this.participants.some(p => p.userId === userId))
62
69
  return false;
63
70
  this.participants.push({
64
71
  userId,
65
72
  position: types_1.PlayerPosition.UN,
66
- ratingHistory: (0, utils_1.addRating)([], initialRating),
73
+ ratingHistory: (0, utils_1.addRating)([], initialRating, new Date(), types_1.RatingSource.SYSTEM),
67
74
  isAdmin,
68
75
  });
69
76
  return true;
@@ -76,11 +83,11 @@ class Group {
76
83
  if (p)
77
84
  p.position = pos;
78
85
  }
79
- addRatingPoint(userId, value, at = new Date()) {
86
+ addRatingPoint(userId, value, source) {
80
87
  const p = this.participants.find(x => x.userId === userId);
81
88
  if (!p)
82
89
  return;
83
- p.ratingHistory = (0, utils_1.addRating)(p.ratingHistory, value, at);
90
+ p.ratingHistory = (0, utils_1.addRating)(p.ratingHistory, value, new Date(), source);
84
91
  }
85
92
  }
86
93
  exports.Group = Group;
@@ -7,6 +7,15 @@ export interface GroupDoc {
7
7
  timezone: string;
8
8
  isMessageLast: boolean;
9
9
  language: LanguageType;
10
+ isPublic?: boolean;
11
+ createdAt?: Date;
12
+ updatedAt?: Date;
13
+ statistics?: {
14
+ seasonsCount: number;
15
+ eventsCount: number;
16
+ matchesCount: number;
17
+ participantsCount: number;
18
+ };
10
19
  }
11
20
  export declare function createRatingPointSchema(): Schema<RatingPoint, import("mongoose").Model<RatingPoint, any, any, any, import("mongoose").Document<unknown, any, RatingPoint> & RatingPoint & {
12
21
  _id: import("mongoose").Types.ObjectId;
@@ -4,10 +4,12 @@ exports.createRatingPointSchema = createRatingPointSchema;
4
4
  exports.createParticipantSchema = createParticipantSchema;
5
5
  exports.createGroupSchema = createGroupSchema;
6
6
  const mongoose_1 = require("mongoose");
7
+ const types_1 = require("../../types");
7
8
  function createRatingPointSchema() {
8
9
  return new mongoose_1.Schema({
9
10
  value: { type: Number, required: true },
10
11
  changedAt: { type: Date, default: Date.now },
12
+ source: { type: String, enum: types_1.RatingSource, default: types_1.RatingSource.SYSTEM },
11
13
  });
12
14
  }
13
15
  function createParticipantSchema() {
@@ -28,6 +30,15 @@ function createGroupSchema() {
28
30
  timezone: { type: String, default: "UTC+0" },
29
31
  isMessageLast: { type: Boolean, default: false },
30
32
  language: { type: String, enum: ['en', 'ru', 'ua'], default: 'en' },
33
+ isPublic: { type: Boolean, default: false, index: true },
34
+ statistics: {
35
+ seasonsCount: { type: Number, default: 0 },
36
+ eventsCount: { type: Number, default: 0 },
37
+ matchesCount: { type: Number, default: 0 },
38
+ participantsCount: { type: Number, default: 0 },
39
+ },
40
+ }, {
41
+ timestamps: true, // Adds createdAt and updatedAt automatically
31
42
  });
32
43
  return GroupSchema;
33
44
  }
@@ -1,25 +1,62 @@
1
1
  import { Group } from "../domain/group";
2
- import { LanguageType } from "../types";
2
+ import { LanguageType, RatingPoint } from "../types";
3
3
  export type GroupWrite = {
4
4
  groupId: number;
5
5
  groupName: string;
6
6
  participants: Array<{
7
7
  userId: number;
8
8
  position: string;
9
- ratingHistory: Array<{
10
- value: number;
11
- changedAt: Date;
12
- }>;
9
+ ratingHistory: Array<RatingPoint>;
13
10
  isAdmin: boolean;
14
11
  }>;
15
12
  timezone: string;
16
13
  isMessageLast: boolean;
17
14
  language: LanguageType;
15
+ isPublic: boolean;
16
+ statistics?: {
17
+ seasonsCount: number;
18
+ eventsCount: number;
19
+ matchesCount: number;
20
+ participantsCount: number;
21
+ };
18
22
  };
23
+ export interface FindPublicGroupsOptions {
24
+ page: number;
25
+ limit: number;
26
+ search?: string;
27
+ sort: 'lastActivity' | 'name' | 'members' | 'games';
28
+ }
29
+ export interface GroupStatistics {
30
+ seasonsCount: number;
31
+ eventsCount: number;
32
+ matchesCount: number;
33
+ participantsCount: number;
34
+ }
35
+ export interface PublicGroupsResult {
36
+ groups: Array<{
37
+ groupId: number;
38
+ groupName: string;
39
+ updatedAt: Date;
40
+ statistics: GroupStatistics;
41
+ }>;
42
+ totalCount: number;
43
+ }
19
44
  export interface GroupsRepository {
20
45
  create(groupData: Group): Promise<Group>;
21
46
  findById(groupId: number): Promise<Group | null>;
22
47
  update(groupId: number, updates: Group): Promise<Group | null>;
23
48
  findAll(): Promise<Group[]>;
24
49
  delete(groupId: number): Promise<void>;
50
+ /**
51
+ * Find public groups with pagination, search, and sorting
52
+ * @param options Query options for filtering, pagination, and sorting
53
+ * @returns Paginated list of public groups with statistics
54
+ */
55
+ findPublicGroups(options: FindPublicGroupsOptions): Promise<PublicGroupsResult>;
56
+ /**
57
+ * Update statistics for a specific group
58
+ * @param groupId The group ID
59
+ * @param updates Partial statistics to update
60
+ */
61
+ updateStatistics(groupId: number, updates: Partial<GroupStatistics>): Promise<void>;
25
62
  }
@@ -11,6 +11,8 @@ function toDomain(doc) {
11
11
  timezone: doc.timezone,
12
12
  isMessageLast: doc.isMessageLast,
13
13
  language: doc.language,
14
+ isPublic: doc.isPublic ?? false,
15
+ statistics: doc.statistics ?? { seasonsCount: 0, eventsCount: 0, matchesCount: 0, participantsCount: 0 },
14
16
  });
15
17
  }
16
18
  function toDoc(group) {
@@ -21,5 +23,7 @@ function toDoc(group) {
21
23
  timezone: group.getTimezone(),
22
24
  isMessageLast: group.getIsMessageLast(),
23
25
  language: group.getLanguage(),
26
+ isPublic: group.getIsPublic(),
27
+ statistics: group.getStatistics(),
24
28
  };
25
29
  }
@@ -1,5 +1,5 @@
1
1
  import type { Model } from "mongoose";
2
- import type { GroupsRepository } from "../../ports/groups.repository";
2
+ import type { GroupsRepository, FindPublicGroupsOptions, PublicGroupsResult, GroupStatistics } 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 {
@@ -10,4 +10,6 @@ export declare class MongoGroupsRepository implements GroupsRepository {
10
10
  update(groupId: number, updates: Group): Promise<Group | null>;
11
11
  findAll(): Promise<Group[]>;
12
12
  delete(groupId: number): Promise<void>;
13
+ findPublicGroups(options: FindPublicGroupsOptions): Promise<PublicGroupsResult>;
14
+ updateStatistics(groupId: number, updates: Partial<GroupStatistics>): Promise<void>;
13
15
  }
@@ -96,5 +96,98 @@ class MongoGroupsRepository {
96
96
  throw new errors_1.QueryError('delete', 'Group', groupId, error);
97
97
  }
98
98
  }
99
+ async findPublicGroups(options) {
100
+ try {
101
+ const { page, limit, search, sort } = options;
102
+ const skip = (page - 1) * limit;
103
+ // Build query
104
+ const query = { isPublic: true };
105
+ // Add search filter if provided
106
+ if (search) {
107
+ query.groupName = { $regex: search, $options: 'i' }; // Case-insensitive search
108
+ }
109
+ // Build sort object based on sort parameter
110
+ let sortObj = {};
111
+ switch (sort) {
112
+ case 'lastActivity':
113
+ sortObj = { updatedAt: -1 }; // Descending (newest first)
114
+ break;
115
+ case 'name':
116
+ sortObj = { groupName: 1 }; // Ascending (A-Z)
117
+ break;
118
+ case 'members':
119
+ // Sort by participants array length
120
+ // Note: This is approximate - for exact sorting, use aggregation pipeline
121
+ sortObj = { participants: -1 }; // Descending (most members first)
122
+ break;
123
+ case 'games':
124
+ // Sort by denormalized matchesCount
125
+ sortObj = { 'statistics.matchesCount': -1 }; // Descending (most games first)
126
+ break;
127
+ }
128
+ // Execute query
129
+ const groups = await this.GroupModel
130
+ .find(query)
131
+ .select('groupId groupName updatedAt participants statistics')
132
+ .sort(sortObj)
133
+ .skip(skip)
134
+ .limit(limit)
135
+ .lean()
136
+ .exec();
137
+ const totalCount = await this.GroupModel.countDocuments(query);
138
+ return {
139
+ groups: groups.map(g => ({
140
+ groupId: g.groupId,
141
+ groupName: g.groupName,
142
+ updatedAt: g.updatedAt, // guaranteed to exist with timestamps: true
143
+ statistics: {
144
+ seasonsCount: g.statistics?.seasonsCount ?? 0,
145
+ eventsCount: g.statistics?.eventsCount ?? 0,
146
+ matchesCount: g.statistics?.matchesCount ?? 0,
147
+ participantsCount: g.participants.length,
148
+ },
149
+ })),
150
+ totalCount,
151
+ };
152
+ }
153
+ catch (error) {
154
+ throw new errors_1.QueryError('findPublicGroups', 'Group', 'public', error);
155
+ }
156
+ }
157
+ async updateStatistics(groupId, updates) {
158
+ try {
159
+ // Validate input
160
+ if (!groupId || groupId === 0) {
161
+ throw new errors_1.ValidationError('Group', 'groupId', groupId, 'must be a non-zero number');
162
+ }
163
+ if (!updates || Object.keys(updates).length === 0) {
164
+ throw new errors_1.ValidationError('Group', 'updates', updates, 'updates object cannot be empty');
165
+ }
166
+ // Build update object with proper nesting
167
+ const updateObj = {};
168
+ if (updates.seasonsCount !== undefined) {
169
+ updateObj['statistics.seasonsCount'] = updates.seasonsCount;
170
+ }
171
+ if (updates.eventsCount !== undefined) {
172
+ updateObj['statistics.eventsCount'] = updates.eventsCount;
173
+ }
174
+ if (updates.matchesCount !== undefined) {
175
+ updateObj['statistics.matchesCount'] = updates.matchesCount;
176
+ }
177
+ if (updates.participantsCount !== undefined) {
178
+ updateObj['statistics.participantsCount'] = updates.participantsCount;
179
+ }
180
+ const result = await this.GroupModel.updateOne({ groupId }, { $set: updateObj });
181
+ if (result.matchedCount === 0) {
182
+ throw new errors_1.EntityNotFoundError('Group', groupId, 'updateStatistics');
183
+ }
184
+ }
185
+ catch (error) {
186
+ if (error instanceof errors_1.ValidationError || error instanceof errors_1.EntityNotFoundError) {
187
+ throw error;
188
+ }
189
+ throw new errors_1.QueryError('updateStatistics', 'Group', groupId, error);
190
+ }
191
+ }
99
192
  }
100
193
  exports.MongoGroupsRepository = MongoGroupsRepository;
@@ -71,6 +71,12 @@ export interface SelectedParticipantsDate {
71
71
  export interface RatingPoint {
72
72
  value: number;
73
73
  changedAt: Date;
74
+ source: RatingSource;
75
+ }
76
+ export declare enum RatingSource {
77
+ ADMIN_MANUAL = "ADMIN_MANUAL",
78
+ GAME_RESULT = "GAME_RESULT",
79
+ SYSTEM = "SYSTEM"
74
80
  }
75
81
  export declare const ACHIEVEMENTS: {
76
82
  readonly WinStreak: {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ACHIEVEMENTS = exports.PlayerPosition = void 0;
3
+ exports.ACHIEVEMENTS = exports.RatingSource = exports.PlayerPosition = void 0;
4
4
  var PlayerPosition;
5
5
  (function (PlayerPosition) {
6
6
  PlayerPosition["FW"] = "FW";
@@ -9,6 +9,12 @@ var PlayerPosition;
9
9
  PlayerPosition["GK"] = "GK";
10
10
  PlayerPosition["UN"] = "UN";
11
11
  })(PlayerPosition || (exports.PlayerPosition = PlayerPosition = {}));
12
+ var RatingSource;
13
+ (function (RatingSource) {
14
+ RatingSource["ADMIN_MANUAL"] = "ADMIN_MANUAL";
15
+ RatingSource["GAME_RESULT"] = "GAME_RESULT";
16
+ RatingSource["SYSTEM"] = "SYSTEM";
17
+ })(RatingSource || (exports.RatingSource = RatingSource = {}));
12
18
  exports.ACHIEVEMENTS = {
13
19
  WinStreak: {
14
20
  emoji: '🔥',
@@ -1,4 +1,4 @@
1
- import { RatingPoint } from "../types";
2
- export declare function addRating(rating: ReadonlyArray<RatingPoint>, newValue: number, date?: Date): RatingPoint[];
1
+ import { RatingPoint, RatingSource } from "../types";
2
+ export declare function addRating(rating: ReadonlyArray<RatingPoint>, newValue: number, date: Date | undefined, source: RatingSource): RatingPoint[];
3
3
  export declare function latestEntry(rating: ReadonlyArray<RatingPoint>): RatingPoint | null;
4
4
  export declare function getCurrentRating(rating?: ReadonlyArray<RatingPoint>): number;
@@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.addRating = addRating;
4
4
  exports.latestEntry = latestEntry;
5
5
  exports.getCurrentRating = getCurrentRating;
6
- function addRating(rating, newValue, date = new Date()) {
7
- return [...rating, { value: newValue, changedAt: date }];
6
+ function addRating(rating, newValue, date = new Date(), source) {
7
+ return [...rating, { value: newValue, changedAt: date, source }];
8
8
  }
9
9
  function latestEntry(rating) {
10
10
  if (!rating || rating.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ballrush-core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",