diff --git a/src/migrations/1719502578294_create-index-for-events-table.ts b/src/migrations/1719502578294_create-index-for-events-table.ts new file mode 100644 index 000000000..0d6d04ad9 --- /dev/null +++ b/src/migrations/1719502578294_create-index-for-events-table.ts @@ -0,0 +1,10 @@ +import type { MigrationBuilder } from "node-pg-migrate" +import EventModel from "../models/Event" + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createIndex(EventModel.tableName, [`(event_data->>'proposal_id')`]) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropIndex(EventModel.tableName, [`(event_data->>'proposal_id')`]) +} \ No newline at end of file diff --git a/src/models/Event.ts b/src/models/Event.ts index 56feb535c..db5f81de2 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -20,16 +20,21 @@ export default class EventModel extends Model { return result } - static async getLatest(filters?: EventFilter): Promise { - const { event_type } = filters || {} + static async getLatest(filters: EventFilter): Promise { + const { with_interval, event_type, proposal_id } = filters const query = SQL` SELECT * FROM ${table(EventModel)} - WHERE created_at >= NOW() - INTERVAL '7 day' + WHERE 1=1 + ${conditional( + with_interval !== undefined ? with_interval : true, + SQL`AND created_at >= NOW() - INTERVAL '7 day'` + )} ${conditional( !!event_type, SQL`AND event_type IN (${join(event_type?.map((type) => SQL`${type}`) || [], SQL`, `)})` )} + ${conditional(!!proposal_id, SQL`AND (event_data ->>'proposal_id') = ${proposal_id}`)} ORDER BY created_at DESC LIMIT ${LATEST_EVENTS_LIMIT} ` @@ -37,15 +42,6 @@ export default class EventModel extends Model { return result } - static async deleteOldEvents() { - const query = SQL` - DELETE - FROM ${table(EventModel)} - WHERE created_at < NOW() - INTERVAL '7 day' - ` - await this.namedQuery('delete_old_events', query) - } - static async isDiscourseEventRegistered(discourseEventId: string) { const query = SQL` SELECT count(*) diff --git a/src/routes/events.ts b/src/routes/events.ts index c1614327f..b73918063 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -4,12 +4,7 @@ import routes from 'decentraland-gatsby/dist/entities/Route/routes' import { Request } from 'express' import { EventsService } from '../services/events' -import { - validateDebugAddress, - validateEventTypesFilters, - validateId, - validateRequiredString, -} from '../utils/validations' +import { validateDebugAddress, validateEventFilters, validateId, validateRequiredString } from '../utils/validations' export default routes((route) => { const withAuth = auth() @@ -19,8 +14,8 @@ export default routes((route) => { }) async function getLatestEvents(req: Request) { - const eventTypes = validateEventTypesFilters(req) - return await EventsService.getLatest(eventTypes) + const filters = validateEventFilters(req) + return await EventsService.getLatest(filters) } async function voted(req: WithAuth) { diff --git a/src/server.ts b/src/server.ts index 2dfad1e01..a5af21c09 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,7 +43,6 @@ import vestings from './routes/vestings' import score from './routes/votes' import webhooks from './routes/webhooks' import { DiscordService } from './services/discord' -import { EventsService } from './services/events' const jobs = manager() jobs.cron('@eachMinute', finishProposal) @@ -51,7 +50,6 @@ jobs.cron('@eachMinute', activateProposals) jobs.cron('@each5Minute', withLock('publishBids', publishBids)) jobs.cron('@each10Second', pingSnapshot) jobs.cron('30 0 * * *', updateGovernanceBudgets) // Runs at 00:30 daily -jobs.cron('0 1 * * *', EventsService.deleteOldEvents) // Runs at 01:00 daily jobs.cron('30 1 * * *', runQueuedAirdropJobs) // Runs at 01:30 daily jobs.cron('30 2 * * *', giveAndRevokeLandOwnerBadges) // Runs at 02:30 daily jobs.cron('30 3 1 * *', giveTopVoterBadges) // Runs at 03:30 on the first day of the month diff --git a/src/services/events.ts b/src/services/events.ts index f9cbc7cdd..20904895a 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -40,7 +40,7 @@ const CLEAR_DELEGATE_SIGNATURE_HASH = '0x9c4f00c4291262731946e308dc2979a56bd22cc const SET_DELEGATE_SIGNATURE_HASH = '0xa9a7fd460f56bddb880a465a9c3e9730389c70bc53108148f16d55a87a6c468e' export class EventsService { - static async getLatest(filters?: EventFilter): Promise { + static async getLatest(filters: EventFilter): Promise { try { const latestEvents = await EventModel.getLatest(filters) @@ -185,14 +185,6 @@ export class EventsService { }) } - static async deleteOldEvents() { - try { - await EventModel.deleteOldEvents() - } catch (error) { - ErrorService.report('Error deleting old events', { error, category: ErrorCategory.Events }) - } - } - private static getProfileCacheKey(address: string) { const cacheKey = `profile-${address.toLowerCase()}` return cacheKey diff --git a/src/shared/types/events.ts b/src/shared/types/events.ts index 6f56a507e..6f01c89b9 100644 --- a/src/shared/types/events.ts +++ b/src/shared/types/events.ts @@ -48,6 +48,8 @@ export enum EventType { export const EventFilterSchema = z.object({ event_type: z.nativeEnum(EventType).array().optional(), + proposal_id: z.string().uuid().optional(), + with_interval: z.boolean().optional(), }) export type EventFilter = z.infer diff --git a/src/utils/validations.test.ts b/src/utils/validations.test.ts index 113fcbb47..a7ec3eac2 100644 --- a/src/utils/validations.test.ts +++ b/src/utils/validations.test.ts @@ -2,7 +2,7 @@ import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import { EventType } from '../shared/types/events' -import { validateEventTypesFilters, validateId } from './validations' +import { validateEventFilters, validateId } from './validations' describe('validateProposalId', () => { const UUID = '00000000-0000-0000-0000-000000000000' @@ -27,51 +27,51 @@ describe('validateProposalId', () => { describe('validateEventTypesFilters', () => { test('Should return an empty object when event_type is not provided', () => { const req = { query: {} } as never - const result = validateEventTypesFilters(req) + const result = validateEventFilters(req) expect(result).toEqual({}) }) test('Should convert event_type into an array when it is a string', () => { const req = { query: { event_type: EventType.Voted } } as never - const result = validateEventTypesFilters(req) + const result = validateEventFilters(req) expect(result).toEqual({ event_type: [EventType.Voted] }) }) test('Should keep event_type as an array when it is already an array', () => { const req = { query: { event_type: [EventType.Voted, EventType.ProjectUpdateCommented] } } as never - const result = validateEventTypesFilters(req) + const result = validateEventFilters(req) expect(result).toEqual({ event_type: [EventType.Voted, EventType.ProjectUpdateCommented] }) }) test('Should throw an error if EventFilterSchema returns an error', () => { const req = { query: { event_type: 'single_event' } } as never - expect(() => validateEventTypesFilters(req)).toThrow() + expect(() => validateEventFilters(req)).toThrow() }) }) describe('validateEventTypesFilters', () => { test('Should return an empty object when event_type is not provided', () => { const req = { query: {} } as never - const result = validateEventTypesFilters(req) + const result = validateEventFilters(req) expect(result).toEqual({}) }) test('Should convert event_type into an array when it is a string', () => { const req = { query: { event_type: EventType.Voted } } as never - const result = validateEventTypesFilters(req) + const result = validateEventFilters(req) expect(result).toEqual({ event_type: [EventType.Voted] }) }) test('Should keep event_type as an array when it is already an array', () => { const req = { query: { event_type: [EventType.Voted, EventType.ProjectUpdateCommented] } } as never - const result = validateEventTypesFilters(req) + const result = validateEventFilters(req) expect(result).toEqual({ event_type: [EventType.Voted, EventType.ProjectUpdateCommented] }) }) test('Should throw an error if EventFilterSchema returns an error', () => { const req = { query: { event_type: 'single_event' } } as never - expect(() => validateEventTypesFilters(req)).toThrow() + expect(() => validateEventFilters(req)).toThrow() }) }) diff --git a/src/utils/validations.ts b/src/utils/validations.ts index 96930f377..75fa2fad0 100644 --- a/src/utils/validations.ts +++ b/src/utils/validations.ts @@ -157,12 +157,16 @@ export function validateAlchemyWebhookSignature(req: Request) { } } -export function validateEventTypesFilters(req: Request) { - const { event_type } = req.query +export function validateEventFilters(req: Request) { + const { event_type, proposal_id, with_interval } = req.query const filters: Record = {} if (event_type) { filters.event_type = isArray(event_type) ? event_type : [event_type] } + if (proposal_id) { + filters.proposal_id = proposal_id.toString() + } + filters.with_interval = with_interval ? stringToBoolean(with_interval.toString()) : undefined const parsedEventTypes = EventFilterSchema.safeParse(filters) if (!parsedEventTypes.success) { throw new Error('Invalid event types: ' + parsedEventTypes.error.message) @@ -218,3 +222,18 @@ export function validateBlockNumber(blockNumber?: unknown | null) { throw new Error('Invalid blockNumber: must be null, undefined, or a number') } } + +export function stringToBoolean(str: string) { + switch (str.toLowerCase().trim()) { + case 'true': + case '1': + case 'yes': + return true + case 'false': + case '0': + case 'no': + return false + default: + throw new Error('Invalid boolean value') + } +}