diff --git a/Backend/src/database/migrations/AddGistIsActiveAndReportCountColumns1750000000004.ts b/Backend/src/database/migrations/AddGistIsActiveAndReportCountColumns1750000000004.ts new file mode 100644 index 00000000..f9495dbc --- /dev/null +++ b/Backend/src/database/migrations/AddGistIsActiveAndReportCountColumns1750000000004.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGistIsActiveAndReportCountColumns1750000000004 implements MigrationInterface { + name = 'AddGistIsActiveAndReportCountColumns1750000000004'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "gists" + ADD COLUMN IF NOT EXISTS "is_active" BOOLEAN NOT NULL DEFAULT true + `); + await queryRunner.query(` + ALTER TABLE "gists" + ADD COLUMN IF NOT EXISTS "report_count" INTEGER NOT NULL DEFAULT 0 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "gists" DROP COLUMN IF EXISTS "report_count"`); + await queryRunner.query(`ALTER TABLE "gists" DROP COLUMN IF EXISTS "is_active"`); + } +} diff --git a/Backend/src/gists/entities/gist.entity.ts b/Backend/src/gists/entities/gist.entity.ts index bd54b2d1..3fc1a52b 100644 --- a/Backend/src/gists/entities/gist.entity.ts +++ b/Backend/src/gists/entities/gist.entity.ts @@ -43,4 +43,10 @@ export class Gist { @Column({ type: 'boolean', default: false }) hidden: boolean; + + @Column({ type: 'integer', default: 0 }) + report_count: number; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; } diff --git a/Backend/src/gists/gists.controller.spec.ts b/Backend/src/gists/gists.controller.spec.ts new file mode 100644 index 00000000..982733b2 --- /dev/null +++ b/Backend/src/gists/gists.controller.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GistsController } from './gists.controller'; +import { GistsService } from './gists.service'; + +describe('GistsController', () => { + let controller: GistsController; + let service: jest.Mocked; + + const mockGist = { + id: '00000000-0000-0000-0000-000000000001', + content: 'hello', + location_cell: 's1t7d8c', + content_hash: 'Qmrealcid', + stellar_gist_id: '123', + tx_hash: 'tx123', + author_address: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', + location: 'POINT(7.4951 9.0579)', + created_at: new Date(), + expires_at: new Date(), + hidden: false, + report_count: 2, + is_active: true, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GistsController], + providers: [ + { + provide: GistsService, + useValue: { + create: jest.fn().mockResolvedValue(mockGist), + findNearby: jest.fn().mockResolvedValue({ data: [mockGist], total: 1 }), + findOne: jest.fn().mockResolvedValue(mockGist), + getContent: jest.fn().mockResolvedValue({ content: 'hello' }), + countNearby: jest.fn().mockResolvedValue({ count: 1 }), + report: jest.fn().mockResolvedValue({ count: 1 }), + getModerator: jest + .fn() + .mockResolvedValue({ moderatorAddress: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN' }), + }, + }, + ], + }).compile(); + + controller = module.get(GistsController); + service = module.get(GistsService); + }); + + it('findOne decorates gist with is_active, report_count, gist_id, content_cid', async () => { + const res = await controller.findOne('00000000-0000-0000-0000-000000000001'); + expect(res).toMatchObject({ + gist_id: '123', + content_cid: 'Qmrealcid', + is_active: true, + report_count: 2, + }); + }); + + it('getModerator returns current moderator address', async () => { + const res = await controller.getModerator(); + expect(res).toEqual({ + moderatorAddress: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', + }); + expect(service.getModerator).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Backend/src/gists/gists.controller.ts b/Backend/src/gists/gists.controller.ts index e86d2be2..7a0b47f3 100644 --- a/Backend/src/gists/gists.controller.ts +++ b/Backend/src/gists/gists.controller.ts @@ -28,7 +28,7 @@ export class GistsController { } // IMPORTANT: must be registered before @Get(':id') so NestJS does not - // match the literal string "count" as a UUID parameter. + // match literal strings as UUID parameters. @Get('count') @SkipThrottle() @ApiOperation({ summary: 'Count gists near a location (optionally broken down by cell)' }) @@ -36,6 +36,13 @@ export class GistsController { return this.gistsService.countNearby(query); } + @Get('moderator') + @SkipThrottle() + @ApiOperation({ summary: 'Get the current moderator address' }) + getModerator() { + return this.gistsService.getModerator(); + } + @Get(':id/content') @SkipThrottle() @ApiOperation({ summary: 'Get the raw IPFS content for a gist' }) @@ -65,6 +72,8 @@ export class GistsController { ...gist, gist_id: gist.stellar_gist_id, content_cid: gist.content_hash, + is_active: gist.is_active ?? true, + report_count: gist.report_count ?? 0, }; } diff --git a/Backend/src/gists/gists.service.spec.ts b/Backend/src/gists/gists.service.spec.ts index 192c8866..818be131 100644 --- a/Backend/src/gists/gists.service.spec.ts +++ b/Backend/src/gists/gists.service.spec.ts @@ -36,6 +36,8 @@ describe('GistsService', () => { created_at: new Date('2026-01-01T00:00:00Z'), expires_at: new Date('2026-01-02T00:00:00Z'), hidden: false, + report_count: 0, + is_active: true, ...overrides, }); @@ -294,4 +296,19 @@ describe('GistsService', () => { expect(result.hidden).toBe(false); }); }); + + describe('getModerator', () => { + it('returns the moderator address from SorobanService', async () => { + const sorobanService = (service as any).sorobanService; + sorobanService.getAdmin = jest.fn().mockResolvedValue({ + admin: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', + mock: true, + }); + + const res = await service.getModerator(); + expect(res).toEqual({ + moderatorAddress: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', + }); + }); + }); }); diff --git a/Backend/src/gists/gists.service.ts b/Backend/src/gists/gists.service.ts index 4c4d076d..a14f1834 100644 --- a/Backend/src/gists/gists.service.ts +++ b/Backend/src/gists/gists.service.ts @@ -154,4 +154,9 @@ export class GistsService { const count = await this.gistRepository.countNearby(lat, lon, radius); return { count, radius, lat, lon }; } + + async getModerator(): Promise<{ moderatorAddress: string | null }> { + const { admin } = await this.sorobanService.getAdmin(); + return { moderatorAddress: admin }; + } } diff --git a/Backend/src/soroban/soroban.service.ts b/Backend/src/soroban/soroban.service.ts index e3833be1..51708a7d 100644 --- a/Backend/src/soroban/soroban.service.ts +++ b/Backend/src/soroban/soroban.service.ts @@ -454,7 +454,7 @@ export class SorobanService { ); } - async getLatestLedger(): Promise { + async getLatestLedger(): Promise { if (this.mockMode) { this.logger.debug('MOCK getLatestLedger() → 1'); return 1; @@ -467,6 +467,59 @@ export class SorobanService { ); } + async getAdmin(): Promise<{ admin: string | null; mock: boolean }> { + if (this.mockMode) { + await this.simulateDelay(); + const admin = + this.config.get('MODERATOR_ADDRESS') ?? + 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; + return { admin, mock: true }; + } + + return withRetry( + async () => this.getAdminLive(), + 'Soroban.getAdmin', + this.maxRetries, + this.logger, + ); + } + + private async getAdminLive(): Promise<{ admin: string | null; mock: boolean }> { + const rpcServer = this.getRpcServer(); + const contract = this.getContract(); + + const simulation = await rpcServer.simulateTransaction( + new TransactionBuilder( + await rpcServer.getAccount(this.getSigner().publicKey()), + { fee: BASE_FEE, networkPassphrase: this.networkPassphrase }, + ) + .addOperation(contract.call('get_admin')) + .setTimeout(30) + .build(), + ); + + if (SorobanRpc.Api.isSimulationError(simulation) || !simulation.result) { + throw new Error('Soroban get_admin failed'); + } + + if (!simulation.result.retval) { + throw new Error('Soroban get_admin returned no value'); + } + + const native = scValToNative(simulation.result.retval); + if (native == null) { + return { admin: null, mock: false }; + } + + const addr = typeof native === 'string' + ? native + : typeof native === 'object' && native !== null && 'toString' in native + ? String(native) + : null; + + return { admin: addr, mock: false }; + } + // ── Private: signers & guards ───────────────────────────────────────────── private resolveSigner(): Keypair | null {