Skip to content

Commit 1384fec

Browse files
authored
Merge pull request MyFanss#1527 from Emeka000/datasource
feat: Adds four backend features to bring comments to audit parity with posts, aggregate a personalized fan feed, implement the favorites API the frontend already calls, and expose Stellar network config for client discovery.
2 parents 8496ef9 + a6eb897 commit 1384fec

33 files changed

Lines changed: 1597 additions & 23 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddCommentSoftDeleteAndAudit1753000000000
4+
implements MigrationInterface
5+
{
6+
async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
ALTER TABLE "comments"
9+
ADD COLUMN "deletedAt" TIMESTAMP NULL,
10+
ADD COLUMN "deletedBy" character varying NULL
11+
`);
12+
await queryRunner.query(
13+
`CREATE INDEX "IDX_comments_deletedAt" ON "comments" ("deletedAt")`,
14+
);
15+
16+
await queryRunner.query(`
17+
CREATE TABLE "comment_audit_logs" (
18+
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
19+
"commentId" character varying NOT NULL,
20+
"deletedBy" character varying NOT NULL,
21+
"action" character varying NOT NULL DEFAULT 'soft_delete',
22+
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
23+
CONSTRAINT "PK_comment_audit_logs" PRIMARY KEY ("id")
24+
)
25+
`);
26+
await queryRunner.query(
27+
`CREATE INDEX "IDX_comment_audit_logs_commentId" ON "comment_audit_logs" ("commentId")`,
28+
);
29+
await queryRunner.query(
30+
`CREATE INDEX "IDX_comment_audit_logs_deletedBy" ON "comment_audit_logs" ("deletedBy")`,
31+
);
32+
}
33+
34+
async down(queryRunner: QueryRunner): Promise<void> {
35+
await queryRunner.query(`DROP INDEX "IDX_comment_audit_logs_deletedBy"`);
36+
await queryRunner.query(`DROP INDEX "IDX_comment_audit_logs_commentId"`);
37+
await queryRunner.query(`DROP TABLE "comment_audit_logs"`);
38+
39+
await queryRunner.query(`DROP INDEX "IDX_comments_deletedAt"`);
40+
await queryRunner.query(`
41+
ALTER TABLE "comments"
42+
DROP COLUMN "deletedBy",
43+
DROP COLUMN "deletedAt"
44+
`);
45+
}
46+
}

backend/src/comments/comments.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
33
import { CommentsController } from './comments.controller';
44
import { CommentsService } from './comments.service';
55
import { Comment } from './entities/comment.entity';
6+
import { CommentAuditLog } from './entities/comment-audit-log.entity';
7+
import { EventsModule } from '../events/events.module';
68

79
@Module({
8-
imports: [TypeOrmModule.forFeature([Comment])],
10+
imports: [TypeOrmModule.forFeature([Comment, CommentAuditLog]), EventsModule],
911
controllers: [CommentsController],
1012
providers: [CommentsService],
1113
exports: [CommentsService],

backend/src/comments/comments.service.spec.ts

Lines changed: 168 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { getRepositoryToken } from '@nestjs/typeorm';
33
import { ForbiddenException, NotFoundException } from '@nestjs/common';
44
import { CommentsService } from './comments.service';
55
import { Comment } from './entities/comment.entity';
6+
import { CommentAuditLog } from './entities/comment-audit-log.entity';
7+
import { EventBus } from '../events/event-bus';
8+
import { CommentDeletedEvent } from '../events/domain-events';
69

710
const makeComment = (overrides: Partial<Comment> = {}): Comment =>
811
({
@@ -13,6 +16,8 @@ const makeComment = (overrides: Partial<Comment> = {}): Comment =>
1316
parentId: null,
1417
createdAt: new Date('2026-01-01T00:00:00Z'),
1518
updatedAt: new Date('2026-01-01T00:00:00Z'),
19+
deletedAt: null,
20+
deletedBy: null,
1621
...overrides,
1722
}) as Comment;
1823

@@ -24,7 +29,10 @@ describe('CommentsService', () => {
2429
findOne: jest.Mock;
2530
findAndCount: jest.Mock;
2631
remove: jest.Mock;
32+
delete: jest.Mock;
2733
};
34+
let auditRepo: { create: jest.Mock; save: jest.Mock };
35+
let eventBus: { publish: jest.Mock };
2836

2937
beforeEach(async () => {
3038
repo = {
@@ -33,12 +41,20 @@ describe('CommentsService', () => {
3341
findOne: jest.fn(),
3442
findAndCount: jest.fn(),
3543
remove: jest.fn(),
44+
delete: jest.fn(),
3645
};
46+
auditRepo = {
47+
create: jest.fn((data: unknown) => data),
48+
save: jest.fn((e: unknown) => Promise.resolve(e)),
49+
};
50+
eventBus = { publish: jest.fn() };
3751

3852
const module: TestingModule = await Test.createTestingModule({
3953
providers: [
4054
CommentsService,
4155
{ provide: getRepositoryToken(Comment), useValue: repo },
56+
{ provide: getRepositoryToken(CommentAuditLog), useValue: auditRepo },
57+
{ provide: EventBus, useValue: eventBus },
4258
],
4359
}).compile();
4460

@@ -143,6 +159,19 @@ describe('CommentsService', () => {
143159
expect(result.limit).toBe(20);
144160
});
145161

162+
it('filters by deletedAt: IsNull()', async () => {
163+
repo.findAndCount.mockResolvedValue([[], 0]);
164+
165+
await service.findAll({ page: 1, limit: 20 });
166+
167+
expect(repo.findAndCount).toHaveBeenCalledWith(
168+
expect.objectContaining({
169+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest matcher typings are `any`
170+
where: expect.objectContaining({ deletedAt: expect.anything() }),
171+
}),
172+
);
173+
});
174+
146175
it('returns empty list when there are no comments', async () => {
147176
repo.findAndCount.mockResolvedValue([[], 0]);
148177

@@ -186,7 +215,7 @@ describe('CommentsService', () => {
186215
// ── findByPost ───────────────────────────────────────────────────────────────
187216

188217
describe('findByPost', () => {
189-
it('returns comments filtered by postId', async () => {
218+
it('returns comments filtered by postId and deletedAt: IsNull()', async () => {
190219
const comment = makeComment({ postId: 'post-42' });
191220
repo.findAndCount.mockResolvedValue([[comment], 1]);
192221

@@ -196,7 +225,13 @@ describe('CommentsService', () => {
196225
});
197226

198227
expect(repo.findAndCount).toHaveBeenCalledWith(
199-
expect.objectContaining({ where: { postId: 'post-42' } }),
228+
expect.objectContaining({
229+
where: expect.objectContaining({
230+
postId: 'post-42',
231+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest matcher typings are `any`
232+
deletedAt: expect.anything(),
233+
}),
234+
}),
200235
);
201236
expect(result.data).toHaveLength(1);
202237
expect(result.data[0].postId).toBe('post-42');
@@ -244,7 +279,11 @@ describe('CommentsService', () => {
244279

245280
const result = await service.findOne('comment-1');
246281

247-
expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 'comment-1' } });
282+
expect(repo.findOne).toHaveBeenCalledWith(
283+
expect.objectContaining({
284+
where: expect.objectContaining({ id: 'comment-1' }),
285+
}),
286+
);
248287
expect(result.id).toBe('comment-1');
249288
expect(result.content).toBe('Great post!');
250289
});
@@ -262,6 +301,14 @@ describe('CommentsService', () => {
262301

263302
await expect(service.findOne('missing-id')).rejects.toThrow('missing-id');
264303
});
304+
305+
it('throws NotFoundException for a soft-deleted comment (filtered by IsNull)', async () => {
306+
repo.findOne.mockResolvedValue(null);
307+
308+
await expect(service.findOne('comment-1')).rejects.toBeInstanceOf(
309+
NotFoundException,
310+
);
311+
});
265312
});
266313

267314
// ── update ───────────────────────────────────────────────────────────────────
@@ -289,7 +336,11 @@ describe('CommentsService', () => {
289336

290337
await service.update('comment-1', { content: 'New content' }, 'author-1');
291338

292-
expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 'comment-1' } });
339+
expect(repo.findOne).toHaveBeenCalledWith(
340+
expect.objectContaining({
341+
where: expect.objectContaining({ id: 'comment-1' }),
342+
}),
343+
);
293344
});
294345

295346
it('throws NotFoundException when comment does not exist', async () => {
@@ -335,23 +386,64 @@ describe('CommentsService', () => {
335386
});
336387
});
337388

338-
// ── remove ───────────────────────────────────────────────────────────────────
389+
// ── remove (soft delete) ─────────────────────────────────────────────────────
339390

340391
describe('remove', () => {
341-
it('removes the comment when it exists', async () => {
342-
const comment = makeComment();
392+
it('sets deletedAt and deletedBy then saves', async () => {
393+
const comment = makeComment({ authorId: 'author-1' });
343394
repo.findOne.mockResolvedValue(comment);
344-
repo.remove.mockResolvedValue(undefined);
395+
repo.save.mockResolvedValue({
396+
...comment,
397+
deletedAt: new Date(),
398+
deletedBy: 'author-1',
399+
});
345400

346401
await service.remove('comment-1', 'author-1');
347402

348-
expect(repo.remove).toHaveBeenCalledWith(comment);
403+
expect(repo.save).toHaveBeenCalledWith(
404+
expect.objectContaining({ deletedBy: 'author-1' }),
405+
);
406+
expect(comment.deletedAt).not.toBeNull();
407+
expect(repo.remove).not.toHaveBeenCalled();
408+
});
409+
410+
it('persists an audit log row with correct fields', async () => {
411+
const comment = makeComment({ authorId: 'author-1' });
412+
repo.findOne.mockResolvedValue(comment);
413+
repo.save.mockResolvedValue(comment);
414+
415+
await service.remove('comment-1', 'author-1');
416+
417+
expect(auditRepo.create).toHaveBeenCalledWith(
418+
expect.objectContaining({
419+
commentId: 'comment-1',
420+
deletedBy: 'author-1',
421+
action: 'soft_delete',
422+
}),
423+
);
424+
expect(auditRepo.save).toHaveBeenCalled();
425+
});
426+
427+
it('emits CommentDeletedEvent with correct commentId and deletedBy', async () => {
428+
const comment = makeComment({ authorId: 'author-1' });
429+
repo.findOne.mockResolvedValue(comment);
430+
repo.save.mockResolvedValue(comment);
431+
432+
await service.remove('comment-1', 'author-1');
433+
434+
expect(eventBus.publish).toHaveBeenCalledWith(
435+
expect.objectContaining({
436+
type: 'comment.deleted',
437+
commentId: 'comment-1',
438+
deletedBy: 'author-1',
439+
}),
440+
);
349441
});
350442

351443
it('returns void on success', async () => {
352444
const comment = makeComment();
353445
repo.findOne.mockResolvedValue(comment);
354-
repo.remove.mockResolvedValue(undefined);
446+
repo.save.mockResolvedValue(comment);
355447

356448
const result = await service.remove('comment-1', 'author-1');
357449

@@ -364,14 +456,37 @@ describe('CommentsService', () => {
364456
await expect(
365457
service.remove('missing', 'author-1'),
366458
).rejects.toBeInstanceOf(NotFoundException);
459+
expect(eventBus.publish).not.toHaveBeenCalled();
460+
expect(auditRepo.save).not.toHaveBeenCalled();
367461
});
368462

369-
it('does not call remove when comment is not found', async () => {
463+
it('does not emit event or write audit log when comment is already soft-deleted', async () => {
464+
// IsNull() filter means the repo returns null for already-deleted comments
370465
repo.findOne.mockResolvedValue(null);
371466

372-
await expect(service.remove('missing', 'author-1')).rejects.toThrow();
467+
await expect(
468+
service.remove('comment-1', 'author-1'),
469+
).rejects.toBeInstanceOf(NotFoundException);
470+
expect(eventBus.publish).not.toHaveBeenCalled();
471+
expect(auditRepo.save).not.toHaveBeenCalled();
472+
});
373473

374-
expect(repo.remove).not.toHaveBeenCalled();
474+
it('audit log is written before event is published', async () => {
475+
const order: string[] = [];
476+
const comment = makeComment({ authorId: 'author-1' });
477+
repo.findOne.mockResolvedValue(comment);
478+
repo.save.mockResolvedValue(comment);
479+
auditRepo.save.mockImplementation((e: unknown) => {
480+
order.push('audit');
481+
return Promise.resolve(e);
482+
});
483+
eventBus.publish.mockImplementation(() => {
484+
order.push('event');
485+
});
486+
487+
await service.remove('comment-1', 'author-1');
488+
489+
expect(order).toEqual(['audit', 'event']);
375490
});
376491

377492
it('throws ForbiddenException when the requester is not the author', async () => {
@@ -381,7 +496,47 @@ describe('CommentsService', () => {
381496
await expect(
382497
service.remove('comment-1', 'someone-else'),
383498
).rejects.toBeInstanceOf(ForbiddenException);
499+
expect(repo.save).not.toHaveBeenCalled();
500+
expect(auditRepo.save).not.toHaveBeenCalled();
501+
expect(eventBus.publish).not.toHaveBeenCalled();
384502
expect(repo.remove).not.toHaveBeenCalled();
385503
});
386504
});
505+
506+
// ── hardDelete ───────────────────────────────────────────────────────────────
507+
508+
describe('hardDelete', () => {
509+
it('is not the default deletion path (hard-deletes only when called explicitly)', async () => {
510+
repo.delete.mockResolvedValue({ affected: 1 });
511+
512+
await service.hardDelete('comment-1');
513+
514+
expect(repo.delete).toHaveBeenCalledWith('comment-1');
515+
});
516+
517+
it('throws NotFoundException when nothing was deleted', async () => {
518+
repo.delete.mockResolvedValue({ affected: 0 });
519+
520+
await expect(service.hardDelete('missing')).rejects.toBeInstanceOf(
521+
NotFoundException,
522+
);
523+
});
524+
});
525+
526+
// ── CommentDeletedEvent ──────────────────────────────────────────────────────
527+
528+
describe('CommentDeletedEvent', () => {
529+
it('has the correct type discriminant', () => {
530+
const event = new CommentDeletedEvent('c1', 'u1');
531+
expect(event.type).toBe('comment.deleted');
532+
expect(event.commentId).toBe('c1');
533+
expect(event.deletedBy).toBe('u1');
534+
});
535+
536+
it('records a timestamp', () => {
537+
const before = Date.now();
538+
const event = new CommentDeletedEvent('c1', 'u1');
539+
expect(event.timestamp).toBeGreaterThanOrEqual(before);
540+
});
541+
});
387542
});

0 commit comments

Comments
 (0)