diff --git a/.env b/.env index 4cfeaa3d2..4842ada32 100644 --- a/.env +++ b/.env @@ -44,7 +44,8 @@ VIDEO_RELEVANCE_VIEWS_TICK=50 # Default channel weight/bias # ] RELEVANCE_WEIGHTS="[1, 0.03, 0.3, 0.5, [7,3], 1]" -MAX_CACHED_ENTITIES=1000 +COMMENT_TIP_TIERS='{"SILVER": 100, "GOLD": 500, "DIAMOND": 1000}' +MAX_CACHED_ENTITIES=5000 APP_PRIVATE_KEY=this-is-not-so-secret-change-it SESSION_EXPIRY_AFTER_INACTIVITY_MINUTES=60 SESSION_MAX_DURATION_HOURS=720 diff --git a/CHANGELOG.md b/CHANGELOG.md index b25f45491..6d81f4259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +# 4.2.0 + +## Affected components: +- Migrations: + - **(A) `1733920148217-Data.js`** + - views migration +- GraphQL schema: + - **(M) `Comment`**: added `tipTier`, `tipAmount` and `sortPriority` fields + - **(M) `OperatorPermission`**: added `SET_TIP_TIERS` variant + - **(A) `CommentTipTier`**: new enum +- GraphQL server: + - **(A) `tipTiers`** (query) + - **(A) `setTipTierAmounts`** (mutation) +- Processor: + - **(M) `Members.MemberRemarked`** (event handler) +- Config: + - **(A) `COMMENT_TIP_TIERS`** +- Dockerfile + +## Changes +- Added support for [Atlas tipping functionality](/~https://github.com/Joystream/atlas/issues/6291): + - added `COMMENT_TIP_TIERS` config variable and corresponding `tipTiers` query and `setTipTierAmounts` mutation which allow configuring the minimum amounts of JOY tokens required to obtain each tier (SILVER / GOLD / DIAMOND) when adding a video comment with a tip. + - modified `MemberRemarked` event handler: `processCreateCommentMessage` now takes `payment` parameter and assigns a `tipTier`, `tipAmount` and `sortPriority` to a comment based on the amount of JOY paid to channel reward account. + - updated GraphQL schema (`Comment`, `CommentTipTier`) and migrations to support new `Comment` fields. + +## Bug Fixes: +- Dockerfile: added missing `entrypoints` and `opentelemetry` directories. + # 4.1.1 ## Affected components: diff --git a/Dockerfile b/Dockerfile index 45ffb763b..0b163bac7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,8 @@ ADD db db ADD assets assets ADD schema schema ADD scripts scripts +ADD entrypoints entrypoints +ADD opentelemetry opentelemetry # TODO: use shorter PROMETHEUS_PORT ENV PROCESSOR_PROMETHEUS_PORT 3000 EXPOSE 3000 diff --git a/db/generateViewsMigration.js b/db/generateViewsMigration.js index 0392c2e71..cecf5321a 100644 --- a/db/generateViewsMigration.js +++ b/db/generateViewsMigration.js @@ -24,6 +24,9 @@ module.exports = class ${className} { const viewDefinitions = getViewDefinitions(db); for (const [tableName, viewConditions] of Object.entries(viewDefinitions)) { if (Array.isArray(viewConditions)) { + await db.query(\` + DROP VIEW IF EXISTS "\${tableName}" CASCADE + \`) await db.query(\` CREATE OR REPLACE VIEW "\${tableName}" AS SELECT * diff --git a/db/migrations/1733920148217-Data.js b/db/migrations/1733920148217-Data.js new file mode 100644 index 000000000..4b9e4e483 --- /dev/null +++ b/db/migrations/1733920148217-Data.js @@ -0,0 +1,15 @@ +module.exports = class Data1733920148217 { + name = 'Data1733920148217' + + async up(db) { + await db.query(`ALTER TABLE "admin"."comment" ADD "tip_tier" character varying(7)`) + await db.query(`ALTER TABLE "admin"."comment" ADD "tip_amount" numeric NOT NULL DEFAULT 0`) + await db.query(`ALTER TABLE "admin"."comment" ADD "sort_priority" integer NOT NULL DEFAULT 0`) + } + + async down(db) { + await db.query(`ALTER TABLE "admin"."comment" DROP COLUMN "tip_tier"`) + await db.query(`ALTER TABLE "admin"."comment" DROP COLUMN "tip_amount"`) + await db.query(`ALTER TABLE "admin"."comment" DROP COLUMN "sort_priority"`) + } +} diff --git a/db/migrations/1730976542053-Views.js b/db/migrations/1733921114970-Views.js similarity index 87% rename from db/migrations/1730976542053-Views.js rename to db/migrations/1733921114970-Views.js index 3f10deac2..a411c3595 100644 --- a/db/migrations/1730976542053-Views.js +++ b/db/migrations/1733921114970-Views.js @@ -1,8 +1,8 @@ const { getViewDefinitions } = require('../viewDefinitions') -module.exports = class Views1730976542053 { - name = 'Views1730976542053' +module.exports = class Views1733921114970 { + name = 'Views1733921114970' async up(db) { // these two queries will be invoked and the cleaned up by the squid itself @@ -15,6 +15,9 @@ module.exports = class Views1730976542053 { const viewDefinitions = getViewDefinitions(db); for (const [tableName, viewConditions] of Object.entries(viewDefinitions)) { if (Array.isArray(viewConditions)) { + await db.query(` + DROP VIEW IF EXISTS "${tableName}" CASCADE + `) await db.query(` CREATE OR REPLACE VIEW "${tableName}" AS SELECT * diff --git a/entrypoints/auth-server.sh b/entrypoints/auth-server.sh index d41c0cae9..67bbe8b87 100755 --- a/entrypoints/auth-server.sh +++ b/entrypoints/auth-server.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh set -e diff --git a/entrypoints/graphql-server.sh b/entrypoints/graphql-server.sh index 7d2768d2f..0770e78ca 100755 --- a/entrypoints/graphql-server.sh +++ b/entrypoints/graphql-server.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh set -e diff --git a/package-lock.json b/package-lock.json index 58a663901..0eb6eef3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "orion", - "version": "4.0.6", + "version": "4.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orion", - "version": "4.0.6", + "version": "4.2.0", "hasInstallScript": true, "workspaces": [ "network-tests" diff --git a/package.json b/package.json index df5f4af1a..661658184 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orion", - "version": "4.1.1", + "version": "4.2.0", "engines": { "node": ">=16" }, diff --git a/schema/auth.graphql b/schema/auth.graphql index 4c61c3750..055c3cf6d 100644 --- a/schema/auth.graphql +++ b/schema/auth.graphql @@ -14,6 +14,7 @@ enum OperatorPermission { SET_PUBLIC_FEED_VIDEOS SET_FEATURED_CRTS SET_CRT_MARKETCAP_MIN_VOLUME + SET_TIP_TIERS } type User @entity @schema(name: "admin") { diff --git a/schema/videoComments.graphql b/schema/videoComments.graphql index b92a69c31..14e3daf35 100644 --- a/schema/videoComments.graphql +++ b/schema/videoComments.graphql @@ -31,6 +31,12 @@ type CommentReactionsCountByReactionId { count: Int! } +enum CommentTipTier { + SILVER + GOLD + DIAMOND +} + type Comment @entity @schema(name: "admin") { "METAPROTOCOL-{network}-{blockNumber}-{indexInBlock}" id: ID! @@ -73,4 +79,13 @@ type Comment @entity @schema(name: "admin") { "Whether a comment has been excluded/hidden (by the gateway operator)" isExcluded: Boolean! + + "Tier received for adding a tip to the comment (if any)" + tipTier: CommentTipTier + + "Tip included when adding the comment (in HAPI)" + tipAmount: BigInt! + + "Base sort priority of the comment (can be increased by a tip)" + sortPriority: Int! } diff --git a/src/mappings/content/commentsAndReactions.ts b/src/mappings/content/commentsAndReactions.ts index 44c9e16b7..ac5a4f74c 100644 --- a/src/mappings/content/commentsAndReactions.ts +++ b/src/mappings/content/commentsAndReactions.ts @@ -17,6 +17,7 @@ import { isSet } from '@joystream/metadata-protobuf/utils' import { assertNotNull, SubstrateBlock } from '@subsquid/substrate-processor' import { BannedMember, + Channel, ChannelRecipient, Comment, CommentCreatedEventData, @@ -27,6 +28,7 @@ import { CommentReply, CommentStatus, CommentTextUpdatedEventData, + CommentTipTier, Event, MemberRecipient, MetaprotocolTransactionResult, @@ -56,6 +58,7 @@ import { import { getAccountForMember, getChannelOwnerMemberByChannelId, memberHandleById } from './utils' import { addNotification } from '../../utils/notification' import { parseVideoTitle } from '../../utils/notification/helpers' +import { HAPI_TO_JOY_RATE } from '../../utils/joystreamPrice' function parseVideoReaction(reaction: ReactVideo.Reaction): VideoReactionOptions { const protobufReactionToGraphqlReaction = { @@ -418,13 +421,43 @@ export async function processReactCommentMessage( return new MetaprotocolTransactionResultOK() } +async function commentTipTierParams( + overlay: EntityManagerOverlay, + tipAmountHapi: bigint +): Promise> { + const tipTiers = await config.get(ConfigVariable.CommentTipTiers, overlay.getEm()) + if (tipAmountHapi >= BigInt(tipTiers.DIAMOND) * BigInt(HAPI_TO_JOY_RATE)) { + return { + tipTier: CommentTipTier.DIAMOND, + sortPriority: 1000, + } + } + if (tipAmountHapi >= BigInt(tipTiers.GOLD) * BigInt(HAPI_TO_JOY_RATE)) { + return { + tipTier: CommentTipTier.GOLD, + sortPriority: 100, + } + } + if (tipAmountHapi >= BigInt(tipTiers.SILVER) * BigInt(HAPI_TO_JOY_RATE)) { + return { + tipTier: CommentTipTier.SILVER, + sortPriority: 10, + } + } + return { + tipTier: null, + sortPriority: 0, + } +} + export async function processCreateCommentMessage( overlay: EntityManagerOverlay, block: SubstrateBlock, indexInBlock: number, txHash: string | undefined, memberId: string, - message: DecodedMetadataObject + message: DecodedMetadataObject, + payment?: [string, bigint] ): Promise { const { videoId, parentCommentId, body } = message @@ -437,6 +470,7 @@ export async function processCreateCommentMessage( } const channelId = assertNotNull(video.channelId) + const channel = await overlay.getRepository(Channel).getByIdOrFail(channelId) const bannedMembers = await overlay .getRepository(BannedMember) .getManyByRelation('channelId', channelId) @@ -473,6 +507,15 @@ export async function processCreateCommentMessage( ) } + let tipAmount = BigInt(0) + if (payment) { + const [tipDestination, tip] = payment + if (tipDestination === channel.rewardAccount) { + tipAmount = tip + } + } + const tipTierParams = await commentTipTierParams(overlay, tipAmount) + // add new comment const comment = overlay.getRepository(Comment).new({ // TODO: Re-think backward compatibility @@ -488,6 +531,8 @@ export async function processCreateCommentMessage( reactionsAndRepliesCount: 0, isEdited: false, isExcluded: false, + tipAmount, + ...tipTierParams, }) // schedule comment counters update diff --git a/src/mappings/membership/metadata.ts b/src/mappings/membership/metadata.ts index d374f9398..c162e5b28 100644 --- a/src/mappings/membership/metadata.ts +++ b/src/mappings/membership/metadata.ts @@ -95,7 +95,8 @@ export async function processMemberRemark( indexInBlock, txHash, memberId, - decodedMessage.createComment + decodedMessage.createComment, + payment ) } diff --git a/src/server-extension/resolvers/AdminResolver/index.ts b/src/server-extension/resolvers/AdminResolver/index.ts index 2c02188ba..b496d9a0a 100644 --- a/src/server-extension/resolvers/AdminResolver/index.ts +++ b/src/server-extension/resolvers/AdminResolver/index.ts @@ -34,6 +34,7 @@ import { model } from '../model' import { AppActionSignatureInput, AppRootDomain, + CommentTipTiers, ChannelWeight, CrtMarketCapMinVolume, ExcludableContentType, @@ -66,6 +67,7 @@ import { SetRootDomainInput, SetSupportedCategoriesInput, SetSupportedCategoriesResult, + SetTipTierAmountsInput, SetVideoHeroInput, SetVideoHeroResult, SetVideoViewPerUserTimeLimitInput, @@ -165,6 +167,22 @@ export class AdminResolver { return { isApplied: true } } + @UseMiddleware(OperatorOnly(OperatorPermission.SET_TIP_TIERS)) + @Mutation(() => CommentTipTiers) + async setTipTierAmounts(@Args() args: SetTipTierAmountsInput): Promise { + const em = await this.em() + const tipTiers = await config.get(ConfigVariable.CommentTipTiers, em) + const newTipTiers = { ...tipTiers, ...args } + await config.set(ConfigVariable.CommentTipTiers, newTipTiers, em) + return newTipTiers + } + + @Query(() => CommentTipTiers) + async tipTiers(): Promise { + const em = await this.em() + return config.get(ConfigVariable.CommentTipTiers, em) + } + @UseMiddleware(OperatorOnly()) @Mutation(() => Int) async setMaxAttemptsOnMailDelivery( diff --git a/src/server-extension/resolvers/AdminResolver/types.ts b/src/server-extension/resolvers/AdminResolver/types.ts index 532601698..41541ea09 100644 --- a/src/server-extension/resolvers/AdminResolver/types.ts +++ b/src/server-extension/resolvers/AdminResolver/types.ts @@ -26,6 +26,30 @@ export class SetVideoWeightsInput { defaultChannelWeight!: number } +@ArgsType() +export class SetTipTierAmountsInput { + @Field(() => Int, { nullable: true }) + SILVER?: number + + @Field(() => Int, { nullable: true }) + GOLD?: number + + @Field(() => Int, { nullable: true }) + DIAMOND?: number +} + +@ObjectType() +export class CommentTipTiers { + @Field(() => Int, { nullable: false }) + SILVER!: number + + @Field(() => Int, { nullable: false }) + GOLD!: number + + @Field(() => Int, { nullable: false }) + DIAMOND!: number +} + @ArgsType() export class SetMaxAttemptsOnMailDeliveryInput { @Field(() => Int, { nullable: false }) diff --git a/src/tests/integration/.env b/src/tests/integration/.env index 849572d88..ccad60ef4 100644 --- a/src/tests/integration/.env +++ b/src/tests/integration/.env @@ -41,6 +41,7 @@ VIDEO_RELEVANCE_VIEWS_TICK=50 # [joystream creation weight, YT creation weight] # ] RELEVANCE_WEIGHTS="[1, 0.03, 0.3, 0.5, [7,3]]" +COMMENT_TIP_TIERS='{"SILVER": 100, "GOLD": 500, "DIAMOND": 1000}' MAX_CACHED_ENTITIES=1000 APP_PRIVATE_KEY=this-is-not-so-secret-change-it SESSION_EXPIRY_AFTER_INACTIVITY_MINUTES=60 diff --git a/src/utils/config.ts b/src/utils/config.ts index 4f53a9f08..453189dc8 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,5 +1,5 @@ import { EntityManager } from 'typeorm' -import { GatewayConfig } from '../model' +import { CommentTipTier, GatewayConfig } from '../model' import { withHiddenEntities } from './sql' export enum ConfigVariable { @@ -25,6 +25,7 @@ export enum ConfigVariable { AppAssetStorage = 'APP_ASSET_STORAGE', AppNameAlt = 'APP_NAME_ALT', NotificationAssetRoot = 'NOTIFICATION_ASSET_ROOT', + CommentTipTiers = 'COMMENT_TIP_TIERS', } const boolType = { @@ -47,6 +48,8 @@ const jsonType = () => ({ deserialize: (v: string) => JSON.parse(v) as T, }) +export type CommentTipTiers = { [key in CommentTipTier]: number } + export const configVariables = { [ConfigVariable.SupportNoCategoryVideo]: boolType, [ConfigVariable.SupportNewCategories]: boolType, @@ -71,6 +74,7 @@ export const configVariables = { [ConfigVariable.AppAssetStorage]: stringType, [ConfigVariable.AppNameAlt]: stringType, [ConfigVariable.NotificationAssetRoot]: stringType, + [ConfigVariable.CommentTipTiers]: jsonType(), } as const type TypeOf = ReturnType<(typeof configVariables)[C]['deserialize']> diff --git a/src/utils/joystreamPrice.ts b/src/utils/joystreamPrice.ts index 2bea5e99f..baa06a308 100644 --- a/src/utils/joystreamPrice.ts +++ b/src/utils/joystreamPrice.ts @@ -3,9 +3,9 @@ import BN from 'bn.js' export let JOYSTREAM_USD_PRICE: number | null = null -const HAPI_TO_JOY_RATE = 10 ** 10 -const HAPI_TO_JOY_RATE_BN = new BN(HAPI_TO_JOY_RATE) -const MAX_SAFE_NUMBER_BN = new BN(Number.MAX_SAFE_INTEGER) +export const HAPI_TO_JOY_RATE = 10 ** 10 +export const HAPI_TO_JOY_RATE_BN = new BN(HAPI_TO_JOY_RATE) +export const MAX_SAFE_NUMBER_BN = new BN(Number.MAX_SAFE_INTEGER) const log = createLogger('price')