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 +}