diff --git a/db/generateViewsMigration.js b/db/generateViewsMigration.js
index 827f8c255..0392c2e71 100644
--- a/db/generateViewsMigration.js
+++ b/db/generateViewsMigration.js
@@ -14,6 +14,13 @@ module.exports = class ${className} {
name = '${className}'
async up(db) {
+ // these two queries will be invoked and the cleaned up by the squid itself
+ // we only do this to be able to reference processor height in mappings
+ await db.query(\`CREATE SCHEMA IF NOT EXISTS squid_processor;\`)
+ await db.query(\`CREATE TABLE IF NOT EXISTS squid_processor.status (
+ id SERIAL PRIMARY KEY,
+ height INT
+ );\`)
const viewDefinitions = getViewDefinitions(db);
for (const [tableName, viewConditions] of Object.entries(viewDefinitions)) {
if (Array.isArray(viewConditions)) {
diff --git a/db/migrations/1721141313646-Data.js b/db/migrations/1721141313646-Data.js
new file mode 100644
index 000000000..b7b8dcf76
--- /dev/null
+++ b/db/migrations/1721141313646-Data.js
@@ -0,0 +1,19 @@
+module.exports = class Data1721141313646 {
+ name = 'Data1721141313646'
+
+ async up(db) {
+ await db.query(`CREATE TABLE "admin"."user_interaction_count" ("id" character varying NOT NULL, "type" text, "entity_id" text, "day_timestamp" TIMESTAMP WITH TIME ZONE NOT NULL, "count" integer NOT NULL, CONSTRAINT "PK_8e334a51febcf02c54dff48147d" PRIMARY KEY ("id"))`)
+ await db.query(`CREATE INDEX "IDX_b5261af5f3fe48d77086ebc602" ON "admin"."user_interaction_count" ("day_timestamp") `)
+ await db.query(`CREATE TABLE "admin"."marketplace_token" ("liquidity" integer, "market_cap" numeric, "cumulative_revenue" numeric, "amm_volume" numeric, "price_change" numeric, "liquidity_change" numeric, "id" character varying NOT NULL, "status" character varying(6) NOT NULL, "avatar" jsonb, "total_supply" numeric NOT NULL, "is_featured" boolean NOT NULL, "symbol" text, "is_invite_only" boolean NOT NULL, "annual_creator_reward_permill" integer NOT NULL, "revenue_share_ratio_permill" integer NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "channel_id" text, "description" text, "whitelist_applicant_note" text, "whitelist_applicant_link" text, "accounts_num" integer NOT NULL, "number_of_revenue_share_activations" integer NOT NULL, "deissued" boolean NOT NULL, "current_amm_sale_id" text, "current_sale_id" text, "current_revenue_share_id" text, "number_of_vested_transfer_issued" integer NOT NULL, "last_price" numeric, CONSTRAINT "PK_d836a8c3d907b67099c140c4d84" PRIMARY KEY ("id"))`)
+ await db.query(`CREATE INDEX "IDX_1268fd020cf195b2e8d5d85093" ON "admin"."marketplace_token" ("symbol") `)
+ await db.query(`CREATE INDEX "IDX_b99bb1ecee77f23016f6ef687c" ON "admin"."marketplace_token" ("created_at") `)
+ }
+
+ async down(db) {
+ await db.query(`DROP TABLE "admin"."user_interaction_count"`)
+ await db.query(`DROP INDEX "admin"."IDX_b5261af5f3fe48d77086ebc602"`)
+ await db.query(`DROP TABLE "admin"."marketplace_token"`)
+ await db.query(`DROP INDEX "admin"."IDX_1268fd020cf195b2e8d5d85093"`)
+ await db.query(`DROP INDEX "admin"."IDX_b99bb1ecee77f23016f6ef687c"`)
+ }
+}
diff --git a/db/migrations/1720623003800-Views.js b/db/migrations/1721141313757-Views.js
similarity index 66%
rename from db/migrations/1720623003800-Views.js
rename to db/migrations/1721141313757-Views.js
index b138499f6..dac2d967e 100644
--- a/db/migrations/1720623003800-Views.js
+++ b/db/migrations/1721141313757-Views.js
@@ -1,10 +1,17 @@
const { getViewDefinitions } = require('../viewDefinitions')
-module.exports = class Views1720623003800 {
- name = 'Views1720623003800'
+module.exports = class Views1721141313757 {
+ name = 'Views1721141313757'
async up(db) {
+ // these two queries will be invoked and the cleaned up by the squid itself
+ // we only do this to be able to reference processor height in mappings
+ await db.query(`CREATE SCHEMA IF NOT EXISTS squid_processor;`)
+ await db.query(`CREATE TABLE IF NOT EXISTS squid_processor.status (
+ id SERIAL PRIMARY KEY,
+ height INT
+ );`)
const viewDefinitions = getViewDefinitions(db);
for (const [tableName, viewConditions] of Object.entries(viewDefinitions)) {
if (Array.isArray(viewConditions)) {
diff --git a/db/viewDefinitions.js b/db/viewDefinitions.js
index 1b14f2cbb..ae80eca44 100644
--- a/db/viewDefinitions.js
+++ b/db/viewDefinitions.js
@@ -2,6 +2,8 @@ const noCategoryVideosSupportedByDefault =
process.env.SUPPORT_NO_CATEGORY_VIDEOS === 'true' ||
process.env.SUPPORT_NO_CATEGORY_VIDEOS === '1'
+const BLOCKS_PER_DAY = 10 * 60 * 24 // 10 blocs per minute, 60 mins * 24 hours
+
// Add public 'VIEW' definitions for hidden entities created by
// applying `@schema(name: "admin") directive to the Graphql entities
function getViewDefinitions(db) {
@@ -87,6 +89,102 @@ function getViewDefinitions(db) {
email_delivery_attempt: ['FALSE'],
// TODO (notifications v2): make this part of the admin schema with appropriate resolver for queries
// notification: ['FALSE'],
+ marketplace_token: `
+ WITH trading_volumes AS
+ (SELECT ac.token_id,
+ SUM(tr.price_paid) as amm_volume
+ FROM amm_transaction tr
+ JOIN amm_curve ac ON ac.id = tr.amm_id
+ GROUP BY token_id),
+
+ base_price_transaction AS (
+ WITH oldest_transactions AS (
+ SELECT DISTINCT ON (ac.token_id)
+ tr.amm_id,
+ ac.token_id,
+ tr.price_per_unit AS oldest_price_paid,
+ tr.created_in
+ FROM amm_transaction tr
+ JOIN amm_curve ac ON tr.amm_id = ac.id
+ WHERE tr.created_in < (SELECT height FROM squid_processor.status) - ${
+ BLOCKS_PER_DAY * 30
+ }
+ ORDER BY ac.token_id, tr.created_in DESC
+ ),
+ fallback_transactions AS (
+ SELECT DISTINCT ON (ac.token_id)
+ tr.amm_id,
+ ac.token_id,
+ tr.price_per_unit AS oldest_price_paid,
+ tr.created_in
+ FROM amm_transaction tr
+ JOIN amm_curve ac ON tr.amm_id = ac.id
+ WHERE tr.created_in > (SELECT height FROM squid_processor.status) - ${
+ BLOCKS_PER_DAY * 30
+ }
+ ORDER BY ac.token_id, tr.created_in ASC
+ )
+ SELECT * FROM oldest_transactions
+ UNION ALL
+ SELECT * FROM fallback_transactions
+ WHERE NOT EXISTS (SELECT 1 FROM oldest_transactions)
+ )
+
+ SELECT
+ COALESCE(ac.total_liq, 0) as liquidity,
+ COALESCE((ct.last_price * ct.total_supply), 0) as market_cap,
+ c.cumulative_revenue,
+ c.id as channel_id,
+ COALESCE(tv.amm_volume, 0) as amm_volume,
+ CASE
+ WHEN ldt_o.oldest_price_paid = 0
+ OR ldt_o.oldest_price_paid IS NULL THEN 0
+ ELSE ((ct.last_price - ldt_o.oldest_price_paid) * 100.0 / ldt_o.oldest_price_paid)
+ END AS price_change,
+ CASE
+ WHEN liq_until.quantity IS NULL THEN 0
+ ELSE ((ac.total_liq - liq_until.quantity) * 100 / GREATEST(liq_until.quantity, 1))
+ END as liquidity_change,
+ ct.*
+ FROM creator_token ct
+ LEFT JOIN token_channel tc ON tc.token_id = ct.id
+ LEFT JOIN channel c ON c.id = tc.channel_id
+ LEFT JOIN base_price_transaction ldt_o ON ldt_o.token_id = ct.id
+ LEFT JOIN
+
+ (SELECT token_id,
+ SUM(CASE
+ WHEN transaction_type = 'BUY' THEN quantity
+ ELSE quantity * -1
+ END) AS total_liq
+ FROM
+ (SELECT ac.token_id,
+ tr.transaction_type,
+ tr.quantity
+ FROM amm_transaction tr
+ JOIN amm_curve ac ON tr.amm_id = ac.id) as tr
+ GROUP BY token_id) as ac ON ac.token_id = ct.id
+
+ LEFT JOIN
+
+ (SELECT token_id,
+ SUM(CASE
+ WHEN transaction_type = 'BUY' THEN quantity
+ ELSE quantity * -1
+ END) AS quantity
+ FROM
+ (SELECT ac.token_id,
+ tr.transaction_type,
+ tr.quantity
+ FROM amm_transaction tr
+ JOIN amm_curve ac ON tr.amm_id = ac.id
+ WHERE tr.created_in <
+ (SELECT height
+ FROM squid_processor.status) - ${BLOCKS_PER_DAY * 30}) as tr
+ GROUP BY token_id) as liq_until ON liq_until.token_id = ct.id
+
+ LEFT JOIN trading_volumes tv ON tv.token_id = ct.id
+ `,
}
}
diff --git a/package-lock.json b/package-lock.json
index a0650bcdd..1decb9e65 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"@polkadot/util-crypto": "9.5.1",
"@sendgrid/mail": "^7.7.0",
"@subsquid/archive-registry": "^2.1.0",
+ "@subsquid/big-decimal": "^0.0.0",
"@subsquid/graphql-server": "3.3.2",
"@subsquid/ss58": "^0.1.3",
"@subsquid/substrate-processor": "^2.2.0",
@@ -55,6 +56,7 @@
"p-limit": "3.1.0",
"patch-package": "^6.5.0",
"pg": "8.8.0",
+ "rolling-rate-limiter": "^0.4.2",
"swagger-ui-express": "^4.6.2",
"type-graphql": "^1.2.0-rc.1",
"typeorm": "^0.3.11",
@@ -8897,6 +8899,14 @@
"node": ">=14"
}
},
+ "node_modules/@subsquid/big-decimal": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/@subsquid/big-decimal/-/big-decimal-0.0.0.tgz",
+ "integrity": "sha512-bdsamXR+wyhaBw7KnWugle9BKkAyrvGSb4cEXf0q0GeKeKZJ/ADkakwT/MuQ4mDQLOxjiin3ZSDJkv7vy4Uvuw==",
+ "dependencies": {
+ "big.js": "~6.2.1"
+ }
+ },
"node_modules/@subsquid/graphiql-console": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@subsquid/graphiql-console/-/graphiql-console-0.3.0.tgz",
@@ -11728,6 +11738,18 @@
"through2": "^3.0.1"
}
},
+ "node_modules/big.js": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.1.tgz",
+ "integrity": "sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ==",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bigjs"
+ }
+ },
"node_modules/bignumber.js": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
@@ -21079,6 +21101,34 @@
"node": ">=8.6"
}
},
+ "node_modules/microtime": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/microtime/-/microtime-3.1.1.tgz",
+ "integrity": "sha512-to1r7o24cDsud9IhN6/8wGmMx5R2kT0w2Xwm5okbYI3d1dk6Xv0m+Z+jg2vS9pt+ocgQHTCtgs/YuyJhySzxNg==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "node-addon-api": "^5.0.0",
+ "node-gyp-build": "^4.4.0"
+ },
+ "engines": {
+ "node": ">= 14.13.0"
+ }
+ },
+ "node_modules/microtime/node_modules/node-addon-api": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
+ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
+ },
+ "node_modules/microtime/node_modules/node-gyp-build": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz",
+ "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -24632,6 +24682,30 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/rolling-rate-limiter": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/rolling-rate-limiter/-/rolling-rate-limiter-0.4.2.tgz",
+ "integrity": "sha512-4lKCwwuINmfPvyfLtvAHn+SiwXisH8fmmEvTSHYAenAfKF0p6IErkUEIS+9dvWAslU+97WtX000ne26RAToo2w==",
+ "dependencies": {
+ "microtime": "^3.0.0",
+ "uuid": "^9.0.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/rolling-rate-limiter/node_modules/uuid": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/run": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/run/-/run-1.5.0.tgz",
diff --git a/package.json b/package.json
index f2ec73eda..6daa323fd 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
"@polkadot/util-crypto": "9.5.1",
"@sendgrid/mail": "^7.7.0",
"@subsquid/archive-registry": "^2.1.0",
+ "@subsquid/big-decimal": "^0.0.0",
"@subsquid/graphql-server": "3.3.2",
"@subsquid/ss58": "^0.1.3",
"@subsquid/substrate-processor": "^2.2.0",
@@ -87,6 +88,7 @@
"p-limit": "3.1.0",
"patch-package": "^6.5.0",
"pg": "8.8.0",
+ "rolling-rate-limiter": "^0.4.2",
"swagger-ui-express": "^4.6.2",
"type-graphql": "^1.2.0-rc.1",
"typeorm": "^0.3.11",
diff --git a/schema/events.graphql b/schema/events.graphql
index badcf357d..c4b2dfbcc 100644
--- a/schema/events.graphql
+++ b/schema/events.graphql
@@ -505,3 +505,20 @@ type CreatorTokenRevenueSplitIssuedEventData {
"Details of the revenue split"
revenueShare: RevenueShare
}
+
+type UserInteractionCount @entity @schema(name: "admin") {
+ "Autoincremented ID"
+ id: ID!
+
+ "Type of the user interaction eg. 'tokenMarketplaceEntry'"
+ type: String
+
+ "ID of the entity that the event is related to for 'tokenMarketplaceEntry' it would be token ID"
+ entityId: String
+
+ "Timestamp of the day that is used to count the interactions"
+ dayTimestamp: DateTime! @index
+
+ "Count of the interactions"
+ count: Int!
+}
diff --git a/schema/token.graphql b/schema/token.graphql
index 054e82ba4..618994471 100644
--- a/schema/token.graphql
+++ b/schema/token.graphql
@@ -102,6 +102,81 @@ type CreatorToken @entity {
lastPrice: BigInt
}
+type MarketplaceToken @entity @schema(name: "admin") {
+ liquidity: Int
+ marketCap: BigInt
+ cumulativeRevenue: BigInt
+ ammVolume: BigInt
+ priceChange: BigDecimal
+ liquidityChange: BigDecimal
+
+ "runtime token identifier"
+ id: ID!
+
+ "status sale / market / idle"
+ status: TokenStatus!
+
+ "avatar object (profile picture)"
+ avatar: TokenAvatar
+
+ "total supply"
+ totalSupply: BigInt!
+
+ "Flag to indicate whether the CRT is featured or not"
+ isFeatured: Boolean!
+
+ "symbol for the token uniqueness guaranteed by runtime"
+ symbol: String @index
+
+ "access status invite only vs anyone"
+ isInviteOnly: Boolean!
+
+ "creator annual revenue (minted)"
+ annualCreatorRewardPermill: Int!
+
+ "revenue share ratio between creator and holder"
+ revenueShareRatioPermill: Int!
+
+ "date at which this token was created"
+ createdAt: DateTime! @index
+
+ "channel from which the token is issued uniqueness guaranteed by runtime"
+ channelId: String
+
+ "about information displayed under the presentation video"
+ description: String
+
+ "note from creator to member interested in joining the whitelist"
+ whitelistApplicantNote: String
+
+ "link for creator to member interested in joining the whitelist"
+ whitelistApplicantLink: String
+
+ "number of accounts to avoid aggregate COUNT"
+ accountsNum: Int!
+
+ "number of revenue shares issued"
+ numberOfRevenueShareActivations: Int!
+
+ "whether it has been deissued or not"
+ deissued: Boolean!
+
+ "current amm sale if ongoing"
+ currentAmmSaleId: String
+
+ "current sale if ongoing"
+ currentSaleId: String
+
+ "current revenue share if ongoing"
+ currentRevenueShareId: String
+
+ "number of vested transfer completed"
+ numberOfVestedTransferIssued: Int!
+
+ "last unit price available"
+ lastPrice: BigInt
+}
+
type TrailerVideo @entity @index(fields: ["token", "video"], unique: true) {
"counter"
id: ID!
diff --git a/src/auth-server/docs/.openapi-generator/FILES b/src/auth-server/docs/.openapi-generator/FILES
index 33a91b3b2..857a276bf 100644
--- a/src/auth-server/docs/.openapi-generator/FILES
+++ b/src/auth-server/docs/.openapi-generator/FILES
@@ -15,6 +15,7 @@ Models/GenericOkResponseData.md
Models/LoginRequestData.md
Models/LoginRequestData_allOf.md
Models/LoginResponseData.md
+Models/RegisterUserInteractionRequestData.md
Models/RequestTokenRequestData.md
Models/SessionEncryptionArtifacts.md
README.md
diff --git a/src/auth-server/docs/Apis/DefaultApi.md b/src/auth-server/docs/Apis/DefaultApi.md
index 6a4fabc21..6184e76e2 100644
--- a/src/auth-server/docs/Apis/DefaultApi.md
+++ b/src/auth-server/docs/Apis/DefaultApi.md
@@ -13,6 +13,7 @@ All URIs are relative to *http://localhost/api/v1*
| [**login**](DefaultApi.md#login) | **POST** /login | |
| [**logout**](DefaultApi.md#logout) | **POST** /logout | |
| [**postSessionArtifacts**](DefaultApi.md#postSessionArtifacts) | **POST** /session-artifacts | |
+| [**registerUserInteraction**](DefaultApi.md#registerUserInteraction) | **POST** /register-user-interaction | |
| [**requestEmailConfirmationToken**](DefaultApi.md#requestEmailConfirmationToken) | **POST** /request-email-confirmation-token | |
@@ -254,6 +255,33 @@ This endpoint does not need any parameter.
- **Content-Type**: application/json
- **Accept**: application/json
+
+# **registerUserInteraction**
+> GenericOkResponseData registerUserInteraction(RegisterUserInteractionRequestData)
+
+
+
+ Register a user interaction with Atlas part.
+
+### Parameters
+
+|Name | Type | Description | Notes |
+|------------- | ------------- | ------------- | -------------|
+| **RegisterUserInteractionRequestData** | [**RegisterUserInteractionRequestData**](../Models/RegisterUserInteractionRequestData.md)| | [optional] |
+
+### Return type
+
+[**GenericOkResponseData**](../Models/GenericOkResponseData.md)
+
+### Authorization
+
+[cookieAuth](../README.md#cookieAuth)
+
+### HTTP request headers
+
+- **Content-Type**: application/json
+- **Accept**: application/json
+
# **requestEmailConfirmationToken**
> GenericOkResponseData requestEmailConfirmationToken(RequestTokenRequestData)
diff --git a/src/auth-server/docs/Models/RegisterUserInteractionRequestData.md b/src/auth-server/docs/Models/RegisterUserInteractionRequestData.md
new file mode 100644
index 000000000..c751349bc
--- /dev/null
+++ b/src/auth-server/docs/Models/RegisterUserInteractionRequestData.md
@@ -0,0 +1,10 @@
+# RegisterUserInteractionRequestData
+## Properties
+
+| Name | Type | Description | Notes |
+|------------ | ------------- | ------------- | -------------|
+| **entityId** | **String** | | [default to null] |
+| **type** | **String** | | [default to null] |
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
diff --git a/src/auth-server/docs/README.md b/src/auth-server/docs/README.md
index a7f237c26..859c73964 100644
--- a/src/auth-server/docs/README.md
+++ b/src/auth-server/docs/README.md
@@ -16,6 +16,7 @@ All URIs are relative to *http://localhost/api/v1*
*DefaultApi* | [**login**](Apis/DefaultApi.md#login) | **POST** /login | Login to user's account by providing a message signed by the associated blockchain account. |
*DefaultApi* | [**logout**](Apis/DefaultApi.md#logout) | **POST** /logout | Terminate the current session. |
*DefaultApi* | [**postSessionArtifacts**](Apis/DefaultApi.md#postsessionartifacts) | **POST** /session-artifacts | Save wallet seed encryption artifacts for the current session on the server. |
+*DefaultApi* | [**registerUserInteraction**](Apis/DefaultApi.md#registeruserinteraction) | **POST** /register-user-interaction | Register a user interaction with Atlas part. |
*DefaultApi* | [**requestEmailConfirmationToken**](Apis/DefaultApi.md#requestemailconfirmationtoken) | **POST** /request-email-confirmation-token | Request a token to be sent to account's e-mail address, which will allow confirming the ownership of the e-mail by the user. |
@@ -38,6 +39,7 @@ All URIs are relative to *http://localhost/api/v1*
- [LoginRequestData](./Models/LoginRequestData.md)
- [LoginRequestData_allOf](./Models/LoginRequestData_allOf.md)
- [LoginResponseData](./Models/LoginResponseData.md)
+ - [RegisterUserInteractionRequestData](./Models/RegisterUserInteractionRequestData.md)
- [RequestTokenRequestData](./Models/RequestTokenRequestData.md)
- [SessionEncryptionArtifacts](./Models/SessionEncryptionArtifacts.md)
diff --git a/src/auth-server/generated/api-types.ts b/src/auth-server/generated/api-types.ts
index a5b821866..8fd15be81 100644
--- a/src/auth-server/generated/api-types.ts
+++ b/src/auth-server/generated/api-types.ts
@@ -4,6 +4,10 @@
*/
export interface paths {
+ '/register-user-interaction': {
+ /** @description Register a user interaction with Atlas part. */
+ post: operations['registerUserInteraction']
+ }
'/anonymous-auth': {
/** @description Authenticate as an anonymous user, either using an existing user identifier or creating a new one. */
post: operations['anonymousAuth']
@@ -58,6 +62,10 @@ export interface components {
signature: string
payload: components['schemas']['ActionExecutionPayload']
}
+ RegisterUserInteractionRequestData: {
+ entityId: string
+ type: string
+ }
AnonymousUserAuthRequestData: {
userId?: string
}
@@ -245,6 +253,11 @@ export interface components {
}
parameters: never
requestBodies: {
+ RegisterUserInteractionRequestBody?: {
+ content: {
+ 'application/json': components['schemas']['RegisterUserInteractionRequestData']
+ }
+ }
AnonymousUserAuthRequestBody?: {
content: {
'application/json': components['schemas']['AnonymousUserAuthRequestData']
@@ -290,6 +303,17 @@ export type $defs = Record
export type external = Record
export interface operations {
+ /** @description Register a user interaction with Atlas part. */
+ registerUserInteraction: {
+ requestBody: components['requestBodies']['RegisterUserInteractionRequestBody']
+ responses: {
+ 200: components['responses']['GenericOkResponse']
+ 400: components['responses']['GenericBadRequestResponse']
+ 401: components['responses']['UnauthorizedAnonymousUserResponse']
+ 429: components['responses']['GenericTooManyRequestsResponse']
+ default: components['responses']['GenericInternalServerErrorResponse']
+ }
+ }
/** @description Authenticate as an anonymous user, either using an existing user identifier or creating a new one. */
anonymousAuth: {
requestBody: components['requestBodies']['AnonymousUserAuthRequestBody']
diff --git a/src/auth-server/handlers/registerUserInteraction.ts b/src/auth-server/handlers/registerUserInteraction.ts
new file mode 100644
index 000000000..425184c07
--- /dev/null
+++ b/src/auth-server/handlers/registerUserInteraction.ts
@@ -0,0 +1,78 @@
+import express from 'express'
+import { AuthContext } from '../../utils/auth'
+import { globalEm } from '../../utils/globalEm'
+import { components } from '../generated/api-types'
+import { TooManyRequestsError, UnauthorizedError } from '../errors'
+import { UserInteractionCount } from '../../model'
+
+import { InMemoryRateLimiter } from 'rolling-rate-limiter'
+
+const interactionLimiter = new InMemoryRateLimiter({
+ interval: 1000 * 60 * 5, // 5 minutes
+ maxInInterval: 1,
+})
+
+type ReqParams = Record
+type ResBody =
+ | components['schemas']['GenericOkResponseData']
+ | components['schemas']['GenericErrorResponseData']
+type ResLocals = { authContext: AuthContext }
+type ReqBody = components['schemas']['RegisterUserInteractionRequestData']
+
+export const registerUserInteraction: (
+ req: express.Request,
+ res: express.Response,
+ next: express.NextFunction
+) => Promise = async (req, res, next) => {
+ try {
+ const { authContext: session } = res.locals
+ const { type, entityId } = req.body
+
+ if (!session) {
+ throw new UnauthorizedError('Cannot register interactions for empty session')
+ }
+
+ const isBlocked = await interactionLimiter.limit(`${type}-${entityId}-${session.userId}`)
+
+ if (isBlocked) {
+ throw new TooManyRequestsError('Too many requests for single entity')
+ }
+
+ const em = await globalEm
+
+ await em.transaction(async (em) => {
+ const date = new Date()
+ const startOfDay = new Date(date.setHours(0, 0, 0, 0))
+ const endOfDay = new Date(date.setHours(23, 59, 59, 999))
+
+ const dailyInteractionRow = await em
+ .getRepository(UserInteractionCount)
+ .createQueryBuilder('entity')
+ .where('entity.entityId = :entityId', { entityId })
+ .andWhere('entity.type = :type', { type })
+ .andWhere('entity.dayTimestamp >= :startOfDay', { startOfDay })
+ .andWhere('entity.dayTimestamp <= :endOfDay', { endOfDay })
+ .getOne()
+
+ if (!dailyInteractionRow) {
+ await em.getRepository(UserInteractionCount).save({
+ id: `${Date.now()}-${entityId}-${type}`,
+ dayTimestamp: new Date(date.setHours(0, 0, 0, 0)),
+ count: 1,
+ type,
+ entityId,
+ })
+
+ return
+ }
+
+ dailyInteractionRow.count++
+
+ await em.save(dailyInteractionRow)
+ })
+
+ res.status(200).json({ success: true })
+ } catch (e) {
+ next(e)
+ }
+}
diff --git a/src/auth-server/openapi.yml b/src/auth-server/openapi.yml
index 2e95976ac..498b5b50d 100644
--- a/src/auth-server/openapi.yml
+++ b/src/auth-server/openapi.yml
@@ -11,6 +11,26 @@ info:
servers:
- url: '/api/v1/'
paths:
+ /register-user-interaction:
+ post:
+ security:
+ - cookieAuth: []
+ operationId: registerUserInteraction
+ x-eov-operation-handler: registerUserInteraction
+ description: Register a user interaction with Atlas part.
+ requestBody:
+ $ref: '#/components/requestBodies/RegisterUserInteractionRequestBody'
+ responses:
+ '200':
+ $ref: '#/components/responses/GenericOkResponse'
+ '400':
+ $ref: '#/components/responses/GenericBadRequestResponse'
+ '401':
+ $ref: '#/components/responses/UnauthorizedAnonymousUserResponse'
+ '429':
+ $ref: '#/components/responses/GenericTooManyRequestsResponse'
+ default:
+ $ref: '#/components/responses/GenericInternalServerErrorResponse'
/anonymous-auth:
post:
operationId: anonymousAuth
@@ -225,6 +245,11 @@ components:
in: cookie
name: session_id
requestBodies:
+ RegisterUserInteractionRequestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisterUserInteractionRequestData'
AnonymousUserAuthRequestBody:
content:
application/json:
@@ -415,6 +440,16 @@ components:
type: string
payload:
$ref: '#/components/schemas/ActionExecutionPayload'
+ RegisterUserInteractionRequestData:
+ type: object
+ required:
+ - entityId
+ - type
+ properties:
+ entityId:
+ type: string
+ type:
+ type: string
AnonymousUserAuthRequestData:
type: object
properties:
diff --git a/src/auth-server/rateLimits.ts b/src/auth-server/rateLimits.ts
index 67bf818d1..28191c150 100644
--- a/src/auth-server/rateLimits.ts
+++ b/src/auth-server/rateLimits.ts
@@ -29,6 +29,12 @@ export const globalRateLimit: SimpleRateLimit = {
// Route-specific rate limits
export const rateLimitsPerRoute: RateLimitsPerRoute = {
+ '/register-user-interaction': {
+ post: {
+ windowMinutes: 5,
+ limit: 30,
+ },
+ },
'/anonymous-auth': {
post: {
windowMinutes: 5,
diff --git a/src/auth-server/tests/interactions.ts b/src/auth-server/tests/interactions.ts
new file mode 100644
index 000000000..d4fd6f91a
--- /dev/null
+++ b/src/auth-server/tests/interactions.ts
@@ -0,0 +1,72 @@
+import './config'
+import request from 'supertest'
+import { app } from '../index'
+import { createAccountAndSignIn, LoggedInAccountInfo, verifyRateLimit } from './common'
+import { rateLimitsPerRoute } from '../rateLimits'
+import { components } from '../generated/api-types'
+import { SESSION_COOKIE_NAME } from '../../utils/auth'
+
+async function userInteraction({
+ sessionId,
+ entityId,
+ type,
+ expectedStatus,
+}: {
+ sessionId?: string
+ type: string
+ entityId: string
+ expectedStatus: number
+}) {
+ const payload: components['schemas']['RegisterUserInteractionRequestData'] = {
+ type,
+ entityId,
+ }
+ return request(app)
+ .post('/api/v1/register-user-interaction')
+ .set('Cookie', sessionId ? `${SESSION_COOKIE_NAME}=${sessionId}` : '')
+ .set('Content-Type', 'application/json')
+ .send(payload)
+ .expect(expectedStatus)
+}
+
+describe('interactions endpoint', () => {
+ let accountInfo: LoggedInAccountInfo
+
+ before(async () => {
+ accountInfo = await createAccountAndSignIn('userInteraction@test.com')
+ })
+
+ it("shouldn't be possible to register interaction without account", async () => {
+ await userInteraction({
+ type: 'test1',
+ entityId: '1',
+ expectedStatus: 401,
+ })
+ })
+
+ it("shouldn't be possible to exceed rolling window limit", async () => {
+ await userInteraction({
+ sessionId: accountInfo.sessionId,
+ type: 'test1',
+ entityId: '1',
+ expectedStatus: 200,
+ })
+
+ await userInteraction({
+ sessionId: accountInfo.sessionId,
+ type: 'test1',
+ entityId: '1',
+ expectedStatus: 429,
+ })
+ })
+
+ it("shouldn't be possible to exceed rate limit", async () => {
+ await verifyRateLimit((i) => {
+ const requestsWithoutSpecificRateLimit = [
+ { req: request(app).post('/api/v1/register-user-interaction'), status: 401 },
+ ]
+
+ return requestsWithoutSpecificRateLimit[i % requestsWithoutSpecificRateLimit.length]
+ }, rateLimitsPerRoute['/register-user-interaction']?.post)
+ })
+})
diff --git a/src/mappings/token/index.ts b/src/mappings/token/index.ts
index 660338ad8..0402d286d 100644
--- a/src/mappings/token/index.ts
+++ b/src/mappings/token/index.ts
@@ -233,7 +233,7 @@ export async function processAmmActivatedEvent({
ammInitPrice: BigInt(intercept),
finalized: false,
})
- token.lastPrice = amm.ammInitPrice
+ token.lastPrice = (amm.ammSlopeParameter * token.totalSupply) / BigInt(2) + amm.ammInitPrice
token.currentAmmSaleId = id
const eventEntity = overlay.getRepository(Event).new({
diff --git a/src/server-extension/resolvers/CreatorToken/index.ts b/src/server-extension/resolvers/CreatorToken/index.ts
index b2ee3b72e..43eed2c62 100644
--- a/src/server-extension/resolvers/CreatorToken/index.ts
+++ b/src/server-extension/resolvers/CreatorToken/index.ts
@@ -1,6 +1,15 @@
-import { Args, Query, Resolver } from 'type-graphql'
+import { parseAnyTree, parseSqlArguments } from '@subsquid/openreader/lib/opencrud/tree'
+import { ListQuery } from '@subsquid/openreader/lib/sql/query'
+import { getResolveTree } from '@subsquid/openreader/lib/util/resolve-tree'
+import { GraphQLResolveInfo } from 'graphql'
+import { Args, Ctx, Info, Query, Resolver } from 'type-graphql'
import { EntityManager } from 'typeorm'
import { CreatorToken, RevenueShare, TokenAccount } from '../../../model'
+import { getCurrentBlockHeight } from '../../../utils/blockHeight'
+import { extendClause } from '../../../utils/sql'
+import { Context } from '../../check'
+import { MarketplaceToken, CreatorToken as TokenReturnType } from '../baseTypes'
+import { model } from '../model'
import { computeTransferrableAmount } from './services'
import {
GetAccountTransferrableBalanceArgs,
@@ -9,8 +18,16 @@ import {
GetCumulativeHistoricalShareAllocationResult,
GetShareDividendsResult,
GetShareDividensArgs,
+ MarketplaceTableTokensArgs,
+ MarketplaceTokenCount,
+ MarketplaceTokensArgs,
+ MarketplaceTokensCountArgs,
+ MarketplaceTokensReturnType,
+ TopSellingTokensReturnType,
} from './types'
+export const BLOCKS_PER_DAY = 10 * 60 * 24 // 10 blocs per minute, 60 mins * 24 hours
+
@Resolver()
export class TokenResolver {
constructor(private em: () => Promise) {}
@@ -41,6 +58,294 @@ export class TokenResolver {
}
}
+ @Query(() => [TopSellingTokensReturnType])
+ async topSellingToken(
+ @Args() args: MarketplaceTokensArgs,
+ @Info() info: GraphQLResolveInfo,
+ @Ctx() ctx: Context
+ ) {
+ const em = await this.em()
+ const { lastProcessedBlock } = await getCurrentBlockHeight(em)
+
+ if (lastProcessedBlock < 0) {
+ throw new Error('Failed to retrieve processor block height')
+ }
+ const tree = getResolveTree(info)
+ const sqlArgs = parseSqlArguments(model, 'CreatorToken', {
+ where: args.where,
+ })
+
+ const tokenSubTree = tree.fieldsByTypeName.TopSellingTokensReturnType.creatorToken
+ const tokenFields = parseAnyTree(model, 'CreatorToken', info.schema, tokenSubTree)
+
+ const topTokensCtes = `
+WITH tokens_volumes AS (
+ SELECT
+ ac.token_id,
+ SUM(tr.price_paid) as ammVolume
+ FROM
+ amm_transaction tr
+ JOIN
+ amm_curve ac ON ac.id = tr.amm_id
+ WHERE
+ tr.created_in >= ${lastProcessedBlock - args.periodDays * BLOCKS_PER_DAY}
+ GROUP BY
+ token_id
+)
+`
+
+ const listQuery = new ListQuery(
+ model,
+ ctx.openreader.dialect,
+ 'CreatorToken',
+ tokenFields,
+ sqlArgs
+ )
+
+ let listQuerySql = listQuery.sql
+
+ listQuerySql = extendClause(listQuerySql, 'SELECT', 'COALESCE(tV.ammVolume, 0)')
+
+ listQuerySql = extendClause(
+ listQuerySql,
+ 'FROM',
+ 'LEFT JOIN tokens_volumes tV ON tV.token_id = creator_token.id',
+ ''
+ )
+
+ if (typeof args.orderByPriceDesc === 'boolean') {
+ listQuerySql = extendClause(
+ listQuerySql,
+ 'ORDER BY',
+ `COALESCE(tV.ammVolume, 0) ${args.orderByPriceDesc ? 'DESC' : 'ASC'}`,
+ ''
+ )
+ }
+
+ const limit = args.limit ?? 10
+
+ listQuerySql = extendClause(listQuerySql, 'LIMIT', String(limit), '')
+
+ listQuerySql = `${topTokensCtes} ${listQuerySql}`
+ ;(listQuery as { sql: string }).sql = listQuerySql
+
+ const oldListQMap = listQuery.map.bind(listQuery)
+ listQuery.map = (rows: unknown[][]) => {
+ const ammVolumes: unknown[] = []
+
+ for (const row of rows) {
+ ammVolumes.push(row.pop())
+ }
+ const channelsMapped = oldListQMap(rows)
+ return channelsMapped.map((creatorToken, i) => ({
+ creatorToken,
+ ammVolume: ammVolumes[i] ?? 0,
+ }))
+ }
+
+ const result = await ctx.openreader.executeQuery(listQuery)
+
+ return result as TopSellingTokensReturnType[]
+ }
+
+ @Query(() => [MarketplaceTokensReturnType])
+ async tokensWithPriceChange(
+ @Args() args: MarketplaceTokensArgs,
+ @Info() info: GraphQLResolveInfo,
+ @Ctx() ctx: Context
+ ) {
+ const em = await this.em()
+ const { lastProcessedBlock } = await getCurrentBlockHeight(em)
+
+ if (lastProcessedBlock < 0) {
+ throw new Error('Failed to retrieve processor block height')
+ }
+
+ const tree = getResolveTree(info)
+ const sqlArgs = parseSqlArguments(model, 'CreatorToken', {
+ where: args.where,
+ })
+
+ const tokenSubTree = tree.fieldsByTypeName.MarketplaceTokensReturnType.creatorToken
+ const tokenFields = parseAnyTree(model, 'CreatorToken', info.schema, tokenSubTree)
+
+ const topTokensCtes = `
+WITH oldest_transactions_before AS
+ (SELECT DISTINCT ON (ac.token_id) tr.amm_id,
+ ac.token_id,
+ tr.price_per_unit as oldest_price_paid,
+ tr.created_in
+ FROM amm_transaction tr
+ JOIN amm_curve ac ON tr.amm_id = ac.id
+ WHERE tr.created_in < ${lastProcessedBlock - args.periodDays * BLOCKS_PER_DAY}
+ ORDER BY ac.token_id,
+ tr.created_in DESC),
+
+ oldest_transactions_after AS
+ (SELECT DISTINCT ON (ac.token_id) tr.amm_id,
+ ac.token_id,
+ tr.price_per_unit as oldest_price_paid,
+ tr.created_in
+ FROM amm_transaction tr
+ JOIN amm_curve ac ON tr.amm_id = ac.id
+ WHERE tr.created_in > ${lastProcessedBlock - args.periodDays * BLOCKS_PER_DAY}
+ ORDER BY ac.token_id,
+ tr.created_in ASC),
+
+
+ price_changes AS
+ (SELECT ct.id,
+ ot.oldest_price_paid,
+ ct.symbol,
+ ct.last_price,
+CASE
+ WHEN ot.oldest_price_paid = 0 AND ota.oldest_price_paid = 0 THEN 0
+ WHEN ot.oldest_price_paid = 0 THEN ((ct.last_price - ota.oldest_price_paid) * 100.0 / ota.oldest_price_paid)
+ ELSE ((ct.last_price - ot.oldest_price_paid) * 100.0 / ot.oldest_price_paid)
+END AS percentage_change
+ FROM creator_token ct
+ LEFT JOIN oldest_transactions_before as ot ON ot.token_id = ct.id
+ LEFT JOIN oldest_transactions_after as ota ON ota.token_id = ct.id)
+`
+
+ const listQuery = new ListQuery(
+ model,
+ ctx.openreader.dialect,
+ 'CreatorToken',
+ tokenFields,
+ sqlArgs
+ )
+
+ let listQuerySql = listQuery.sql
+
+ listQuerySql = extendClause(
+ listQuerySql,
+ 'SELECT',
+ `COALESCE(pc.percentage_change, 0) as pricePercentageChange`
+ )
+
+ listQuerySql = extendClause(
+ listQuerySql,
+ 'FROM',
+ 'LEFT JOIN price_changes pc ON creator_token.id = pc.id',
+ ''
+ )
+
+ if (typeof args.orderByPriceDesc === 'boolean') {
+ listQuerySql = extendClause(
+ listQuerySql,
+ 'ORDER BY',
+ `COALESCE(pc.percentage_change, 0) ${args.orderByPriceDesc ? 'DESC' : 'ASC'}`,
+ ''
+ )
+ }
+
+ const limit = args.limit ?? 10
+
+ listQuerySql = extendClause(listQuerySql, 'LIMIT', String(limit), '')
+
+ listQuerySql = `${topTokensCtes} ${listQuerySql}`
+ ;(listQuery as { sql: string }).sql = listQuerySql
+
+ const oldListQMap = listQuery.map.bind(listQuery)
+ listQuery.map = (rows: unknown[][]) => {
+ const pricePercentageChanges: unknown[] = []
+
+ for (const row of rows) {
+ pricePercentageChanges.push(row.pop())
+ }
+ const channelsMapped = oldListQMap(rows)
+ return channelsMapped.map((creatorToken, i) => ({
+ creatorToken,
+ pricePercentageChange: pricePercentageChanges[i] ?? 0,
+ }))
+ }
+
+ const result = await ctx.openreader.executeQuery(listQuery)
+
+ return result as TokenReturnType[]
+ }
+
+ @Query(() => MarketplaceTokenCount)
+ async getMarketplaceTokensCount(
+ @Args() args: MarketplaceTokensCountArgs,
+ @Info() _: GraphQLResolveInfo,
+ @Ctx() ctx: Context
+ ): Promise {
+ const sqlArgs = parseSqlArguments(model, 'MarketplaceToken', {
+ where: args.where,
+ })
+
+ const idField = [
+ {
+ 'field': 'id',
+ 'aliases': ['id'],
+ 'kind': 'scalar',
+ 'type': { 'kind': 'scalar', 'name': 'ID' },
+ 'prop': {
+ 'type': { 'kind': 'scalar', 'name': 'ID' },
+ 'nullable': false,
+ 'description': 'runtime token identifier',
+ },
+ 'index': 0,
+ },
+ ]
+
+ // TODO: this could be replaced with CountQuery
+ const listQuery = new ListQuery(
+ model,
+ ctx.openreader.dialect,
+ 'MarketplaceToken',
+ idField as any,
+ sqlArgs
+ )
+
+ let listQuerySql = listQuery.sql
+
+ listQuerySql = `SELECT COUNT(*) ${listQuerySql.slice(listQuerySql.indexOf('FROM'))}`
+ ;(listQuery as { sql: string }).sql = listQuerySql
+
+ const result = await ctx.openreader.executeQuery(listQuery)
+
+ return {
+ // since ID is index 0 variable
+ count: result[0].id,
+ }
+ }
+
+ @Query(() => [MarketplaceToken])
+ async getMarketplaceTokens(
+ @Args() args: MarketplaceTableTokensArgs,
+ @Info() info: GraphQLResolveInfo,
+ @Ctx() ctx: Context
+ ): Promise {
+ const tree = getResolveTree(info)
+
+ const sqlArgs = parseSqlArguments(model, 'MarketplaceToken', {
+ limit: args.limit,
+ where: args.where,
+ orderBy: args.orderBy,
+ offset: args.offset,
+ })
+
+ const marketplaceTokensFields = parseAnyTree(model, 'MarketplaceToken', info.schema, tree)
+ const listQuery = new ListQuery(
+ model,
+ ctx.openreader.dialect,
+ 'MarketplaceToken',
+ marketplaceTokensFields,
+ sqlArgs
+ )
+
+ const listQuerySql = listQuery.sql
+
+ ;(listQuery as { sql: string }).sql = listQuerySql
+
+ const result = await ctx.openreader.executeQuery(listQuery)
+
+ return result as MarketplaceToken[]
+ }
+
@Query(() => GetCumulativeHistoricalShareAllocationResult)
async getCumulativeHistoricalShareAllocation(
@Args() { tokenId }: GetCumulativeHistoricalShareAllocationArgs
diff --git a/src/server-extension/resolvers/CreatorToken/types.ts b/src/server-extension/resolvers/CreatorToken/types.ts
index 249689d30..990f79c10 100644
--- a/src/server-extension/resolvers/CreatorToken/types.ts
+++ b/src/server-extension/resolvers/CreatorToken/types.ts
@@ -1,4 +1,10 @@
-import { ArgsType, Field, Int, ObjectType } from 'type-graphql'
+import { ArgsType, Field, Float, Int, ObjectType } from 'type-graphql'
+import {
+ CreatorToken,
+ MarketplaceTokenOrderByInput,
+ MarketplaceTokenWhereInput,
+ TokenWhereInput,
+} from '../baseTypes'
@ArgsType()
export class GetShareDividensArgs {
@@ -44,3 +50,79 @@ export class GetAccountTransferrableBalanceResult {
@Field(() => Int, { nullable: false })
transferrableCrtAmount!: number
}
+
+@ObjectType()
+export class MarketplaceTokensReturnType {
+ @Field(() => CreatorToken, { nullable: false }) creatorToken!: CreatorToken
+ @Field(() => Float, { nullable: false }) pricePercentageChange!: number
+}
+
+@ObjectType()
+export class TopSellingTokensReturnType {
+ @Field(() => CreatorToken, { nullable: false }) creatorToken!: CreatorToken
+ @Field(() => String, { nullable: false }) ammVolume!: string
+}
+
+@ArgsType()
+export class MarketplaceTokensArgs {
+ @Field(() => TokenWhereInput, { nullable: true })
+ where?: Record
+
+ @Field(() => Int, {
+ nullable: false,
+ description: 'The number of days in period',
+ })
+ periodDays: number
+
+ @Field(() => Int, {
+ nullable: true,
+ })
+ limit?: number
+
+ @Field(() => Boolean, {
+ nullable: true,
+ description: 'Whether the result should be order by price change descending',
+ })
+ orderByPriceDesc: boolean | null
+}
+
+@ObjectType()
+export class MarketplaceTokenCount {
+ @Field(() => Int, { nullable: false }) count: number
+}
+
+@ArgsType()
+export class MarketplaceTokensCountArgs {
+ @Field(() => MarketplaceTokenWhereInput, { nullable: true })
+ where?: Record
+}
+
+@ArgsType()
+export class MarketplaceTableTokensArgs {
+ @Field(() => MarketplaceTokenWhereInput, { nullable: true })
+ where?: Record
+
+ @Field(() => Int, {
+ nullable: true,
+ defaultValue: 10,
+ description: 'The number of videos to return',
+ })
+ limit?: number
+
+ @Field(() => Int, {
+ nullable: true,
+ })
+ offset?: number
+
+ @Field(() => [MarketplaceTokenOrderByInput], {
+ nullable: true,
+ description: 'Order of input',
+ })
+ orderBy?: unknown[]
+}
+
+@ArgsType()
+export class TopSellingTokensArgs {
+ @Field(() => TokenWhereInput, { nullable: true })
+ where?: Record
+}
diff --git a/src/server-extension/resolvers/StateResolver/index.ts b/src/server-extension/resolvers/StateResolver/index.ts
index 4d3c1f303..1969854a5 100644
--- a/src/server-extension/resolvers/StateResolver/index.ts
+++ b/src/server-extension/resolvers/StateResolver/index.ts
@@ -1,9 +1,14 @@
-import { Resolver, Root, Subscription, Query, ObjectType, Field } from 'type-graphql'
-import type { EntityManager } from 'typeorm'
-import { ProcessorState } from './types'
import _, { isObject } from 'lodash'
+import { Args, Query, Resolver, Root, Subscription } from 'type-graphql'
+import type { EntityManager } from 'typeorm'
import { globalEm } from '../../../utils/globalEm'
import { has } from '../../../utils/misc'
+import {
+ EarningStatsOutput,
+ ProcessorState,
+ TopInteractedEntity,
+ TopInteractedEntityArgs,
+} from './types'
class ProcessorStateRetriever {
public state: ProcessorState
@@ -74,6 +79,36 @@ export class StateResolver {
return state
}
+ @Query(() => [TopInteractedEntity])
+ async getTopInteractedEntities(
+ @Args() args: TopInteractedEntityArgs
+ ): Promise {
+ const em = await this.tx()
+
+ const result: { entity_id: string; entrycount: number }[] = await em.query(
+ `
+ SELECT
+ entity_id,
+ SUM(count) as entryCount
+ FROM
+ admin.user_interaction_count
+ WHERE
+ type = $1 AND day_timestamp >= NOW() - INTERVAL '${args.period} DAYS'
+ GROUP BY
+ entity_id
+ ORDER BY
+ entryCount DESC
+ LIMIT 10;
+ `,
+ [args.type]
+ )
+
+ return result.map((res) => ({
+ interactionCount: res.entrycount,
+ entityId: res.entity_id,
+ }))
+ }
+
@Query(() => EarningStatsOutput)
async totalJoystreamEarnings(): Promise {
const em = await this.tx()
@@ -121,15 +156,3 @@ export class StateResolver {
}
}
}
-
-@ObjectType()
-export class EarningStatsOutput {
- @Field({ nullable: false })
- crtSaleVolume: string
-
- @Field({ nullable: false })
- totalRewardsPaid: string
-
- @Field({ nullable: false })
- nftSaleVolume: string
-}
diff --git a/src/server-extension/resolvers/StateResolver/types.ts b/src/server-extension/resolvers/StateResolver/types.ts
index 6c0974404..8ee8dffdb 100644
--- a/src/server-extension/resolvers/StateResolver/types.ts
+++ b/src/server-extension/resolvers/StateResolver/types.ts
@@ -1,7 +1,37 @@
-import { Field, Int, ObjectType } from 'type-graphql'
+import { ArgsType, Field, Int, ObjectType } from 'type-graphql'
@ObjectType()
export class ProcessorState {
@Field(() => Int, { nullable: false })
lastProcessedBlock!: number
}
+
+@ObjectType()
+export class TopInteractedEntity {
+ @Field({ nullable: false })
+ entityId: string
+
+ @Field({ nullable: false })
+ interactionCount: number
+}
+
+@ArgsType()
+export class TopInteractedEntityArgs {
+ @Field(() => String, { nullable: false })
+ type: string
+
+ @Field(() => Int, { nullable: false })
+ period: number
+}
+
+@ObjectType()
+export class EarningStatsOutput {
+ @Field({ nullable: false })
+ crtSaleVolume: string
+
+ @Field({ nullable: false })
+ totalRewardsPaid: string
+
+ @Field({ nullable: false })
+ nftSaleVolume: string
+}
diff --git a/src/server-extension/resolvers/baseTypes.ts b/src/server-extension/resolvers/baseTypes.ts
index c42145544..41d24acae 100644
--- a/src/server-extension/resolvers/baseTypes.ts
+++ b/src/server-extension/resolvers/baseTypes.ts
@@ -61,3 +61,25 @@ export class VideosConnection {
export const OwnedNftWhereInput = new GraphQLScalarType({
name: 'OwnedNftWhereInput',
})
+
+@ObjectType()
+export class CreatorToken {
+ @Field(() => String, { nullable: false }) id!: string
+}
+
+export const TokenWhereInput = new GraphQLScalarType({
+ name: 'CreatorTokenWhereInput',
+})
+
+export const MarketplaceTokenWhereInput = new GraphQLScalarType({
+ name: 'MarketplaceTokenWhereInput',
+})
+
+export const MarketplaceTokenOrderByInput = new GraphQLScalarType({
+ name: 'id_ASC',
+})
+
+@ObjectType()
+export class MarketplaceToken {
+ @Field(() => String, { nullable: false }) id!: string
+}