diff --git a/package.json b/package.json index 7765ea7c1..efe3c5852 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "query-node-start": "NODE_ENV=production patch-package --patch-dir assets/patches && squid-graphql-server --subscriptions", "auth-server-start": "node lib/auth-server/index.js", "postinstall": "patch-package --patch-dir assets/patches", + "mail-scheduler": "npx ts-node ./src/mail-scheduler/index.ts", "tests:codegen": "npx graphql-codegen -c ./src/tests/v1/codegen.yml && npx graphql-codegen -c ./src/tests/v2/codegen.yml", "tests:compareState": "npx ts-node ./src/tests/compareState.ts", "tests:benchmark": "npx ts-node ./src/tests/benchmarks/index.ts", diff --git a/src/server-extension/resolvers/AssetsResolver/index.ts b/src/server-extension/resolvers/AssetsResolver/index.ts index 7f77aefb5..cbb715095 100644 --- a/src/server-extension/resolvers/AssetsResolver/index.ts +++ b/src/server-extension/resolvers/AssetsResolver/index.ts @@ -8,30 +8,18 @@ import { import _ from 'lodash' import { globalEm } from '../../../utils/globalEm' import { performance } from 'perf_hooks' -import urljoin from 'url-join' import { Context } from '@subsquid/openreader/lib/context' -import haversineDistance from 'haversine-distance' -import { createLogger, Logger } from '@subsquid/logger' +import { Logger } from '@subsquid/logger' -const rootLogger = createLogger('api:assets') - -type Coordinates = { - lat: number - lon: number -} -type NodeData = { - location?: Coordinates - endpoint: string -} - -type DistributionBucketCachedData = { - nodes: NodeData[] -} - -type DistributionBucketIdsByBagId = Map -type BucketsById = Map - -class DistributionBucketsCache { +import { + BucketsById, + Coordinates, + DistributionBucketCachedData, + DistributionBucketIdsByBagId, +} from './types' +import { getAssetUrls, locationLogger, rootLogger } from './utils' + +export class DistributionBucketsCache { protected bucketIdsByBagId: DistributionBucketIdsByBagId protected bucketsById: BucketsById protected em: EntityManager @@ -172,11 +160,6 @@ class DistributionBucketsCache { } } -const distributionBucketsCache = new DistributionBucketsCache() -distributionBucketsCache.init(6000) - -const locationLogger = rootLogger.child('location') - function isValidLat(lat: number | undefined): lat is number { if (lat === undefined) { return false @@ -191,21 +174,6 @@ function isValidLon(lon: number | undefined): lon is number { return !Number.isNaN(lon) && lon >= -180 && lon <= 180 } -function getDistance(node: NodeData, clientLoc: Coordinates) { - return node.location ? haversineDistance(clientLoc, node.location) : Infinity -} - -function sortNodesByClosest(nodes: NodeData[], clientLoc: Coordinates): void { - nodes.sort((nodeA, nodeB) => getDistance(nodeA, clientLoc) - getDistance(nodeB, clientLoc)) - nodes.forEach((n) => { - locationLogger.trace( - `Node: ${JSON.stringify(n)}, Client loc: ${JSON.stringify( - clientLoc - )}, Distance: ${getDistance(n, clientLoc)}` - ) - }) -} - function getClientLoc(ctx: Context): Coordinates | undefined { const clientLoc = ctx.req.headers['x-client-loc'] if (typeof clientLoc !== 'string') { @@ -255,23 +223,13 @@ export class AssetsResolver { 'incorrect query: to use resolvedUrls make sure to add storageBag.id into query for StorageDataObject' ) } - const clientLoc = await getClientLoc(ctx) - const limit = await getResolvedUrlsLimit(ctx) + + const clientLoc = getClientLoc(ctx) + const limit = getResolvedUrlsLimit(ctx) + // The resolvedUrl field is initially populated with the object ID const [objectId] = object.resolvedUrls - if (!object.storageBag?.id || !objectId) { - return [] - } - const buckets = await distributionBucketsCache.getBucketsByBagId(object.storageBag.id) - const nodes = buckets.flatMap((b) => b.nodes) - if (clientLoc) { - sortNodesByClosest(nodes, clientLoc) - } else { - nodes.sort(() => (_.random(0, 1) ? 1 : -1)) - } - return nodes - .slice(0, limit || nodes.length) - .map((n) => urljoin(n.endpoint, 'api/v1/assets/', objectId)) + return await getAssetUrls(objectId, object.storageBag.id, { clientLoc, limit }) } } diff --git a/src/server-extension/resolvers/AssetsResolver/types.ts b/src/server-extension/resolvers/AssetsResolver/types.ts new file mode 100644 index 000000000..c1501f281 --- /dev/null +++ b/src/server-extension/resolvers/AssetsResolver/types.ts @@ -0,0 +1,15 @@ +export type Coordinates = { + lat: number + lon: number +} +export type NodeData = { + location?: Coordinates + endpoint: string +} + +export type DistributionBucketCachedData = { + nodes: NodeData[] +} + +export type DistributionBucketIdsByBagId = Map +export type BucketsById = Map diff --git a/src/server-extension/resolvers/AssetsResolver/utils.ts b/src/server-extension/resolvers/AssetsResolver/utils.ts new file mode 100644 index 000000000..d2c9c00dd --- /dev/null +++ b/src/server-extension/resolvers/AssetsResolver/utils.ts @@ -0,0 +1,53 @@ +import { createLogger } from '@subsquid/logger' +import haversineDistance from 'haversine-distance' +import { random } from 'lodash' +import urljoin from 'url-join' + +import { Coordinates, NodeData } from './types' +import { DistributionBucketsCache } from '.' + +export const rootLogger = createLogger('api:assets') +export const locationLogger = rootLogger.child('location') + +let distributionBucketsCache: DistributionBucketsCache +export async function getAssetUrls( + objectId: string | undefined | null, + bagId: string | undefined | null, + { clientLoc, limit }: { clientLoc?: Coordinates; limit: number } +): Promise { + if (!objectId || !bagId) { + return [] + } + + if (!distributionBucketsCache) { + distributionBucketsCache = new DistributionBucketsCache() + distributionBucketsCache.init(6000) + } + + const buckets = distributionBucketsCache.getBucketsByBagId(bagId) + const nodes = buckets.flatMap((b) => b.nodes) + if (clientLoc) { + sortNodesByClosest(nodes, clientLoc) + } else { + nodes.sort(() => (random(0, 1) ? 1 : -1)) + } + + return nodes + .slice(0, limit || nodes.length) + .map((n) => urljoin(n.endpoint, 'api/v1/assets/', objectId)) +} + +function getDistance(node: NodeData, clientLoc: Coordinates) { + return node.location ? haversineDistance(clientLoc, node.location) : Infinity +} + +function sortNodesByClosest(nodes: NodeData[], clientLoc: Coordinates): void { + nodes.sort((nodeA, nodeB) => getDistance(nodeA, clientLoc) - getDistance(nodeB, clientLoc)) + nodes.forEach((n) => { + locationLogger.trace( + `Node: ${JSON.stringify(n)}, Client loc: ${JSON.stringify( + clientLoc + )}, Distance: ${getDistance(n, clientLoc)}` + ) + }) +} diff --git a/src/utils/notification/helpers.ts b/src/utils/notification/helpers.ts index 84600d5af..022459e89 100644 --- a/src/utils/notification/helpers.ts +++ b/src/utils/notification/helpers.ts @@ -263,3 +263,32 @@ async function saveNextNotificationId( }) await em.save(nextEntityId) } + +const JOY_DECIMAL = 10 +export const formatJOY = (hapiAmount: bigint | number): string => { + const [intPart, decPart] = splitInt(String(hapiAmount), JOY_DECIMAL) + + const formatedIntPart = chunkFromEnd(intPart, 3).join(' ') || '0' + + const fractionDigits = (decPart.match(/[1-9]/)?.index ?? -1) + 1 + const roundedDecPart = + fractionDigits === 0 + ? '' + : !intPart && fractionDigits > 2 + ? roundDecPart(decPart, fractionDigits).replace(/\.?0+$/, '') + : roundDecPart(decPart, 2).replace(/^\.00/, '') + + return `${formatedIntPart}${roundedDecPart} $JOY` +} +const splitInt = (numStr: string, decimalSize: number): [string, string] => { + const intPart = numStr.slice(0, -decimalSize) ?? '' + const decPart = numStr.slice(-decimalSize).padStart(decimalSize, '0') || '0' + return [intPart, decPart] +} +const chunkFromEnd = (str: string, interval: number): string[] => + Array.from({ length: Math.floor((str.length - 1) / interval) }).reduce( + ([head, ...tail]: string[]) => [head.slice(0, -interval), head.slice(-interval), ...tail], + [str] + ) +const roundDecPart = (decPart: string, fractionDigits: number): string => + Number(`.${decPart}`).toFixed(fractionDigits).slice(1) diff --git a/src/utils/notification/notificationAvatars.ts b/src/utils/notification/notificationAvatars.ts index 9a79dd522..40d3ba41e 100644 --- a/src/utils/notification/notificationAvatars.ts +++ b/src/utils/notification/notificationAvatars.ts @@ -1,39 +1,38 @@ +import { memoize } from 'lodash' import { EntityManager } from 'typeorm' + import { Channel, MemberMetadata, StorageDataObject } from '../../model' +import { getAssetUrls } from '../../server-extension/resolvers/AssetsResolver/utils' import { ConfigVariable, config } from '../config' -export const getNotificationAvatar = async ( - em: EntityManager, - type: 'channelId' | 'membershipId', - param: string -): Promise => { - switch (type) { - case 'channelId': { - const channel = await em.getRepository(Channel).findOneBy({ id: param }) - - if (!channel?.avatarPhotoId) break - - const avatar = await em - .getRepository(StorageDataObject) - .findOneBy({ id: channel.avatarPhotoId }) +export const getNotificationAvatar = memoize( + async (em: EntityManager, type: 'channelId' | 'membershipId', param: string): Promise => { + switch (type) { + case 'channelId': { + const channel = await em.getRepository(Channel).findOneBy({ id: param }) + const objectId = channel?.avatarPhotoId + if (!objectId) break - if (!avatar || !avatar.isAccepted || !avatar.resolvedUrls[0]) break + const object = await em.getRepository(StorageDataObject).findOneBy({ id: objectId }) + const avatarUrls = await getAssetUrls(objectId, object?.storageBagId, { limit: 1 }) + if (!avatarUrls?.[0]) break - return avatar.resolvedUrls[0] - } + return avatarUrls[0] + } - case 'membershipId': { - const member = await em.getRepository(MemberMetadata).findOneBy({ id: param }) - const avatar = member?.avatar + case 'membershipId': { + const member = await em.getRepository(MemberMetadata).findOneBy({ id: param }) + const avatar = member?.avatar - // AvatarObject is not yet supported - if (!avatar || avatar.isTypeOf !== 'AvatarUri') break + // AvatarObject is not yet supported + if (!avatar || avatar.isTypeOf !== 'AvatarUri') break - return avatar.avatarUri + return avatar.avatarUri + } } - } - // Fallback to a placeholder - const notificationAssetRoot = await config.get(ConfigVariable.AppAssetStorage, em) - return `${notificationAssetRoot}/placeholder/avatar.png` -} + // Fallback to a placeholder + const notificationAssetRoot = await config.get(ConfigVariable.AppAssetStorage, em) + return `${notificationAssetRoot}/placeholder/avatar.png` + } +) diff --git a/src/utils/notification/notificationsData.ts b/src/utils/notification/notificationsData.ts index 6e746b0ce..ebde18b69 100644 --- a/src/utils/notification/notificationsData.ts +++ b/src/utils/notification/notificationsData.ts @@ -1,8 +1,9 @@ import { EntityManager } from 'typeorm' -import { Channel, Notification } from '../../model' +import { Notification } from '../../model' import { getNotificationAvatar } from './notificationAvatars' import { getNotificationIcon } from './notificationIcons' import { getNotificationLink } from './notificationLinks' +import { formatJOY } from './helpers' export type NotificationData = { icon: string @@ -253,32 +254,3 @@ export const getNotificationData = async ( } } } - -const JOY_DECIMAL = 10 -export const formatJOY = (hapiAmount: bigint | number): string => { - const [intPart, decPart] = splitInt(String(hapiAmount), JOY_DECIMAL) - - const formatedIntPart = chunkFromEnd(intPart, 3).join(' ') || '0' - - const fractionDigits = (decPart.match(/[1-9]/)?.index ?? -1) + 1 - const roundedDecPart = - fractionDigits === 0 - ? '' - : !intPart && fractionDigits > 2 - ? roundDecPart(decPart, fractionDigits).replace(/\.?0+$/, '') - : roundDecPart(decPart, 2).replace(/^\.00/, '') - - return `${formatedIntPart}${roundedDecPart} $JOY` -} -const splitInt = (numStr: string, decimalSize: number): [string, string] => { - const intPart = numStr.slice(0, -decimalSize) ?? '' - const decPart = numStr.slice(-decimalSize).padStart(decimalSize, '0') || '0' - return [intPart, decPart] -} -const chunkFromEnd = (str: string, interval: number): string[] => - Array.from({ length: Math.floor((str.length - 1) / interval) }).reduce( - ([head, ...tail]: string[]) => [head.slice(0, -interval), head.slice(-interval), ...tail], - [str] - ) -const roundDecPart = (decPart: string, fractionDigits: number): string => - Number(`.${decPart}`).toFixed(fractionDigits).slice(1) diff --git a/src/utils/notification/testing/formatJOY.test.ts b/src/utils/notification/testing/formatJOY.test.ts index fb1d3edb8..8698afbb9 100644 --- a/src/utils/notification/testing/formatJOY.test.ts +++ b/src/utils/notification/testing/formatJOY.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { formatJOY } from '../notificationsData' +import { formatJOY } from '../helpers' describe('formatJOY', () => { it('Parse HAPI amounts into JOY values', () => {