diff --git a/Backend/src/gists/gist.repository.ts b/Backend/src/gists/gist.repository.ts index 8867c7ff..8982b2c5 100644 --- a/Backend/src/gists/gist.repository.ts +++ b/Backend/src/gists/gist.repository.ts @@ -217,4 +217,36 @@ export class GistRepository { ); return rows.map((r) => ({ cell: r.location_cell, count: parseInt(r.count, 10) })); } + + async updateContentHash(stellarGistId: string, contentHash: string): Promise { + const result = await this.dataSource.query>( + `UPDATE gists SET content_hash = $2 WHERE stellar_gist_id = $1 RETURNING id`, + [stellarGistId, contentHash], + ); + return result.length > 0; + } + + async setGistActive(stellarGistId: string, isActive: boolean): Promise { + const result = await this.dataSource.query>( + `UPDATE gists SET is_active = $2 WHERE stellar_gist_id = $1 RETURNING id`, + [stellarGistId, isActive], + ); + return result.length > 0; + } + + async setGistHidden(stellarGistId: string, hidden: boolean): Promise { + const result = await this.dataSource.query>( + `UPDATE gists SET hidden = $2 WHERE stellar_gist_id = $1 RETURNING id`, + [stellarGistId, hidden], + ); + return result.length > 0; + } + + async updateReportCount(stellarGistId: string, count: number): Promise { + const result = await this.dataSource.query>( + `UPDATE gists SET report_count = $2 WHERE stellar_gist_id = $1 RETURNING id`, + [stellarGistId, count], + ); + return result.length > 0; + } } diff --git a/Backend/src/indexer/indexer.service.spec.ts b/Backend/src/indexer/indexer.service.spec.ts index 1ffd446e..98aa01cd 100644 --- a/Backend/src/indexer/indexer.service.spec.ts +++ b/Backend/src/indexer/indexer.service.spec.ts @@ -82,6 +82,10 @@ describe('IndexerService', () => { findByStellarGistId: jest.fn(), existsByStellarGistId: jest.fn(), create: jest.fn(), + updateContentHash: jest.fn(), + setGistActive: jest.fn(), + setGistHidden: jest.fn(), + updateReportCount: jest.fn(), } as unknown as jest.Mocked; geoService = { @@ -443,13 +447,9 @@ describe('IndexerService', () => { secondEvent, ]); - gistRepo.findByStellarGistId - .mockRejectedValueOnce( - new Error('Database failure'), - ) - .mockResolvedValueOnce(null); - - gistRepo.existsByStellarGistId.mockResolvedValue(false); + gistRepo.existsByStellarGistId + .mockRejectedValueOnce(new Error('Database failure')) + .mockResolvedValueOnce(false); gistRepo.create.mockResolvedValue({} as never); await expect( @@ -570,5 +570,61 @@ describe('IndexerService', () => { await firstPoll; }); + + it('handles gist_edited event by updating content_hash', async () => { + soroban.getEventsSince.mockResolvedValue([ + { + type: 'gist_edited', + ledger: 105, + gist: { + gistId: 'gist-1', + locationCell: 'u4pruyd', + contentHash: 'QmUpdatedHash', + author: 'GABCD', + createdAt: 1700000000, + expiresAt: 1700086400, + hidden: false, + }, + }, + ]); + + await service.poll(); + + expect(gistRepo.updateContentHash).toHaveBeenCalledWith('gist-1', 'QmUpdatedHash'); + }); + + it('handles gist_deleted and gist_removed events by setting is_active = false', async () => { + soroban.getEventsSince.mockResolvedValue([ + { type: 'gist_deleted', ledger: 106, gistId: 'gist-1' }, + { type: 'gist_removed', ledger: 107, gistId: 'gist-2' }, + ]); + + await service.poll(); + + expect(gistRepo.setGistActive).toHaveBeenCalledWith('gist-1', false); + expect(gistRepo.setGistActive).toHaveBeenCalledWith('gist-2', false); + }); + + it('handles gist_hidden and gist_unhidden events by updating hidden column', async () => { + soroban.getEventsSince.mockResolvedValue([ + { type: 'gist_hidden', ledger: 108, gistId: 'gist-1' }, + { type: 'gist_unhidden', ledger: 109, gistId: 'gist-1' }, + ]); + + await service.poll(); + + expect(gistRepo.setGistHidden).toHaveBeenCalledWith('gist-1', true); + expect(gistRepo.setGistHidden).toHaveBeenCalledWith('gist-1', false); + }); + + it('handles gist_reported event by updating report count', async () => { + soroban.getEventsSince.mockResolvedValue([ + { type: 'gist_reported', ledger: 110, gistId: 'gist-1', count: 3 }, + ]); + + await service.poll(); + + expect(gistRepo.updateReportCount).toHaveBeenCalledWith('gist-1', 3); + }); }); }); \ No newline at end of file diff --git a/Backend/src/indexer/indexer.service.ts b/Backend/src/indexer/indexer.service.ts index d8b14d25..552371cd 100644 --- a/Backend/src/indexer/indexer.service.ts +++ b/Backend/src/indexer/indexer.service.ts @@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { SorobanService } from '../soroban/soroban.service'; -import { GistRepository } from '../gists/gist.repository'; +import { GistRepository, PG_UNIQUE_VIOLATION } from '../gists/gist.repository'; import { GeoService } from '../geo/geo.service'; import { IndexerState } from './indexer-state.entity'; @@ -120,75 +120,81 @@ export class IndexerService { * dedicated persistence handler when that issue lands. */ private async handleEvent(event: any): Promise { - if ( - event.type !== 'gist_posted' && - event.type !== 'gist_edited' - ) { - this.logger.debug( - `Skipping non-indexable event: ${event.type}`, - ); - return; - } - - const { gist } = event; + switch (event.type) { + case 'gist_posted': { + const { gist } = event; + if (!gist) { + throw new Error(`Missing gist payload for ${event.type} event`); + } + if (!gist.gistId || !gist.locationCell) { + throw new Error(`Malformed gist event at ledger ${event.ledger}`); + } + const alreadyIndexed = await this.gistRepository.existsByStellarGistId(gist.gistId); + if (alreadyIndexed) { + this.logger.debug(`Gist ${gist.gistId} already indexed — skipping duplicate insert`); + break; + } + const { lat, lon } = this.geoService.decode(gist.locationCell); + try { + await this.gistRepository.create({ + content: '', + lat, + lon, + location_cell: gist.locationCell, + content_hash: gist.contentHash, + stellar_gist_id: gist.gistId, + tx_hash: null, + }); + this.logger.debug(`Indexed gist_posted: ${gist.gistId}`); + } catch (err) { + const code = (err as { code?: string })?.code; + if (code === PG_UNIQUE_VIOLATION) { + this.logger.debug(`Gist ${gist.gistId} unique violation — duplicate event handled`); + } else { + throw err; + } + } + break; + } - if (!gist) { - throw new Error( - `Missing gist payload for ${event.type} event`, - ); - } + case 'gist_edited': { + const { gist } = event; + if (!gist?.gistId) { + throw new Error(`Malformed gist_edited event at ledger ${event.ledger}`); + } + await this.gistRepository.updateContentHash(gist.gistId, gist.contentHash); + this.logger.debug(`Indexed gist_edited: ${gist.gistId}`); + break; + } - if ( - !gist.gistId || - !gist.locationCell - ) { - throw new Error( - `Malformed gist event at ledger ${event.ledger}`, - ); - } + case 'gist_deleted': + case 'gist_removed': { + await this.gistRepository.setGistActive(event.gistId, false); + this.logger.debug(`Indexed ${event.type}: ${event.gistId}`); + break; + } - const existing = - await this.gistRepository.findByStellarGistId( - gist.gistId, - ); + case 'gist_hidden': { + await this.gistRepository.setGistHidden(event.gistId, true); + this.logger.debug(`Indexed gist_hidden: ${event.gistId}`); + break; + } - if (existing) { - this.logger.debug( - `Skipping already-indexed gist ${gist.gistId}`, - ); - return; - } + case 'gist_unhidden': { + await this.gistRepository.setGistHidden(event.gistId, false); + this.logger.debug(`Indexed gist_unhidden: ${event.gistId}`); + break; + } - const alreadyIndexed = - await this.gistRepository.existsByStellarGistId( - gist.gistId, - ); + case 'gist_reported': { + await this.gistRepository.updateReportCount(event.gistId, event.count); + this.logger.debug(`Indexed gist_reported: ${event.gistId} count=${event.count}`); + break; + } - if (alreadyIndexed) { - this.logger.debug( - `Gist ${gist.gistId} already indexed`, - ); - return; + default: + this.logger.debug(`Skipping non-indexable event: ${event.type}`); } - - const { lat, lon } = - this.geoService.decode( - gist.locationCell, - ); - - await this.gistRepository.create({ - content: '', - lat, - lon, - location_cell: gist.locationCell, - content_hash: gist.contentHash, - stellar_gist_id: gist.gistId, - tx_hash: null, - }); - - this.logger.debug( - `Indexed gist ${gist.gistId} @ cell ${gist.locationCell} (ledger ${event.ledger})`, - ); } /**