Skip to content

Commit

Permalink
feat: activity ticker (#1455)
Browse files Browse the repository at this point in the history
* chore: events migration and model

* chore: latest events route

* chore: proposal created event

* chore: update created event

* chore: proposal update created event wip

* chore: remove username from events

* chore: report event creation errors

* chore: create vote event on voting

* chore: fetch all user names and avatars (refactor pending)

* feat: show events in home page

* chore: catalyst profiles refactor (#1462)

* chore: get dcl profile refactor

* chore: useProfiles refactor

* chore: minor refactors

* refactor: remove profile refetch

* chore: add todo

* refactor: address pr comments

* refactor: avatars (#1465)

* refactor: remove blockie

* refactor: avatar love

* refactor: avatar sizes

* refactor: remove avatar src

* refactor: avatar sizes

* feat: improve activity ticker styles

* refactor: get profile uses

* refactor: remove repeated type

* refactor: renames to avatar type enums

* feat: update event copies

* refactor: move event types to shared folder

* refactor: create update service

* refactor: update update service

* fix: import

* feat: refetch events every minute

* refactor: address PR comments

* refactor: only report errors to rollbar in prod env

* refactor: rename

* chore: delete old events job

* refactor: remove comment

* chore: cache users profiles for 1h and shorten addresses for users without name

* feat: user address on activity ticker avatar

* refactor: address pr comments

---------

Co-authored-by: Andy Espagnolo <[email protected]>
  • Loading branch information
1emu and andyesp authored Dec 19, 2023
1 parent df3223e commit a90709e
Show file tree
Hide file tree
Showing 65 changed files with 860 additions and 441 deletions.
30 changes: 30 additions & 0 deletions src/back/models/Event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Model } from 'decentraland-gatsby/dist/entities/Database/model'
import { SQL, table } from 'decentraland-gatsby/dist/entities/Database/utils'

import { Event } from '../../shared/types/events'

export default class EventModel extends Model<Event> {
static tableName = 'events'
static withTimestamps = false
static primaryKey = 'id'

static async getLatest(): Promise<Event[]> {
const query = SQL`
SELECT *
FROM ${table(EventModel)}
WHERE created_at >= NOW() - INTERVAL '7 day'
ORDER BY created_at DESC
`
const result = await this.namedQuery<Event>('get_latest_events', query)
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)
}
}
25 changes: 25 additions & 0 deletions src/back/routes/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { WithAuth, auth } from 'decentraland-gatsby/dist/entities/Auth/middleware'
import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle'
import routes from 'decentraland-gatsby/dist/entities/Route/routes'

import { EventsService } from '../services/events'
import { validateProposalId, validateRequiredString } from '../utils/validations'

export default routes((route) => {
const withAuth = auth()
route.get('/events', handleAPI(getLatestEvents))
route.post('/events/voted', withAuth, handleAPI(voted))
})

async function getLatestEvents() {
return await EventsService.getLatest()
}

async function voted(req: WithAuth) {
const user = req.auth!

validateProposalId(req.body.proposalId)
validateRequiredString('proposalTitle', req.body.proposalTitle)
validateRequiredString('choice', req.body.choice)
return await EventsService.voted(req.body.proposalId, req.body.proposalTitle, req.body.choice, user)
}
3 changes: 2 additions & 1 deletion src/back/routes/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ export async function createProposalHiring(req: WithAuth) {
}
try {
const profile = await getProfile(configuration.address)
configuration.name = profile?.name
configuration.name = profile.username || configuration.address
} catch (error) {
ErrorService.report('Error getting profile', {
error,
Expand Down Expand Up @@ -479,6 +479,7 @@ export async function createProposalBid(req: WithAuth) {
await BidService.createBid(linked_proposal_id, user, bid)
}

/* eslint-disable @typescript-eslint/no-explicit-any */
export async function createProposal(proposalInCreation: ProposalInCreation) {
try {
return await ProposalService.createProposal(proposalInCreation)
Expand Down
82 changes: 22 additions & 60 deletions src/back/routes/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import Time from '../../utils/date/Time'
import { ErrorCategory } from '../../utils/errorCategories'
import { isProdEnv } from '../../utils/governanceEnvs'
import { CoauthorService } from '../services/coauthor'
import { DiscordService } from '../services/discord'
import { UpdateService } from '../services/update'

export default routes((route) => {
Expand Down Expand Up @@ -103,43 +102,20 @@ async function getProposalUpdateComments(req: Request<{ update_id: string }>) {
async function createProposalUpdate(req: WithAuth<Request<{ proposal: string }>>) {
const { author, health, introduction, highlights, blockers, next_steps, additional_notes } = req.body

const user = req.auth!
const proposalId = req.params.proposal
const proposal = await ProposalModel.findOne<ProposalAttributes>({ id: proposalId })
const isAuthorOrCoauthor =
user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposalId, user))) && author === user

if (!proposal || !isAuthorOrCoauthor) {
throw new RequestError(`Unauthorized`, RequestError.Forbidden)
}

const updates = await UpdateModel.find<UpdateAttributes>({
proposal_id: proposalId,
status: UpdateStatus.Pending,
})

const currentUpdate = getCurrentUpdate(updates)
const nextPendingUpdate = getNextPendingUpdate(updates)

if (updates.length > 0 && (currentUpdate || nextPendingUpdate)) {
throw new RequestError(`Updates pending for this proposal`, RequestError.BadRequest)
}

const data = {
proposal_id: proposal.id,
author,
health,
introduction,
highlights,
blockers,
next_steps,
additional_notes,
}
const update = await UpdateModel.createUpdate(data)
await DiscourseService.createUpdate(update, proposal.title)
DiscordService.newUpdate(proposal.id, proposal.title, update.id, user)

return update
//TODO: validate update data :)
return await UpdateService.create(
{
proposal_id: req.params.proposal,
author,
health,
introduction,
highlights,
blockers,
next_steps,
additional_notes,
},
req.auth!
)
}

async function updateProposalUpdate(req: WithAuth<Request<{ proposal: string }>>) {
Expand All @@ -151,11 +127,9 @@ async function updateProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
throw new RequestError(`Update not found: "${id}"`, RequestError.NotFound)
}

const { completion_date } = update

const user = req.auth
const proposal = await ProposalModel.findOne<ProposalAttributes>({ id: req.params.proposal })

const proposal = await ProposalModel.findOne<ProposalAttributes>({ id: req.params.proposal })
const isAuthorOrCoauthor =
user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposalId, user))) && author === user

Expand All @@ -170,9 +144,8 @@ async function updateProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
throw new RequestError(`Update is not on time: "${update.id}"`, RequestError.BadRequest)
}

const status = !update.due_date || isOnTime ? UpdateStatus.Done : UpdateStatus.Late

await UpdateModel.update<UpdateAttributes>(
return await UpdateService.updateProposalUpdate(
update,
{
author,
health,
Expand All @@ -181,24 +154,13 @@ async function updateProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
blockers,
next_steps,
additional_notes,
status,
completion_date: completion_date || now,
updated_at: now,
},
{ id }
id,
proposal,
user!,
now,
isOnTime
)

const updatedUpdate = await UpdateService.getById(id)
if (updatedUpdate) {
if (!completion_date) {
await DiscourseService.createUpdate(updatedUpdate, proposal.title)
DiscordService.newUpdate(proposal.id, proposal.title, update.id, user)
} else {
UpdateService.commentUpdateEditInDiscourse(updatedUpdate)
}
}

return true
}

async function deleteProposalUpdate(req: WithAuth<Request<{ proposal: string }>>) {
Expand Down
8 changes: 2 additions & 6 deletions src/back/services/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,10 @@ export class DiscordService {
if (user) {
try {
const profile = await getProfile(user)
const profileHasName = !!profile && profile.hasClaimedName && !!profile.name && profile.name.length > 0
const displayableUser = profileHasName ? profile.name : user

const hasAvatar = !!profile && !!profile.avatar

embed.setAuthor({
name: displayableUser,
iconURL: hasAvatar ? profile.avatar.snapshots.face256 : DEFAULT_AVATAR,
name: profile.username || user,
iconURL: profile.avatar,
url: getProfileUrl(user),
})
} catch (error) {
Expand Down
155 changes: 155 additions & 0 deletions src/back/services/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import crypto from 'crypto'

import { addressShortener } from '../../helpers'
import CacheService, { TTL_1_HS } from '../../services/CacheService'
import { ErrorService } from '../../services/ErrorService'
import {
EventType,
EventWithAuthor,
ProposalCreatedEvent,
UpdateCreatedEvent,
VotedEvent,
} from '../../shared/types/events'
import { DEFAULT_AVATAR_IMAGE, getProfiles } from '../../utils/Catalyst'
import { DclProfile } from '../../utils/Catalyst/types'
import { ErrorCategory } from '../../utils/errorCategories'
import EventModel from '../models/Event'

export class EventsService {
static async getLatest(): Promise<EventWithAuthor[]> {
try {
const latestEvents = await EventModel.getLatest()
const addresses = latestEvents.map((event) => event.address)

const addressesToProfile = await this.getAddressesToProfiles(addresses)

const latestEventsWithAuthor: EventWithAuthor[] = []
for (const event of latestEvents) {
const { address } = event
latestEventsWithAuthor.push({
author: addressesToProfile[address].username || addressShortener(address),
avatar: addressesToProfile[address].avatar,
...event,
})
}

return latestEventsWithAuthor
} catch (error) {
ErrorService.report('Error fetching events', { error, category: ErrorCategory.Events })
return []
}
}

private static async getAddressesToProfiles(addresses: string[]) {
try {
const profiles = await this.getProfilesWithCache(addresses)
return profiles.reduce((acc, profile) => {
acc[profile.address] = profile
return acc
}, {} as Record<string, DclProfile>)
} catch (error) {
ErrorService.report('Error fetching profiles', { error, category: ErrorCategory.Events })
return addresses.reduce((acc, address) => {
acc[address] = { address, avatar: DEFAULT_AVATAR_IMAGE, username: null, hasCustomAvatar: false }
return acc
}, {} as Record<string, DclProfile>)
}
}

static async proposalCreated(proposal_id: string, proposal_title: string, address: string) {
try {
const proposalCreatedEvent: ProposalCreatedEvent = {
id: crypto.randomUUID(),
address,
event_type: EventType.ProposalCreated,
event_data: { proposal_id, proposal_title },
created_at: new Date(),
}
await EventModel.create(proposalCreatedEvent)
} catch (error) {
this.reportEventError(error as Error, EventType.ProposalCreated, { address, proposal_id, proposal_title })
}
}

static async updateCreated(update_id: string, proposal_id: string, proposal_title: string, address: string) {
try {
const updateCreatedEvent: UpdateCreatedEvent = {
id: crypto.randomUUID(),
address,
event_type: EventType.UpdateCreated,
event_data: { update_id, proposal_id, proposal_title },
created_at: new Date(),
}
await EventModel.create(updateCreatedEvent)
} catch (error) {
this.reportEventError(error as Error, EventType.UpdateCreated, {
address,
update_id,
proposal_id,
proposal_title,
})
}
}

static async voted(proposal_id: string, proposal_title: string, choice: string, address: string) {
try {
const votedEvent: VotedEvent = {
id: crypto.randomUUID(),
address,
event_type: EventType.Voted,
event_data: { proposal_id, proposal_title, choice },
created_at: new Date(),
}
await EventModel.create(votedEvent)
} catch (error) {
this.reportEventError(error as Error, EventType.Voted, { address, proposal_id, proposal_title, choice })
}
}

private static reportEventError(error: Error, eventType: EventType, args: Record<string, unknown>) {
ErrorService.report('Error creating event', {
error,
event_type: eventType,
...args,
category: ErrorCategory.Events,
})
}

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
}

static async getProfilesWithCache(addresses: string[]): Promise<DclProfile[]> {
const profiles: DclProfile[] = []
const addressesToFetch: string[] = []

for (const address of addresses) {
const cachedProfile = CacheService.get<DclProfile>(this.getProfileCacheKey(address))
if (cachedProfile) {
profiles.push(cachedProfile)
} else {
addressesToFetch.push(address)
}
}

if (addressesToFetch.length > 0) {
const dclProfiles: DclProfile[] = await getProfiles(addressesToFetch)

for (const dclProfile of dclProfiles) {
CacheService.set(this.getProfileCacheKey(dclProfile.address), dclProfile, TTL_1_HS)
profiles.push(dclProfile)
}
}

return profiles
}
}
Loading

0 comments on commit a90709e

Please sign in to comment.