diff --git a/app/api/.env.example b/app/api/.env.example index 886cca3ba..c0ab266c1 100644 --- a/app/api/.env.example +++ b/app/api/.env.example @@ -39,4 +39,14 @@ WEB_CLIENT_BASE_URL=https://pocre.netlify.app # the base url of web app # pinata config (for ipfs storage) PINATA_JWT_SECRET=api_key_jwt_secret_provided_by_pinata PINATA_API_JSON_PIN_URL=pinata_api_url_to_pin_json -PINATA_API_UNPIN_URL=pinata_api_url_to_unpin_data \ No newline at end of file +PINATA_API_UNPIN_URL=pinata_api_url_to_unpin_data + +# crypto transactions config +MIN_BLOCK_CONFIRMATIONS_FOR_VALID_CRYPTO_TRANSACTION=the_min_amount_of_validated_blocks_after_which_we_assume_transaction_is_valid + +# blockfrost config (used for validating crypto transactions) +BLOCKFROST_PROJECT_ID=project_id_from_blockfrost +BLOCKFROST_API_BASE_URL=https://cardano-preview.blockfrost.io/api/v0 +BLOCKFROST_API_TRANSACTIONS_ENDPOINT=txs +BLOCKFROST_API_BLOCKS_ENDPOINT=blocks +BLOCKFROST_WEBHOOK_AUTH_TOKEN=webhook_token_from_blockfrost \ No newline at end of file diff --git a/app/api/package.json b/app/api/package.json index 33f9f8323..7cc73c7ce 100644 --- a/app/api/package.json +++ b/app/api/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@babel/runtime": "^7.14.6", + "@blockfrost/blockfrost-js": "^5.2.0", "@sendgrid/mail": "^7.7.0", "@types/joi": "^17.2.3", "@types/jsonwebtoken": "^8.5.9", diff --git a/app/api/src/config/config.ts b/app/api/src/config/config.ts index 4f36b3378..af5cad6e3 100644 --- a/app/api/src/config/config.ts +++ b/app/api/src/config/config.ts @@ -40,6 +40,16 @@ const envVarsSchema = Joi.object() PINATA_JWT_SECRET: Joi.string().description('pinata jwt secret used to store data on ipfs').required(), PINATA_API_JSON_PIN_URL: Joi.string().description('pinata api url to pin json data').required(), PINATA_API_UNPIN_URL: Joi.string().description('pinata api url to unpin data').required(), + BLOCKFROST_PROJECT_ID: Joi.string().description('blockfrost project id to interact with their service').required(), + BLOCKFROST_API_BASE_URL: Joi.string().description('blockfrost base url to make http calls').required(), + BLOCKFROST_API_TRANSACTIONS_ENDPOINT: Joi.string().description('blockfrost endpoint to query transactions').required(), + BLOCKFROST_API_BLOCKS_ENDPOINT: Joi.string().description('blockfrost endpoint to query blocks').required(), + BLOCKFROST_WEBHOOK_AUTH_TOKEN: Joi.string() + .description('blockfrost webhook token to verify webhooks signature') + .required(), + MIN_BLOCK_CONFIRMATIONS_FOR_VALID_CRYPTO_TRANSACTION: Joi.string() + .description('the minimun block confirmation threshold after which we consider a transaction successful') + .required(), }) .unknown(); @@ -93,4 +103,18 @@ export default { unpin: envVars.PINATA_API_UNPIN_URL, }, }, + crypto: { + valid_transaction: { + min_block_confirmations: envVars.MIN_BLOCK_CONFIRMATIONS_FOR_VALID_CRYPTO_TRANSACTION, + }, + }, + blockfrost: { + project_id: envVars.BLOCKFROST_PROJECT_ID, + base_api_url: envVars.BLOCKFROST_API_BASE_URL, + endpoints: { + transactions: envVars.BLOCKFROST_API_TRANSACTIONS_ENDPOINT, + blocks: envVars.BLOCKFROST_API_BLOCKS_ENDPOINT, + }, + webhook_token: envVars.BLOCKFROST_WEBHOOK_AUTH_TOKEN, + }, }; diff --git a/app/api/src/constants/transactionPurposes.ts b/app/api/src/constants/transactionPurposes.ts new file mode 100644 index 000000000..9572db363 --- /dev/null +++ b/app/api/src/constants/transactionPurposes.ts @@ -0,0 +1,8 @@ +export default Object.freeze({ + PUBLISH_CREATION: 'publish_creation', + FINALIZE_CREATION: 'finalize_creation', + START_LITIGATION: 'start_litigation', + CAST_LITIGATION_VOTE: 'cast_litigation_vote', + REDEEM_LITIGATED_ITEM: 'redeem_litigated_item', + ACCEPT_RECOGNITION: 'accept_recognition', +}); diff --git a/app/api/src/controllers/creation.controller.ts b/app/api/src/controllers/creation.controller.ts index 35586808f..edbda10bb 100644 --- a/app/api/src/controllers/creation.controller.ts +++ b/app/api/src/controllers/creation.controller.ts @@ -4,11 +4,13 @@ import config from '../config/config'; import publishPlatforms from '../constants/publishPlatforms'; import reputationStarTimeWindows from '../constants/reputationStarTimeWindows'; import statusTypes from '../constants/statusTypes'; +import transactionPurposes from '../constants/transactionPurposes'; import * as creationService from '../services/creation.service'; import * as litigationService from '../services/litigation.service'; import { getMaterialById, updateMaterialById } from '../services/material.service'; import { createRecognition } from '../services/recognition.service'; import { getTagById } from '../services/tag.service'; +import { getTransactionById } from '../services/transaction.service'; import { getUserByCriteria, IUserDoc } from '../services/user.service'; import ApiError from '../utils/ApiError'; import catchAsync from '../utils/catchAsync'; @@ -98,45 +100,10 @@ export const createCreation = catchAsync(async (req, res): Promise => { ) .toISOString(), is_fully_owned: false, + is_draft: true, + is_claimable: true, }); - // send recognitions to material authors if the creation is published - if (!req.body.is_draft && req.body.materials && req.body.materials.length > 0) { - // get all materials - // eslint-disable-next-line @typescript-eslint/return-await - const materials = await Promise.all(req.body.materials.map(async (id: string) => await getMaterialById(id))); - - await Promise.all( - materials.map(async (m: any) => { - const foundAuthor = await getUserByCriteria('user_id', m.author_id, true); - - // send invitation emails to new authors - if (foundAuthor && foundAuthor.is_invited && foundAuthor.email_address) { - await sendMail({ - to: foundAuthor?.email_address as string, - subject: `Invitation to recognize authorship of "${m.material_title}"`, - message: `You were recognized as author of "${m.material_title}" by ${ - (req.user as IUserDoc)?.user_name - }. Please signup on ${config.web_client_base_url}/signup?token=${encode( - foundAuthor.user_id - )} to be recognized as the author.`, - }).catch(() => null); - } - - // send recognition - const recognition = await createRecognition({ - recognition_for: m.author_id, - recognition_by: (req.user as IUserDoc).user_id, - status: 'pending', - status_updated: new Date().toISOString(), - }); - - // update material with recognition - await updateMaterialById(m.material_id, { recognition_id: recognition.recognition_id }, { owner_id: m.author_id }); - }) - ); - } - res.send(newCreation); }); @@ -215,78 +182,75 @@ export const updateCreationById = catchAsync(async (req, res): Promise => } ); - // send recognitions to material authors if the creation is published - if (foundCreation?.is_draft && req.body.is_draft === false && updatedCreation && updatedCreation.materials.length > 0) { - // get all materials - // eslint-disable-next-line @typescript-eslint/return-await - const materials = await Promise.all(updatedCreation.materials.map(async (id: string) => await getMaterialById(id))); - - await Promise.all( - materials.map(async (m: any) => { - const foundAuthor = await getUserByCriteria('user_id', m.author_id, true); - - // send invitation emails to new authors - if (foundAuthor && foundAuthor.is_invited && foundAuthor.email_address) { - await sendMail({ - to: foundAuthor?.email_address as string, - subject: `Invitation to recognize authorship of "${m.material_title}"`, - message: `You were recognized as author of "${m.material_title}" by ${ - (req.user as IUserDoc)?.user_name - }. Please signup on ${config.web_client_base_url}/signup?token=${encode( - foundAuthor.user_id - )} to be recognized as the author.`, - }).catch(() => null); - } - - // send recognition - const recognition = await createRecognition({ - recognition_for: m.author_id, - recognition_by: (req.user as IUserDoc).user_id, - status: 'pending', - status_updated: new Date().toISOString(), - }); - - // update material with recognition - await updateMaterialById(m.material_id, { - recognition_id: recognition.recognition_id, - }); - }) - ); - } - res.send(updatedCreation); }); export const publishCreation = catchAsync(async (req, res): Promise => { - // get original creation - const foundCreation = await creationService.getCreationById(req.params.creation_id); + const reqUser = req.user as IUserDoc; - // block publishing if creation is draft - if (foundCreation?.is_draft) { - throw new ApiError(httpStatus.NOT_ACCEPTABLE, `draft creation cannot be published`); - } + // get original creation + const foundCreation = await creationService.getCreationById(req.params.creation_id, { + owner_id: reqUser.user_id, + }); const updateBody = await (async () => { - if (req.body.publish_on === publishPlatforms.BLOCKCHAIN) { - // block owning if caw has not passed - if (foundCreation && moment().isBefore(moment(new Date(foundCreation?.creation_authorship_window)))) { - throw new ApiError(httpStatus.NOT_ACCEPTABLE, `finalization only allowed after creation authorship window`); - } - - return { is_onchain: true, is_fully_owned: true }; - } - + // remove from draft and upload to ipfs (assuming the 'publish_creation' transaction is now validated) if (req.body.publish_on === publishPlatforms.IPFS) { - // remove extra keys from creation json - const jsonForIPFS: any = foundCreation; + const tempUpdateBody = { is_draft: false }; + + // prepare creation json + const jsonForIPFS: any = { ...foundCreation, ...tempUpdateBody }; delete jsonForIPFS.ipfs_hash; // store on ipfs const hash = await pinJSON(jsonForIPFS).catch(() => null); - if (!hash) throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, `failed to upload creation to ipfs`); - return { ipfs_hash: hash }; + // send recognitions to material authors since the creation is now published + if (foundCreation && foundCreation.materials.length > 0) { + // get all materials + // eslint-disable-next-line @typescript-eslint/return-await + const materials = await Promise.all(foundCreation.materials.map(async (id: string) => await getMaterialById(id))); + + await Promise.all( + materials.map(async (m: any) => { + const foundAuthor = await getUserByCriteria('user_id', m.author_id, true); + + // send invitation emails to new authors + if (foundAuthor && foundAuthor.is_invited && foundAuthor.email_address) { + await sendMail({ + to: foundAuthor?.email_address as string, + subject: `Invitation to recognize authorship of "${m.material_title}"`, + message: `You were recognized as author of "${m.material_title}" by ${ + (req.user as IUserDoc)?.user_name + }. Please signup on ${config.web_client_base_url}/signup?token=${encode( + foundAuthor.user_id + )} to be recognized as the author.`, + }).catch(() => null); + } + + // send recognition + const recognition = await createRecognition({ + recognition_for: m.author_id, + recognition_by: (req.user as IUserDoc).user_id, + status: 'pending', + status_updated: new Date().toISOString(), + }); + + // update material with recognition + await updateMaterialById(m.material_id, { + recognition_id: recognition.recognition_id, + }); + }) + ); + } + + return { ipfs_hash: hash, ...tempUpdateBody }; + } + + // set fully owned status (assuming the 'finalize_creation' transaction is now validated) + if (req.body.publish_on === publishPlatforms.BLOCKCHAIN) { + return { is_fully_owned: true }; } })(); @@ -301,3 +265,64 @@ export const publishCreation = catchAsync(async (req, res): Promise => { res.send(updatedCreation); }); + +export const registerCreationTransaction = catchAsync(async (req, res): Promise => { + const reqUser = req.user as IUserDoc; + + // verify transaction, will throw an error if transaction is not found + const foundTransaction = await getTransactionById(req.body.transaction_id, { + owner_id: reqUser.user_id, + }); + + // verify creation, will throw an error if creation is not found + const foundCreation = await creationService.getCreationById(req.params.creation_id, { + populate: ['transactions'], + owner_id: reqUser.user_id, + }); + + // check if transaction has correct purposes + if ( + foundTransaction && + !( + foundTransaction.transaction_purpose === transactionPurposes.FINALIZE_CREATION || + foundTransaction.transaction_purpose === transactionPurposes.PUBLISH_CREATION + ) + ) { + throw new ApiError(httpStatus.NOT_ACCEPTABLE, `invalid transaction purpose for creation`); + } + + // check if caw has passed if transaction purpose is to finalize creation + if ( + foundTransaction && + foundTransaction.transaction_purpose === transactionPurposes.FINALIZE_CREATION && + foundCreation && + moment().isBefore(moment(new Date(foundCreation.creation_authorship_window))) + ) { + throw new ApiError(httpStatus.NOT_ACCEPTABLE, `finalization only allowed after creation authorship window`); + } + + // check if original creation already has this transaction + if ( + foundCreation && + foundCreation.transactions && + foundTransaction && + foundCreation.transactions.find( + (x: any) => + x.transaction_id === foundTransaction.transaction_id || + x.transaction_purpose === foundTransaction.transaction_purpose + ) + ) { + throw new ApiError(httpStatus.NOT_ACCEPTABLE, `transaction already registered for creation`); + } + + // update creation + const updatedCreation = await creationService.updateCreationById( + req.params.creation_id, + { transactions: [...(foundCreation?.transactions || []).map((x: any) => x.transaction_id), req.body.transaction_id] }, + { + owner_id: (req.user as IUserDoc).user_id, + } + ); + + res.send(updatedCreation); +}); diff --git a/app/api/src/controllers/transaction.controller.ts b/app/api/src/controllers/transaction.controller.ts new file mode 100644 index 000000000..f3b5bfe02 --- /dev/null +++ b/app/api/src/controllers/transaction.controller.ts @@ -0,0 +1,11 @@ +import * as transactionService from '../services/transaction.service'; +import { IUserDoc } from '../services/user.service'; +import catchAsync from '../utils/catchAsync'; + +export const createTransaction = catchAsync(async (req, res): Promise => { + const newTransaction = await transactionService.createTransaction({ + ...req.body, + maker_id: (req.user as IUserDoc).user_id, + }); + res.send(newTransaction); +}); diff --git a/app/api/src/controllers/webhook.controller.ts b/app/api/src/controllers/webhook.controller.ts new file mode 100644 index 000000000..089094936 --- /dev/null +++ b/app/api/src/controllers/webhook.controller.ts @@ -0,0 +1,82 @@ +import { verifyWebhookSignature } from '@blockfrost/blockfrost-js'; +import httpStatus from 'http-status'; +import config from '../config/config'; +import publishPlatforms from '../constants/publishPlatforms'; +import transactionPurposes from '../constants/transactionPurposes'; +import { getTransactionByHash, updateTransactionById } from '../services/transaction.service'; +import ApiError from '../utils/ApiError'; +import { getBlockInfo, getTransactionInfo, ICardanoTransaction } from '../utils/cardano'; +import catchAsync from '../utils/catchAsync'; +import { publishCreation } from './creation.controller'; + +export const processTransaction = catchAsync(async (req, res, next): Promise => { + try { + // verify blockfrost webhook signature + const isValidSignature = (() => { + // check if signature is present + const signatureHeader = req.headers['blockfrost-signature']; + if (!signatureHeader) throw new Error(`signature header is required`); + + // check if signature is valid + return verifyWebhookSignature( + JSON.stringify(req.body), // [Note from blockfrost docs]: Stringified request.body (Note: In AWS Lambda you don't need to call JSON.stringify as event.body is already stringified) + signatureHeader, + config.blockfrost.webhook_token, + 600 // 600 seconds = 10 minutes - time tolerance for signature validity, if this webhook was older than 600 seconds its invalid + ); + })(); + if (!isValidSignature) throw new Error(`invalid signature header`); + + // get transaction info + const webhookPayload = req?.body?.payload?.[0]?.tx as ICardanoTransaction; + const pocreTransaction = await getTransactionByHash(webhookPayload.hash, { + populate: 'maker_id', + }); + const cardanoTransation = await getTransactionInfo(webhookPayload.hash); + const cardanoBlock = await getBlockInfo(cardanoTransation?.block as string); + + // validate if minimum amount of blocks are confirmed + if (cardanoBlock && cardanoBlock.confirmations < config.crypto.valid_transaction.min_block_confirmations) { + throw new Error(`minimum blocks not confirmed`); + } + + // if transaction was for creation, then transform req and pass it to creations + if ( + pocreTransaction && + (pocreTransaction.transaction_purpose === transactionPurposes.PUBLISH_CREATION || + pocreTransaction.transaction_purpose === transactionPurposes.FINALIZE_CREATION) && + cardanoTransation && + cardanoTransation.metadata.pocre_entity === 'creation' + ) { + // transform current request to glue with creation controller + const transformedReq: any = { + ...req, + user: (pocreTransaction as any).maker, + params: { + creation_id: cardanoTransation.metadata.pocre_id, + }, + body: { + publish_on: + pocreTransaction.transaction_purpose === transactionPurposes.PUBLISH_CREATION + ? publishPlatforms.IPFS + : publishPlatforms.BLOCKCHAIN, + }, + }; + + // publish the creation + await publishCreation(transformedReq, res, next); // if this returns non truthy response, then webhook fails + + // confirm the transaction + await updateTransactionById(pocreTransaction.transaction_id, { + is_validated: true, + }); + + return; + } + + res.status(httpStatus.NOT_IMPLEMENTED).send(`no operation performed by pocre`); // let the webhook know that pocre did not used this info + } catch (e: unknown) { + const error = e as Error; + throw new ApiError(httpStatus.BAD_REQUEST, error.message); + } +}); diff --git a/app/api/src/db/map.ts b/app/api/src/db/map.ts index fd4a66e25..09624818b 100644 --- a/app/api/src/db/map.ts +++ b/app/api/src/db/map.ts @@ -5,6 +5,7 @@ interface IPkMap { author_id: string; tags: string; materials: string; + transactions: string; assumed_author: string; winner: string; issuer_id: string; @@ -17,6 +18,7 @@ interface IPkMap { decision_id: string; tag_id: string; litigation_id: string; + transaction_id: string; } interface ITableNameMap { @@ -26,6 +28,7 @@ interface ITableNameMap { author_id: string; tags: string; materials: string; + transactions: string; assumed_author: string; winner: string; issuer_id: string; @@ -38,13 +41,14 @@ interface ITableNameMap { decision_id: string; tag_id: string; litigation_id: string; + transaction_id: string; } /** * List of primary and foreign key names mapped to parent table primary key names */ const pkMap: IPkMap = { - // decision table + // decision and transaction table maker_id: 'user_id', // recognition table recognition_by: 'user_id', @@ -54,6 +58,7 @@ const pkMap: IPkMap = { // creation table tags: 'tag_id', materials: 'material_id', + transactions: 'transaction_id', // litigation table assumed_author: 'user_id', winner: 'user_id', @@ -68,6 +73,7 @@ const pkMap: IPkMap = { decision_id: 'decision_id', tag_id: 'tag_id', litigation_id: 'litigation_id', + transaction_id: 'transaction_id', }; /** @@ -84,6 +90,7 @@ const tableNameMap: ITableNameMap = { // creation table tags: 'tag', materials: 'material', + transactions: 'transaction', // litigation table assumed_author: 'VIEW_users_public_fields', winner: 'VIEW_users_public_fields', @@ -98,18 +105,24 @@ const tableNameMap: ITableNameMap = { decision_id: 'decision', litigation_id: 'litigation', tag_id: 'tag', + transaction_id: 'transaction', }; /** * List of field names defined with array type in db tables */ -const arrayFields: string[] = ['recognitions', 'decisions', 'tags', 'materials']; +const arrayFields: string[] = ['recognitions', 'decisions', 'tags', 'materials', 'transactions']; /** * List of deep fields that can be populated in decision */ const decisionDeepFields: string[] = ['maker_id']; +/** + * List of deep fields that can be populated in transaction + */ +const transactionDeepFields: string[] = ['maker_id']; + /** * List of deep fields that can be populated in recognition */ @@ -127,7 +140,14 @@ const materialDeepFields: string[] = [ /** * List of deep fields that can be populated in creation */ -const creationDeepFields: string[] = ['author_id', 'tags', 'materials', ...materialDeepFields.map((x) => `materials.${x}`)]; +const creationDeepFields: string[] = [ + 'author_id', + 'tags', + 'materials', + ...materialDeepFields.map((x) => `materials.${x}`), + 'transactions', + ...transactionDeepFields.map((x) => `transactions.${x}`), +]; /** * List of deep fields that can be populated in litigation @@ -153,6 +173,7 @@ export { tableNameMap, arrayFields, decisionDeepFields, + transactionDeepFields, recognitionDeepFields, materialDeepFields, creationDeepFields, diff --git a/app/api/src/db/pool.ts b/app/api/src/db/pool.ts index 6bcf6def0..f70bcaddf 100644 --- a/app/api/src/db/pool.ts +++ b/app/api/src/db/pool.ts @@ -40,6 +40,12 @@ const init = async (): Promise> => { WHEN duplicate_object THEN null; END $$; + DO $$ BEGIN + CREATE TYPE transaction_purpose_enums AS ENUM ('publish_creation', 'finalize_creation', 'start_litigation', 'cast_litigation_vote', 'redeem_litigated_item', 'accept_recognition'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + /* ************************************ */ /* TABLES */ /* ************************************ */ @@ -127,12 +133,12 @@ const init = async (): Promise> => { author_id UUID NOT NULL, tags UUID[], materials UUID[], + transactions UUID[], creation_date TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(), - is_draft bool default false, + is_draft bool default true, ipfs_hash character varying, is_claimable bool default true, - is_onchain bool default false, creation_authorship_window TIMESTAMP NOT NULL DEFAULT NOW(), is_fully_owned bool default false, CONSTRAINT author_id @@ -170,6 +176,20 @@ const init = async (): Promise> => { ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS transaction ( + transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + transaction_hash character varying NOT NULL UNIQUE, + transaction_purpose transaction_purpose_enums NOT NULL, + is_validated bool DEFAULT false, + maker_id UUID NOT NULL, + blocking_issue character varying, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT maker_id + FOREIGN KEY(maker_id) + REFERENCES users(user_id) + ON DELETE CASCADE + ); + /* ************************************ */ /* VIEWS */ /* [NOTE]: Used by app to remove sensitive fields from api response */ @@ -216,6 +236,12 @@ const init = async (): Promise> => { AS $$ UPDATE litigation SET decisions = array_remove(decisions, decision_id); $$; + + CREATE OR REPLACE PROCEDURE remove_transaction_references(transaction_id UUID) + LANGUAGE SQL + AS $$ + UPDATE creation SET transactions = array_remove(transactions, transaction_id); + $$; ` ); }; diff --git a/app/api/src/docs/components.yml b/app/api/src/docs/components.yml index e03d529d9..514abcb18 100644 --- a/app/api/src/docs/components.yml +++ b/app/api/src/docs/components.yml @@ -20,7 +20,7 @@ components: type: string format: date-time is_invited: - type: boolean + type: bool example: user_id: f2b4befe-f940-4a8e-86e1-8427a29df76e user_name: john @@ -175,8 +175,6 @@ components: type: bool is_claimable: type: bool - is_onchain: - type: bool ipfs_hash: type: string created_at: @@ -195,7 +193,6 @@ components: created_at: 2022-09-05T19:00:00.000Z is_draft: false is_claimable: true - is_onchain: false ipfs_hash: dd7a824bb0a94868bdbfcfa6bdd36629 CreationProof: @@ -236,7 +233,7 @@ components: creation_description: an example creation creation_link: https://example.com author_id: dd7a824b-b0a9-4868-bdbf-cfa6bdd36629 - author: + author: user_id: f2b4befe-f940-4a8e-86e1-8427a29df76e user_name: john wallet_address: 28y9gd27g2g237g80hnibhi @@ -246,21 +243,21 @@ components: verified_id: 28y9gd27g2g237g80hnibhi reputation_stars: 0 date_joined: 2022-09-05T19:00:00.000Z - tags: + tags: - tag_id: fb018e05-5792-48a4-bee3-4518453443d3 tag_name: enhancement tag_description: improvement tag tag_created: 2022-09-06T19:00:00.000Z - materials: + materials: - material_id: 5d6ad369-55d8-469d-bf26-4a90017861dd material_title: plastic material_description: dangerous material_link: https://example.com material_type: audio recognition_id: 12ed7a55-a1aa-4895-83e9-7aa615247390 - recognition: + recognition: recognition_id: 8d098580-1e4c-40c8-95f3-9c78de09a121 - recognition_by: + recognition_by: user_id: f2b4befe-f940-4a8e-86e1-8427a29df76e user_name: john wallet_address: 28y9gd27g2g237g80hnibhi @@ -270,7 +267,7 @@ components: verified_id: 28y9gd27g2g237g80hnibhi reputation_stars: 0 date_joined: 2022-09-05T19:00:00.000Z - recognition_for: + recognition_for: user_id: f2b4befe-f940-4a8e-86e1-8427a29df76e user_name: john wallet_address: 28y9gd27g2g237g80hnibhi @@ -285,7 +282,7 @@ components: status: pending status_updated: 2022-09-07T19:00:00.000Z author_id: 9cf446ed-04f8-41fe-ba40-1c33e5670ca5 - author: + author: user_id: f2b4befe-f940-4a8e-86e1-8427a29df76e user_name: john wallet_address: 28y9gd27g2g237g80hnibhi @@ -358,7 +355,7 @@ components: reconcilate: false created_at: 2022-09-05T19:00:00.000Z ownership_transferred: false - + Token: type: object properties: @@ -372,10 +369,10 @@ components: properties: user: $ref: '#/components/schemas/User' - token: + token: type: string - example: - user: + example: + user: user_id: f2b4befe-f940-4a8e-86e1-8427a29df76e user_name: john user_bio: ready to explore @@ -385,12 +382,41 @@ components: date_joined: 2022-09-05T19:00:00.000Z token: eyJhbGciOiJIUzI1NiJ9.MUExelAxZVA1UUdlZmkyRE1QVGZUTDVTTG12N0RpdmZOYQ.Qet6M0nRv2VD7gQgUfi9ivWo6L8IKojzGWuloFBkxvE + Transaction: + type: object + properties: + transaction_id: + type: string + format: uuid + transaction_hash: + type: string + transaction_purpose: + type: string + is_validated: + type: bool + blocking_issue: + type: string + maker_id: + type: string + format: uuid + created_at: + type: string + format: date-time + example: + transaction_id: fb018e05-5792-48a4-bee3-4518453443d3 + transaction_hash: aa62ba02b24185e0d9eb2074518c0be09d3b76644de5511eeaa09fc117c1f38f + transaction_purpose: publish_creation + is_validated: false + blocking_issue: null + maker_id: fb018e05-5792-48a4-bee3-4518453443d3 + created_at: 2022-09-06T19:00:00.000Z + FileMediaType: type: object properties: media_type: type: string - example: + example: media_type: image Error: @@ -518,7 +544,7 @@ components: $ref: '#/components/schemas/Error' example: code: 409 - message: material already assigned to a litigation + message: material already assigned to a litigation MaterialAlreadyOwned: description: Material is already owned content: @@ -527,7 +553,7 @@ components: $ref: '#/components/schemas/Error' example: code: 409 - message: material is already owned + message: material is already owned MaterialDoesNotBelongToCreation: description: Material does not belong to creation content: @@ -581,7 +607,7 @@ components: $ref: '#/components/schemas/Error' example: code: 406 - message: creation has ongoing material recognition process + message: creation has ongoing material recognition process CreationOngoingLitigation: description: Creation has ongoing litigation process content: @@ -590,16 +616,7 @@ components: $ref: '#/components/schemas/Error' example: code: 406 - message: creation has ongoing litigation process - CreationDraftNotAllowedToPublish: - description: Draft creation cannot be published - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - code: 406 - message: draft creation cannot be published + message: creation has ongoing litigation process CreationAlreadyAssignedToLitigation: description: Creation already assigned to a litigation content: @@ -608,7 +625,7 @@ components: $ref: '#/components/schemas/Error' example: code: 409 - message: creation already assigned to a litigation + message: creation already assigned to a litigation CreationAlreadyOwned: description: Creation is already owned content: @@ -617,7 +634,7 @@ components: $ref: '#/components/schemas/Error' example: code: 409 - message: creation is already owned + message: creation is already owned CreationLitigationNotAllowed: description: Creation with materials are not allowed to be litigated content: @@ -663,6 +680,24 @@ components: example: code: 404 message: litigation not found + TransactionNotFound: + description: Transaction not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 404 + message: transaction not found + TransactionAlreadyExists: + description: Transaction already exists + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 400 + message: transaction already exists InvalidMediaLink: description: Forbidden content: @@ -677,4 +712,4 @@ components: bearerAuth: type: http scheme: bearer - bearerFormat: JWT \ No newline at end of file + bearerFormat: JWT diff --git a/app/api/src/routes/v1/creation.route.ts b/app/api/src/routes/v1/creation.route.ts index f2e2d85f6..eca3a3eb1 100644 --- a/app/api/src/routes/v1/creation.route.ts +++ b/app/api/src/routes/v1/creation.route.ts @@ -18,8 +18,8 @@ router .delete(auth(), validate(creationValidation.deleteCreation), creationController.deleteCreationById); router - .route('/:creation_id/publish') - .post(auth(), validate(creationValidation.publishCreation), creationController.publishCreation); + .route('/:creation_id/transaction') + .post(auth(), validate(creationValidation.registerCreationTransaction), creationController.registerCreationTransaction); router .route('/:creation_id/proof') @@ -456,10 +456,10 @@ export default router; /** * @swagger - * /creations/{creation_id}/publish: + * /creations/{creation_id}/transaction: * post: - * summary: Publish a creation - * description: Stores the publish status of a creation. + * summary: Register a transaction for creation + * description: Registers a transaction for creation * tags: [Creation] * security: * - bearerAuth: [] @@ -470,14 +470,19 @@ export default router; * schema: * type: string * description: Creation id - * - in: body - * name: publish_on - * schema: - * type: string - * enum: - * - blochchain - * - ipfs - * description: The platform on which to publish the creation. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - transaction_id + * properties: + * transaction_id: + * type: string + * example: + * transaction_id: 476790e7-a6dc-4aea-8421-06bacfa2daf6 * responses: * "201": * description: Created @@ -486,25 +491,17 @@ export default router; * schema: * $ref: '#/components/schemas/Creation' * "404": - * $ref: '#/components/responses/CreationNotFound' - * "406": - * $ref: '#/components/responses/CreationDraftNotAllowedToPublish' + * $ref: '#/components/responses/TransactionNotFound' * "500": * content: * application/json: * schema: * oneOf: * - $ref: '#/components/responses/InternalServerError' - * - $ref: '#/components/responses/CreationIPFSFailedUpload' * examples: * InternalServerError: * summary: internal server error * value: * code: 500 * message: internal server error - * CreationIPFSFailedUpload: - * summary: failed to upload creation to ipfs - * value: - * code: 500 - * message: failed to upload creation to ipfs */ diff --git a/app/api/src/routes/v1/index.ts b/app/api/src/routes/v1/index.ts index c0cb70429..0abaca746 100644 --- a/app/api/src/routes/v1/index.ts +++ b/app/api/src/routes/v1/index.ts @@ -10,6 +10,8 @@ import tokenRoute from './token.route'; import authRoute from './auth.route'; import docsRoute from './docs.route'; import filesRoute from './files.route'; +import transactionRoute from './transaction.route'; +import webhookRoute from './webhook.route'; import config from '../../config/config'; const router = express.Router(); @@ -55,6 +57,14 @@ const defaultRoutes = [ path: '/files', route: filesRoute, }, + { + path: '/transactions', + route: transactionRoute, + }, + { + path: '/webhooks', + route: webhookRoute, + }, ]; const devRoutes = [ diff --git a/app/api/src/routes/v1/transaction.route.ts b/app/api/src/routes/v1/transaction.route.ts new file mode 100644 index 000000000..170f2b41a --- /dev/null +++ b/app/api/src/routes/v1/transaction.route.ts @@ -0,0 +1,70 @@ +import express from 'express'; +import * as transactionController from '../../controllers/transaction.controller'; +import auth from '../../middlewares/auth'; +import validate from '../../middlewares/validate'; +import * as transactionValidation from '../../validations/transaction.validation'; + +const router = express.Router(); + +router.route('/').post(auth(), validate(transactionValidation.createTransaction), transactionController.createTransaction); + +export default router; + +/** + * @swagger + * tags: + * name: Transaction + * description: Transaction management and retrieval + */ + +/** + * @swagger + * /transactions: + * post: + * summary: Create a transaction + * description: Creates a new transaction. + * tags: [Transaction] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - transaction_hash + * - transaction_purpose + * properties: + * transaction_hash: + * type: string + * transaction_purpose: + * type: string + * enum: [publish_creation, finalize_creation, start_litigation, cast_litigation_vote, redeem_litigated_item, accept_recognition] + * example: + * transaction_hash: aa62ba02b24185e0d9eb2074518c0be09d3b76644de5511eeaa09fc117c1f38f + * transaction_purpose: publish_creation + * responses: + * "201": + * description: Created + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Transaction' + * "404": + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/responses/UserNotFound' + * examples: + * UserNotFound: + * summary: user not found + * value: + * code: 404 + * message: user not found + * "409": + * $ref: '#/components/responses/TagAlreadyExists' + * "500": + * $ref: '#/components/responses/InternalServerError' + */ diff --git a/app/api/src/routes/v1/webhook.route.ts b/app/api/src/routes/v1/webhook.route.ts new file mode 100644 index 000000000..9c12f1fe0 --- /dev/null +++ b/app/api/src/routes/v1/webhook.route.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import * as webhookController from '../../controllers/webhook.controller'; + +const router = express.Router(); + +router.route('/transaction').post(webhookController.processTransaction); + +export default router; diff --git a/app/api/src/services/creation.service.ts b/app/api/src/services/creation.service.ts index ef243ef9a..7fea6936c 100644 --- a/app/api/src/services/creation.service.ts +++ b/app/api/src/services/creation.service.ts @@ -1,10 +1,10 @@ import httpStatus from 'http-status'; import statusTypes from '../constants/statusTypes'; +import supportedMediaTypes from '../constants/supportedMediaTypes'; import { populator } from '../db/plugins/populator'; import * as db from '../db/pool'; -import { getUserByCriteria, IUser, IUserDoc, updateUserById } from './user.service'; import ApiError from '../utils/ApiError'; -import supportedMediaTypes from '../constants/supportedMediaTypes'; +import { getUserByCriteria, IUserDoc, updateUserById } from './user.service'; const types = Object.values(supportedMediaTypes); type TCreationTypes = typeof types[number]; @@ -17,11 +17,11 @@ interface ICreation { author_id: string; tags: string[]; materials?: string[]; + transactions?: string[]; creation_date: string; is_draft: boolean; is_claimable: boolean; ipfs_hash: string; - is_onchain: boolean; is_fully_owned: boolean; creation_authorship_window: string; } @@ -56,11 +56,11 @@ interface ICreationDoc { author_id: string; tags: string[]; materials: string[]; + transactions: string[]; creation_date: string; is_draft: boolean; is_claimable: boolean; ipfs_hash: string; - is_onchain: boolean; is_fully_owned: boolean; creation_authorship_window: string; } @@ -99,6 +99,7 @@ export const getAuthorCreationsCount = async (author_id?: string) => { ]); return parseInt(resCreation.rows[0].total_results); }; + /** * Check if a creation has duplicate materials * @param {string[]} materials @@ -121,6 +122,33 @@ export const verifyCreationMaterialDuplicates = async (materials: string[], excl if (foundMaterial) throw new ApiError(httpStatus.NOT_FOUND, 'material already assigned to a creation'); }; +/** + * Check if a creation has duplicate transactions + * @param {string[]} transactions + * @param {string} exclude_creation + * @returns {Promise} + */ +export const verifyCreationTransactionDuplicates = async ( + transactions: string[], + exclude_creation?: string +): Promise => { + const foundMaterial = await (async () => { + try { + const result = await db.instance.query( + `SELECT * FROM creation WHERE transactions && $1 ${exclude_creation ? 'AND creation_id <> $2' : ''};`, + [`{${transactions.reduce((x, y, index) => `${index === 1 ? `"${x}"` : x},"${y}"`)}}`, exclude_creation].filter( + Boolean + ) + ); + return result.rows[0]; + } catch { + throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'internal server error'); + } + })(); + + if (foundMaterial) throw new ApiError(httpStatus.NOT_FOUND, 'transaction already assigned to a creation'); +}; + /** * Create a creation * @param {ICreation} creationBody @@ -141,6 +169,7 @@ export const createCreation = async (creationBody: ICreation): Promise 0) { + await verifyCreationTransactionDuplicates(updateBody.transactions, id); + } + // build sql conditions and values const conditions: string[] = []; const values: (string | string[] | null | boolean)[] = []; diff --git a/app/api/src/services/transaction.service.ts b/app/api/src/services/transaction.service.ts new file mode 100644 index 000000000..9e099cd4b --- /dev/null +++ b/app/api/src/services/transaction.service.ts @@ -0,0 +1,209 @@ +import httpStatus from 'http-status'; +import { DatabaseError } from 'pg'; +import transactionPurposes from '../constants/transactionPurposes'; +import { populator } from '../db/plugins/populator'; +import * as db from '../db/pool'; +import ApiError from '../utils/ApiError'; + +const types = Object.values(transactionPurposes); +type TTransactionPurposes = typeof types[number]; +interface ITransaction { + transaction_hash: string; + transaction_purpose: TTransactionPurposes; + maker_id: string; + is_validated?: boolean; + blocking_issue?: string; +} +interface ITransactionDoc { + transaction_id: string; + transaction_hash: string; + transaction_purpose: TTransactionPurposes; + is_validated: boolean; + maker_id: string; + blocking_issue: string; + created_at: string; +} + +/** + * Create a transaction + * @param {ITransaction} transactionBody + * @returns {Promise} + */ +export const createTransaction = async (transactionBody: ITransaction): Promise => { + try { + const result = await db.instance.query( + ` + INSERT + INTO + transaction + ( + transaction_hash, + transaction_purpose, + maker_id + ) + values + ($1,$2,$3) + RETURNING + *; + `, + [transactionBody.transaction_hash, transactionBody.transaction_purpose, transactionBody.maker_id] + ); + const transaction = result.rows[0]; + return transaction; + } catch (e: unknown) { + const err = e as DatabaseError; + if (err.message && err.message.includes('duplicate key')) { + if (err.message.includes('transaction_hash')) throw new ApiError(httpStatus.CONFLICT, `transaction already exists`); + } + + throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, `internal server error`); + } +}; + +/** + * Get transaction by criteria + * @param {string} criteria - the criteria to find transaction + * @param {string} equals - the value on which criteria matches + * @param {object} options - optional config object + * @param {string|string[]} options.populate - the list of fields to populate + * @param {string} options.owner_id - returns the transaction that belongs to owner_id + * @returns {Promise} + */ +export const getTransactionByCriteria = async ( + criteria: 'transaction_id' | 'transaction_hash', + equals: string, + options?: { + populate?: string | string[]; + owner_id?: string; + } +): Promise => { + const transaction = await (async () => { + try { + const result = await db.instance.query( + ` + SELECT + * + ${populator({ + tableAlias: 't', + fields: options ? (typeof options.populate === 'string' ? [options.populate] : options.populate) : [], + })} + FROM + transaction t + WHERE + ${criteria} = $1 + ${options && options.owner_id ? 'AND maker_id = $2' : ''} + LIMIT 1 + ;`, + [equals, options && options.owner_id ? options.owner_id : false].filter(Boolean) + ); + return result.rows[0]; + } catch { + throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'internal server error'); + } + })(); + + if (!transaction) throw new ApiError(httpStatus.NOT_FOUND, 'transaction not found'); + + return transaction; +}; + +/** + * Get transaction by id + * @param {string} id + * @param {object} options - optional config object + * @param {string|string[]} options.populate - the list of fields to populate + * @param {string} options.owner_id - returns the transaction that belongs to owner_id + * @returns {Promise} + */ +export const getTransactionById = async ( + id: string, + options?: { + populate?: string | string[]; + owner_id?: string; + } +): Promise => { + return getTransactionByCriteria('transaction_id', id, options); +}; + +/** + * Get transaction by hash + * @param {string} hash + * @param {object} options - optional config object + * @param {string|string[]} options.populate - the list of fields to populate + * @param {string} options.owner_id - returns the transaction that belongs to owner_id + * @returns {Promise} + */ +export const getTransactionByHash = async ( + hash: string, + options?: { + populate?: string | string[]; + owner_id?: string; + } +): Promise => { + return getTransactionByCriteria('transaction_hash', hash, options); +}; + +/** + * Update transaction by id + * @param {string} id + * @param {Partial} updateBody + * @param {object} options - optional config object + * @param {string} options.owner_id - updates the transaction that belongs to owner_id + * @returns {Promise} + */ +export const updateTransactionById = async ( + id: string, + updateBody: Partial, + options?: { owner_id?: string } +): Promise => { + // check if transaction exists, throws error if not found + await getTransactionById(id, { owner_id: options?.owner_id }); + + // build sql conditions and values + const conditions: string[] = []; + const values: (string | null | boolean)[] = []; + Object.entries(updateBody).map(([k, v], index) => { + conditions.push(`${k} = $${index + 2}`); + values.push(v); + return null; + }); + + // update transaction + try { + const updateQry = await db.instance.query( + ` + UPDATE transaction SET + ${conditions.filter(Boolean).join(',')} + WHERE transaction_id = $1 RETURNING *; + `, + [id, ...values] + ); + const transaction = updateQry.rows[0]; + return transaction; + } catch { + throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, `internal server error`); + } +}; + +/** + * Delete transaction by id + * @param {string} id + * @param {object} options - optional config object + * @param {string} options.owner_id - deletes the transaction that belongs to owner_id + * @returns {Promise} + */ +export const deleteTransactionById = async ( + id: string, + options?: { owner_id?: string } +): Promise => { + // check if transaction exists, throws error if not found + const transaction = await getTransactionById(id, { owner_id: options?.owner_id }); + + try { + await db.instance.query(`DELETE FROM transaction WHERE transaction_id = $1;`, [id]); + await db.instance.query(`CALL remove_transaction_references($1);`, [id]); // remove this tag from everywhere it is used + return transaction; + } catch { + throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'internal server error'); + } +}; diff --git a/app/api/src/utils/cardano.ts b/app/api/src/utils/cardano.ts new file mode 100644 index 000000000..736c4db80 --- /dev/null +++ b/app/api/src/utils/cardano.ts @@ -0,0 +1,138 @@ +import got from 'got'; +import httpStatus from 'http-status'; +import config from '../config/config'; +import ApiError from './ApiError'; + +export interface ICardanoAmount { + unit: 'lovelace' | 'ada'; + quantity: string; +} + +export interface ICardanoTransaction { + hash: string; + block: string; + block_height: number; + block_time: number; + slot: number; + index: number; + output_amount: ICardanoAmount[]; + fees: string; + deposit: string; + size: number; + invalid_before: null | string | undefined; + invalid_hereafter: null | string | undefined; + utxo_count: number; + withdrawal_count: number; + mir_cert_count: number; + delegation_count: number; + stake_cert_count: number; + pool_update_count: number; + pool_retire_count: number; + asset_mint_or_burn_count: number; + redeemer_count: number; + valid_contract: boolean; +} + +export interface IPocreCardanoTransaction extends ICardanoTransaction { + /** + * Important: + * --------- + * The metadata structure should would work for all pocre entities (creation, recognitions etc) + * If in future we extend this, remember to align frontend clients with new changes + */ + metadata: { + pocre_id: string; + pocre_entity: 'creation' | 'recognition' | 'litigation_id'; + pocre_version: number; + }; +} + +export interface ICardanoBlock { + time: number; + height: number; + hash: string; + slot: number; + epoch: number; + epoch_slot: number; + slot_leader: string; + size: number; + tx_count: number; + output: string; + fees: string; + block_vrf: string; + op_cert: string; + op_cert_counter: string; + previous_block: string; + next_block: string; + confirmations: number; +} + +/** + * A simple http layer to query blockfrost + * @param {string} endpoint - the endpoint to hit + */ +const queryBlockfrost = async (endpoint: string) => { + const response = await got.get(`${config.blockfrost.base_api_url}/${endpoint}`, { + headers: { + project_id: config.blockfrost.project_id, + }, + }); + + return JSON.parse(response.body); +}; + +/** + * Gets block info from cardano blockchain + * @param {string} blockHash - the hash of block + * @returns {Promise} - cardano block info + */ +export const getBlockInfo = async (blockHash: string): Promise => { + const { blockfrost } = config; + + try { + const response = await queryBlockfrost(`${blockfrost.endpoints.blocks}/${blockHash}`); + return response as ICardanoBlock; + } catch { + throw new ApiError(httpStatus.NOT_ACCEPTABLE, `failed to get block info`); + } +}; + +/** + * Gets transaction info from cardano blockchain + * @param {string} transactionHash - the hash of transaction + * @returns {Promise} - cardano transaction info with pocre metadata + */ +export const getTransactionInfo = async (transactionHash: string): Promise => { + const { blockfrost } = config; + + try { + const txEndpoint = `${blockfrost.endpoints.transactions}/${transactionHash}`; + const responseTx = await queryBlockfrost(txEndpoint); + const responseMetaData = await queryBlockfrost(`${txEndpoint}/metadata`); + + // merge metadata and transaction info + const mergedResponse = { + ...responseTx, + metadata: responseMetaData?.[0]?.json_metadata || null, + } as IPocreCardanoTransaction; + + // make sure metadata has all necessary fields + if ( + !mergedResponse.metadata || + !mergedResponse.metadata.pocre_entity || + !mergedResponse.metadata.pocre_id || + !mergedResponse.metadata.pocre_version + ) { + throw new Error('broken transaction metadata'); + } + + return mergedResponse; + } catch (e: unknown) { + const error = e as Error; + + throw new ApiError( + httpStatus.NOT_ACCEPTABLE, + error.message === 'broken transaction metadata' ? error.message : `failed to get transaction info` + ); + } +}; diff --git a/app/api/src/validations/creation.validation.ts b/app/api/src/validations/creation.validation.ts index 9475a7893..6b3fbc04c 100644 --- a/app/api/src/validations/creation.validation.ts +++ b/app/api/src/validations/creation.validation.ts @@ -1,7 +1,6 @@ import Joi from 'joi'; -import { creationDeepFields } from '../db/map'; import supportedMediaTypes from '../constants/supportedMediaTypes'; -import publishPlatforms from '../constants/publishPlatforms'; +import { creationDeepFields } from '../db/map'; export const createCreation = { body: Joi.object().keys({ @@ -11,8 +10,6 @@ export const createCreation = { tags: Joi.array().items(Joi.string().uuid()).unique().required().min(1), materials: Joi.array().items(Joi.string().uuid()).unique().optional().min(1), creation_date: Joi.string().isoDate().required(), - is_draft: Joi.bool().default(false), - is_claimable: Joi.bool().default(true), }), }; @@ -81,8 +78,6 @@ export const updateCreation = { tags: Joi.array().items(Joi.string().uuid()).unique().optional().min(1), materials: Joi.array().items(Joi.string().uuid()).unique().optional(), creation_date: Joi.string().isoDate().optional(), - is_draft: Joi.bool(), - is_claimable: Joi.bool(), }) .min(1), }; @@ -93,11 +88,11 @@ export const deleteCreation = { }), }; -export const publishCreation = { +export const registerCreationTransaction = { params: Joi.object().keys({ creation_id: Joi.string().uuid().required(), }), body: Joi.object().keys({ - publish_on: Joi.string().valid(publishPlatforms.BLOCKCHAIN, publishPlatforms.IPFS).required(), + transaction_id: Joi.string().uuid().required(), }), }; diff --git a/app/api/src/validations/transaction.validation.ts b/app/api/src/validations/transaction.validation.ts new file mode 100644 index 000000000..2ead1f7ad --- /dev/null +++ b/app/api/src/validations/transaction.validation.ts @@ -0,0 +1,11 @@ +import Joi from 'joi'; +import transactionPurposes from '../constants/transactionPurposes'; + +export const createTransaction = { + body: Joi.object().keys({ + transaction_hash: Joi.string().required(), + transaction_purpose: Joi.string() + .valid(...Object.values(transactionPurposes)) + .required(), + }), +}; diff --git a/app/api/yarn.lock b/app/api/yarn.lock index cc0bab2b4..03c4d68bd 100644 --- a/app/api/yarn.lock +++ b/app/api/yarn.lock @@ -1016,6 +1016,36 @@ "@babel/helper-validator-identifier" "^7.18.6" to-fast-properties "^2.0.0" +"@blockfrost/blockfrost-js@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@blockfrost/blockfrost-js/-/blockfrost-js-5.2.0.tgz#da4cd974ae5fcf8546926b4a5ca954bab113914a" + integrity sha512-lP8lFnOldlP12z4vuofEmF6MMXjEoxU2tKycUyBSiNJp+81CbnJHF5EPeWnxVU795Oi01SwQ2ceVrga0oXZ+kQ== + dependencies: + "@blockfrost/blockfrost-utils" "2.0.0" + "@blockfrost/openapi" "0.1.49" + "@emurgo/cardano-serialization-lib-nodejs" "^10.2.0" + "@emurgo/cip14-js" "3.0.1" + bottleneck "^2.19.5" + form-data "^4.0.0" + got "^11.8.5" + +"@blockfrost/blockfrost-utils@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@blockfrost/blockfrost-utils/-/blockfrost-utils-2.0.0.tgz#a86b835dba056214035a924064af2a3bef60649f" + integrity sha512-1NFmDtzY5j1qKz7GviuI+dBWs34vPjo/kJB5ue8jZh97bYWrOPUUcl0iCxpvSiKNxJr1y4J6wFozoEBjFMJr/Q== + dependencies: + "@emurgo/cardano-serialization-lib-nodejs" "^11.0.5" + bech32 "^2.0.0" + yaml "^2.1.1" + +"@blockfrost/openapi@0.1.49": + version "0.1.49" + resolved "https://registry.yarnpkg.com/@blockfrost/openapi/-/openapi-0.1.49.tgz#fff3d775ebc051d80d6c3be1289699055125cf04" + integrity sha512-RnBwYXkEljQek9Fm2ayl2px6jerTat64vBd/om0LQZHMdBQsYhh4zpTlPm9Pk8DfFk2JfoWOC/HwF2Ij7d+QzQ== + dependencies: + "@redocly/openapi-cli" "^1.0.0-beta.95" + yaml "^2.1.3" + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" @@ -1037,6 +1067,24 @@ enabled "2.0.x" kuler "^2.0.0" +"@emurgo/cardano-serialization-lib-nodejs@^10.2.0": + version "10.2.0" + resolved "https://registry.yarnpkg.com/@emurgo/cardano-serialization-lib-nodejs/-/cardano-serialization-lib-nodejs-10.2.0.tgz#e76ee34fca434b5b0c95cf33ef61548d6845f79e" + integrity sha512-rRWBQcbQlMj4GS7gt6toxRzY9cjMfFBWYKWrfH+eEqUXSO+3blKKA3T/yra3khxU/8+EAY1T94uoUDvjkrpTzg== + +"@emurgo/cardano-serialization-lib-nodejs@^11.0.5": + version "11.3.0" + resolved "https://registry.yarnpkg.com/@emurgo/cardano-serialization-lib-nodejs/-/cardano-serialization-lib-nodejs-11.3.0.tgz#f8896ba4dd4411c97503d99f8fbcd2ef23087646" + integrity sha512-F1WXfvTtOZkJf3mSA9yToJaLCpPNOqyK5IljigS8fHxrLjSDEqVGVmQNM2R0NQTNsQLKxcSPBf0I3gukgoHnbQ== + +"@emurgo/cip14-js@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@emurgo/cip14-js/-/cip14-js-3.0.1.tgz#68bcf6db1e4891d347e19f1e643df9be5ebc92dc" + integrity sha512-u0XobeajNSlmeGBmY3ntA+NE/Vns7hKP0xrFzWyAO7YubETOifTjUddJN4gpvXE4S08DPUcNBVe3sx1m5GPIOg== + dependencies: + bech32 "2.0.0" + blake2b "2.1.3" + "@eslint/eslintrc@^0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" @@ -1288,6 +1336,49 @@ dependencies: debug "^4.3.1" +"@redocly/ajv@^8.6.4": + version "8.11.0" + resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.11.0.tgz#2fad322888dc0113af026e08fceb3e71aae495ae" + integrity sha512-9GWx27t7xWhDIR02PA18nzBdLcKQRgc46xNQvjFkrYk4UOmvKhJ/dawwiX0cCOeetN5LcaaiqQbVOWYK62SGHw== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +"@redocly/openapi-cli@^1.0.0-beta.95": + version "1.0.0-beta.95" + resolved "https://registry.yarnpkg.com/@redocly/openapi-cli/-/openapi-cli-1.0.0-beta.95.tgz#c4cc68f0e8a81bb87d054062300e9c204c04ca00" + integrity sha512-pl/OAeKh/psk6kF9SZjRieJK15T6T5GYcKVeBHvT7vtuhIBRBkrLC3bf3BhiMQx49BdSTB7Tk4/0LFPy0zr1MA== + dependencies: + "@redocly/openapi-core" "1.0.0-beta.95" + "@types/node" "^14.11.8" + assert-node-version "^1.0.3" + chokidar "^3.5.1" + colorette "^1.2.0" + glob "^7.1.6" + glob-promise "^3.4.0" + handlebars "^4.7.6" + portfinder "^1.0.26" + simple-websocket "^9.0.0" + yargs "17.0.1" + +"@redocly/openapi-core@1.0.0-beta.95": + version "1.0.0-beta.95" + resolved "https://registry.yarnpkg.com/@redocly/openapi-core/-/openapi-core-1.0.0-beta.95.tgz#23f92f524080c080060204f9103380de28ecae50" + integrity sha512-7Nnc4Obp/1lbrjNjD33oOnZCuoJa8awhBCEyyayPWGQFp1SkhjpZJnfnKkFuYbQzMjTIAvEeSp9DOQK/E0fgEA== + dependencies: + "@redocly/ajv" "^8.6.4" + "@types/node" "^14.11.8" + colorette "^1.2.0" + js-levenshtein "^1.1.6" + js-yaml "^4.1.0" + lodash.isequal "^4.5.0" + minimatch "^3.0.4" + node-fetch "^2.6.1" + pluralize "^8.0.0" + yaml-ast-parser "0.0.43" + "@sendgrid/client@^7.7.0": version "7.7.0" resolved "https://registry.yarnpkg.com/@sendgrid/client/-/client-7.7.0.tgz#f8f67abd604205a0d0b1af091b61517ef465fdbf" @@ -1476,6 +1567,14 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/glob@*": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-8.1.0.tgz#b63e70155391b0584dce44e7ea25190bbc38f2fc" + integrity sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w== + dependencies: + "@types/minimatch" "^5.1.2" + "@types/node" "*" + "@types/graceful-fs@^4.1.2": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" @@ -1543,6 +1642,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== +"@types/minimatch@^5.1.2": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" + integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== + "@types/morgan@^1.9.3": version "1.9.3" resolved "https://registry.yarnpkg.com/@types/morgan/-/morgan-1.9.3.tgz#ae04180dff02c437312bc0cfb1e2960086b2f540" @@ -1560,6 +1664,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== +"@types/node@^14.11.8": + version "14.18.37" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.37.tgz#0bfcd173e8e1e328337473a8317e37b3b14fd30d" + integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -1932,6 +2041,14 @@ asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" +assert-node-version@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/assert-node-version/-/assert-node-version-1.0.3.tgz#caea5d1b6a58dbce59661208df1e1b9e4c580f91" + integrity sha512-XcKBGJ1t0RrCcus9dQX57FER4PTEz/+Tee2jj+EdFIGyw5j8hwDNXZzgRYLQ916twVjSuA47adrZsSxLbpEX9A== + dependencies: + expected-node-version "^1.0.0" + semver "^5.0.3" + assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" @@ -1962,7 +2079,7 @@ async-listener@^0.6.0: semver "^5.3.0" shimmer "^1.1.0" -async@^2.6.3, async@~2.6.1: +async@^2.6.3, async@^2.6.4, async@~2.6.1: version "2.6.4" resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== @@ -2134,11 +2251,31 @@ bcryptjs@^2.4.3: resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" integrity sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ== +bech32@2.0.0, bech32@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-2.0.0.tgz#078d3686535075c8c79709f054b1b226a133b355" + integrity sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +blake2b-wasm@^1.1.0: + version "1.1.7" + resolved "https://registry.yarnpkg.com/blake2b-wasm/-/blake2b-wasm-1.1.7.tgz#e4d075da10068e5d4c3ec1fb9accc4d186c55d81" + integrity sha512-oFIHvXhlz/DUgF0kq5B1CqxIDjIJwh9iDeUUGQUcvgiGz7Wdw03McEO7CfLBy7QKGdsydcMCgO9jFNBAFCtFcA== + dependencies: + nanoassert "^1.0.0" + +blake2b@2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/blake2b/-/blake2b-2.1.3.tgz#f5388be424768e7c6327025dad0c3c6d83351bca" + integrity sha512-pkDss4xFVbMb4270aCyGD3qLv92314Et+FsKzilCLxDz5DuZ2/1g3w4nmBbu6nKApPspnjG7JcwTjGZnduB1yg== + dependencies: + blake2b-wasm "^1.1.0" + nanoassert "^1.0.0" + blessed@0.1.81: version "0.1.81" resolved "https://registry.yarnpkg.com/blessed/-/blessed-0.1.81.tgz#f962d687ec2c369570ae71af843256e6d0ca1129" @@ -2167,6 +2304,11 @@ body-parser@1.20.0: type-is "~1.6.18" unpipe "1.0.0" +bottleneck@^2.19.5: + version "2.19.5" + resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.19.5.tgz#5df0b90f59fd47656ebe63c78a98419205cadd91" + integrity sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2437,7 +2579,7 @@ color@^3.1.3: color-convert "^1.9.3" color-string "^1.6.0" -colorette@^1.4.0: +colorette@^1.2.0, colorette@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== @@ -2455,7 +2597,7 @@ colorspace@1.1.x: color "^3.1.3" text-hex "1.0.x" -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -3229,6 +3371,11 @@ execa@^5.1.1: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +expected-node-version@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/expected-node-version/-/expected-node-version-1.0.2.tgz#b8d225b9bf676a9e87e06dbd615b52fc9d1e386b" + integrity sha512-OSaCdgF02srujDqJz1JWGpqk8Rq3uNYHLmtpBHJrZN3BvuMvzijJMqRVxZN1qLJtKVwjXhmOp+lfsRUqx8n54w== + express-rate-limit@^5.3.0: version "5.5.1" resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.5.1.tgz#110c23f6a65dfa96ab468eda95e71697bc6987a2" @@ -3428,6 +3575,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -3589,6 +3745,13 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" +glob-promise@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-3.4.0.tgz#b6b8f084504216f702dc2ce8c9bc9ac8866fdb20" + integrity sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw== + dependencies: + "@types/glob" "*" + glob@7.1.6: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" @@ -3601,7 +3764,7 @@ glob@7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.5, glob@^7.1.3, glob@^7.1.4, glob@^7.2.0: +glob@^7.0.5, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -3637,7 +3800,7 @@ globby@^11.0.3: merge2 "^1.4.1" slash "^3.0.0" -got@^11.8.6: +got@^11.8.5, got@^11.8.6: version "11.8.6" resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== @@ -3659,6 +3822,18 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.9: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +handlebars@^4.7.6: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" @@ -4122,6 +4297,11 @@ js-git@^0.7.8: git-sha1 "^0.1.2" pako "^0.2.5" +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4592,6 +4772,13 @@ mkdirp@1.0.4, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mkdirp@^0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + module-details-from-path@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b" @@ -4642,6 +4829,11 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nanoassert@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/nanoassert/-/nanoassert-1.1.0.tgz#4f3152e09540fde28c76f44b19bbcd1d5a42478d" + integrity sha512-C40jQ3NzfkP53NsO8kEOFd79p4b9kDXQMwgiY1z8ZwrDZgUyom0AHwGegF4Dm99L+YoYhuaB0ceerUcXmqr1rQ== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -4661,11 +4853,23 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +neo-async@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + netmask@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== +node-fetch@^2.6.1: + version "2.6.9" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" + integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== + dependencies: + whatwg-url "^5.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5121,6 +5325,11 @@ please-upgrade-node@^3.2.0: dependencies: semver-compare "^1.0.0" +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + pm2-axon-rpc@~0.7.0, pm2-axon-rpc@~0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/pm2-axon-rpc/-/pm2-axon-rpc-0.7.1.tgz#2daec5383a63135b3f18babb70266dacdcbc429a" @@ -5206,6 +5415,15 @@ pngjs@^5.0.0: resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== +portfinder@^1.0.26: + version "1.0.32" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81" + integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg== + dependencies: + async "^2.6.4" + debug "^3.2.7" + mkdirp "^0.5.6" + postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" @@ -5353,6 +5571,13 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -5624,7 +5849,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -5666,7 +5891,7 @@ semver@7.0.0, semver@~7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.3.0, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: +semver@^5.0.3, semver@^5.3.0, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -5775,6 +6000,17 @@ simple-update-notifier@^1.0.7: dependencies: semver "~7.0.0" +simple-websocket@^9.0.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/simple-websocket/-/simple-websocket-9.1.0.tgz#91cbb39eafefbe7e66979da6c639109352786a7f" + integrity sha512-8MJPnjRN6A8UCp1I+H/dSFyjwJhp6wta4hsVRhjf8w9qBHRzxYt14RaOcjvQnhD1N4yKOddEjflwMnQM4VtXjQ== + dependencies: + debug "^4.3.1" + queue-microtask "^1.2.2" + randombytes "^2.1.0" + readable-stream "^3.6.0" + ws "^7.4.2" + slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" @@ -6131,6 +6367,11 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + triple-beam@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" @@ -6278,6 +6519,11 @@ typescript@^4.1.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== +uglify-js@^3.1.4: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -6415,6 +6661,19 @@ walker@^1.0.7: dependencies: makeerror "1.0.12" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" @@ -6468,6 +6727,11 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" @@ -6501,7 +6765,7 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -ws@^7.0.0: +ws@^7.0.0, ws@^7.4.2: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== @@ -6554,6 +6818,11 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml-ast-parser@0.0.43: + version "0.0.43" + resolved "https://registry.yarnpkg.com/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" + integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== + yaml@2.0.0-1: version "2.0.0-1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-1.tgz#8c3029b3ee2028306d5bcf396980623115ff8d18" @@ -6564,6 +6833,11 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.1.1, yaml@^2.1.3: + version "2.2.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.1.tgz#3014bf0482dcd15147aa8e56109ce8632cd60ce4" + integrity sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw== + yamljs@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/yamljs/-/yamljs-0.3.0.tgz#dc060bf267447b39f7304e9b2bfbe8b5a7ddb03b" @@ -6590,6 +6864,19 @@ yargs-parser@^21.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== +yargs@17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.0.1.tgz#6a1ced4ed5ee0b388010ba9fd67af83b9362e0bb" + integrity sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + yargs@^15.3.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" diff --git a/app/web-frontend/src/api/requests.js b/app/web-frontend/src/api/requests.js index a20ad454d..701b151e5 100644 --- a/app/web-frontend/src/api/requests.js +++ b/app/web-frontend/src/api/requests.js @@ -39,13 +39,17 @@ const User = { ...REQUEST_TEMPLATE('users'), invite: REQUEST_TEMPLATE('users/invite').create, verifyEmail: REQUEST_TEMPLATE('users/verifyUserEmail').create, confirmEmail: REQUEST_TEMPLATE('users/verifyUserEmail').getById, }; const Material = REQUEST_TEMPLATE('materials'); -const Creation = { ...REQUEST_TEMPLATE('creations'), publish: async (id, requestBody) => await REQUEST_TEMPLATE(`creations/${id}/publish`).create(requestBody) }; +const Creation = { + ...REQUEST_TEMPLATE('creations'), + registerTransaction: async (id, requestBody) => await REQUEST_TEMPLATE(`creations/${id}/transaction`).create(requestBody), +}; const Decision = REQUEST_TEMPLATE('decision'); const Recognition = REQUEST_TEMPLATE('recognitions'); const Litigation = REQUEST_TEMPLATE('litigations'); const Tag = REQUEST_TEMPLATE('tags'); const Auth = { login: REQUEST_TEMPLATE('auth/login').create, signup: REQUEST_TEMPLATE('auth/signup').create }; const Files = { getMediaType: REQUEST_TEMPLATE('files/media-type').getAll }; +const Transaction = { create: REQUEST_TEMPLATE('transactions').create }; export { User, @@ -57,4 +61,5 @@ export { Tag, Auth, Files, + Transaction, }; diff --git a/app/web-frontend/src/components/cards/CreationCard/index.jsx b/app/web-frontend/src/components/cards/CreationCard/index.jsx index f1edc7447..22cb86f15 100644 --- a/app/web-frontend/src/components/cards/CreationCard/index.jsx +++ b/app/web-frontend/src/components/cards/CreationCard/index.jsx @@ -59,6 +59,7 @@ function CreationCard({ author = '', authorProfileId = '', mediaUrl = '', + canFinalize = false, mediaType = '', ipfsHash = '', canEdit = true, @@ -67,9 +68,9 @@ function CreationCard({ onEditClick = () => {}, onDeleteClick = () => {}, finalizationDate = '', - canFinalize = false, isFinalized = false, onFinalize = () => {}, + paymentStatus, }) { const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(null); const [showMediaPreview, setShowMediaPreview] = useState(null); @@ -253,14 +254,26 @@ function CreationCard({ - + {finalizationDate && ( + + )} + {paymentStatus && ( + + )} {canFinalize && ( - {isFinalized ? ( - - ) : ( - - )} + + + )} + + {isFinalized && ( + + + )} @@ -308,7 +329,7 @@ function CreationCard({ canEdit={canEdit} onEditClick={onEditClick} /> - { canShare && ( + {canShare && ( setShowSocialMediaSharePreview(true)} /> diff --git a/app/web-frontend/src/config.js b/app/web-frontend/src/config.js index 0492e3020..f6860973b 100644 --- a/app/web-frontend/src/config.js +++ b/app/web-frontend/src/config.js @@ -29,10 +29,6 @@ const CHARGES = { }; const TRANSACTION_PURPOSES = { - CREATION: { - PUBLISHING_ON_IPFS: 'CREATION_PENDING', - FINALIZING_ON_CHAIN: 'CREATION_FINALIZED', - }, RECOGNITION_ACCEPT: 'RECOGNITION_ACCEPT', LITIGATION: { START: 'LITIGATION_START', diff --git a/app/web-frontend/src/pages/Creations/Details/index.jsx b/app/web-frontend/src/pages/Creations/Details/index.jsx index 407f729ca..d6922d51c 100644 --- a/app/web-frontend/src/pages/Creations/Details/index.jsx +++ b/app/web-frontend/src/pages/Creations/Details/index.jsx @@ -18,6 +18,7 @@ import moment from 'moment'; import QRCode from 'qrcode'; import { useEffect, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; +import transactionPurposes from 'utils/constants/transactionPurposes'; import authUser from 'utils/helpers/authUser'; import useCreationDelete from '../common/hooks/useCreationDelete'; import useCreationPublish from '../common/hooks/useCreationPublish'; @@ -108,6 +109,16 @@ export default function CreationDetails() { await deleteCreation(id); }; + const isProcessingPublishingPayment = (creation?.transactions || [])?.find( + (t) => !t.is_validated + && t.transaction_purpose === transactionPurposes.PUBLISH_CREATION, + ); + + const isProcessingFinalizationPayment = (creation?.transactions || [])?.find( + (t) => !t.is_validated + && t.transaction_purpose === transactionPurposes.FINALIZE_CREATION, + ); + return ( {(isDeletingCreation || isPublishingCreation) && } @@ -194,8 +205,8 @@ export default function CreationDetails() { > {user?.user_id === creation?.author?.user_id && !creation?.is_draft - && !creation?.is_onchain && !creation?.is_fully_owned + && !isProcessingFinalizationPayment && moment().isAfter(moment(creation?.creation_authorship_window)) && ( - )} + { + user?.user_id === creation?.author?.user_id + && creation?.is_draft + && !isProcessingPublishingPayment + && ( + + ) + } {user?.user_id === creation?.author?.user_id && ( - { !creation?.is_draft && ( + {!creation?.is_draft && ( @@ -273,10 +289,12 @@ export default function CreationDetails() { {creation?.author?.user_name} + {!creation?.is_draft && creation?.is_fully_owned && } - + {!creation.is_draft && } + {isProcessingPublishingPayment && } - {!creation?.is_draft && creation?.is_onchain && } + {isProcessingFinalizationPayment && } {creation?.creation_description && ( diff --git a/app/web-frontend/src/pages/Creations/Details/useDetails.jsx b/app/web-frontend/src/pages/Creations/Details/useDetails.jsx index 83a82ce98..0b1360b33 100644 --- a/app/web-frontend/src/pages/Creations/Details/useDetails.jsx +++ b/app/web-frontend/src/pages/Creations/Details/useDetails.jsx @@ -16,7 +16,7 @@ const useDetails = () => { queryFn: async () => { const toPopulate = [ 'author_id', 'tags', 'materials', 'materials.recognition_id', 'materials.recognition_id.recognition_by', - 'materials.recognition_id.recognition_for', 'materials.author_id', + 'materials.recognition_id.recognition_for', 'materials.author_id', 'transactions', ]; return await Creation.getById(creationId, toPopulate.map((x) => `populate=${x}`).join('&')); }, diff --git a/app/web-frontend/src/pages/Creations/Home/index.jsx b/app/web-frontend/src/pages/Creations/Home/index.jsx index 6f236baec..799ffe59c 100644 --- a/app/web-frontend/src/pages/Creations/Home/index.jsx +++ b/app/web-frontend/src/pages/Creations/Home/index.jsx @@ -159,25 +159,30 @@ function Creations() { author_image: m?.author?.image_url, authorProfileId: m?.author?.user_id, })) : []} - canEdit={x?.is_draft && userId === login?.user_id} + canEdit={ + x?.is_draft + && userId === login?.user_id + && !x?.isProcessingPublishingPayment + } + paymentStatus={x?.isProcessingPublishingPayment ? 'Pending payment verifcation to publish' : (x?.isProcessingFinalizationPayment ? 'Pending payment verifcation to finalize' : null)} canShare={!x?.is_draft} canDelete={userId === login?.user_id} onEditClick={() => navigate(`/creations/${x?.creation_id}/update`)} // eslint-disable-next-line no-return-await onDeleteClick={async () => await deleteCreation(x?.creation_id)} - finalizationDate={x?.creation_authorship_window} + finalizationDate={!x?.is_draft ? x?.cawDate : false} canFinalize={ !x?.is_draft && userId === login?.user_id && !x?.is_fully_owned && x?.isCAWPassed - && !x?.is_onchain + && !x?.isProcessingFinalizationPayment } onFinalize={async () => await publishCreation({ id: x?.creation_id, ipfsHash: x?.ipfs_hash, })} - isFinalized={x?.is_onchain} + isFinalized={x?.is_fully_owned} /> ), )} diff --git a/app/web-frontend/src/pages/Creations/Home/useCreations.jsx b/app/web-frontend/src/pages/Creations/Home/useCreations.jsx index ae2066394..6ea968272 100644 --- a/app/web-frontend/src/pages/Creations/Home/useCreations.jsx +++ b/app/web-frontend/src/pages/Creations/Home/useCreations.jsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Creation } from 'api/requests'; import useSuggestions from 'hooks/useSuggestions'; import moment from 'moment'; +import transactionPurposes from 'utils/constants/transactionPurposes'; import authUser from 'utils/helpers/authUser'; // get auth user @@ -25,7 +26,7 @@ const useCreations = (userId) => { } = useQuery({ queryKey: ['creations'], queryFn: async () => { - const toPopulate = ['author_id', 'materials', 'materials.author_id']; + const toPopulate = ['author_id', 'materials', 'materials.author_id', 'transactions']; const unsortedCreations = await Creation.getAll( `page=${1}&limit=100&descend_fields[]=creation_date&query=${userId || user.user_id}&search_fields[]=author_id&${toPopulate.map((x) => `populate=${x}`).join('&')}`, ); @@ -38,8 +39,16 @@ const useCreations = (userId) => { ).map((x) => ({ ...x, creation_date: moment(x?.creation_date).format('Do MMMM YYYY'), - creation_authorship_window: moment(x?.creation_authorship_window).format('Do MMMM YYYY'), - isCAWPassed: moment().isAfter(moment(x?.creation?.creation_authorship_window)), + isCAWPassed: moment().isAfter(moment(x?.creation_authorship_window)), + cawDate: moment(x?.creation_authorship_window).format('Do MMMM YYYY'), + isProcessingPublishingPayment: (x?.transactions || [])?.find( + (t) => !t.is_validated + && t.transaction_purpose === transactionPurposes.PUBLISH_CREATION, + ), + isProcessingFinalizationPayment: (x?.transactions || [])?.find( + (t) => !t.is_validated + && t.transaction_purpose === transactionPurposes.FINALIZE_CREATION, + ), })), }; }, diff --git a/app/web-frontend/src/pages/Creations/common/form/index.jsx b/app/web-frontend/src/pages/Creations/common/form/index.jsx index a8fa33a63..a0a7eedaf 100644 --- a/app/web-frontend/src/pages/Creations/common/form/index.jsx +++ b/app/web-frontend/src/pages/Creations/common/form/index.jsx @@ -54,14 +54,14 @@ function CreationForm({ id = null, activeStep = null, onCreationFetch = () => {} if (step === 1) { // create creation if (!id) { - await makeCreation({ ...values, is_draft: true }); // this redirects to update creation page + await makeCreation({ ...values }); // this redirects to update creation page return; } // update creation const draft = { ...creationDraft, ...values }; setCreationDraft(draft); - await updateCreation({ ...draft, is_draft: true }); + await updateCreation({ ...draft }); } if (step === 2) { @@ -70,7 +70,7 @@ function CreationForm({ id = null, activeStep = null, onCreationFetch = () => {} materials: values, }; setCreationDraft(draft); - await updateCreation({ ...draft, is_draft: true }); + await updateCreation({ ...draft }); } if (step === 3 && id) { diff --git a/app/web-frontend/src/pages/Creations/common/form/useCreationForm.jsx b/app/web-frontend/src/pages/Creations/common/form/useCreationForm.jsx index 5f2f0065d..4aafb2290 100644 --- a/app/web-frontend/src/pages/Creations/common/form/useCreationForm.jsx +++ b/app/web-frontend/src/pages/Creations/common/form/useCreationForm.jsx @@ -1,13 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { - Creation, Material, Tag, User, + Creation, Material, Tag, Transaction, User, } from 'api/requests'; -import { CHARGES, IPFS_BASE_URL, TRANSACTION_PURPOSES } from 'config'; +import { CHARGES } from 'config'; import useSuggestions from 'hooks/useSuggestions'; import moment from 'moment'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import publishPlatforms from 'utils/constants/publishPlatforms'; +import transactionPurposes from 'utils/constants/transactionPurposes'; import authUser from 'utils/helpers/authUser'; import { transactADAToPOCRE } from 'utils/helpers/wallet'; @@ -82,20 +82,28 @@ const makeCommonResource = async ( }; const publishIPFSCreationOnChain = async (creationId) => { - const creationOnIPFS = await Creation.publish(creationId, { - publish_on: publishPlatforms.IPFS, - }); - - // make transaction to store ipfs on chain - await transactADAToPOCRE({ + // make crypto transaction + const txHash = await transactADAToPOCRE({ amountADA: CHARGES.CREATION.PUBLISHING_ON_IPFS, - purposeDesc: TRANSACTION_PURPOSES.CREATION.PUBLISHING_ON_IPFS, walletName: authUser.getUser()?.selectedWallet, metaData: { - ipfsHash: creationOnIPFS.ipfs_hash, - ipfsURL: IPFS_BASE_URL, + pocre_id: creationId, + pocre_entity: 'creation', + purpose: transactionPurposes.PUBLISH_CREATION, }, }); + if (!txHash) throw new Error('Failed to make transaction'); + + // make pocre transaction to store this info + const transaction = await Transaction.create({ + transaction_hash: txHash, + transaction_purpose: transactionPurposes.PUBLISH_CREATION, + }); + + // register pocre transaction for creation + await Creation.registerTransaction(creationId, { + transaction_id: transaction.transaction_id, + }); }; const transformAuthorNameForDisplay = (author, user) => { @@ -207,7 +215,6 @@ const useCreationForm = ({ creation_link: creationBody.source, tags: tags.map((tag) => tag.tag_id), creation_date: new Date(creationBody.date).toISOString(), // send date in utc - is_draft: true, // create in draft and finalize after fully updated }); // remove queries cache @@ -237,7 +244,6 @@ const useCreationForm = ({ }), tags: creation.original.tags, creation_link: updateBody.source, - is_draft: updateBody.is_draft, }; const { tags, materials } = await makeCommonResource( diff --git a/app/web-frontend/src/pages/Creations/common/hooks/useCreationPublish.jsx b/app/web-frontend/src/pages/Creations/common/hooks/useCreationPublish.jsx index b8632150e..56e8fe091 100644 --- a/app/web-frontend/src/pages/Creations/common/hooks/useCreationPublish.jsx +++ b/app/web-frontend/src/pages/Creations/common/hooks/useCreationPublish.jsx @@ -1,9 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { IPFS_BASE_URL, CHARGES, TRANSACTION_PURPOSES } from 'config'; +import { Creation, Transaction } from 'api/requests'; +import { CHARGES, IPFS_BASE_URL } from 'config'; +import transactionPurposes from 'utils/constants/transactionPurposes'; import authUser from 'utils/helpers/authUser'; import { transactADAToPOCRE } from 'utils/helpers/wallet'; -import { Creation } from 'api/requests'; -import publishPlatforms from 'utils/constants/publishPlatforms'; const usePublish = () => { const queryClient = useQueryClient(); @@ -18,47 +18,41 @@ const usePublish = () => { reset: resetPublishErrors, } = useMutation({ mutationFn: async ({ id, ipfsHash }) => { - // update in db - await Creation.publish(id, { publish_on: publishPlatforms.BLOCKCHAIN }); - - // make transaction + // make crypto transaction const txHash = await transactADAToPOCRE({ amountADA: CHARGES.CREATION.FINALIZING_ON_CHAIN, - purposeDesc: TRANSACTION_PURPOSES.CREATION.FINALIZING_ON_CHAIN, walletName: authUser.getUser()?.selectedWallet, metaData: { ipfsHash, ipfsURL: IPFS_BASE_URL, + pocre_id: id, + pocre_entity: 'creation', + purpose: transactionPurposes.FINALIZE_CREATION, }, }); - if (!txHash) throw new Error('Failed to make transaction'); - // update queries - queryClient.cancelQueries({ queryKey: ['creations'] }); - queryClient.setQueryData(['creations'], (data) => { - if (data && data.results) { - const temporaryCreations = data.results; - - const foundCreation = temporaryCreations.find((x) => x.creation_id === id); - if (foundCreation) foundCreation.is_onchain = true; - - return { ...data, results: [...temporaryCreations] }; - } - return data; + // make pocre transaction to store this info + const transaction = await Transaction.create({ + transaction_hash: txHash, + transaction_purpose: transactionPurposes.FINALIZE_CREATION, }); - queryClient.setQueryData([`creations-${id}`], (data) => { - if (data && data?.creation_id) { - return { ...data, is_onchain: true }; - } - return data; + + // register pocre transaction for creation + await Creation.registerTransaction(id, { + transaction_id: transaction.transaction_id, }); + + // update queries + queryClient.cancelQueries({ queryKey: ['creations'] }); + queryClient.invalidateQueries({ queryKey: ['creations'] }); + queryClient.invalidateQueries({ queryKey: [`creations-${id}`] }); }, }); return { publishCreationStatus: { - success: isPublishSuccess ? 'Creation published successfully!' : null, + success: isPublishSuccess ? 'Payment successful!' : null, error: isPublishError ? error?.message || 'Failed to publish creation' : null, }, publishCreation, diff --git a/app/web-frontend/src/utils/constants/transactionPurposes.js b/app/web-frontend/src/utils/constants/transactionPurposes.js new file mode 100644 index 000000000..9572db363 --- /dev/null +++ b/app/web-frontend/src/utils/constants/transactionPurposes.js @@ -0,0 +1,8 @@ +export default Object.freeze({ + PUBLISH_CREATION: 'publish_creation', + FINALIZE_CREATION: 'finalize_creation', + START_LITIGATION: 'start_litigation', + CAST_LITIGATION_VOTE: 'cast_litigation_vote', + REDEEM_LITIGATED_ITEM: 'redeem_litigated_item', + ACCEPT_RECOGNITION: 'accept_recognition', +}); diff --git a/app/web-frontend/src/utils/helpers/wallet.js b/app/web-frontend/src/utils/helpers/wallet.js index 46f23d715..dadf015f6 100644 --- a/app/web-frontend/src/utils/helpers/wallet.js +++ b/app/web-frontend/src/utils/helpers/wallet.js @@ -32,9 +32,8 @@ const getWalletAddress = async (walletName) => { const transactADAToPOCRE = async ({ amountADA = 0, - purposeDesc = '', - metaData = {}, walletName = '', + metaData = {}, }) => { try { // connect to the wallet @@ -44,13 +43,11 @@ const transactADAToPOCRE = async ({ const tx = new Transaction({ initiator: wallet }); // set amount - tx.sendLovelace(POCRE_WALLET_ADDRESS, `${amountADA * 1_000_000}`); // convert ada to lovelace + tx.sendLovelace(POCRE_WALLET_ADDRESS.PREVIEW, `${amountADA * 1_000_000}`); // convert ada to lovelace // set metadata tx.setMetadata(0, { - amount: { lovelace: amountADA * 1_000_000, ada: amountADA }, - pocreVersion: '0.1', // beta - purposeDesc, + pocre_version: '0.1', // beta ...metaData, });