diff --git a/db/migrations/1719062161915-Data.js b/db/migrations/1719062975710-Data.js similarity index 99% rename from db/migrations/1719062161915-Data.js rename to db/migrations/1719062975710-Data.js index be5f6423e..3d721fb7e 100644 --- a/db/migrations/1719062161915-Data.js +++ b/db/migrations/1719062975710-Data.js @@ -1,5 +1,5 @@ -module.exports = class Data1719062161915 { - name = 'Data1719062161915' +module.exports = class Data1719062975710 { + name = 'Data1719062975710' async up(db) { await db.query(`CREATE TABLE "admin"."channel_follow" ("id" character varying NOT NULL, "user_id" character varying, "channel_id" text NOT NULL, "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_9410df2b9a316af3f0d216f9487" PRIMARY KEY ("id"))`) diff --git a/docker-compose.yml b/docker-compose.yml index 6bb4bfbf8..64d2d4e2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,8 @@ services: volumes: orion_db_data: -# networks: -# default: -# external: -# name: joystream_default + +networks: + default: + external: + name: joystream_default diff --git a/package-lock.json b/package-lock.json index d4b2b482c..cf0e5a08b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,8 @@ "type-graphql": "^1.2.0-rc.1", "typeorm": "^0.3.11", "ua-parser-js": "^1.0.34", - "url-join": "^4" + "url-join": "^4", + "uuid": "^10.0.0" }, "devDependencies": { "@apollo/client": "^3.7.17", @@ -176,6 +177,14 @@ "node": ">=10" } }, + "network-tests/node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -26791,9 +26800,13 @@ } }, "node_modules/uuid": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", - "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index 61462ea6c..326f68953 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,8 @@ "type-graphql": "^1.2.0-rc.1", "typeorm": "^0.3.11", "ua-parser-js": "^1.0.34", - "url-join": "^4" + "url-join": "^4", + "uuid": "^10.0.0" }, "devDependencies": { "@apollo/client": "^3.7.17", @@ -105,8 +106,8 @@ "@subsquid/substrate-typegen": "^2.1.0", "@subsquid/typeorm-codegen": "0.3.1", "@types/async-lock": "^1.1.3", - "@types/chai": "^4.3.11", "@types/big-json": "^3.2.4", + "@types/chai": "^4.3.11", "@types/cookie-parser": "^1.4.3", "@types/express-rate-limit": "^6.0.0", "@types/mocha": "^10.0.1", diff --git a/schema/bounties.graphql b/schema/bounties.graphql index c47a5c12b..f06afdcef 100644 --- a/schema/bounties.graphql +++ b/schema/bounties.graphql @@ -11,7 +11,9 @@ type Bounty @entity @schema(name: "admin") { } type ActivatedBounty @entity { + id: ID! bounty: Bounty! channelOwner: Channel! key: String + completed: Boolean! } diff --git a/src/server-extension/resolvers/BountyResolver/index.ts b/src/server-extension/resolvers/BountyResolver/index.ts new file mode 100644 index 000000000..6f4872d80 --- /dev/null +++ b/src/server-extension/resolvers/BountyResolver/index.ts @@ -0,0 +1,174 @@ +import 'reflect-metadata' +import { Resolver, UseMiddleware, Ctx, Args, Arg, Mutation, Query } from 'type-graphql' +import { EntityManager } from 'typeorm' +import { AccountOnly, OperatorOnly } from '../middleware' +import { withHiddenEntities } from '../../../utils/sql' +import { ActivatedBounty, Bounty, Channel } from '../../../model' +import { + ActivatedBountyOperationResult, + AddActivatedBountyArgs, + AddBountyArgs, + BountyOperationResult, + GetActivatedBountiesForChannel, +} from './types' +import { v4 as uuidv4 } from 'uuid' +import { Context } from '../../check' + +@Resolver() +export class BountyResolver { + constructor(private em: () => Promise) {} + + @Mutation(() => BountyOperationResult) + @UseMiddleware(OperatorOnly()) + async addBounty(@Args() params: AddBountyArgs): Promise { + const em = await this.em() + return withHiddenEntities(em, async () => { + const id = uuidv4() + const bounty = new Bounty({ + id, + createdAt: new Date(), + expirationDate: params.expirationDate, + maxPayoutUSD: params.maxPayoutUSD, + title: params.title, + description: params.description, + coverImageLink: params.coverImageLink, + talkingPointsText: params.talkingPointsText, + }) + await em.save(bounty) + + return { bountyId: id } + }) + } + + // add a new resolver in order to add a activated bounty entity to the database + @Mutation(() => ActivatedBountyOperationResult) + @UseMiddleware(AccountOnly) + async addActivatedBounty( + @Args() params: AddActivatedBountyArgs, + @Ctx() ctx: Context + ): Promise { + const em = await this.em() + return withHiddenEntities(em, async () => { + await this.ensureChannelBelongsToContextAccount(em, params.channelOwnerId, ctx) + + const bounty = await em.getRepository(Bounty).findOne({ where: { id: params.bountyId } }) + if (!bounty) { + throw new Error(`Bounty with id ${params.bountyId} not found`) + } + const id = uuidv4() + const activatedBounty = new ActivatedBounty({ + id, + bountyId: bounty.id, + channelOwnerId: params.channelOwnerId, + completed: false, + }) + bounty.activatedBounties.push(activatedBounty) + + await em.save(activatedBounty) + await em.save(bounty) + + return { bountyId: id } + }) + } + + // add a resolver in order to remove an activated bounty + @Mutation(() => ActivatedBountyOperationResult) + @UseMiddleware(AccountOnly) + async removeActivatedBounty( + @Arg('id') id: string, + @Ctx() ctx: Context + ): Promise { + const em = await this.em() + return withHiddenEntities(em, async () => { + const activatedBounty = await em.getRepository(ActivatedBounty).findOne({ where: { id } }) + if (!activatedBounty) { + throw new Error(`Activated bounty with id ${id} not found`) + } + await this.ensureActivatedBountyBelongsToContextAccount(em, activatedBounty, ctx) + + const bounty = await em + .getRepository(Bounty) + .findOne({ where: { id: activatedBounty.bountyId! } }) + if (!bounty) { + throw new Error(`Bounty for activated bounty ${activatedBounty.bountyId} not found`) + } + + bounty.activatedBounties = bounty.activatedBounties.filter((ab) => ab.id !== id) + + await em.remove(activatedBounty) + await em.save(bounty) + + return { bountyId: id } + }) + } + + // mark activated bounty as completed + @Mutation(() => ActivatedBountyOperationResult) + @UseMiddleware(OperatorOnly()) + async markActivatedBountyAsCompleted( + @Arg('id') id: string + ): Promise { + const em = await this.em() + return withHiddenEntities(em, async () => { + const bounty = await em.getRepository(ActivatedBounty).findOne({ where: { id } }) + if (!bounty) { + throw new Error(`Activated bounty with id ${id} not found`) + } + + bounty.completed = true + + await em.save(bounty) + + return { bountyId: id } + }) + } + + // get all bounties for a specific channel + @Query(() => [GetActivatedBountiesForChannel]) + @UseMiddleware(AccountOnly) + async getBountiesForChannel( + @Arg('channelId') channelId: string, + @Ctx() ctx: Context + ): Promise { + const em = await this.em() + return withHiddenEntities(em, async () => { + await this.ensureChannelBelongsToContextAccount(em, channelId, ctx) + + const bountiesResult: any = await em + .createQueryBuilder(Bounty, 'bounty') + .innerJoin(ActivatedBounty, 'activatedBounty', 'bounty.id = activatedBounty.bountyId') + .addSelect('activatedBounty.completed', 'completed') // Select ActivatedBounty.completed in addition to all Bounty properties + .getMany() + + return bountiesResult.map((bounty: any) => ({ + maxPayoutsUSD: bounty.maxPayoutUSD, + title: bounty.title, + description: bounty.description, + coverImageLink: bounty.coverImageLink, + expirationDate: bounty.expirationDate, + talkingPointsText: bounty.talkingPointsText, + completed: bounty.activatedBounties.completed, + })) + }) + } + + async ensureActivatedBountyBelongsToContextAccount( + em: EntityManager, + activatedBounty: ActivatedBounty, + ctx: Context + ) { + await this.ensureChannelBelongsToContextAccount(em, activatedBounty.channelOwnerId!, ctx) + } + + async ensureChannelBelongsToContextAccount(em: EntityManager, channelId: string, ctx: Context) { + const channel = await em.getRepository(Channel).findOne({ where: { id: channelId } }) + if (!channel) { + throw new Error(`Channel with id ${channelId} not found`) + } + if (channel.ownerMemberId !== ctx.account?.membershipId) { + throw new Error( + `Channel with id ${channelId} does not belong to account with id ${ctx.accountId}` + ) + } + } +} diff --git a/src/server-extension/resolvers/BountyResolver/types.ts b/src/server-extension/resolvers/BountyResolver/types.ts new file mode 100644 index 000000000..bd0e896e3 --- /dev/null +++ b/src/server-extension/resolvers/BountyResolver/types.ts @@ -0,0 +1,71 @@ +import { ArgsType, Field, ObjectType, InputType, Int, registerEnumType } from 'type-graphql' +import { DateTime } from '@subsquid/graphql-server' + +@ArgsType() +export class AddBountyArgs { + @Field(() => Int, { nullable: false }) + maxPayoutUSD: number + + @Field(() => String, { nullable: false }) + title: string + + @Field(() => String, { nullable: false }) + description: string + + @Field(() => String, { nullable: true }) + coverImageLink: string + + @Field(() => DateTime, { nullable: false }) + expirationDate: Date + + @Field(() => String, { nullable: true }) + talkingPointsText: string +} + +@ObjectType() +export class BountyOperationResult { + @Field(() => [String], { nullable: false }) + bountyId!: string +} + +@ArgsType() +export class AddActivatedBountyArgs { + @Field(() => String, { nullable: false }) + bountyId: string + + @Field(() => String, { nullable: false }) + channelOwnerId: string + + @Field(() => String, { nullable: false }) + key: string +} + +@ObjectType() +export class ActivatedBountyOperationResult { + @Field(() => [String], { nullable: false }) + bountyId!: string +} + +@ObjectType() +export class GetActivatedBountiesForChannel { + @Field(() => Int, { nullable: false }) + maxPayoutUSD: number + + @Field(() => String, { nullable: false }) + title: string + + @Field(() => String, { nullable: false }) + description: string + + @Field(() => String, { nullable: true }) + coverImageLink: string + + @Field(() => DateTime, { nullable: false }) + expirationDate: Date + + @Field(() => String, { nullable: true }) + talkingPointsText: string + + @Field(() => Boolean, { nullable: false }) + completed: boolean +}