From 2277d817652fc015992b4c4c76e6e782452fbc9a Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 15:59:05 +0500 Subject: [PATCH 01/11] added transaction routes and docs --- app/api/src/constants/transactionPurposes.ts | 8 + .../src/controllers/transaction.controller.ts | 11 + app/api/src/db/map.ts | 12 +- app/api/src/db/pool.ts | 20 ++ app/api/src/docs/components.yml | 89 ++++++-- app/api/src/routes/v1/index.ts | 5 + app/api/src/routes/v1/transaction.route.ts | 70 ++++++ app/api/src/services/transaction.service.ts | 207 ++++++++++++++++++ .../src/validations/transaction.validation.ts | 11 + 9 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 app/api/src/constants/transactionPurposes.ts create mode 100644 app/api/src/controllers/transaction.controller.ts create mode 100644 app/api/src/routes/v1/transaction.route.ts create mode 100644 app/api/src/services/transaction.service.ts create mode 100644 app/api/src/validations/transaction.validation.ts 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/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/db/map.ts b/app/api/src/db/map.ts index fd4a66e25..fdaa08da0 100644 --- a/app/api/src/db/map.ts +++ b/app/api/src/db/map.ts @@ -17,6 +17,7 @@ interface IPkMap { decision_id: string; tag_id: string; litigation_id: string; + transaction_id: string; } interface ITableNameMap { @@ -38,13 +39,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', @@ -68,6 +70,7 @@ const pkMap: IPkMap = { decision_id: 'decision_id', tag_id: 'tag_id', litigation_id: 'litigation_id', + transaction_id: 'transaction_id', }; /** @@ -98,6 +101,7 @@ const tableNameMap: ITableNameMap = { decision_id: 'decision', litigation_id: 'litigation', tag_id: 'tag', + transaction_id: 'transaction', }; /** @@ -110,6 +114,11 @@ const arrayFields: string[] = ['recognitions', 'decisions', 'tags', 'materials'] */ 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 */ @@ -153,6 +162,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..f2b2b4741 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 */ /* ************************************ */ @@ -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 */ diff --git a/app/api/src/docs/components.yml b/app/api/src/docs/components.yml index e03d529d9..8b64c73a2 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 @@ -236,7 +236,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 +246,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 +270,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 +285,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 +358,7 @@ components: reconcilate: false created_at: 2022-09-05T19:00:00.000Z ownership_transferred: false - + Token: type: object properties: @@ -372,10 +372,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 +385,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 +547,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 +556,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 +610,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,7 +619,7 @@ components: $ref: '#/components/schemas/Error' example: code: 406 - message: creation has ongoing litigation process + message: creation has ongoing litigation process CreationDraftNotAllowedToPublish: description: Draft creation cannot be published content: @@ -599,7 +628,7 @@ components: $ref: '#/components/schemas/Error' example: code: 406 - message: draft creation cannot be published + message: draft creation cannot be published CreationAlreadyAssignedToLitigation: description: Creation already assigned to a litigation content: @@ -608,7 +637,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 +646,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 +692,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 +724,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/index.ts b/app/api/src/routes/v1/index.ts index c0cb70429..9ba409fbf 100644 --- a/app/api/src/routes/v1/index.ts +++ b/app/api/src/routes/v1/index.ts @@ -10,6 +10,7 @@ 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 config from '../../config/config'; const router = express.Router(); @@ -55,6 +56,10 @@ const defaultRoutes = [ path: '/files', route: filesRoute, }, + { + path: '/transactions', + route: transactionRoute, + }, ]; 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/services/transaction.service.ts b/app/api/src/services/transaction.service.ts new file mode 100644 index 000000000..de12ac445 --- /dev/null +++ b/app/api/src/services/transaction.service.ts @@ -0,0 +1,207 @@ +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; + 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 ( + id: string, + options?: { + populate?: string | string[]; + owner_id?: string; + } +): Promise => { + return getTransactionByCriteria('transaction_hash', id, 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)[] = []; + 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]); + return transaction; + } catch { + throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'internal server error'); + } +}; 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(), + }), +}; From f84f9d42ab718c5c632926ea8fa2171cd18b68e7 Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 16:07:43 +0500 Subject: [PATCH 02/11] block user from directly managing creation draft status --- .../src/controllers/creation.controller.ts | 153 +++++++----------- .../src/validations/creation.validation.ts | 4 - 2 files changed, 58 insertions(+), 99 deletions(-) diff --git a/app/api/src/controllers/creation.controller.ts b/app/api/src/controllers/creation.controller.ts index 35586808f..bcdbb64aa 100644 --- a/app/api/src/controllers/creation.controller.ts +++ b/app/api/src/controllers/creation.controller.ts @@ -98,45 +98,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 +180,76 @@ 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); + // requires creation_id in params and publish_on in body and req.user + 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 }; } })(); diff --git a/app/api/src/validations/creation.validation.ts b/app/api/src/validations/creation.validation.ts index 9475a7893..0d5c392b2 100644 --- a/app/api/src/validations/creation.validation.ts +++ b/app/api/src/validations/creation.validation.ts @@ -11,8 +11,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 +79,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), }; From 8f9fc73fb4270b6174f36df077ca858253b35b16 Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 16:11:36 +0500 Subject: [PATCH 03/11] removed redundant is_onchain field in creation --- app/api/src/db/pool.ts | 3 +-- app/api/src/docs/components.yml | 3 --- app/api/src/services/creation.service.ts | 2 -- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/api/src/db/pool.ts b/app/api/src/db/pool.ts index f2b2b4741..7d3bc9d8f 100644 --- a/app/api/src/db/pool.ts +++ b/app/api/src/db/pool.ts @@ -135,10 +135,9 @@ const init = async (): Promise> => { materials 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 diff --git a/app/api/src/docs/components.yml b/app/api/src/docs/components.yml index 8b64c73a2..ac35cb34d 100644 --- a/app/api/src/docs/components.yml +++ b/app/api/src/docs/components.yml @@ -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: diff --git a/app/api/src/services/creation.service.ts b/app/api/src/services/creation.service.ts index ef243ef9a..5587b6bf9 100644 --- a/app/api/src/services/creation.service.ts +++ b/app/api/src/services/creation.service.ts @@ -21,7 +21,6 @@ interface ICreation { is_draft: boolean; is_claimable: boolean; ipfs_hash: string; - is_onchain: boolean; is_fully_owned: boolean; creation_authorship_window: string; } @@ -60,7 +59,6 @@ interface ICreationDoc { is_draft: boolean; is_claimable: boolean; ipfs_hash: string; - is_onchain: boolean; is_fully_owned: boolean; creation_authorship_window: string; } From 65679cae06854cc287f1704bb779442b59678b1c Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 16:16:02 +0500 Subject: [PATCH 04/11] block user from directly publishing creation - removed publish route --- app/api/src/docs/components.yml | 9 --- app/api/src/routes/v1/creation.route.ts | 59 ------------------- .../src/validations/creation.validation.ts | 12 +--- 3 files changed, 1 insertion(+), 79 deletions(-) diff --git a/app/api/src/docs/components.yml b/app/api/src/docs/components.yml index ac35cb34d..514abcb18 100644 --- a/app/api/src/docs/components.yml +++ b/app/api/src/docs/components.yml @@ -617,15 +617,6 @@ components: 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 CreationAlreadyAssignedToLitigation: description: Creation already assigned to a litigation content: diff --git a/app/api/src/routes/v1/creation.route.ts b/app/api/src/routes/v1/creation.route.ts index f2e2d85f6..da9ddd260 100644 --- a/app/api/src/routes/v1/creation.route.ts +++ b/app/api/src/routes/v1/creation.route.ts @@ -17,10 +17,6 @@ router .patch(auth(), validate(creationValidation.updateCreation), creationController.updateCreationById) .delete(auth(), validate(creationValidation.deleteCreation), creationController.deleteCreationById); -router - .route('/:creation_id/publish') - .post(auth(), validate(creationValidation.publishCreation), creationController.publishCreation); - router .route('/:creation_id/proof') .get(validate(creationValidation.getCreationProof), creationController.getCreationProofById); @@ -453,58 +449,3 @@ export default router; * "500": * $ref: '#/components/responses/InternalServerError' */ - -/** - * @swagger - * /creations/{creation_id}/publish: - * post: - * summary: Publish a creation - * description: Stores the publish status of a creation. - * tags: [Creation] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: creation_id - * required: true - * 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. - * responses: - * "201": - * description: Created - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Creation' - * "404": - * $ref: '#/components/responses/CreationNotFound' - * "406": - * $ref: '#/components/responses/CreationDraftNotAllowedToPublish' - * "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/validations/creation.validation.ts b/app/api/src/validations/creation.validation.ts index 0d5c392b2..84f56c655 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({ @@ -88,12 +87,3 @@ export const deleteCreation = { creation_id: Joi.string().uuid().required(), }), }; - -export const publishCreation = { - params: Joi.object().keys({ - creation_id: Joi.string().uuid().required(), - }), - body: Joi.object().keys({ - publish_on: Joi.string().valid(publishPlatforms.BLOCKCHAIN, publishPlatforms.IPFS).required(), - }), -}; From 977fc1c7da6939a88cae231c3de1faebf8a0c5c1 Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 16:21:34 +0500 Subject: [PATCH 05/11] allow users to register transactions for creations --- .../src/controllers/creation.controller.ts | 63 +++++++++++++++++++ app/api/src/db/map.ts | 15 ++++- app/api/src/db/pool.ts | 7 +++ app/api/src/routes/v1/creation.route.ts | 56 +++++++++++++++++ app/api/src/services/creation.service.ts | 59 ++++++++++++----- app/api/src/services/transaction.service.ts | 1 + .../src/validations/creation.validation.ts | 9 +++ 7 files changed, 193 insertions(+), 17 deletions(-) diff --git a/app/api/src/controllers/creation.controller.ts b/app/api/src/controllers/creation.controller.ts index bcdbb64aa..df872193f 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'; @@ -264,3 +266,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/db/map.ts b/app/api/src/db/map.ts index fdaa08da0..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; @@ -27,6 +28,7 @@ interface ITableNameMap { author_id: string; tags: string; materials: string; + transactions: string; assumed_author: string; winner: string; issuer_id: string; @@ -56,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', @@ -87,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', @@ -107,7 +111,7 @@ const tableNameMap: ITableNameMap = { /** * 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 @@ -136,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 diff --git a/app/api/src/db/pool.ts b/app/api/src/db/pool.ts index 7d3bc9d8f..f70bcaddf 100644 --- a/app/api/src/db/pool.ts +++ b/app/api/src/db/pool.ts @@ -133,6 +133,7 @@ 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 true, @@ -235,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/routes/v1/creation.route.ts b/app/api/src/routes/v1/creation.route.ts index da9ddd260..eca3a3eb1 100644 --- a/app/api/src/routes/v1/creation.route.ts +++ b/app/api/src/routes/v1/creation.route.ts @@ -17,6 +17,10 @@ router .patch(auth(), validate(creationValidation.updateCreation), creationController.updateCreationById) .delete(auth(), validate(creationValidation.deleteCreation), creationController.deleteCreationById); +router + .route('/:creation_id/transaction') + .post(auth(), validate(creationValidation.registerCreationTransaction), creationController.registerCreationTransaction); + router .route('/:creation_id/proof') .get(validate(creationValidation.getCreationProof), creationController.getCreationProofById); @@ -449,3 +453,55 @@ export default router; * "500": * $ref: '#/components/responses/InternalServerError' */ + +/** + * @swagger + * /creations/{creation_id}/transaction: + * post: + * summary: Register a transaction for creation + * description: Registers a transaction for creation + * tags: [Creation] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: creation_id + * required: true + * schema: + * type: string + * description: Creation id + * 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 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Creation' + * "404": + * $ref: '#/components/responses/TransactionNotFound' + * "500": + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/responses/InternalServerError' + * examples: + * InternalServerError: + * summary: internal server error + * value: + * code: 500 + * message: internal server error + */ diff --git a/app/api/src/services/creation.service.ts b/app/api/src/services/creation.service.ts index 5587b6bf9..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,6 +17,7 @@ interface ICreation { author_id: string; tags: string[]; materials?: string[]; + transactions?: string[]; creation_date: string; is_draft: boolean; is_claimable: boolean; @@ -55,6 +56,7 @@ interface ICreationDoc { author_id: string; tags: string[]; materials: string[]; + transactions: string[]; creation_date: string; is_draft: boolean; is_claimable: boolean; @@ -97,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 @@ -119,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 @@ -139,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 index de12ac445..0f539fb65 100644 --- a/app/api/src/services/transaction.service.ts +++ b/app/api/src/services/transaction.service.ts @@ -200,6 +200,7 @@ export const deleteTransactionById = async ( 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/validations/creation.validation.ts b/app/api/src/validations/creation.validation.ts index 84f56c655..6b3fbc04c 100644 --- a/app/api/src/validations/creation.validation.ts +++ b/app/api/src/validations/creation.validation.ts @@ -87,3 +87,12 @@ export const deleteCreation = { creation_id: Joi.string().uuid().required(), }), }; + +export const registerCreationTransaction = { + params: Joi.object().keys({ + creation_id: Joi.string().uuid().required(), + }), + body: Joi.object().keys({ + transaction_id: Joi.string().uuid().required(), + }), +}; From 698d3df5ca6a454c1d31942971a994560bf5a5c8 Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 17:07:00 +0500 Subject: [PATCH 06/11] added cardano utility to interact with blockchain using blockfrost --- app/api/.env.example | 8 ++- app/api/src/config/config.ts | 12 ++++ app/api/src/utils/cardano.ts | 113 +++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 app/api/src/utils/cardano.ts diff --git a/app/api/.env.example b/app/api/.env.example index 886cca3ba..98d03207c 100644 --- a/app/api/.env.example +++ b/app/api/.env.example @@ -39,4 +39,10 @@ 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 + +# blockfrost config (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 \ No newline at end of file diff --git a/app/api/src/config/config.ts b/app/api/src/config/config.ts index 4f36b3378..84e9bd085 100644 --- a/app/api/src/config/config.ts +++ b/app/api/src/config/config.ts @@ -40,6 +40,10 @@ 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(), }) .unknown(); @@ -93,4 +97,12 @@ export default { unpin: envVars.PINATA_API_UNPIN_URL, }, }, + 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, + }, + }, }; diff --git a/app/api/src/utils/cardano.ts b/app/api/src/utils/cardano.ts new file mode 100644 index 000000000..b85ff7537 --- /dev/null +++ b/app/api/src/utils/cardano.ts @@ -0,0 +1,113 @@ +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 { + metadata: any; // [IMPORTANT]: we need to define a metadata structure that would work for all pocre entities +} + +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, + }; + + return mergedResponse as IPocreCardanoTransaction; + } catch { + throw new ApiError(httpStatus.NOT_ACCEPTABLE, `failed to get transaction info`); + } +}; From 92d380b9751a16aaade915028aec169c13b0aae2 Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 19:23:05 +0500 Subject: [PATCH 07/11] validate transactions via blockfrost webhooks for creations --- app/api/.env.example | 8 +- app/api/src/config/config.ts | 12 +++ .../src/controllers/creation.controller.ts | 1 - app/api/src/controllers/webhook.controller.ts | 82 +++++++++++++++++++ app/api/src/routes/v1/index.ts | 5 ++ app/api/src/routes/v1/webhook.route.ts | 8 ++ app/api/src/services/transaction.service.ts | 7 +- app/api/src/utils/cardano.ts | 35 ++++++-- 8 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 app/api/src/controllers/webhook.controller.ts create mode 100644 app/api/src/routes/v1/webhook.route.ts diff --git a/app/api/.env.example b/app/api/.env.example index 98d03207c..c0ab266c1 100644 --- a/app/api/.env.example +++ b/app/api/.env.example @@ -41,8 +41,12 @@ 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 -# blockfrost config (for validating crypto transactions) +# 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 \ No newline at end of file +BLOCKFROST_API_BLOCKS_ENDPOINT=blocks +BLOCKFROST_WEBHOOK_AUTH_TOKEN=webhook_token_from_blockfrost \ No newline at end of file diff --git a/app/api/src/config/config.ts b/app/api/src/config/config.ts index 84e9bd085..af5cad6e3 100644 --- a/app/api/src/config/config.ts +++ b/app/api/src/config/config.ts @@ -44,6 +44,12 @@ const envVarsSchema = Joi.object() 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(); @@ -97,6 +103,11 @@ 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, @@ -104,5 +115,6 @@ export default { 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/controllers/creation.controller.ts b/app/api/src/controllers/creation.controller.ts index df872193f..edbda10bb 100644 --- a/app/api/src/controllers/creation.controller.ts +++ b/app/api/src/controllers/creation.controller.ts @@ -186,7 +186,6 @@ export const updateCreationById = catchAsync(async (req, res): Promise => }); export const publishCreation = catchAsync(async (req, res): Promise => { - // requires creation_id in params and publish_on in body and req.user const reqUser = req.user as IUserDoc; // get original creation 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/routes/v1/index.ts b/app/api/src/routes/v1/index.ts index 9ba409fbf..0abaca746 100644 --- a/app/api/src/routes/v1/index.ts +++ b/app/api/src/routes/v1/index.ts @@ -11,6 +11,7 @@ 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(); @@ -60,6 +61,10 @@ const defaultRoutes = [ path: '/transactions', route: transactionRoute, }, + { + path: '/webhooks', + route: webhookRoute, + }, ]; const devRoutes = [ 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/transaction.service.ts b/app/api/src/services/transaction.service.ts index 0f539fb65..9e099cd4b 100644 --- a/app/api/src/services/transaction.service.ts +++ b/app/api/src/services/transaction.service.ts @@ -11,6 +11,7 @@ interface ITransaction { transaction_hash: string; transaction_purpose: TTransactionPurposes; maker_id: string; + is_validated?: boolean; blocking_issue?: string; } interface ITransactionDoc { @@ -133,13 +134,13 @@ export const getTransactionById = async ( * @returns {Promise} */ export const getTransactionByHash = async ( - id: string, + hash: string, options?: { populate?: string | string[]; owner_id?: string; } ): Promise => { - return getTransactionByCriteria('transaction_hash', id, options); + return getTransactionByCriteria('transaction_hash', hash, options); }; /** @@ -160,7 +161,7 @@ export const updateTransactionById = async ( // build sql conditions and values const conditions: string[] = []; - const values: (string | null)[] = []; + const values: (string | null | boolean)[] = []; Object.entries(updateBody).map(([k, v], index) => { conditions.push(`${k} = $${index + 2}`); values.push(v); diff --git a/app/api/src/utils/cardano.ts b/app/api/src/utils/cardano.ts index b85ff7537..736c4db80 100644 --- a/app/api/src/utils/cardano.ts +++ b/app/api/src/utils/cardano.ts @@ -34,7 +34,17 @@ export interface ICardanoTransaction { } export interface IPocreCardanoTransaction extends ICardanoTransaction { - metadata: any; // [IMPORTANT]: we need to define a metadata structure that would work for all pocre entities + /** + * 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 { @@ -104,10 +114,25 @@ export const getTransactionInfo = async (transactionHash: string): Promise Date: Sat, 11 Mar 2023 20:26:36 +0500 Subject: [PATCH 08/11] show pending transaction status for publishing creations --- app/web-frontend/src/api/requests.js | 7 +++- .../components/cards/CreationCard/index.jsx | 33 ++++++++++++------ app/web-frontend/src/config.js | 4 --- .../src/pages/Creations/Details/index.jsx | 29 +++++++++++----- .../pages/Creations/Details/useDetails.jsx | 2 +- .../src/pages/Creations/Home/index.jsx | 12 ++++--- .../src/pages/Creations/Home/useCreations.jsx | 7 +++- .../src/pages/Creations/common/form/index.jsx | 6 ++-- .../Creations/common/form/useCreationForm.jsx | 34 +++++++++++-------- .../utils/constants/transactionPurposes.js | 8 +++++ app/web-frontend/src/utils/helpers/wallet.js | 7 ++-- 11 files changed, 97 insertions(+), 52 deletions(-) create mode 100644 app/web-frontend/src/utils/constants/transactionPurposes.js 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..4a6ec1636 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({ - + {paymentStatus && ( + + )} + {finalizationDate && ( + + )} {canFinalize && ( - { 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..eee20fafb 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,11 @@ export default function CreationDetails() { await deleteCreation(id); }; + const isProcessingPublishingPayment = (creation?.transactions || [])?.find( + (t) => !t.is_validated + && t.transaction_purpose === transactionPurposes.PUBLISH_CREATION, + ); + return ( {(isDeletingCreation || isPublishingCreation) && } @@ -194,7 +200,6 @@ export default function CreationDetails() { > {user?.user_id === creation?.author?.user_id && !creation?.is_draft - && !creation?.is_onchain && !creation?.is_fully_owned && moment().isAfter(moment(creation?.creation_authorship_window)) && ( @@ -209,11 +214,16 @@ export default function CreationDetails() { )} - {user?.user_id === creation?.author?.user_id && creation?.is_draft && ( - - )} + { + user?.user_id === creation?.author?.user_id + && creation?.is_draft + && !isProcessingPublishingPayment + && ( + + ) + } {user?.user_id === creation?.author?.user_id && ( - { !creation?.is_draft && ( + {!creation?.is_draft && ( @@ -274,9 +284,10 @@ export default function CreationDetails() { - + {!creation.is_draft && } + {isProcessingPublishingPayment && } - {!creation?.is_draft && creation?.is_onchain && } + {!creation?.is_draft && creation?.is_fully_owned && } {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..73d0b8a70 100644 --- a/app/web-frontend/src/pages/Creations/Home/index.jsx +++ b/app/web-frontend/src/pages/Creations/Home/index.jsx @@ -159,25 +159,29 @@ 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'} 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?.creation_authorship_window : false} canFinalize={ !x?.is_draft && userId === login?.user_id && !x?.is_fully_owned && x?.isCAWPassed - && !x?.is_onchain } 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..be742f0ce 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('&')}`, ); @@ -40,6 +41,10 @@ const useCreations = (userId) => { 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)), + isProcessingPublishingPayment: (x?.transactions || [])?.find( + (t) => !t.is_validated + && t.transaction_purpose === transactionPurposes.PUBLISH_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/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..39e35eff1 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 @@ -48,9 +47,7 @@ const transactADAToPOCRE = async ({ // set metadata tx.setMetadata(0, { - amount: { lovelace: amountADA * 1_000_000, ada: amountADA }, - pocreVersion: '0.1', // beta - purposeDesc, + pocre_version: '0.1', // beta ...metaData, }); From f4d0149547085ce556997c11841db6621ec60fb4 Mon Sep 17 00:00:00 2001 From: huzaifa-99 Date: Sat, 11 Mar 2023 21:01:07 +0500 Subject: [PATCH 09/11] show pending transaction status for finalizing creations --- .../components/cards/CreationCard/index.jsx | 44 ++++++++++------- .../src/pages/Creations/Details/index.jsx | 9 +++- .../src/pages/Creations/Home/index.jsx | 5 +- .../src/pages/Creations/Home/useCreations.jsx | 8 +++- .../common/hooks/useCreationPublish.jsx | 48 ++++++++----------- 5 files changed, 64 insertions(+), 50 deletions(-) diff --git a/app/web-frontend/src/components/cards/CreationCard/index.jsx b/app/web-frontend/src/components/cards/CreationCard/index.jsx index 4a6ec1636..22cb86f15 100644 --- a/app/web-frontend/src/components/cards/CreationCard/index.jsx +++ b/app/web-frontend/src/components/cards/CreationCard/index.jsx @@ -254,24 +254,24 @@ function CreationCard({ - {paymentStatus && ( + {finalizationDate && ( )} - {finalizationDate && ( + {paymentStatus && ( )} {canFinalize && ( @@ -281,20 +281,28 @@ function CreationCard({ alignItems="flex-start" justifyContent="center" > - {isFinalized ? ( - - ) : ( - - )} + + + )} + + {isFinalized && ( + + + )} diff --git a/app/web-frontend/src/pages/Creations/Details/index.jsx b/app/web-frontend/src/pages/Creations/Details/index.jsx index eee20fafb..d6922d51c 100644 --- a/app/web-frontend/src/pages/Creations/Details/index.jsx +++ b/app/web-frontend/src/pages/Creations/Details/index.jsx @@ -114,6 +114,11 @@ export default function CreationDetails() { && t.transaction_purpose === transactionPurposes.PUBLISH_CREATION, ); + const isProcessingFinalizationPayment = (creation?.transactions || [])?.find( + (t) => !t.is_validated + && t.transaction_purpose === transactionPurposes.FINALIZE_CREATION, + ); + return ( {(isDeletingCreation || isPublishingCreation) && } @@ -201,6 +206,7 @@ export default function CreationDetails() { {user?.user_id === creation?.author?.user_id && !creation?.is_draft && !creation?.is_fully_owned + && !isProcessingFinalizationPayment && moment().isAfter(moment(creation?.creation_authorship_window)) && (