Skip to content

Commit eec1439

Browse files
authored
Merge pull request #795 from kelly-musk/feat/issues-728-730-731-740
feat: notification digest batching, creators search, postgres backup drill, rate-limit tests
2 parents f9b18de + 2a06cc0 commit eec1439

25 files changed

Lines changed: 1766 additions & 150 deletions

.github/workflows/ci.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,59 @@ jobs:
167167
run: npm run build
168168
working-directory: frontend
169169

170+
db-backup-drill:
171+
name: Postgres Backup / Restore Drill
172+
runs-on: ubuntu-latest
173+
timeout-minutes: 10
174+
needs: [backend-migrations]
175+
176+
services:
177+
postgres:
178+
image: postgres:16-alpine
179+
env:
180+
POSTGRES_USER: myfans_ci
181+
POSTGRES_PASSWORD: myfans_ci
182+
POSTGRES_DB: myfans_test
183+
ports:
184+
- 5432:5432
185+
options: >
186+
--health-cmd pg_isready
187+
--health-interval 5s
188+
--health-timeout 5s
189+
--health-retries 10
190+
191+
env:
192+
DB_HOST: localhost
193+
DB_PORT: 5432
194+
DB_USER: myfans_ci
195+
DB_PASSWORD: myfans_ci
196+
DB_NAME: myfans_test
197+
JWT_SECRET: ci-test-secret-not-for-production
198+
NODE_ENV: test
199+
200+
steps:
201+
- uses: actions/checkout@v4
202+
203+
- uses: actions/setup-node@v4
204+
with:
205+
node-version: '20'
206+
cache: 'npm'
207+
cache-dependency-path: backend/package-lock.json
208+
209+
- name: Install dependencies
210+
run: npm ci
211+
working-directory: backend
212+
213+
- name: Install postgres client
214+
run: sudo apt-get install -y postgresql-client
215+
216+
- name: Run migrations (seed schema)
217+
run: npm run migration:run
218+
working-directory: backend
219+
220+
- name: Backup / restore drill
221+
run: ./scripts/pg-backup-restore.sh drill
222+
170223
contract:
171224
name: Contract (Rust)
172225
runs-on: ubuntu-latest

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ You will keep only these three folders and this README; other files can be remov
192192
- **[Security Policy](SECURITY.md)** - Security reporting, penetration testing tracker, and best practices
193193
- **[Bug Bash Checklist](docs/BUG_BASH_CHECKLIST.md)** - Comprehensive QA checklist before major releases
194194
- **[Changelog Guide](docs/CHANGELOG_GUIDE.md)** - How to use conventional commits for automatic changelog generation
195+
- **[Postgres Backup / Restore](docs/POSTGRES_BACKUP_RESTORE.md)** - Backup runbook, restore decision tree, and CI drill
195196

196197
### Development
197198
- **[Changelog](CHANGELOG.md)** - Automatically generated from conventional commits

backend/src/app.module.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ const IDEMPOTENCY_ROUTES = [
4040
@Module({
4141
imports: [
4242
ThrottlerModule.forRoot([
43-
{ name: 'short', ttl: 60000, limit: 10 },
44-
{ name: 'medium', ttl: 60000, limit: 50 },
45-
{ name: 'long', ttl: 60000, limit: 100 },
43+
{ name: 'auth', ttl: 60000, limit: 5 },
44+
{ name: 'short', ttl: 60000, limit: 10 },
45+
{ name: 'medium', ttl: 60000, limit: 50 },
46+
{ name: 'long', ttl: 60000, limit: 100 },
4647
]),
4748
LoggingModule,
4849
MetricsModule,

backend/src/common/dto/paginated-response.dto.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,41 @@ export class PaginatedResponseDto<T> {
1616
@ApiProperty({ description: 'Whether there are more items' })
1717
hasMore: boolean;
1818

19-
constructor(data: T[], limit: number, nextCursor: string | null, hasMore: boolean) {
19+
@ApiPropertyOptional({ description: 'Total number of matching items' })
20+
total: number;
21+
22+
@ApiPropertyOptional({ description: 'Current page number (1-based)' })
23+
page: number;
24+
25+
@ApiPropertyOptional({ description: 'Total number of pages' })
26+
totalPages: number;
27+
28+
constructor(
29+
data: T[],
30+
limitOrTotal: number,
31+
nextCursorOrPage: string | number | null,
32+
hasmoreOrLimit: boolean | number,
33+
page?: number,
34+
) {
2035
this.data = data;
2136
this.cursor = null;
22-
this.limit = limit;
23-
this.nextCursor = nextCursor;
24-
this.hasMore = hasMore;
37+
38+
// Overload: (data, total, page, limit) — used by searchCreators
39+
if (typeof nextCursorOrPage === 'number' && typeof hasmoreOrLimit === 'number') {
40+
this.total = limitOrTotal;
41+
this.page = nextCursorOrPage;
42+
this.limit = hasmoreOrLimit;
43+
this.nextCursor = null;
44+
this.hasMore = this.page * this.limit < this.total;
45+
this.totalPages = Math.ceil(this.total / this.limit);
46+
} else {
47+
// Original: (data, limit, nextCursor, hasMore)
48+
this.limit = limitOrTotal;
49+
this.nextCursor = nextCursorOrPage as string | null;
50+
this.hasMore = hasmoreOrLimit as boolean;
51+
this.total = data.length;
52+
this.page = page ?? 1;
53+
this.totalPages = 1;
54+
}
2555
}
2656
}

backend/src/creators/creators.service.ts

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -120,37 +120,29 @@ export class CreatorsService {
120120
async searchCreators(
121121
searchDto: SearchCreatorsDto,
122122
): Promise<PaginatedResponseDto<PublicCreatorDto>> {
123-
const { cursor, limit = 20, q } = searchDto;
123+
const { page = 1, limit = 20, q } = searchDto;
124124
const trimmed = q?.trim();
125125

126126
const qb = this.userRepository
127127
.createQueryBuilder('user')
128128
.leftJoin('user.creator', 'creator')
129129
.addSelect('creator.bio', 'creator_bio')
130130
.where('user.is_creator = :isCreator', { isCreator: true })
131-
.orderBy('user.id', 'ASC')
132-
.take(limit + 1);
133-
134-
if (cursor) {
135-
const cursorId = parseInt(cursor, 10);
136-
if (!isNaN(cursorId)) {
137-
qb.andWhere('user.id > :cursorId', { cursorId });
138-
}
139-
}
140-
141-
const { entities, raw } = await qb.getRawAndEntities();
142-
const hasMore = entities.length > limit;
143-
if (hasMore) {
144-
entities.pop();
145-
}
146-
147-
let nextCursor: string | null = null;
148-
if (entities.length > 0) {
149-
nextCursor = String(entities[entities.length - 1].id);
131+
.orderBy('user.username', 'ASC')
132+
.skip((page - 1) * limit)
133+
.take(limit);
134+
135+
if (trimmed) {
136+
qb.andWhere(
137+
'(LOWER(user.display_name) LIKE :search OR LOWER(user.username) LIKE :search)',
138+
{ search: `${trimmed.toLowerCase()}%` },
139+
);
150140
}
151141

152-
return new PaginatedResponseDto(entities, limit, nextCursor, hasMore);
153-
}
142+
const [{ entities, raw }, total] = await Promise.all([
143+
qb.getRawAndEntities(),
144+
qb.getCount(),
145+
]);
154146

155147
const data = entities.map((user, index) => {
156148
const dto = new PublicCreatorDto(user, user.creator);
@@ -159,7 +151,7 @@ export class CreatorsService {
159151
});
160152

161153
this.logger.debug(
162-
`Creator search returned ${data.length} rows for query "${trimmed ?? ''}"`,
154+
`Creator search returned ${data.length}/${total} rows for query "${trimmed ?? ''}"`,
163155
);
164156

165157
return new PaginatedResponseDto(data, total, page, limit);

backend/src/migration.datasource.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CreateWalletChallenges1711554834000 } from './auth/1711554834000-Create
88
import { CreateIdempotencyKeys1711554835000 } from './idempotency/1711554835000-CreateIdempotencyKeys';
99
import { AddQueuedAtToModerationFlags1745000000000 } from './moderation/1745000000000-AddQueuedAtToModerationFlags';
1010
import { CreateReferralTables1745000000000 } from './referral/1745000000000-CreateReferralTables';
11+
import { AddDigestColumnsToNotifications1745100000000 } from './notifications/1745100000000-AddDigestColumnsToNotifications';
1112

1213
export const migrationDataSource = new DataSource({
1314
type: 'postgres',
@@ -25,5 +26,6 @@ export const migrationDataSource = new DataSource({
2526
CreateIdempotencyKeys1711554835000,
2627
AddQueuedAtToModerationFlags1745000000000,
2728
CreateReferralTables1745000000000,
29+
AddDigestColumnsToNotifications1745100000000,
2830
],
2931
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddDigestColumnsToNotifications1745100000000
4+
implements MigrationInterface
5+
{
6+
async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "digest_count" integer NOT NULL DEFAULT 1`,
9+
);
10+
await queryRunner.query(
11+
`ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "digest_event_times" jsonb`,
12+
);
13+
}
14+
15+
async down(queryRunner: QueryRunner): Promise<void> {
16+
await queryRunner.query(
17+
`ALTER TABLE "notifications" DROP COLUMN IF EXISTS "digest_event_times"`,
18+
);
19+
await queryRunner.query(
20+
`ALTER TABLE "notifications" DROP COLUMN IF EXISTS "digest_count"`,
21+
);
22+
}
23+
}

backend/src/notifications/dto/notification.dto.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
1+
import { IsBoolean, IsEnum, IsInt, IsISO8601, IsOptional, IsString, Min } from 'class-validator';
22
import { NotificationType } from '../entities/notification.entity';
33

44
export class CreateNotificationDto {
@@ -16,6 +16,15 @@ export class CreateNotificationDto {
1616

1717
@IsOptional()
1818
metadata?: Record<string, unknown>;
19+
20+
@IsOptional()
21+
@IsInt()
22+
@Min(1)
23+
digest_count?: number;
24+
25+
@IsOptional()
26+
@IsISO8601({}, { each: true })
27+
digest_event_times?: string[];
1928
}
2029

2130
export class MarkReadDto {

backend/src/notifications/entities/notification.entity.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export class Notification {
4949
@Column({ type: 'jsonb', nullable: true })
5050
metadata: Record<string, unknown> | null;
5151

52+
/** Number of individual events collapsed into this digest (1 = not a digest). */
53+
@Column({ type: 'int', default: 1 })
54+
digest_count: number;
55+
56+
/** ISO timestamps of the individual events batched into this digest. */
57+
@Column({ type: 'jsonb', nullable: true })
58+
digest_event_times: string[] | null;
59+
5260
@CreateDateColumn()
5361
created_at: Date;
5462
}

0 commit comments

Comments
 (0)