Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddGistIsActiveAndReportCountColumns1750000000004 implements MigrationInterface {
name = 'AddGistIsActiveAndReportCountColumns1750000000004';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE "gists" DROP COLUMN IF EXISTS "report_count"`);
await queryRunner.query(`ALTER TABLE "gists" DROP COLUMN IF EXISTS "is_active"`);
}
}
6 changes: 6 additions & 0 deletions Backend/src/gists/entities/gist.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
67 changes: 67 additions & 0 deletions Backend/src/gists/gists.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<GistsService>;

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>(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);
});
});
11 changes: 10 additions & 1 deletion Backend/src/gists/gists.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,21 @@ 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)' })
countNearby(@Query() query: QueryGistsDto) {
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' })
Expand Down Expand Up @@ -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,
};
}

Expand Down
17 changes: 17 additions & 0 deletions Backend/src/gists/gists.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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',
});
});
});
});
5 changes: 5 additions & 0 deletions Backend/src/gists/gists.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
55 changes: 54 additions & 1 deletion Backend/src/soroban/soroban.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ export class SorobanService {
);
}

async getLatestLedger(): Promise<number> {
async getLatestLedger(): Promise<number> {
if (this.mockMode) {
this.logger.debug('MOCK getLatestLedger() → 1');
return 1;
Expand All @@ -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<string>('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 {
Expand Down
Loading