diff --git a/Backend/src/common/logger/winston.config.ts b/Backend/src/common/logger/winston.config.ts index 6d6cc719..b77a01ee 100644 --- a/Backend/src/common/logger/winston.config.ts +++ b/Backend/src/common/logger/winston.config.ts @@ -1,5 +1,8 @@ import { utilities as nestWinstonUtilities, WinstonModuleOptions } from 'nest-winston'; import * as winston from 'winston'; +// winston-daily-rotate-file is a CommonJS module without a default export; tsconfig has no +// esModuleInterop, so the `import = require` form is needed for the class to be constructible. +// eslint-disable-next-line @typescript-eslint/no-require-imports import DailyRotateFile = require('winston-daily-rotate-file'); import { getCorrelationId } from './correlation-id.store'; diff --git a/Backend/src/config/env.validation.ts b/Backend/src/config/env.validation.ts index 8c11d26c..36fd911d 100644 --- a/Backend/src/config/env.validation.ts +++ b/Backend/src/config/env.validation.ts @@ -27,6 +27,11 @@ export const envValidationSchema = Joi.object({ ), CONTRACT_ID_GIST_REGISTRY: Joi.string().allow('').default(''), STELLAR_SECRET_KEY: Joi.string().allow('').default(''), + // Funded public key used as the simulation source for read-only calls (no secret needed) + SOROBAN_READ_ACCOUNT: Joi.string() + .pattern(/^G[A-Z2-7]{55}$/) + .allow('') + .default(''), // IPFS (Pinata) — optional; empty means mock CIDs in dev PINATA_API_KEY: Joi.string().allow('').default(''), diff --git a/Backend/src/gists/gist.repository.integration-spec.ts b/Backend/src/gists/gist.repository.integration-spec.ts index 2f4c01da..27a3bdac 100644 --- a/Backend/src/gists/gist.repository.integration-spec.ts +++ b/Backend/src/gists/gist.repository.integration-spec.ts @@ -539,4 +539,40 @@ describe('GistRepository (integration)', () => { // The hidden gist's cell should not inflate counts }); }); + + describe('is_active filtering', () => { + const MARKER = `inactive-${Date.now()}`; + let gistId: string; + + beforeAll(async () => { + const gist = await repository.create({ + content: 'gist that will be deactivated', + lat: 9.0579, + lon: 7.4951, + location_cell: 's1t7d8c', + stellar_gist_id: MARKER, + }); + gistId = gist.id; + }); + + afterAll(async () => { + await dataSource.query(`DELETE FROM gists WHERE stellar_gist_id = $1`, [MARKER]); + }); + + it('returns an active gist from findByGistId and findNearby', async () => { + expect((await repository.findByGistId(gistId))?.id).toBe(gistId); + const page = await repository.findNearby({ lat: 9.0579, lon: 7.4951, radiusMeters: 500, limit: 100 }); + expect(page.data.some((g) => g.id === gistId)).toBe(true); + }); + + it('hides the gist everywhere once is_active is false', async () => { + const before = await repository.countNearby(9.0579, 7.4951, 500); + await repository.setGistActive(MARKER, false); + + expect(await repository.findByGistId(gistId)).toBeNull(); + const page = await repository.findNearby({ lat: 9.0579, lon: 7.4951, radiusMeters: 500, limit: 100 }); + expect(page.data.some((g) => g.id === gistId)).toBe(false); + expect(await repository.countNearby(9.0579, 7.4951, 500)).toBe(before - 1); + }); + }); }); diff --git a/Backend/src/gists/gist.repository.ts b/Backend/src/gists/gist.repository.ts index 8982b2c5..6b3b62ae 100644 --- a/Backend/src/gists/gist.repository.ts +++ b/Backend/src/gists/gist.repository.ts @@ -79,6 +79,7 @@ export class GistRepository { clauses.push(`g.expires_at > NOW()`); clauses.push(`g.hidden = false`); + clauses.push(`g.is_active = true`); if (cursor) { const decoded = PaginationHelper.decodeCursor(cursor) ?? cursor; @@ -145,6 +146,7 @@ export class GistRepository { WHERE id = $1 AND expires_at > NOW() AND hidden = false + AND is_active = true LIMIT 1 `, [id], @@ -194,7 +196,8 @@ export class GistRepository { ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3 ) AND expires_at > NOW() - AND hidden = false`, + AND hidden = false + AND is_active = true`, [lon, lat, radiusMeters], ); return parseInt(row.count, 10); @@ -212,6 +215,7 @@ export class GistRepository { $3 ) AND expires_at > NOW() AND hidden = false + AND is_active = true GROUP BY location_cell ORDER BY count DESC`, [lon, lat, radiusMeters], ); diff --git a/Backend/src/gists/gists.service.ts b/Backend/src/gists/gists.service.ts index a14f1834..5bb294e0 100644 --- a/Backend/src/gists/gists.service.ts +++ b/Backend/src/gists/gists.service.ts @@ -13,11 +13,6 @@ import { stripHtml } from '../common/utils/sanitize'; const DEFAULT_TTL_HOURS = 24; -interface CacheEntry { - data: Record; - expiresAt: number; -} - export interface CountNearbyResult { count: number; radius: number; @@ -29,8 +24,6 @@ export interface CountNearbyResult { @Injectable() export class GistsService { private readonly logger = new Logger(GistsService.name); - private readonly contentCache = new Map(); - private static readonly CACHE_TTL_MS = 5 * 60 * 1000; constructor( @InjectDataSource() private readonly dataSource: DataSource, diff --git a/Backend/src/soroban/soroban.service.ts b/Backend/src/soroban/soroban.service.ts index 0fdaa9d1..67015944 100644 --- a/Backend/src/soroban/soroban.service.ts +++ b/Backend/src/soroban/soroban.service.ts @@ -500,7 +500,7 @@ export class SorobanService { const simulation = await rpcServer.simulateTransaction( new TransactionBuilder( - await rpcServer.getAccount(this.getSigner().publicKey()), + await rpcServer.getAccount(this.getReadAccountKey()), { fee: BASE_FEE, networkPassphrase: this.networkPassphrase }, ) .addOperation(contract.call('get_admin')) @@ -560,6 +560,16 @@ export class SorobanService { return this.contract; } + /** + * Source account for read-only simulations. Simulation needs a funded public + * account but no signature, so reads use `SOROBAN_READ_ACCOUNT` when set and + * fall back to the signer's public key otherwise. + */ + private getReadAccountKey(): string { + const configured = this.config.get('SOROBAN_READ_ACCOUNT'); + return configured || this.getSigner().publicKey(); + } + private getSigner(): Keypair { if (!this.signer) { throw new Error('STELLAR_SECRET_KEY is required for Soroban live mode'); @@ -633,7 +643,7 @@ export class SorobanService { const simulation = await rpcServer.simulateTransaction( new TransactionBuilder( - await rpcServer.getAccount(this.getSigner().publicKey()), + await rpcServer.getAccount(this.getReadAccountKey()), { fee: BASE_FEE, networkPassphrase: this.networkPassphrase }, ) .addOperation( @@ -665,7 +675,7 @@ export class SorobanService { const simulation = await rpcServer.simulateTransaction( new TransactionBuilder( - await rpcServer.getAccount(this.getSigner().publicKey()), + await rpcServer.getAccount(this.getReadAccountKey()), { fee: BASE_FEE, networkPassphrase: this.networkPassphrase }, ) .addOperation( @@ -697,7 +707,7 @@ export class SorobanService { const simulation = await rpcServer.simulateTransaction( new TransactionBuilder( - await rpcServer.getAccount(this.getSigner().publicKey()), + await rpcServer.getAccount(this.getReadAccountKey()), { fee: BASE_FEE, networkPassphrase: this.networkPassphrase }, ) .addOperation(