Skip to content

Commit 5bbfff6

Browse files
authored
Merge branch 'main' into fix-1246-user-consent-indexes
2 parents e479f3f + 159d0c3 commit 5bbfff6

82 files changed

Lines changed: 4048 additions & 401 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,6 @@ jobs:
6565

6666
- name: Setup pnpm
6767
uses: pnpm/action-setup@v4
68-
with:
69-
version: 11
7068

7169
- name: Setup Node
7270
uses: actions/setup-node@v4
@@ -113,8 +111,6 @@ jobs:
113111

114112
- name: Setup pnpm
115113
uses: pnpm/action-setup@v4
116-
with:
117-
version: 11
118114

119115
- name: Setup Node
120116
uses: actions/setup-node@v4

jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ module.exports = {
7373

7474
// ─── Ignore patterns ───────────────────────────────────────────────────────
7575
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/coverage/', '\\.integration\\.spec\\.ts$'],
76+
transformIgnorePatterns: [
77+
'[/\\\\]node_modules[/\\\\](?!(\\.pnpm|sanitize-html|htmlparser2|entities|dom-serializer|domelementtype|domhandler|domutils)[/\\\\])',
78+
],
7679

7780
// ─── Output & lifecycle ────────────────────────────────────────────────────
7881
verbose: true,

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"author": "",
66
"private": true,
77
"license": "UNLICENSED",
8+
"packageManager": "pnpm@9.1.0",
9+
"prNote": "Added packageManager field per issue #1209",
810
"scripts": {
911
"license:scan": "node scripts/scan-licenses.js",
1012
"build": "nest build",

pnpm-lock.yaml

Lines changed: 13 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
packages:
2+
- '.'
3+
14
allowBuilds:
25
'@apollo/protobufjs': true
36
'@nestjs/core': true

src/ab-testing/entities/experiment-metric.entity.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
UpdateDateColumn,
77
ManyToOne,
88
VersionColumn,
9+
Index,
910
} from 'typeorm';
1011
import { Experiment } from './experiment.entity';
1112
export enum MetricType {
@@ -20,6 +21,10 @@ export enum MetricType {
2021
* Represents the experiment Metric entity.
2122
*/
2223
@Entity({ name: 'experiment_metrics' })
24+
@Index('IDX_experiment_metrics_experiment_id', ['experiment'])
25+
@Index('IDX_experiment_metrics_type', ['type'])
26+
@Index('IDX_experiment_metrics_created_at', ['createdAt'])
27+
@Index('IDX_experiment_metrics_experiment_is_primary', ['experiment', 'isPrimary'])
2328
export class ExperimentMetric {
2429
@PrimaryGeneratedColumn('uuid')
2530
id: string;

src/achievements/achievements-notifications.service.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { MoreThanOrEqual } from 'typeorm';
34
import { AchievementsNotificationsService } from './achievements-notifications.service';
45
import { UserAchievement } from './entities/user-achievement.entity';
56
import { Achievement } from './entities/achievement.entity';
@@ -83,6 +84,41 @@ describe('AchievementsNotificationsService', () => {
8384
expect(count).toBe(2);
8485
});
8586

87+
it('selects achievements unlocked since midnight today with notificationSent=false', async () => {
88+
const achievement = makeUserAchievement();
89+
mockRepo.find.mockResolvedValue([achievement]);
90+
mockRepo.update.mockResolvedValue({ affected: 1 });
91+
92+
const today = new Date();
93+
today.setHours(0, 0, 0, 0);
94+
95+
const count = await service.sendBatchNotifications();
96+
97+
expect(count).toBe(1);
98+
expect(mockRepo.find).toHaveBeenCalledWith(
99+
expect.objectContaining({
100+
where: {
101+
unlockedAt: MoreThanOrEqual(today),
102+
notificationSent: false,
103+
},
104+
}),
105+
);
106+
});
107+
108+
it('processes a large backlog in bounded chunks', async () => {
109+
const batchA = Array.from({ length: 100 }, () => makeUserAchievement());
110+
const batchB = Array.from({ length: 3 }, () => makeUserAchievement());
111+
// First call returns a full batch, second returns a short batch (loop ends).
112+
mockRepo.find.mockResolvedValueOnce(batchA).mockResolvedValueOnce(batchB);
113+
mockRepo.update.mockResolvedValue({ affected: 1 });
114+
115+
const count = await service.sendBatchNotifications();
116+
117+
expect(count).toBe(103);
118+
expect(mockRepo.find).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
119+
expect(mockRepo.update).toHaveBeenCalledTimes(103);
120+
});
121+
86122
it('returns 0 when find throws', async () => {
87123
mockRepo.find.mockRejectedValue(new Error('connection lost'));
88124

src/achievements/achievements-notifications.service.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable, Logger } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
3-
import { Repository } from 'typeorm';
3+
import { MoreThanOrEqual, Repository } from 'typeorm';
44
import { UserAchievement } from './entities/user-achievement.entity';
55

66
/**
@@ -62,25 +62,45 @@ export class AchievementsNotificationsService {
6262

6363
/**
6464
* Send batch notifications for achievements unlocked today
65+
* Processes achievements in bounded chunks so a large backlog is not
66+
* loaded or sent in a single tight loop.
6567
*/
6668
async sendBatchNotifications(): Promise<number> {
69+
const BATCH_SIZE = 100;
70+
6771
try {
6872
const today = new Date();
6973
today.setHours(0, 0, 0, 0);
7074

71-
const achievements = await this.userAchievementRepository.find({
72-
where: {
73-
unlockedAt: new Date(),
74-
notificationSent: false,
75-
},
76-
relations: ['user', 'achievement'],
77-
});
78-
7975
let sentCount = 0;
80-
81-
for (const userAchievement of achievements) {
82-
await this.sendAchievementUnlockedNotification(userAchievement);
83-
sentCount++;
76+
let skip = 0;
77+
let keepProcessing = true;
78+
79+
while (keepProcessing) {
80+
const achievements = await this.userAchievementRepository.find({
81+
where: {
82+
unlockedAt: MoreThanOrEqual(today),
83+
notificationSent: false,
84+
},
85+
relations: ['user', 'achievement'],
86+
take: BATCH_SIZE,
87+
skip,
88+
});
89+
90+
if (achievements.length === 0) {
91+
keepProcessing = false;
92+
continue;
93+
}
94+
95+
for (const userAchievement of achievements) {
96+
await this.sendAchievementUnlockedNotification(userAchievement);
97+
sentCount++;
98+
}
99+
100+
skip += achievements.length;
101+
102+
// Stop when a chunk returns fewer than the batch size (no more pending).
103+
keepProcessing = achievements.length === BATCH_SIZE;
84104
}
85105

86106
this.logger.log(`Sent ${sentCount} achievement notifications`);

src/assessment/entities/assessment-attempt.entity.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import {
33
PrimaryGeneratedColumn,
44
Column,
55
ManyToOne,
6+
JoinColumn,
67
OneToMany,
78
VersionColumn,
9+
Index,
810
} from 'typeorm';
911
import { AssessmentStatus } from '../enums/assessment-status.enum';
1012
import { Answer } from './answer.entity';
@@ -14,6 +16,10 @@ import { Assessment } from './assessment.entity';
1416
* Represents the assessment Attempt entity.
1517
*/
1618
@Entity()
19+
@Index('IDX_assessment_attempt_studentId', ['studentId'])
20+
@Index('IDX_assessment_attempt_assessmentId', ['assessmentId'])
21+
@Index('IDX_assessment_attempt_status', ['status'])
22+
@Index('IDX_assessment_attempt_studentId_assessmentId', ['studentId', 'assessmentId'])
1723
export class AssessmentAttempt {
1824
@PrimaryGeneratedColumn('uuid')
1925
id: string;
@@ -25,8 +31,12 @@ export class AssessmentAttempt {
2531
studentId: string;
2632

2733
@ManyToOne(() => Assessment)
34+
@JoinColumn({ name: 'assessmentId' })
2835
assessment: Assessment;
2936

37+
@Column({ name: 'assessmentId', type: 'uuid', nullable: true })
38+
assessmentId: string | null;
39+
3040
@Column({ type: 'enum', enum: AssessmentStatus })
3141
status: AssessmentStatus;
3242

0 commit comments

Comments
 (0)