From 289a793382dd6d2ebdbc236de43020baeaca9e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:23:34 +0200 Subject: [PATCH 01/11] Add XApiCredentials interface and integrate into social media engagement requirements in manifest. (#17) --- .../server/src/common/interfaces/job.ts | 8 ++ .../server/test/fixtures/manifest.ts | 2 +- recording-oracle/src/common/interfaces/job.ts | 8 ++ .../src/modules/job/fixtures/index.ts | 2 +- .../src/modules/job/job-manifest.dto.ts | 39 +++++- .../src/modules/job/job.service.spec.ts | 112 +++++++++++++++++- .../validation/x-api/x-api.service.spec.ts | 106 +++++++++++++++++ .../modules/validation/x-api/x-api.service.ts | 54 ++++++--- 8 files changed, 309 insertions(+), 22 deletions(-) diff --git a/exchange-oracle/server/src/common/interfaces/job.ts b/exchange-oracle/server/src/common/interfaces/job.ts index d9251c4..47c3009 100644 --- a/exchange-oracle/server/src/common/interfaces/job.ts +++ b/exchange-oracle/server/src/common/interfaces/job.ts @@ -21,12 +21,20 @@ export interface SocialMediaPromotionRequirements { minReposts?: number; } +export interface XApiCredentials { + consumerKey: string; + consumerSecret: string; + accessToken: string; + accessTokenSecret: string; +} + export interface SocialMediaEngagementRequirements { targetPostUrl: string; checkLike?: boolean; checkRepost?: boolean; checkQuote?: boolean; checkComment?: boolean; + xApiCredentials?: XApiCredentials; } export type ManifestRequirements = diff --git a/exchange-oracle/server/test/fixtures/manifest.ts b/exchange-oracle/server/test/fixtures/manifest.ts index 3b0e0f0..4fea3ee 100644 --- a/exchange-oracle/server/test/fixtures/manifest.ts +++ b/exchange-oracle/server/test/fixtures/manifest.ts @@ -22,7 +22,7 @@ export function createManifest( }, requirements: { targetPostUrl: `https://x.com/${faker.internet.username()}/status/${faker.string.numeric(8)}`, - checkLike: true, + checkQuote: true, }, ...overrides, } as ManifestDto; diff --git a/recording-oracle/src/common/interfaces/job.ts b/recording-oracle/src/common/interfaces/job.ts index 9b17113..8723777 100644 --- a/recording-oracle/src/common/interfaces/job.ts +++ b/recording-oracle/src/common/interfaces/job.ts @@ -17,12 +17,20 @@ export interface ISocialMediaPromotionRequirements { minReposts?: number; } +export interface IXApiCredentials { + consumerKey: string; + consumerSecret: string; + accessToken: string; + accessTokenSecret: string; +} + export interface ISocialMediaEngagementRequirements { targetPostUrl: string; checkLike?: boolean; checkRepost?: boolean; checkQuote?: boolean; checkComment?: boolean; + xApiCredentials?: IXApiCredentials; } export type IManifestRequirements = diff --git a/recording-oracle/src/modules/job/fixtures/index.ts b/recording-oracle/src/modules/job/fixtures/index.ts index 457aa0b..f03ce5d 100644 --- a/recording-oracle/src/modules/job/fixtures/index.ts +++ b/recording-oracle/src/modules/job/fixtures/index.ts @@ -23,7 +23,7 @@ export const generateManifest = ( }, requirements: { targetPostUrl: `https://x.com/${faker.internet.username()}/status/${faker.string.numeric(8)}`, - checkLike: true, + checkQuote: true, }, ...overrides, } as IManifest; diff --git a/recording-oracle/src/modules/job/job-manifest.dto.ts b/recording-oracle/src/modules/job/job-manifest.dto.ts index 9f28958..39b4257 100644 --- a/recording-oracle/src/modules/job/job-manifest.dto.ts +++ b/recording-oracle/src/modules/job/job-manifest.dto.ts @@ -78,6 +78,24 @@ class SocialMediaPromotionRequirementsDto { minReposts?: number; } +class XApiCredentialsDto { + @IsString() + @IsNotEmpty() + consumerKey!: string; + + @IsString() + @IsNotEmpty() + consumerSecret!: string; + + @IsString() + @IsNotEmpty() + accessToken!: string; + + @IsString() + @IsNotEmpty() + accessTokenSecret!: string; +} + class SocialMediaEngagementRequirementsDto { @IsString() @IsNotEmpty() @@ -98,6 +116,11 @@ class SocialMediaEngagementRequirementsDto { @IsOptional() @IsBoolean() checkComment?: boolean; + + @ValidateNested() + @IsOptional() + @Type(() => XApiCredentialsDto) + xApiCredentials?: XApiCredentialsDto; } class ManifestAiValidationDto { @@ -207,12 +230,24 @@ export function validateManifestDto(manifest: IManifest): IManifest { if ( requirements.checkComment && - !requirements.checkLike && + !(requirements.checkLike && requirements.xApiCredentials) && !requirements.checkRepost && !requirements.checkQuote ) { throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ - 'checkComment requires checkLike, checkRepost, or checkQuote for social_media_engagement', + 'checkComment requires checkRepost, checkQuote, or checkLike with xApiCredentials for social_media_engagement', + ]); + } + + if (requirements.checkLike && !requirements.xApiCredentials) { + throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ + 'xApiCredentials must be provided when checkLike is enabled for social_media_engagement', + ]); + } + + if (requirements.xApiCredentials && !requirements.checkLike) { + throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ + 'xApiCredentials can only be provided when checkLike is enabled for social_media_engagement', ]); } } diff --git a/recording-oracle/src/modules/job/job.service.spec.ts b/recording-oracle/src/modules/job/job.service.spec.ts index 388a15f..aae33c6 100644 --- a/recording-oracle/src/modules/job/job.service.spec.ts +++ b/recording-oracle/src/modules/job/job.service.spec.ts @@ -159,7 +159,9 @@ describe('JobService', () => { jobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValue(null); (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); storageService.download.mockResolvedValue( - generateManifest({ requestType: 'unsupported' as JobRequestType }), + generateManifest({ + requestType: 'unsupported' as JobRequestType, + }), ); await expect( @@ -355,6 +357,62 @@ describe('JobService', () => { checkLike: true, checkRepost: true, checkComment: true, + xApiCredentials: { + consumerKey: 'consumer-key', + consumerSecret: 'consumer-secret', + accessToken: 'access-token', + accessTokenSecret: 'access-token-secret', + }, + }, + }); + storageService.download.mockResolvedValue(manifest); + + await expect( + jobService.getManifest(faker.internet.url()), + ).resolves.toEqual( + expect.objectContaining({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + }), + ); + }); + + it('validates like-only engagement manifests with X credentials', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + requirements: { + targetPostUrl: 'https://x.com/test/status/123', + checkLike: true, + xApiCredentials: { + consumerKey: 'consumer-key', + consumerSecret: 'consumer-secret', + accessToken: 'access-token', + accessTokenSecret: 'access-token-secret', + }, + }, + }); + storageService.download.mockResolvedValue(manifest); + + await expect( + jobService.getManifest(faker.internet.url()), + ).resolves.toEqual( + expect.objectContaining({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + }), + ); + }); + + it('validates engagement manifests with X credentials', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + requirements: { + targetPostUrl: 'https://x.com/test/status/123', + checkLike: true, + xApiCredentials: { + consumerKey: 'consumer-key', + consumerSecret: 'consumer-secret', + accessToken: 'access-token', + accessTokenSecret: 'access-token-secret', + }, }, }); storageService.download.mockResolvedValue(manifest); @@ -420,6 +478,58 @@ describe('JobService', () => { jobService.getManifest(faker.internet.url()), ).rejects.toBeInstanceOf(ValidationError); }); + + it('rejects engagement manifests with partial X credentials', async () => { + storageService.download.mockResolvedValue( + generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + requirements: { + targetPostUrl: 'https://x.com/test/status/123', + checkLike: true, + xApiCredentials: { + consumerKey: 'consumer-key', + } as never, + }, + }), + ); + + await expect( + jobService.getManifest(faker.internet.url()), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('rejects engagement manifests that check likes without X credentials', async () => { + storageService.download.mockResolvedValue( + generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + requirements: { + targetPostUrl: 'https://x.com/test/status/123', + checkLike: true, + }, + }), + ); + + await expect( + jobService.getManifest(faker.internet.url()), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('rejects engagement manifests where comments depend only on skipped likes', async () => { + storageService.download.mockResolvedValue( + generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + requirements: { + targetPostUrl: 'https://x.com/test/status/123', + checkLike: true, + checkComment: true, + }, + }), + ); + + await expect( + jobService.getManifest(faker.internet.url()), + ).rejects.toBeInstanceOf(ValidationError); + }); }); }); }); diff --git a/recording-oracle/src/modules/validation/x-api/x-api.service.spec.ts b/recording-oracle/src/modules/validation/x-api/x-api.service.spec.ts index eb2c23c..e2c5a76 100644 --- a/recording-oracle/src/modules/validation/x-api/x-api.service.spec.ts +++ b/recording-oracle/src/modules/validation/x-api/x-api.service.spec.ts @@ -22,6 +22,12 @@ describe('XApiService', () => { let service: XApiService; let fetchMock: jest.Mock; let xApiConfigService: XApiConfigServiceMock; + const requesterCredentials = { + consumerKey: 'requester-consumer-key', + consumerSecret: 'requester-consumer-secret', + accessToken: 'requester-access-token', + accessTokenSecret: 'requester-access-token-secret', + }; beforeEach(async () => { fetchMock = jest.fn(); @@ -131,6 +137,24 @@ describe('XApiService', () => { ); }); + it('authenticates liking users requests with manifest X credentials when provided', async () => { + mockXApiResponse({ data: [] }); + + await service.getLikingUsernames( + '123', + new Set(['alice']), + requesterCredentials, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toContain( + 'oauth_consumer_key="requester-consumer-key"', + ); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toContain( + 'oauth_token="requester-access-token"', + ); + }); + it('fails engagement X API calls clearly when OAuth config is missing', async () => { xApiConfigService.consumerKey = undefined; @@ -152,6 +176,7 @@ describe('XApiService', () => { checkRepost: true, checkQuote: true, checkComment: true, + xApiCredentials: requesterCredentials, }, }); const submissions = [ @@ -189,14 +214,17 @@ describe('XApiService', () => { expect(service.getLikingUsernames).toHaveBeenCalledWith( '123', new Set(['alice', 'bob']), + requesterCredentials, ); expect(service.getRepostingUsernames).toHaveBeenCalledWith( '123', new Set(['alice', 'bob']), + requesterCredentials, ); expect(service.getQuotingUsernames).toHaveBeenCalledWith( '123', new Set(['alice']), + requesterCredentials, ); expect(service.getCommentingUsernames).not.toHaveBeenCalled(); }); @@ -211,6 +239,7 @@ describe('XApiService', () => { checkRepost: true, checkQuote: true, checkComment: true, + xApiCredentials: requesterCredentials, }, }); const submissions = [ @@ -293,10 +322,12 @@ describe('XApiService', () => { expect(service.getQuotingUsernames).toHaveBeenCalledWith( '123', new Set(['alice', 'bob']), + undefined, ); expect(service.getCommentingUsernames).toHaveBeenCalledWith( '123', new Set(['alice']), + undefined, ); }); @@ -310,6 +341,7 @@ describe('XApiService', () => { checkRepost: false, checkQuote: false, checkComment: true, + xApiCredentials: requesterCredentials, }, }); const submissions = [ @@ -342,6 +374,80 @@ describe('XApiService', () => { expect(getCommentingUsernamesSpy).not.toHaveBeenCalled(); }); + it('does not call like validation and rejects when checkLike has no manifest X credentials', async () => { + const targetPostUrl = 'https://x.com/human/status/123'; + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + requirements: { + targetPostUrl, + checkLike: true, + }, + }); + const submissions = [generateSubmission({ id: 1, solution: 'alice' })]; + const getLikingUsernamesSpy = jest.spyOn(service, 'getLikingUsernames'); + + await expect( + service.validateSubmissions( + submissions, + manifest as ISocialMediaEngagementManifest, + ), + ).resolves.toEqual([ + { + submission: submissions[0], + rejectionReason: SubmissionRejectionReason.MissingRequiredLike, + }, + ]); + + expect(getLikingUsernamesSpy).not.toHaveBeenCalled(); + }); + + it('uses manifest X credentials for all enabled engagement checks when provided', async () => { + mockXApiResponse({ + data: [{ id: '1', username: 'alice' }], + }); + mockXApiResponse({ + data: [{ id: '1', username: 'alice' }], + }); + + const targetPostUrl = 'https://x.com/human/status/123'; + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + requirements: { + targetPostUrl, + checkLike: true, + checkRepost: true, + xApiCredentials: requesterCredentials, + }, + }); + const submissions = [generateSubmission({ id: 1, solution: 'alice' })]; + + await expect( + service.validateSubmissions( + submissions, + manifest as ISocialMediaEngagementManifest, + ), + ).resolves.toEqual([ + { + submission: submissions[0], + rejectionReason: null, + }, + ]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0].toString()).toContain( + '/tweets/123/liking_users', + ); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toContain( + 'oauth_consumer_key="requester-consumer-key"', + ); + expect(fetchMock.mock.calls[1][0].toString()).toContain( + '/tweets/123/retweeted_by', + ); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toContain( + 'oauth_consumer_key="requester-consumer-key"', + ); + }); + it('maps quote authors from includes users for quote validation', async () => { mockXApiResponse({ data: [ diff --git a/recording-oracle/src/modules/validation/x-api/x-api.service.ts b/recording-oracle/src/modules/validation/x-api/x-api.service.ts index 2d25c67..4dce7bc 100644 --- a/recording-oracle/src/modules/validation/x-api/x-api.service.ts +++ b/recording-oracle/src/modules/validation/x-api/x-api.service.ts @@ -4,7 +4,10 @@ import { Injectable } from '@nestjs/common'; import { XApiConfigService } from '../../../common/config/x-api-config.service'; import { SubmissionRejectionReason } from '../../../common/constants/errors'; import { ServerError, ValidationError } from '../../../common/errors'; -import { ISocialMediaEngagementManifest } from '../../../common/interfaces/job'; +import { + ISocialMediaEngagementManifest, + IXApiCredentials, +} from '../../../common/interfaces/job'; import { SubmissionEntity } from '../../submission/submission.entity'; import type { SubmissionValidationResult } from '../validation.service'; import { @@ -21,13 +24,6 @@ type EngagementMatches = { commentingUsernames: Set; }; -type XApiCredentials = { - consumerKey: string; - consumerSecret: string; - accessToken: string; - accessTokenSecret: string; -}; - @Injectable() export class XApiService { constructor(private readonly xApiConfigService: XApiConfigService) {} @@ -49,6 +45,7 @@ export class XApiService { const targetUsernames = new Set( submissions.map((submission) => submission.solution.toLowerCase()), ); + const jobCredentials = manifest.requirements.xApiCredentials; try { const matches: EngagementMatches = { @@ -59,10 +56,11 @@ export class XApiService { }; let candidates = new Set(targetUsernames); - if (manifest.requirements.checkLike) { + if (manifest.requirements.checkLike && jobCredentials) { matches.likingUsernames = await this.getLikingUsernames( targetPostId, candidates, + jobCredentials, ); candidates = this.filterCandidates(candidates, matches.likingUsernames); } @@ -71,6 +69,7 @@ export class XApiService { matches.repostingUsernames = await this.getRepostingUsernames( targetPostId, candidates, + jobCredentials, ); candidates = this.filterCandidates( candidates, @@ -82,6 +81,7 @@ export class XApiService { matches.quotingUsernames = await this.getQuotingUsernames( targetPostId, candidates, + jobCredentials, ); candidates = this.filterCandidates( candidates, @@ -93,6 +93,7 @@ export class XApiService { matches.commentingUsernames = await this.getCommentingUsernames( targetPostId, candidates, + jobCredentials, ); } @@ -121,6 +122,7 @@ export class XApiService { getLikingUsernames( tweetId: string, targetUsernames: Set, + credentials?: IXApiCredentials, ): Promise> { return this.collectPaginatedMatches( `/tweets/${tweetId}/liking_users`, @@ -129,12 +131,14 @@ export class XApiService { { 'user.fields': 'username,name', }, + credentials, ); } getRepostingUsernames( tweetId: string, targetUsernames: Set, + credentials?: IXApiCredentials, ): Promise> { return this.collectPaginatedMatches( `/tweets/${tweetId}/retweeted_by`, @@ -143,12 +147,14 @@ export class XApiService { { 'user.fields': 'username,name', }, + credentials, ); } async getCommentingUsernames( tweetId: string, targetUsernames: Set, + credentials?: IXApiCredentials, ): Promise> { const matches = new Set(); @@ -170,6 +176,7 @@ export class XApiService { 'tweet.fields': 'author_id,conversation_id,referenced_tweets', 'user.fields': 'username,name', }, + credentials, ); for (const userMatch of userMatches) { @@ -183,6 +190,7 @@ export class XApiService { getQuotingUsernames( tweetId: string, targetUsernames: Set, + credentials?: IXApiCredentials, ): Promise> { return this.collectPaginatedMatches( `/tweets/${tweetId}/quote_tweets`, @@ -193,6 +201,7 @@ export class XApiService { 'tweet.fields': 'author_id,created_at,public_metrics', 'user.fields': 'username,name', }, + credentials, ); } @@ -201,6 +210,7 @@ export class XApiService { targetUsernames: Set, getUsername: (item: T, payload: XApiListResponse) => string | undefined, endpointParams: Record = {}, + credentials?: IXApiCredentials, ): Promise> { const matches = new Set(); let nextToken: string | undefined; @@ -223,6 +233,7 @@ export class XApiService { const payload = await this.xGet>( endpoint, requestParams, + credentials, ); for (const item of payload.data ?? []) { const username = getUsername(item, payload); @@ -346,6 +357,7 @@ export class XApiService { private async xGet( endpoint: string, params: Record, + credentials?: IXApiCredentials, ): Promise { const url = new URL(`${this.xApiConfigService.baseUrl}${endpoint}`); Object.entries(params).forEach(([key, value]) => { @@ -354,7 +366,11 @@ export class XApiService { const response = await fetch(url, { headers: { - Authorization: this.getOAuthAuthorizationHeader('GET', url), + Authorization: this.getOAuthAuthorizationHeader( + 'GET', + url, + credentials, + ), }, }); @@ -417,14 +433,18 @@ export class XApiService { return errorMessages?.length ? errorMessages.join('; ') : null; } - private getOAuthAuthorizationHeader(method: string, url: URL): string { - const credentials = this.getOAuthCredentials(); + private getOAuthAuthorizationHeader( + method: string, + url: URL, + credentials?: IXApiCredentials, + ): string { + const oauthCredentials = credentials ?? this.getOAuthCredentials(); const oauthParams: Record = { - oauth_consumer_key: credentials.consumerKey, + oauth_consumer_key: oauthCredentials.consumerKey, oauth_nonce: randomBytes(16).toString('hex'), oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: Math.floor(Date.now() / 1000).toString(), - oauth_token: credentials.accessToken, + oauth_token: oauthCredentials.accessToken, oauth_version: '1.0', }; @@ -432,7 +452,7 @@ export class XApiService { method, url, oauthParams, - credentials, + oauthCredentials, ); return `OAuth ${Object.entries(oauthParams) @@ -448,7 +468,7 @@ export class XApiService { method: string, url: URL, oauthParams: Record, - credentials: XApiCredentials, + credentials: IXApiCredentials, ): string { const allParams: Array<[string, string]> = []; @@ -489,7 +509,7 @@ export class XApiService { .digest('base64'); } - private getOAuthCredentials(): XApiCredentials { + private getOAuthCredentials(): IXApiCredentials { const { consumerKey, consumerSecret, accessToken, accessTokenSecret } = this.xApiConfigService; From 766343401349fb14c9fb3012cafcb93b9933e899 Mon Sep 17 00:00:00 2001 From: portuu3 <61605646+portuu3@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:08:08 +0200 Subject: [PATCH 02/11] Add linkedin validation for social media engagement (#18) * add linkedin validation for social media engagement * Add LinkdAPI sdk * Refactor LinkedIn engagement validation and update related tests --------- Co-authored-by: portuu3 <> Co-authored-by: flopez7 --- recording-oracle/.env.example | 6 + recording-oracle/package.json | 1 + .../src/common/config/config.module.ts | 3 + .../src/common/config/env-schema.ts | 5 + .../common/config/linkdapi-config.service.ts | 29 ++ .../src/common/constants/errors.ts | 2 + .../src/modules/job/job-manifest.dto.ts | 49 ++- .../src/modules/job/job.service.spec.ts | 41 ++ .../submission/submission.constants.ts | 38 ++ .../submission/submission.service.spec.ts | 22 + .../modules/submission/submission.service.ts | 15 +- .../linkdapi/linkdapi.interfaces.ts | 83 ++++ .../linkdapi/linkdapi.service.spec.ts | 212 ++++++++++ .../validation/linkdapi/linkdapi.service.ts | 394 ++++++++++++++++++ .../modules/validation/validation.module.ts | 3 +- .../validation/validation.service.spec.ts | 54 +++ .../modules/validation/validation.service.ts | 25 +- .../validation/x-api/x-api.interfaces.ts | 7 + .../modules/validation/x-api/x-api.service.ts | 8 +- recording-oracle/yarn.lock | 8 + 20 files changed, 971 insertions(+), 34 deletions(-) create mode 100644 recording-oracle/src/common/config/linkdapi-config.service.ts create mode 100644 recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts create mode 100644 recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts create mode 100644 recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts diff --git a/recording-oracle/.env.example b/recording-oracle/.env.example index e423c5f..70e5f68 100644 --- a/recording-oracle/.env.example +++ b/recording-oracle/.env.example @@ -34,5 +34,11 @@ X_API_BASE_URL=https://api.x.com/2 X_API_PAGE_SIZE=100 X_API_MAX_PAGES_PER_ACTION=10 +# LinkdAPI +LINKDAPI_API_KEY=replace_me +LINKDAPI_BASE_URL=https://linkdapi.com +LINKDAPI_PAGE_SIZE=100 +LINKDAPI_MAX_PAGES_PER_ACTION=10 + # Encryption PGP_ENCRYPT=false diff --git a/recording-oracle/package.json b/recording-oracle/package.json index fa5622c..ce69b0e 100644 --- a/recording-oracle/package.json +++ b/recording-oracle/package.json @@ -48,6 +48,7 @@ "dotenv": "^17.2.2", "helmet": "^7.1.0", "joi": "^17.13.3", + "linkdapi": "^1.0.2", "minio": "8.0.6", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", diff --git a/recording-oracle/src/common/config/config.module.ts b/recording-oracle/src/common/config/config.module.ts index 47cad45..6c3e176 100644 --- a/recording-oracle/src/common/config/config.module.ts +++ b/recording-oracle/src/common/config/config.module.ts @@ -7,6 +7,7 @@ import { Web3ConfigService } from './web3-config.service'; import { NetworkConfigService } from './network-config.service'; import { DatabaseConfigService } from './database-config.service'; import { GrokConfigService } from './grok-config.service'; +import { LinkdapiConfigService } from './linkdapi-config.service'; import { XApiConfigService } from './x-api-config.service'; @Global() @@ -20,6 +21,7 @@ import { XApiConfigService } from './x-api-config.service'; NetworkConfigService, DatabaseConfigService, GrokConfigService, + LinkdapiConfigService, XApiConfigService, ], exports: [ @@ -31,6 +33,7 @@ import { XApiConfigService } from './x-api-config.service'; NetworkConfigService, DatabaseConfigService, GrokConfigService, + LinkdapiConfigService, XApiConfigService, ], }) diff --git a/recording-oracle/src/common/config/env-schema.ts b/recording-oracle/src/common/config/env-schema.ts index a67ec63..0ba9b11 100644 --- a/recording-oracle/src/common/config/env-schema.ts +++ b/recording-oracle/src/common/config/env-schema.ts @@ -75,4 +75,9 @@ export const envValidator = Joi.object({ X_API_BASE_URL: Joi.string().uri(), X_API_PAGE_SIZE: Joi.number(), X_API_MAX_PAGES_PER_ACTION: Joi.number(), + // LinkdAPI + LINKDAPI_API_KEY: Joi.string().optional(), + LINKDAPI_BASE_URL: Joi.string().uri(), + LINKDAPI_PAGE_SIZE: Joi.number(), + LINKDAPI_MAX_PAGES_PER_ACTION: Joi.number(), }); diff --git a/recording-oracle/src/common/config/linkdapi-config.service.ts b/recording-oracle/src/common/config/linkdapi-config.service.ts new file mode 100644 index 0000000..e4a3d66 --- /dev/null +++ b/recording-oracle/src/common/config/linkdapi-config.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class LinkdapiConfigService { + constructor(private readonly configService: ConfigService) {} + + get apiKey(): string | undefined { + return this.configService.get('LINKDAPI_API_KEY'); + } + + get baseUrl(): string { + return this.configService.get( + 'LINKDAPI_BASE_URL', + 'https://linkdapi.com', + ); + } + + get pageSize(): number { + return Math.min( + this.configService.get('LINKDAPI_PAGE_SIZE', 100), + 100, + ); + } + + get maxPagesPerAction(): number { + return this.configService.get('LINKDAPI_MAX_PAGES_PER_ACTION', 10); + } +} diff --git a/recording-oracle/src/common/constants/errors.ts b/recording-oracle/src/common/constants/errors.ts index d1a0865..37a7017 100644 --- a/recording-oracle/src/common/constants/errors.ts +++ b/recording-oracle/src/common/constants/errors.ts @@ -9,6 +9,8 @@ export enum ErrorJob { SolutionAlreadyExists = 'Solution already exists', InvalidPostUrl = 'Post URL must be a valid x.com status URL', InvalidXUsername = 'X username must be a valid handle', + InvalidSocialProfile = 'Social profile must be a valid handle or LinkedIn profile URL', + UnsupportedSocialPlatform = 'Social media engagement platform must be x or linkedin', } /** diff --git a/recording-oracle/src/modules/job/job-manifest.dto.ts b/recording-oracle/src/modules/job/job-manifest.dto.ts index 39b4257..c0d6b5c 100644 --- a/recording-oracle/src/modules/job/job-manifest.dto.ts +++ b/recording-oracle/src/modules/job/job-manifest.dto.ts @@ -216,6 +216,7 @@ export function validateManifestDto(manifest: IManifest): IManifest { ) { const requirements = validatedManifest.requirements as ISocialMediaEngagementRequirements; + const platform = validatedManifest.platforms[0]?.toLowerCase(); const hasAnyEngagementCheck = requirements.checkLike || requirements.checkRepost || @@ -228,27 +229,39 @@ export function validateManifestDto(manifest: IManifest): IManifest { ]); } - if ( - requirements.checkComment && - !(requirements.checkLike && requirements.xApiCredentials) && - !requirements.checkRepost && - !requirements.checkQuote - ) { - throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ - 'checkComment requires checkRepost, checkQuote, or checkLike with xApiCredentials for social_media_engagement', - ]); - } + if (platform === 'linkedin') { + if (requirements.checkRepost || requirements.checkQuote) { + throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ + 'LinkedIn engagement supports only checkLike and checkComment', + ]); + } - if (requirements.checkLike && !requirements.xApiCredentials) { - throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ - 'xApiCredentials must be provided when checkLike is enabled for social_media_engagement', - ]); + return validatedManifest as IManifest; } - if (requirements.xApiCredentials && !requirements.checkLike) { - throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ - 'xApiCredentials can only be provided when checkLike is enabled for social_media_engagement', - ]); + if (platform === 'x') { + if ( + requirements.checkComment && + !(requirements.checkLike && requirements.xApiCredentials) && + !requirements.checkRepost && + !requirements.checkQuote + ) { + throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ + 'checkComment requires checkRepost, checkQuote, or checkLike with xApiCredentials for social_media_engagement', + ]); + } + + if (requirements.checkLike && !requirements.xApiCredentials) { + throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ + 'xApiCredentials must be provided when checkLike is enabled for social_media_engagement', + ]); + } + + if (requirements.xApiCredentials && !requirements.checkLike) { + throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ + 'xApiCredentials can only be provided when checkLike is enabled for social_media_engagement', + ]); + } } } diff --git a/recording-oracle/src/modules/job/job.service.spec.ts b/recording-oracle/src/modules/job/job.service.spec.ts index aae33c6..70769f3 100644 --- a/recording-oracle/src/modules/job/job.service.spec.ts +++ b/recording-oracle/src/modules/job/job.service.spec.ts @@ -447,6 +447,28 @@ describe('JobService', () => { }), ); }); + + it('validates LinkedIn like and comment engagement manifests without X credentials', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['linkedin'], + requirements: { + targetPostUrl: + 'https://www.linkedin.com/feed/update/urn:li:activity:7353638537595932672/', + checkLike: true, + checkComment: true, + }, + }); + storageService.download.mockResolvedValue(manifest); + + await expect( + jobService.getManifest(faker.internet.url()), + ).resolves.toEqual( + expect.objectContaining({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + }), + ); + }); }); describe('fail', () => { @@ -479,6 +501,25 @@ describe('JobService', () => { ).rejects.toBeInstanceOf(ValidationError); }); + it('rejects LinkedIn engagement manifests that require reposts or quotes', async () => { + storageService.download.mockResolvedValue( + generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['linkedin'], + requirements: { + targetPostUrl: + 'https://www.linkedin.com/feed/update/urn:li:activity:7353638537595932672/', + checkRepost: true, + checkQuote: true, + }, + }), + ); + + await expect( + jobService.getManifest(faker.internet.url()), + ).rejects.toThrow(ErrorJob.InvalidManifest); + }); + it('rejects engagement manifests with partial X credentials', async () => { storageService.download.mockResolvedValue( generateManifest({ diff --git a/recording-oracle/src/modules/submission/submission.constants.ts b/recording-oracle/src/modules/submission/submission.constants.ts index 34472f9..97670f7 100644 --- a/recording-oracle/src/modules/submission/submission.constants.ts +++ b/recording-oracle/src/modules/submission/submission.constants.ts @@ -2,12 +2,50 @@ import { SubmissionRejectionReason } from '../../common/constants/errors'; import { AbuseProbability } from '../../common/interfaces/job'; import { SubmissionValidationRule } from './submission.dto'; +export type SocialProfileNormalizer = { + normalize: (profile: string) => string | null; + isValid: (profile: string) => boolean; +}; + export const ABUSE_PRIORITY: Record = { low: 1, medium: 2, high: 3, }; +export const SOCIAL_PROFILE_NORMALIZERS: SocialProfileNormalizer[] = [ + { + normalize: (profile: string): string | null => + profile.trim().replace(/^@/, '').toLowerCase(), + isValid: (profile: string): boolean => /^[a-z0-9_]{1,15}$/.test(profile), + }, + { + normalize: (profile: string): string | null => { + const trimmedProfile = profile.trim().replace(/^@/, ''); + + try { + const parsedUrl = new URL(trimmedProfile); + const path = parsedUrl.pathname.replace(/\/+$/, ''); + const match = path.match(/^\/in\/([^/]+)$/i); + + if ( + parsedUrl.protocol === 'https:' && + parsedUrl.hostname.endsWith('linkedin.com') && + match + ) { + return match[1].toLowerCase(); + } + } catch { + return trimmedProfile.toLowerCase(); + } + + return null; + }, + isValid: (profile: string): boolean => + /^[a-z0-9][a-z0-9_-]{0,99}$/.test(profile), + }, +]; + export const SUBMISSION_VALIDATION_RULES: SubmissionValidationRule[] = [ { isValid: (validation) => validation.postExists, diff --git a/recording-oracle/src/modules/submission/submission.service.spec.ts b/recording-oracle/src/modules/submission/submission.service.spec.ts index ca76b72..42486fc 100644 --- a/recording-oracle/src/modules/submission/submission.service.spec.ts +++ b/recording-oracle/src/modules/submission/submission.service.spec.ts @@ -125,6 +125,28 @@ describe('SubmissionService', () => { }), ); }); + + it('creates an engagement submission with a normalized LinkedIn profile URL', async () => { + jobService.createJob.mockResolvedValue( + generateJob({ jobType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT }), + ); + + await expect( + submissionService.createSubmission({ + ...webhook, + eventData: { + assigneeId: workerAddress, + solution: 'https://www.linkedin.com/in/Human-Protocol/', + }, + }), + ).resolves.toBe('Submission received.'); + + expect(submissionRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + solution: 'human-protocol', + }), + ); + }); }); describe('fail', () => { diff --git a/recording-oracle/src/modules/submission/submission.service.ts b/recording-oracle/src/modules/submission/submission.service.ts index 4c4e133..4d801dc 100644 --- a/recording-oracle/src/modules/submission/submission.service.ts +++ b/recording-oracle/src/modules/submission/submission.service.ts @@ -26,6 +26,7 @@ import { WebhookDto, } from '../../modules/webhook/webhook.dto'; +import { SOCIAL_PROFILE_NORMALIZERS } from './submission.constants'; import { SubmissionEntity } from './submission.entity'; import { SubmissionRepository } from './submission.repository'; @@ -128,7 +129,7 @@ export class SubmissionService { ): Promise { const normalizedSolution = jobType === JobRequestType.SOCIAL_MEDIA_ENGAGEMENT - ? this.validateXUsername(solution) + ? this.validateSocialProfile(solution) : this.validatePostUrl(solution); const existingSubmission = await this.submissionRepository.findOneByJobIdAndWorkerAddress( @@ -183,13 +184,15 @@ export class SubmissionService { return parsedUrl.toString(); } - private validateXUsername(username: string): string { - const normalizedUsername = username.trim().replace(/^@/, '').toLowerCase(); - if (!/^[a-z0-9_]{1,15}$/.test(normalizedUsername)) { - throw new ValidationError(ErrorJob.InvalidXUsername); + private validateSocialProfile(profile: string): string { + for (const normalizer of SOCIAL_PROFILE_NORMALIZERS) { + const normalizedProfile = normalizer.normalize(profile); + if (normalizedProfile && normalizer.isValid(normalizedProfile)) { + return normalizedProfile; + } } - return normalizedUsername; + throw new ValidationError(ErrorJob.InvalidSocialProfile); } private async recordValidationResult( diff --git a/recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts b/recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts new file mode 100644 index 0000000..1b805f2 --- /dev/null +++ b/recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts @@ -0,0 +1,83 @@ +export type LinkdapiErrorResponse = { + success?: boolean; + message?: string; + error?: string | { message?: string }; + detail?: string; +}; + +export type LinkdapiResponse = LinkdapiErrorResponse & { + data?: T; +}; + +export type LinkdapiProfile = { + id?: string; + urn?: string; + entityUrn?: string; + profileUrn?: string; + username?: string; + publicIdentifier?: string; + public_identifier?: string; + url?: string; + profileUrl?: string; + profileURL?: string; + name?: string; + fullName?: string; + full_name?: string; + firstName?: string; + first_name?: string; + lastName?: string; + last_name?: string; +}; + +export type LinkdapiEngagementItem = LinkdapiProfile & { + author?: LinkdapiProfile; + actor?: LinkdapiProfile; + profile?: LinkdapiProfile; + user?: LinkdapiProfile; + commenter?: LinkdapiProfile; + creator?: LinkdapiProfile; + member?: LinkdapiProfile; + header?: string; + url?: string; +}; + +export type LinkdapiPagination = { + cursor?: string; + nextCursor?: string; + next_cursor?: string; +}; + +export type LinkdapiPaginatedData = { + cursor?: string; + nextCursor?: string; + next_cursor?: string; + pagination?: LinkdapiPagination; +}; + +export type LinkdapiPostLikesData = LinkdapiPaginatedData & { + likes?: LinkdapiEngagementItem[]; +}; + +export type LinkdapiPostCommentsData = LinkdapiPaginatedData & { + comments?: LinkdapiEngagementItem[]; +}; + +export type LinkdapiPageRequest = { + start: number; + cursor: string; +}; + +export type LinkdapiPaginatedRequest = ( + page: LinkdapiPageRequest, +) => Promise>; + +export type LinkdapiPaginatedRequestOptions< + T extends LinkdapiPaginatedData, + TItem, +> = { + operationName: string; + useCursor: boolean; + request: LinkdapiPaginatedRequest; + selectItems: (data: T) => TItem[]; + shouldStop?: (items: TItem[]) => boolean; +}; diff --git a/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts new file mode 100644 index 0000000..d3c56ce --- /dev/null +++ b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts @@ -0,0 +1,212 @@ +import { Test } from '@nestjs/testing'; + +import { LinkdapiConfigService } from '../../../common/config/linkdapi-config.service'; +import { + ErrorJob, + SubmissionRejectionReason, +} from '../../../common/constants/errors'; +import { JobRequestType } from '../../../common/enums/job'; +import { ISocialMediaEngagementManifest } from '../../../common/interfaces/job'; +import { generateManifest } from '../../job/fixtures'; +import { generateSubmission } from '../../submission/fixtures'; +import { LinkdapiService } from './linkdapi.service'; + +type LinkdapiConfigServiceMock = { + apiKey?: string; + baseUrl: string; + pageSize: number; + maxPagesPerAction: number; +}; + +describe('LinkdapiService', () => { + let service: LinkdapiService; + let fetchMock: jest.Mock; + let linkdapiConfigService: LinkdapiConfigServiceMock; + + beforeEach(async () => { + fetchMock = jest.fn(); + global.fetch = fetchMock; + linkdapiConfigService = { + apiKey: 'linkdapi-key', + baseUrl: 'https://linkdapi.com', + pageSize: 100, + maxPagesPerAction: 3, + }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + LinkdapiService, + { + provide: LinkdapiConfigService, + useValue: linkdapiConfigService, + }, + ], + }).compile(); + + service = moduleRef.get(LinkdapiService); + }); + + it('paginates liking users and matches LinkedIn profile slugs case-insensitively', async () => { + mockLinkdapiResponse({ + data: { + likes: [ + { + profile: { + profileUrl: 'https://www.linkedin.com/in/Alice-Builder/', + }, + }, + ], + pagination: { nextCursor: 'next' }, + }, + }); + mockLinkdapiResponse({ + data: { + likes: [{ profile: { publicIdentifier: 'bob-builder' } }], + }, + }); + + const result = await service.getLikingUsers( + '7353638537595932672', + new Set(['alice-builder', 'bob-builder']), + ); + + expect(result).toEqual(new Set(['alice-builder', 'bob-builder'])); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0].toString()).toContain( + '/api/v1/posts/likes', + ); + expect(fetchMock.mock.calls[1][0].toString()).toContain('start=1'); + expect(fetchMock.mock.calls[0][1].headers['X-linkdapi-apikey']).toBe( + 'linkdapi-key', + ); + }); + + it('validates LinkedIn engagement submissions with likes and comments', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['linkedin'], + requirements: { + targetPostUrl: + 'https://www.linkedin.com/feed/update/urn:li:activity:7353638537595932672/', + checkLike: true, + checkComment: true, + }, + }) as ISocialMediaEngagementManifest; + const submissions = [ + generateSubmission({ + id: 1, + solution: 'https://www.linkedin.com/in/alice-builder/', + }), + generateSubmission({ id: 2, solution: 'bob-builder' }), + ]; + + jest + .spyOn(service, 'getLikingUsers') + .mockResolvedValue(new Set(['alice-builder', 'bob-builder'])); + jest + .spyOn(service, 'getCommentingUsers') + .mockResolvedValue(new Set(['alice-builder'])); + + await expect( + service.validateSubmissions(submissions, manifest), + ).resolves.toEqual([ + { + submission: submissions[0], + rejectionReason: null, + }, + { + submission: submissions[1], + rejectionReason: SubmissionRejectionReason.MissingRequiredComment, + }, + ]); + + expect(service.getLikingUsers).toHaveBeenCalledWith( + '7353638537595932672', + new Set(['alice-builder', 'bob-builder']), + ); + expect(service.getCommentingUsers).toHaveBeenCalledWith( + '7353638537595932672', + new Set(['alice-builder', 'bob-builder']), + ); + }); + + it('rejects unsupported LinkedIn repost and quote checks', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['linkedin'], + requirements: { + targetPostUrl: + 'https://www.linkedin.com/feed/update/urn:li:activity:7353638537595932672/', + checkRepost: true, + checkQuote: true, + }, + }) as ISocialMediaEngagementManifest; + + await expect( + service.validateSubmissions( + [generateSubmission({ solution: 'alice-builder' })], + manifest, + ), + ).rejects.toThrow(ErrorJob.InvalidManifest); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns target post rejection when the LinkedIn post URN cannot be extracted', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['linkedin'], + requirements: { + targetPostUrl: 'https://www.linkedin.com/posts/no-post-id', + checkLike: true, + }, + }) as ISocialMediaEngagementManifest; + const submissions = [generateSubmission({ solution: 'alice-builder' })]; + + await expect( + service.validateSubmissions(submissions, manifest), + ).resolves.toEqual([ + { + submission: submissions[0], + rejectionReason: SubmissionRejectionReason.TargetPostNotFound, + }, + ]); + }); + + it('throws a clear server error when LinkdAPI credentials are missing', async () => { + const serviceWithoutApiKey = new LinkdapiService({ + ...linkdapiConfigService, + apiKey: undefined, + } as LinkdapiConfigService); + + await expect( + serviceWithoutApiKey.getLikingUsers( + '7353638537595932672', + new Set(['alice']), + ), + ).rejects.toThrow( + 'LinkdAPI config is required to process LinkedIn social_media_engagement jobs', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('maps 404 LinkdAPI responses to target post not found', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: jest.fn().mockResolvedValue(JSON.stringify({ message: 'missing' })), + }); + + await expect( + service.getLikingUsers('7353638537595932672', new Set(['alice'])), + ).rejects.toThrow(SubmissionRejectionReason.TargetPostNotFound); + }); + + function mockLinkdapiResponse(payload: unknown): void { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(payload), + }); + } +}); diff --git a/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts new file mode 100644 index 0000000..2ee3d46 --- /dev/null +++ b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts @@ -0,0 +1,394 @@ +import { Injectable } from '@nestjs/common'; +import { HTTPError, LinkdAPI, LinkdAPIError } from 'linkdapi'; + +import { LinkdapiConfigService } from '../../../common/config/linkdapi-config.service'; +import { + ErrorJob, + SubmissionRejectionReason, +} from '../../../common/constants/errors'; +import { ServerError, ValidationError } from '../../../common/errors'; +import { ISocialMediaEngagementManifest } from '../../../common/interfaces/job'; +import { SubmissionEntity } from '../../submission/submission.entity'; +import type { SubmissionValidationResult } from '../validation.service'; +import { + LinkdapiEngagementItem, + LinkdapiPaginatedData, + LinkdapiPaginatedRequestOptions, + LinkdapiPostCommentsData, + LinkdapiPostLikesData, + LinkdapiResponse, +} from './linkdapi.interfaces'; + +@Injectable() +export class LinkdapiService { + private readonly api?: LinkdAPI; + + constructor(private readonly linkdapiConfigService: LinkdapiConfigService) { + const apiKey = this.linkdapiConfigService.apiKey; + + if (apiKey) { + this.api = new LinkdAPI({ + apiKey, + baseUrl: this.linkdapiConfigService.baseUrl, + }); + } + } + + async validateSubmissions( + submissions: SubmissionEntity[], + manifest: ISocialMediaEngagementManifest, + ): Promise { + if (manifest.requirements.checkRepost || manifest.requirements.checkQuote) { + throw new ValidationError(ErrorJob.InvalidManifest, undefined, [ + 'LinkedIn engagement supports only checkLike and checkComment', + ]); + } + + const targetPostUrn = this.extractPostUrn( + manifest.requirements.targetPostUrl, + ); + if (!targetPostUrn) { + return this.rejectSubmissions( + submissions, + SubmissionRejectionReason.TargetPostNotFound, + ); + } + + const targetUsers = new Set( + submissions.map((submission) => + this.normalizeSubmittedProfile(submission.solution), + ), + ); + const matches = { + likingUsers: new Set(), + commentingUsers: new Set(), + }; + let candidates = new Set(targetUsers); + + try { + if (manifest.requirements.checkLike) { + matches.likingUsers = await this.getLikingUsers( + targetPostUrn, + candidates, + ); + candidates = new Set( + [...candidates].filter((user) => matches.likingUsers.has(user)), + ); + } + + if (manifest.requirements.checkComment && candidates.size > 0) { + matches.commentingUsers = await this.getCommentingUsers( + targetPostUrn, + candidates, + ); + } + + return submissions.map((submission) => { + const user = this.normalizeSubmittedProfile(submission.solution); + let rejectionReason: SubmissionRejectionReason | null = null; + + if (manifest.requirements.checkLike && !matches.likingUsers.has(user)) { + rejectionReason = SubmissionRejectionReason.MissingRequiredLike; + } else if ( + manifest.requirements.checkComment && + !matches.commentingUsers.has(user) + ) { + rejectionReason = SubmissionRejectionReason.MissingRequiredComment; + } + + return { + submission, + rejectionReason, + }; + }); + } catch (error) { + if ( + error instanceof ValidationError && + error.message === SubmissionRejectionReason.TargetPostNotFound + ) { + return this.rejectSubmissions( + submissions, + SubmissionRejectionReason.TargetPostNotFound, + ); + } + + throw error; + } + } + + async getLikingUsers( + postUrn: string, + targetUsers: Set, + ): Promise> { + if (targetUsers.size === 0) { + return new Set(); + } + + const api = this.ensureApi(); + + const items = await this.requestPaginatedData({ + operationName: 'getPostLikes', + useCursor: false, + request: ({ start }) => + api.getPostLikes(postUrn, start) as Promise< + LinkdapiResponse + >, + selectItems: (data) => data.likes ?? [], + shouldStop: (items) => this.hasAllTargetUsers(items, targetUsers), + }); + + return this.getMatchingUsers(items, targetUsers); + } + + async getCommentingUsers( + postUrn: string, + targetUsers: Set, + ): Promise> { + if (targetUsers.size === 0) { + return new Set(); + } + + const api = this.ensureApi(); + + const items = await this.requestPaginatedData({ + operationName: 'getPostComments', + useCursor: true, + request: ({ start, cursor }) => + api.getPostComments( + postUrn, + start, + this.linkdapiConfigService.pageSize, + cursor, + ) as Promise>, + selectItems: (data) => data.comments ?? [], + shouldStop: (items) => this.hasAllTargetUsers(items, targetUsers), + }); + + return this.getMatchingUsers(items, targetUsers); + } + + private async requestPaginatedData({ + operationName, + useCursor, + request, + selectItems, + shouldStop, + }: LinkdapiPaginatedRequestOptions): Promise { + const allItems: TItem[] = []; + let start = 0; + let cursor = ''; + + try { + for ( + let page = 0; + page < this.linkdapiConfigService.maxPagesPerAction; + page += 1 + ) { + const payload = await request({ start, cursor }); + + if (payload.success === false) { + const details = payload.message ?? payload.detail; + throw new ServerError( + details + ? `LinkdAPI ${operationName} failed: ${details}` + : `LinkdAPI ${operationName} failed`, + ); + } + + const data = + payload && typeof payload === 'object' && 'data' in payload + ? (payload.data as T) + : (payload as T); + const items = selectItems(data); + + if (items.length === 0) { + break; + } + + allItems.push(...items); + if (shouldStop?.(allItems)) { + break; + } + + const record = + data && typeof data === 'object' + ? (data as Record) + : {}; + const pagination = + record.pagination && typeof record.pagination === 'object' + ? (record.pagination as Record) + : {}; + const cursorValue = + record.cursor ?? + record.nextCursor ?? + record.next_cursor ?? + pagination.cursor ?? + pagination.nextCursor; + const nextCursor = + typeof cursorValue === 'string' && cursorValue.length > 0 + ? cursorValue + : null; + + if (useCursor && nextCursor && nextCursor !== cursor) { + cursor = nextCursor; + } else { + start += items.length; + cursor = ''; + } + } + + return allItems; + } catch (error) { + if (error instanceof ServerError) { + throw error; + } + + if (error instanceof HTTPError) { + if (error.statusCode === 404) { + throw new ValidationError( + SubmissionRejectionReason.TargetPostNotFound, + ); + } + + throw new ServerError( + error.responseBody + ? `LinkdAPI ${operationName} failed with HTTP ${error.statusCode}: ${error.responseBody}` + : `LinkdAPI ${operationName} failed with HTTP ${error.statusCode}`, + ); + } + + if (error instanceof LinkdAPIError) { + throw new ServerError( + `LinkdAPI ${operationName} failed: ${error.message}`, + ); + } + + throw error; + } + } + + private getMatchingUsers( + items: LinkdapiEngagementItem[], + targetUsers: Set, + ): Set { + const matches = new Set(); + + for (const item of items) { + const profile = + [ + item.author, + item.actor, + item.profile, + item.user, + item.commenter, + item.creator, + item.member, + item, + ].find((candidate) => candidate && typeof candidate === 'object') ?? + null; + + if (!profile) { + continue; + } + + const url = + profile.url ?? profile.profileUrl ?? profile.profileURL ?? item.url; + const username = + profile.username ?? + profile.publicIdentifier ?? + profile.public_identifier ?? + this.profileFromUrl(url); + const urn = + profile.urn ?? profile.profileUrn ?? profile.entityUrn ?? profile.id; + const firstName = profile.firstName ?? profile.first_name; + const lastName = profile.lastName ?? profile.last_name; + const fullName = [firstName, lastName].filter(Boolean).join(' '); + const profileName = + profile.name ?? profile.fullName ?? profile.full_name ?? fullName; + const name = + profileName || + item.header?.replace( + /\s+(likes|reacted to|commented on|reposted).*/i, + '', + ); + + [username, url, urn, name].forEach((key) => { + if (!key) { + return; + } + + const normalizedKey = this.normalizeSubmittedProfile(key); + if (targetUsers.has(normalizedKey)) { + matches.add(normalizedKey); + } + }); + } + + return matches; + } + + private hasAllTargetUsers( + items: LinkdapiEngagementItem[], + targetUsers: Set, + ): boolean { + return this.getMatchingUsers(items, targetUsers).size === targetUsers.size; + } + + private extractPostUrn(postUrl?: string): string | null { + if (!postUrl) { + return null; + } + + const decodedUrl = decodeURIComponent(postUrl); + const urnMatch = decodedUrl.match( + /urn:li:(?:activity|ugcPost|share):(\d+)/i, + ); + if (urnMatch) { + return urnMatch[1]; + } + + const activitySlugMatch = decodedUrl.match( + /(?:activity|ugcPost|share)-(\d{10,})/i, + ); + if (activitySlugMatch) { + return activitySlugMatch[1]; + } + + const numericMatch = decodedUrl.match(/\b(\d{16,})\b/); + return numericMatch?.[1] ?? null; + } + + private normalizeSubmittedProfile(value: string): string { + const slug = this.profileFromUrl(value); + return (slug ?? value).trim().toLowerCase(); + } + + private ensureApi(): LinkdAPI { + if (!this.api) { + throw new ServerError( + 'LinkdAPI config is required to process LinkedIn social_media_engagement jobs', + ); + } + + return this.api; + } + + private profileFromUrl(url?: string): string | null { + if (!url) { + return null; + } + + const match = url.match(/linkedin\.com\/in\/([^/?#]+)/i); + return match?.[1] ?? null; + } + + private rejectSubmissions( + submissions: SubmissionEntity[], + rejectionReason: SubmissionRejectionReason, + ): SubmissionValidationResult[] { + return submissions.map((submission) => ({ + submission, + rejectionReason, + })); + } +} diff --git a/recording-oracle/src/modules/validation/validation.module.ts b/recording-oracle/src/modules/validation/validation.module.ts index b315b46..f59f4d5 100644 --- a/recording-oracle/src/modules/validation/validation.module.ts +++ b/recording-oracle/src/modules/validation/validation.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { GrokService } from './grok/grok.service'; +import { LinkdapiService } from './linkdapi/linkdapi.service'; import { ValidationService } from './validation.service'; import { XApiService } from './x-api/x-api.service'; @Module({ - providers: [GrokService, XApiService, ValidationService], + providers: [GrokService, LinkdapiService, XApiService, ValidationService], exports: [ValidationService], }) export class ValidationModule {} diff --git a/recording-oracle/src/modules/validation/validation.service.spec.ts b/recording-oracle/src/modules/validation/validation.service.spec.ts index c65d4ea..963e981 100644 --- a/recording-oracle/src/modules/validation/validation.service.spec.ts +++ b/recording-oracle/src/modules/validation/validation.service.spec.ts @@ -1,5 +1,6 @@ import { Test } from '@nestjs/testing'; +import { ErrorJob } from '../../common/constants/errors'; import { JobRequestType } from '../../common/enums/job'; import { ISocialMediaEngagementManifest, @@ -8,12 +9,14 @@ import { import { generateManifest } from '../job/fixtures'; import { generateSubmission } from '../submission/fixtures'; import { GrokService } from './grok/grok.service'; +import { LinkdapiService } from './linkdapi/linkdapi.service'; import { ValidationService } from './validation.service'; import { XApiService } from './x-api/x-api.service'; describe('ValidationService', () => { let validationService: ValidationService; let grokService: jest.Mocked; + let linkdapiService: jest.Mocked; let xApiService: jest.Mocked; beforeEach(async () => { @@ -32,11 +35,18 @@ describe('ValidationService', () => { validateSubmissions: jest.fn(), }, }, + { + provide: LinkdapiService, + useValue: { + validateSubmissions: jest.fn(), + }, + }, ], }).compile(); validationService = moduleRef.get(ValidationService); grokService = moduleRef.get(GrokService); + linkdapiService = moduleRef.get(LinkdapiService); xApiService = moduleRef.get(XApiService); }); @@ -60,6 +70,7 @@ describe('ValidationService', () => { manifest, ); expect(xApiService.validateSubmissions).not.toHaveBeenCalled(); + expect(linkdapiService.validateSubmissions).not.toHaveBeenCalled(); }); it('delegates engagement validation to X API', async () => { @@ -83,6 +94,49 @@ describe('ValidationService', () => { submissions, manifest, ); + expect(linkdapiService.validateSubmissions).not.toHaveBeenCalled(); + expect(grokService.validateSubmission).not.toHaveBeenCalled(); + }); + + it('delegates LinkedIn engagement validation to LinkdAPI', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['linkedin'], + }) as ISocialMediaEngagementManifest; + const submissions = [generateSubmission({ solution: 'human-protocol' })]; + const validationResults = [ + { + submission: submissions[0], + rejectionReason: null, + }, + ]; + linkdapiService.validateSubmissions.mockResolvedValue(validationResults); + + await expect( + validationService.validateEngagementSubmissions(submissions, manifest), + ).resolves.toBe(validationResults); + + expect(linkdapiService.validateSubmissions).toHaveBeenCalledWith( + submissions, + manifest, + ); + expect(xApiService.validateSubmissions).not.toHaveBeenCalled(); + expect(grokService.validateSubmission).not.toHaveBeenCalled(); + }); + + it('throws when engagement platform is not supported', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['instagram'], + }) as ISocialMediaEngagementManifest; + const submissions = [generateSubmission({ solution: 'human-protocol' })]; + + expect(() => + validationService.validateEngagementSubmissions(submissions, manifest), + ).toThrow(ErrorJob.UnsupportedSocialPlatform); + + expect(linkdapiService.validateSubmissions).not.toHaveBeenCalled(); + expect(xApiService.validateSubmissions).not.toHaveBeenCalled(); expect(grokService.validateSubmission).not.toHaveBeenCalled(); }); }); diff --git a/recording-oracle/src/modules/validation/validation.service.ts b/recording-oracle/src/modules/validation/validation.service.ts index ab3fcb9..56ce046 100644 --- a/recording-oracle/src/modules/validation/validation.service.ts +++ b/recording-oracle/src/modules/validation/validation.service.ts @@ -1,12 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { SubmissionRejectionReason } from '../../common/constants/errors'; +import { + ErrorJob, + SubmissionRejectionReason, +} from '../../common/constants/errors'; +import { ValidationError } from '../../common/errors'; import { ISocialMediaEngagementManifest, ISocialMediaPromotionManifest, } from '../../common/interfaces/job'; import type { SubmissionEntity } from '../submission/submission.entity'; import { GrokService } from './grok/grok.service'; +import { LinkdapiService } from './linkdapi/linkdapi.service'; import { XApiService } from './x-api/x-api.service'; export type SubmissionValidationResult = { @@ -18,6 +23,7 @@ export type SubmissionValidationResult = { export class ValidationService { constructor( private readonly grokService: GrokService, + private readonly linkdapiService: LinkdapiService, private readonly xApiService: XApiService, ) {} @@ -32,6 +38,21 @@ export class ValidationService { submissions: SubmissionEntity[], manifest: ISocialMediaEngagementManifest, ): Promise { - return this.xApiService.validateSubmissions(submissions, manifest); + const platform = this.getEngagementPlatform(manifest); + + switch (platform) { + case 'linkedin': + return this.linkdapiService.validateSubmissions(submissions, manifest); + case 'x': + return this.xApiService.validateSubmissions(submissions, manifest); + default: + throw new ValidationError(ErrorJob.UnsupportedSocialPlatform); + } + } + + private getEngagementPlatform( + manifest: ISocialMediaEngagementManifest, + ): string { + return manifest.platforms[0]?.toLowerCase() ?? ''; } } diff --git a/recording-oracle/src/modules/validation/x-api/x-api.interfaces.ts b/recording-oracle/src/modules/validation/x-api/x-api.interfaces.ts index 957eebb..b419d38 100644 --- a/recording-oracle/src/modules/validation/x-api/x-api.interfaces.ts +++ b/recording-oracle/src/modules/validation/x-api/x-api.interfaces.ts @@ -48,3 +48,10 @@ export interface XApiErrorResponse { status?: number; }>; } + +export type EngagementMatches = { + likingUsernames: Set; + repostingUsernames: Set; + quotingUsernames: Set; + commentingUsernames: Set; +}; diff --git a/recording-oracle/src/modules/validation/x-api/x-api.service.ts b/recording-oracle/src/modules/validation/x-api/x-api.service.ts index 4dce7bc..d26fec9 100644 --- a/recording-oracle/src/modules/validation/x-api/x-api.service.ts +++ b/recording-oracle/src/modules/validation/x-api/x-api.service.ts @@ -11,19 +11,13 @@ import { import { SubmissionEntity } from '../../submission/submission.entity'; import type { SubmissionValidationResult } from '../validation.service'; import { + EngagementMatches, XApiErrorResponse, XApiListResponse, XApiTweet, XApiUser, } from './x-api.interfaces'; -type EngagementMatches = { - likingUsernames: Set; - repostingUsernames: Set; - quotingUsernames: Set; - commentingUsernames: Set; -}; - @Injectable() export class XApiService { constructor(private readonly xApiConfigService: XApiConfigService) {} diff --git a/recording-oracle/yarn.lock b/recording-oracle/yarn.lock index 7971601..d90ebb4 100644 --- a/recording-oracle/yarn.lock +++ b/recording-oracle/yarn.lock @@ -5903,6 +5903,13 @@ __metadata: languageName: node linkType: hard +"linkdapi@npm:^1.0.2": + version: 1.0.2 + resolution: "linkdapi@npm:1.0.2" + checksum: 10c0/27d47d4be4cf380784a91b32913afcf86c1ac15b44d6bce46df59790c0e883742e02e10c1b887326e13044bbe8eb31b46f46d16d714e1071e453309f619509a7 + languageName: node + linkType: hard + "load-esm@npm:1.0.3": version: 1.0.3 resolution: "load-esm@npm:1.0.3" @@ -7080,6 +7087,7 @@ __metadata: helmet: "npm:^7.1.0" jest: "npm:^29.7.0" joi: "npm:^17.13.3" + linkdapi: "npm:^1.0.2" minio: "npm:8.0.6" pg: "npm:^8.16.3" prettier: "npm:^3.8.1" From 411d12be6334ca936936e47b53cb751ea23346a4 Mon Sep 17 00:00:00 2001 From: portuu3 <61605646+portuu3@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:57:01 +0200 Subject: [PATCH 03/11] Linkdapi fixes (#19) * Fixes for linkedin validation * fix validation --------- Co-authored-by: portuu3 <> --- .../src/common/constants/errors.ts | 3 +- .../submission/submission.constants.ts | 8 +- .../submission/submission.service.spec.ts | 84 ++++++++ .../linkdapi/linkdapi.interfaces.ts | 3 + .../linkdapi/linkdapi.service.spec.ts | 191 +++++++++++++++++- .../validation/linkdapi/linkdapi.service.ts | 56 ++++- 6 files changed, 330 insertions(+), 15 deletions(-) diff --git a/recording-oracle/src/common/constants/errors.ts b/recording-oracle/src/common/constants/errors.ts index 37a7017..95bd757 100644 --- a/recording-oracle/src/common/constants/errors.ts +++ b/recording-oracle/src/common/constants/errors.ts @@ -8,8 +8,7 @@ export enum ErrorJob { InvalidJobType = 'Manifest contains an invalid job type', SolutionAlreadyExists = 'Solution already exists', InvalidPostUrl = 'Post URL must be a valid x.com status URL', - InvalidXUsername = 'X username must be a valid handle', - InvalidSocialProfile = 'Social profile must be a valid handle or LinkedIn profile URL', + InvalidSocialProfile = 'Social profile must be a valid handle or profile URL', UnsupportedSocialPlatform = 'Social media engagement platform must be x or linkedin', } diff --git a/recording-oracle/src/modules/submission/submission.constants.ts b/recording-oracle/src/modules/submission/submission.constants.ts index 97670f7..009e348 100644 --- a/recording-oracle/src/modules/submission/submission.constants.ts +++ b/recording-oracle/src/modules/submission/submission.constants.ts @@ -42,7 +42,13 @@ export const SOCIAL_PROFILE_NORMALIZERS: SocialProfileNormalizer[] = [ return null; }, isValid: (profile: string): boolean => - /^[a-z0-9][a-z0-9_-]{0,99}$/.test(profile), + /^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/.test(profile), + }, + { + normalize: (profile: string): string | null => + profile.trim().replace(/\s+/g, ' ').toLowerCase(), + isValid: (profile: string): boolean => + /^[\p{L}\p{N}](?:[\p{L}\p{N} .'-]{0,98}[\p{L}\p{N}])$/u.test(profile), }, ]; diff --git a/recording-oracle/src/modules/submission/submission.service.spec.ts b/recording-oracle/src/modules/submission/submission.service.spec.ts index 42486fc..d0dfdd6 100644 --- a/recording-oracle/src/modules/submission/submission.service.spec.ts +++ b/recording-oracle/src/modules/submission/submission.service.spec.ts @@ -147,6 +147,72 @@ describe('SubmissionService', () => { }), ); }); + + it('creates an engagement submission with a normalized LinkedIn profile URL containing periods', async () => { + jobService.createJob.mockResolvedValue( + generateJob({ jobType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT }), + ); + + await expect( + submissionService.createSubmission({ + ...webhook, + eventData: { + assigneeId: workerAddress, + solution: 'https://www.linkedin.com/in/Human.Protocol/', + }, + }), + ).resolves.toBe('Submission received.'); + + expect(submissionRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + solution: 'human.protocol', + }), + ); + }); + + it('creates an engagement submission with a normalized LinkedIn profile slug', async () => { + jobService.createJob.mockResolvedValue( + generateJob({ jobType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT }), + ); + + await expect( + submissionService.createSubmission({ + ...webhook, + eventData: { + assigneeId: workerAddress, + solution: 'Human.Protocol', + }, + }), + ).resolves.toBe('Submission received.'); + + expect(submissionRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + solution: 'human.protocol', + }), + ); + }); + + it('creates an engagement submission with a normalized LinkedIn display name', async () => { + jobService.createJob.mockResolvedValue( + generateJob({ jobType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT }), + ); + + await expect( + submissionService.createSubmission({ + ...webhook, + eventData: { + assigneeId: workerAddress, + solution: 'Oriol Blanch', + }, + }), + ).resolves.toBe('Submission received.'); + + expect(submissionRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + solution: 'oriol blanch', + }), + ); + }); }); describe('fail', () => { @@ -176,6 +242,24 @@ describe('SubmissionService', () => { expect(submissionRepository.createUnique).not.toHaveBeenCalled(); }); + + it('rejects invalid engagement submissions with a social profile message', async () => { + jobService.createJob.mockResolvedValue( + generateJob({ jobType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT }), + ); + + await expect( + submissionService.createSubmission({ + ...webhook, + eventData: { + assigneeId: workerAddress, + solution: 'not @ valid profile!', + }, + }), + ).rejects.toThrow(ErrorJob.InvalidSocialProfile); + + expect(submissionRepository.createUnique).not.toHaveBeenCalled(); + }); }); }); diff --git a/recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts b/recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts index 1b805f2..617e452 100644 --- a/recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts +++ b/recording-oracle/src/modules/validation/linkdapi/linkdapi.interfaces.ts @@ -52,6 +52,8 @@ export type LinkdapiPaginatedData = { nextCursor?: string; next_cursor?: string; pagination?: LinkdapiPagination; + currentPage?: number; + pages?: number; }; export type LinkdapiPostLikesData = LinkdapiPaginatedData & { @@ -76,6 +78,7 @@ export type LinkdapiPaginatedRequestOptions< TItem, > = { operationName: string; + pageSize: number; useCursor: boolean; request: LinkdapiPaginatedRequest; selectItems: (data: T) => TItem[]; diff --git a/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts index d3c56ce..9c8c20c 100644 --- a/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts +++ b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.spec.ts @@ -55,8 +55,10 @@ describe('LinkdapiService', () => { profileUrl: 'https://www.linkedin.com/in/Alice-Builder/', }, }, + ...Array.from({ length: 9 }, () => ({ + profile: { publicIdentifier: 'someone-else' }, + })), ], - pagination: { nextCursor: 'next' }, }, }); mockLinkdapiResponse({ @@ -75,12 +77,83 @@ describe('LinkdapiService', () => { expect(fetchMock.mock.calls[0][0].toString()).toContain( '/api/v1/posts/likes', ); - expect(fetchMock.mock.calls[1][0].toString()).toContain('start=1'); + expect(fetchMock.mock.calls[1][0].toString()).toContain('start=10'); expect(fetchMock.mock.calls[0][1].headers['X-linkdapi-apikey']).toBe( 'linkdapi-key', ); }); + it('stops paginating likes when LinkdAPI returns the final page', async () => { + mockLinkdapiResponse({ + data: { + currentPage: 1, + pages: 1, + likes: Array.from({ length: 10 }, () => ({ + actor: { publicIdentifier: 'bob-builder' }, + })), + }, + }); + + const result = await service.getLikingUsers( + '7353638537595932672', + new Set(['alice-builder']), + ); + + expect(result).toEqual(new Set()); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('matches LinkedIn display names with and without spaces', async () => { + mockLinkdapiResponse({ + data: { + likes: [ + { + actor: { + name: 'Oriol Blanch', + url: 'https://www.linkedin.com/in/ACoAACEE-_0BbIdm6ZyR4TaauGijdThxUHYtUys', + }, + }, + ], + }, + }); + + const result = await service.getLikingUsers( + '7353638537595932672', + new Set(['oriol blanch', 'oriolblanch']), + ); + + expect(result).toEqual(new Set(['oriol blanch'])); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('validates a LinkedIn submission using a display name without spaces', async () => { + const manifest = generateManifest({ + requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, + platforms: ['linkedin'], + requirements: { + targetPostUrl: + 'https://www.linkedin.com/feed/update/urn:li:activity:7353638537595932672/', + checkLike: true, + }, + }) as ISocialMediaEngagementManifest; + const submission = generateSubmission({ solution: 'OriolBlanch' }); + + mockLinkdapiResponse({ + data: { + likes: [{ actor: { name: 'Oriol Blanch' } }], + }, + }); + + await expect( + service.validateSubmissions([submission], manifest), + ).resolves.toEqual([ + { + submission, + rejectionReason: null, + }, + ]); + }); + it('validates LinkedIn engagement submissions with likes and comments', async () => { const manifest = generateManifest({ requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, @@ -130,6 +203,120 @@ describe('LinkdapiService', () => { ); }); + it('requests LinkedIn comments with the configured page size', async () => { + mockLinkdapiResponse({ + data: { + comments: [{ commenter: { publicIdentifier: 'alice-builder' } }], + }, + }); + + const result = await service.getCommentingUsers( + '7353638537595932672', + new Set(['alice-builder']), + ); + + expect(result).toEqual(new Set(['alice-builder'])); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const requestUrl = new URL(fetchMock.mock.calls[0][0] as string); + expect(requestUrl.pathname).toBe('/api/v1/posts/comments'); + expect(requestUrl.searchParams.get('urn')).toBe('7353638537595932672'); + expect(requestUrl.searchParams.get('start')).toBe('0'); + expect(requestUrl.searchParams.get('count')).toBe('100'); + expect(requestUrl.searchParams.has('sortBy')).toBe(false); + expect(fetchMock.mock.calls[0][1].headers['X-linkdapi-apikey']).toBe( + 'linkdapi-key', + ); + }); + + it('stops paginating LinkedIn comments when the page is shorter than the configured page size', async () => { + mockLinkdapiResponse({ + data: { + comments: [{ commenter: { publicIdentifier: 'bob-builder' } }], + }, + }); + + const result = await service.getCommentingUsers( + '7353638537595932672', + new Set(['alice-builder']), + ); + + expect(result).toEqual(new Set()); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('paginates full LinkedIn comment pages by the configured page size', async () => { + mockLinkdapiResponse({ + data: { + comments: Array.from({ length: 100 }, () => ({ + commenter: { publicIdentifier: 'bob-builder' }, + })), + }, + }); + mockLinkdapiResponse({ + data: { + comments: [{ commenter: { publicIdentifier: 'alice-builder' } }], + }, + }); + + const result = await service.getCommentingUsers( + '7353638537595932672', + new Set(['alice-builder']), + ); + + expect(result).toEqual(new Set(['alice-builder'])); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const secondRequestUrl = new URL(fetchMock.mock.calls[1][0] as string); + expect(secondRequestUrl.searchParams.get('start')).toBe('100'); + }); + + it('treats later body-level LinkdAPI failures as the end of engagement results', async () => { + mockLinkdapiResponse({ + data: { + likes: Array.from({ length: 10 }, () => ({ + actor: { publicIdentifier: 'bob-builder' }, + })), + }, + }); + mockLinkdapiResponse({ + success: false, + message: "the data cannot be displayed or it doesn't exist", + }); + + const result = await service.getLikingUsers( + '7353638537595932672', + new Set(['alice-builder']), + ); + + expect(result).toEqual(new Set()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('treats later paginated 404 responses as the end of LinkedIn engagement results', async () => { + mockLinkdapiResponse({ + data: { + comments: Array.from({ length: 100 }, () => ({ + commenter: { publicIdentifier: 'bob-builder' }, + })), + }, + }); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: jest.fn().mockResolvedValue(JSON.stringify({ message: 'missing' })), + }); + + const result = await service.getCommentingUsers( + '7353638537595932672', + new Set(['alice-builder']), + ); + + expect(result).toEqual(new Set()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('rejects unsupported LinkedIn repost and quote checks', async () => { const manifest = generateManifest({ requestType: JobRequestType.SOCIAL_MEDIA_ENGAGEMENT, diff --git a/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts index 2ee3d46..536cafd 100644 --- a/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts +++ b/recording-oracle/src/modules/validation/linkdapi/linkdapi.service.ts @@ -55,8 +55,8 @@ export class LinkdapiService { } const targetUsers = new Set( - submissions.map((submission) => - this.normalizeSubmittedProfile(submission.solution), + submissions.flatMap((submission) => + this.getProfileMatchKeys(submission.solution), ), ); const matches = { @@ -84,14 +84,17 @@ export class LinkdapiService { } return submissions.map((submission) => { - const user = this.normalizeSubmittedProfile(submission.solution); + const userKeys = this.getProfileMatchKeys(submission.solution); let rejectionReason: SubmissionRejectionReason | null = null; - if (manifest.requirements.checkLike && !matches.likingUsers.has(user)) { + if ( + manifest.requirements.checkLike && + !userKeys.some((user) => matches.likingUsers.has(user)) + ) { rejectionReason = SubmissionRejectionReason.MissingRequiredLike; } else if ( manifest.requirements.checkComment && - !matches.commentingUsers.has(user) + !userKeys.some((user) => matches.commentingUsers.has(user)) ) { rejectionReason = SubmissionRejectionReason.MissingRequiredComment; } @@ -128,6 +131,7 @@ export class LinkdapiService { const items = await this.requestPaginatedData({ operationName: 'getPostLikes', + pageSize: 10, useCursor: false, request: ({ start }) => api.getPostLikes(postUrn, start) as Promise< @@ -152,6 +156,7 @@ export class LinkdapiService { const items = await this.requestPaginatedData({ operationName: 'getPostComments', + pageSize: this.linkdapiConfigService.pageSize, useCursor: true, request: ({ start, cursor }) => api.getPostComments( @@ -167,8 +172,13 @@ export class LinkdapiService { return this.getMatchingUsers(items, targetUsers); } + getTargetPostUrn(postUrl?: string): string | null { + return this.extractPostUrn(postUrl); + } + private async requestPaginatedData({ operationName, + pageSize, useCursor, request, selectItems, @@ -187,6 +197,10 @@ export class LinkdapiService { const payload = await request({ start, cursor }); if (payload.success === false) { + if (allItems.length > 0) { + return allItems; + } + const details = payload.message ?? payload.detail; throw new ServerError( details @@ -228,11 +242,21 @@ export class LinkdapiService { typeof cursorValue === 'string' && cursorValue.length > 0 ? cursorValue : null; + const currentPage = + typeof record.currentPage === 'number' ? record.currentPage : null; + const pages = typeof record.pages === 'number' ? record.pages : null; + + if ( + items.length < pageSize || + (currentPage !== null && pages !== null && currentPage >= pages) + ) { + break; + } if (useCursor && nextCursor && nextCursor !== cursor) { cursor = nextCursor; } else { - start += items.length; + start += pageSize; cursor = ''; } } @@ -245,6 +269,10 @@ export class LinkdapiService { if (error instanceof HTTPError) { if (error.statusCode === 404) { + if (allItems.length > 0) { + return allItems; + } + throw new ValidationError( SubmissionRejectionReason.TargetPostNotFound, ); @@ -317,9 +345,11 @@ export class LinkdapiService { return; } - const normalizedKey = this.normalizeSubmittedProfile(key); - if (targetUsers.has(normalizedKey)) { - matches.add(normalizedKey); + const matchedKey = this.getProfileMatchKeys(key).find((matchKey) => + targetUsers.has(matchKey), + ); + if (matchedKey) { + matches.add(matchedKey); } }); } @@ -360,7 +390,13 @@ export class LinkdapiService { private normalizeSubmittedProfile(value: string): string { const slug = this.profileFromUrl(value); - return (slug ?? value).trim().toLowerCase(); + return (slug ?? value).trim().replace(/\s+/g, ' ').toLowerCase(); + } + + private getProfileMatchKeys(value: string): string[] { + const normalizedValue = this.normalizeSubmittedProfile(value); + const withoutSpaces = normalizedValue.replace(/\s+/g, ''); + return [...new Set([normalizedValue, withoutSpaces])]; } private ensureApi(): LinkdAPI { From 7795c45ab777d7b90b83165ca94fdfdbd739e2fe Mon Sep 17 00:00:00 2001 From: portuu3 <61605646+portuu3@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:17:51 +0200 Subject: [PATCH 04/11] Exchange Oracle UI (#20) * Enhance SolutionForm and related components for social media engagement submissions * Enhance UI components and functionality for social media engagement submissions - Updated DefaultHeader with new styles and links for better navigation. - Enhanced SolutionForm to include new requirements for social media engagement, including LinkedIn profile name validation. - Improved FundingMethod component to provide clearer instructions for wallet connection. - Refined WalletModal design and functionality for a better user experience. - Updated Home and Solution pages for consistent styling and layout. - Added manifestUrl to AssignmentDetails for better data handling. - Adjusted theme settings for a cohesive dark mode experience across the application. - Updated tests to reflect changes in the assignment structure and requirements. * Refactor WalletModal to use Modal and Paper components, enhance connection handling and error display * Refactor WalletModal to enhance wallet connection UI, add search functionality, and improve error handling * Add rollupOptions to Vite config for inline dynamic imports * Refactor WagmiProvider to define connectors separately for improved readability and maintainability * lint fix * fix lint --------- Co-authored-by: portuu3 <> --- .../src/components/Headers/DefaultHeader.tsx | 62 +- .../src/components/SolutionForm/index.tsx | 551 ++++++++++++++++-- .../src/components/Wallet/FundingMethod.tsx | 92 +-- .../src/components/Wallet/WalletModal.tsx | 360 +++++++++--- exchange-oracle/client/src/index.css | 13 +- .../client/src/pages/Home/index.tsx | 58 +- .../client/src/pages/Solution/index.tsx | 14 +- .../client/src/providers/WagmiProvider.tsx | 24 +- exchange-oracle/client/src/services/job.ts | 29 + exchange-oracle/client/src/theme.ts | 160 +++-- exchange-oracle/client/vite.config.ts | 5 + .../assignment/assignment.controller.spec.ts | 29 + .../assignment/assignment.controller.ts | 24 + .../src/modules/assignment/assignment.dto.ts | 40 ++ .../modules/assignment/assignment.service.ts | 76 ++- 15 files changed, 1276 insertions(+), 261 deletions(-) diff --git a/exchange-oracle/client/src/components/Headers/DefaultHeader.tsx b/exchange-oracle/client/src/components/Headers/DefaultHeader.tsx index c458a4a..cafd64e 100644 --- a/exchange-oracle/client/src/components/Headers/DefaultHeader.tsx +++ b/exchange-oracle/client/src/components/Headers/DefaultHeader.tsx @@ -1,3 +1,4 @@ +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { AppBar, Box, Link as MuiLink, Toolbar } from '@mui/material'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -5,31 +6,78 @@ import logoImg from '../../assets/logo.svg'; export function DefaultHeader() { return ( - - - + + + Marketing Exchange Oracle - + + Assignment + + Dashboard + HUMAN Website + diff --git a/exchange-oracle/client/src/components/SolutionForm/index.tsx b/exchange-oracle/client/src/components/SolutionForm/index.tsx index 6e9e27c..ea8cde7 100644 --- a/exchange-oracle/client/src/components/SolutionForm/index.tsx +++ b/exchange-oracle/client/src/components/SolutionForm/index.tsx @@ -1,9 +1,220 @@ -import { Box, Button, Grid, TextField, Typography } from '@mui/material'; -import { useState } from 'react'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import SendIcon from '@mui/icons-material/Send'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Divider, + Link, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useSnackbar } from '../../providers/SnackProvider'; import { useAccount, useWalletClient } from 'wagmi'; import * as jobService from '../../services/job'; +import type { AssignmentDetails } from '../../services/job'; + +type SolutionCopy = { + title: string; + inputLabel: string; + inputPlaceholder: string; + helperText: string; + submitLabel: string; +}; + +type RequirementItem = { + label: string; + value: string; +}; + +const getBooleanRequirementLabels = ( + requirements: Record, +): string[] => { + const labels: Array<[string, string]> = [ + ['checkLike', 'Like'], + ['checkRepost', 'Repost'], + ['checkQuote', 'Quote'], + ['checkComment', 'Comment'], + ['requiresMedia', 'Media required'], + ['mustBePublic', 'Public post'], + ]; + + return labels + .filter(([key]) => requirements[key] === true) + .map(([, label]) => label); +}; + +const getPlatformLabel = (platform?: string): string => { + if (!platform) return 'Social'; + if (platform.toLowerCase() === 'x') return 'X'; + if (platform.toLowerCase() === 'linkedin') return 'LinkedIn'; + return platform; +}; + +const getStringListRequirement = ( + requirements: Record, + key: string, +): string | null => { + const value = requirements[key]; + + return Array.isArray(value) && value.length > 0 + ? value.map(String).join(', ') + : null; +}; + +const getPositiveNumberRequirement = ( + requirements: Record, + key: string, +): string | null => { + const value = requirements[key]; + + return typeof value === 'number' && value > 0 ? value.toString() : null; +}; + +const getPostRequirementItems = ( + requirements: Record, +): RequirementItem[] => { + const items: RequirementItem[] = []; + const requiredHashtags = getStringListRequirement( + requirements, + 'requiredHashtags', + ); + const requiredKeywords = getStringListRequirement( + requirements, + 'requiredKeywords', + ); + const requiredLink = + typeof requirements.requiredLink === 'string' + ? requirements.requiredLink + : null; + const minLength = getPositiveNumberRequirement(requirements, 'minLength'); + const minLiveDurationHours = getPositiveNumberRequirement( + requirements, + 'minLiveDurationHours', + ); + const minFollowers = getPositiveNumberRequirement( + requirements, + 'minFollowers', + ); + const minAccountAgeDays = getPositiveNumberRequirement( + requirements, + 'minAccountAgeDays', + ); + const minLikes = getPositiveNumberRequirement(requirements, 'minLikes'); + const minReposts = getPositiveNumberRequirement(requirements, 'minReposts'); + + if (requiredHashtags) { + items.push({ label: 'Hashtags', value: requiredHashtags }); + } + if (requiredKeywords) { + items.push({ label: 'Keywords', value: requiredKeywords }); + } + if (requiredLink) { + items.push({ label: 'Link', value: requiredLink }); + } + if (minLength) { + items.push({ label: 'Minimum length', value: `${minLength} characters` }); + } + if (requirements.requiresMedia === true) { + items.push({ label: 'Media', value: 'Required' }); + } + if (requirements.mustBePublic === true) { + items.push({ label: 'Visibility', value: 'Public post required' }); + } + if (minLiveDurationHours) { + items.push({ + label: 'Live duration', + value: `${minLiveDurationHours} hours`, + }); + } + if (minFollowers) { + items.push({ label: 'Followers', value: `At least ${minFollowers}` }); + } + if (minAccountAgeDays) { + items.push({ + label: 'Account age', + value: `At least ${minAccountAgeDays} days`, + }); + } + if (minLikes) { + items.push({ label: 'Likes', value: `At least ${minLikes}` }); + } + if (minReposts) { + items.push({ label: 'Reposts', value: `At least ${minReposts}` }); + } + + return items; +}; + +const getSolutionCopy = ( + assignment?: AssignmentDetails | null, +): SolutionCopy => { + const platform = assignment?.platforms?.[0]?.toLowerCase(); + + if (assignment?.jobType === 'social_media_engagement') { + if (platform === 'x') { + return { + title: 'Submit X Engagement', + inputLabel: 'X handle', + inputPlaceholder: '@human_protocol', + helperText: 'Enter the X username that engaged with the target post.', + submitLabel: 'Submit handle', + }; + } + + if (platform === 'linkedin') { + return { + title: 'Submit LinkedIn Engagement', + inputLabel: 'LinkedIn profile name', + inputPlaceholder: 'John Doe', + helperText: + 'Enter the display name from the LinkedIn profile, not the profile URL.', + submitLabel: 'Submit name', + }; + } + + return { + title: 'Submit Engagement', + inputLabel: 'Social profile', + inputPlaceholder: 'Your profile handle or URL', + helperText: 'Enter the profile that completed the required engagement.', + submitLabel: 'Submit profile', + }; + } + + if (platform === 'linkedin') { + return { + title: 'Submit LinkedIn Post', + inputLabel: 'Post URL', + inputPlaceholder: 'https://www.linkedin.com/posts/...', + helperText: 'Paste the public LinkedIn post URL for this assignment.', + submitLabel: 'Submit post', + }; + } + + return { + title: 'Submit X Post', + inputLabel: 'Post URL', + inputPlaceholder: 'https://x.com/username/status/123', + helperText: 'Paste the public X post URL for this assignment.', + submitLabel: 'Submit post', + }; +}; + +const isUrlLikeValue = (value: string): boolean => { + const trimmedValue = value.trim(); + + return ( + /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedValue) || + /^www\./i.test(trimmedValue) || + /(^|\.)linkedin\.com\//i.test(trimmedValue) + ); +}; const SolutionForm: React.FC = () => { const { assignmentId } = useParams<{ assignmentId: string }>(); @@ -18,6 +229,42 @@ const SolutionForm: React.FC = () => { }, }); const [solution, setSolution] = useState(''); + const [assignment, setAssignment] = useState(null); + const [isLoadingAssignment, setIsLoadingAssignment] = useState(true); + const [assignmentError, setAssignmentError] = useState(null); + + const copy = useMemo(() => getSolutionCopy(assignment), [assignment]); + const platformLabel = getPlatformLabel(assignment?.platforms?.[0]); + const requirementLabels = useMemo( + () => + assignment?.jobType === 'social_media_engagement' + ? getBooleanRequirementLabels(assignment.requirements) + : [], + [assignment], + ); + const isXPromotionSubmission = + assignment?.jobType === 'social_media_promotion' && + (assignment.platforms?.[0]?.toLowerCase() ?? 'x') === 'x'; + const postRequirementItems = useMemo( + () => + assignment && isXPromotionSubmission + ? getPostRequirementItems(assignment.requirements) + : [], + [assignment, isXPromotionSubmission], + ); + const targetPostUrl = + typeof assignment?.requirements.targetPostUrl === 'string' + ? assignment.requirements.targetPostUrl + : null; + const manifestUrl = assignment?.manifestUrl ?? null; + const isLinkedInEngagementSubmission = + assignment?.jobType === 'social_media_engagement' && + assignment.platforms?.[0]?.toLowerCase() === 'linkedin'; + const hasInvalidLinkedInName = + isLinkedInEngagementSubmission && isUrlLikeValue(solution); + const solutionHelperText = hasInvalidLinkedInName + ? 'Submit the LinkedIn profile name, for example John Doe. Do not submit a URL.' + : copy.helperText; type SnackbarApi = { openSnackbar: ( @@ -29,6 +276,41 @@ const SolutionForm: React.FC = () => { const { showError, openSnackbar } = useSnackbar() as SnackbarApi; + useEffect(() => { + let isMounted = true; + + const loadAssignment = async () => { + if (!assignmentId) { + setIsLoadingAssignment(false); + setAssignmentError('Missing assignment id'); + return; + } + + try { + setIsLoadingAssignment(true); + const details = await jobService.getAssignmentDetails(assignmentId); + if (isMounted) { + setAssignment(details); + setAssignmentError(null); + } + } catch { + if (isMounted) { + setAssignmentError('Assignment details could not be loaded.'); + } + } finally { + if (isMounted) { + setIsLoadingAssignment(false); + } + } + }; + + loadAssignment(); + + return () => { + isMounted = false; + }; + }, [assignmentId]); + const handleSubmit = async () => { if (!signer) { openSnackbar('Please connect your wallet first', 'error'); @@ -39,6 +321,12 @@ const SolutionForm: React.FC = () => { openSnackbar('Missing assignment id', 'error'); return; } + + if (hasInvalidLinkedInName) { + openSnackbar('Submit the LinkedIn profile name, not a URL', 'error'); + return; + } + const message = { assignment_id: assignmentId, solution, @@ -55,56 +343,227 @@ const SolutionForm: React.FC = () => { return ( - - - + + Loading assignment + + ) : ( + + {assignmentError && ( + {assignmentError} + )} + + + + + + {copy.title} + + + Assignment #{assignmentId} + + + + {assignment?.jobType && ( + + )} + + + + + {assignment?.jobDescription && ( + + + Description: + + + {assignment.jobDescription} + + + )} + + + + + {(targetPostUrl || + requirementLabels.length > 0 || + postRequirementItems.length > 0) && ( + + + {targetPostUrl && ( + + + Target post + + + + )} + {postRequirementItems.length > 0 && ( + + + Post requirements: + + + {postRequirementItems.map((item) => ( + + + {item.label}: + + + {item.value} + + + ))} + + + )} + {requirementLabels.length > 0 && ( + + + Required actions: + + {requirementLabels.map((label) => ( + + ))} + + )} + + + )} + + setSolution(e.target.value)} + error={hasInvalidLinkedInName} + helperText={solutionHelperText} + fullWidth + InputProps={{ + sx: { + minHeight: 56, + fontWeight: 700, + }, }} + /> + + - - - + manifest + + )} + + )} ); }; diff --git a/exchange-oracle/client/src/components/Wallet/FundingMethod.tsx b/exchange-oracle/client/src/components/Wallet/FundingMethod.tsx index 3db204f..82071a5 100644 --- a/exchange-oracle/client/src/components/Wallet/FundingMethod.tsx +++ b/exchange-oracle/client/src/components/Wallet/FundingMethod.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Grid, Typography } from '@mui/material'; +import { Box, Button, Stack, Typography } from '@mui/material'; import { useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import fundCryptoImg from '../../assets/fund-crypto.png'; @@ -25,54 +25,60 @@ export const FundingMethod = () => { return ( <> {isConnected ? ( - // Mostrar el formulario de solución si está conectado + ) : ( - - - + + crypto + + + + Connect Your Wallet + + - crypto - - Click to connect your wallet - - - - - + Use the worker wallet assigned to this task before submitting + your solution. + + + + )} = { +const WALLET_ICONS: Record = { metaMask: metaMaskSvg, + injected: metaMaskSvg, coinbaseWalletSDK: coinbaseSvg, + coinbaseWallet: coinbaseSvg, walletConnect: walletConnectSvg, }; @@ -25,87 +39,273 @@ export default function WalletModal({ open: boolean; onClose: () => void; }) { - const { connect, connectors, error } = useConnect(); + const { connectAsync } = useConnect(); + const connectors = useConnectors(); + const { disconnectAsync } = useDisconnect(); + const [error, setError] = useState(null); + const [showAllWallets, setShowAllWallets] = useState(false); + const [connectingConnectorId, setConnectingConnectorId] = useState< + string | null + >(null); + const isMobile = useMediaQuery('(max-width: 900px)'); + + const displayedConnectors = useMemo( + () => (showAllWallets ? connectors : connectors.slice(0, 6)), + [connectors, showAllWallets] + ); + + const hasMoreWallets = connectors.length > displayedConnectors.length; + + const handleConnect = async (connector: Connector) => { + setError(null); + setConnectingConnectorId(connector.id); - const theme = useTheme(); + try { + if (connector.id === 'walletConnect') { + onClose(); + } + + await connectAsync({ connector }); + onClose(); + } catch (e) { + const err = e as { message?: string }; + + if (err.message?.includes('Connector already connected')) { + await disconnectAsync(); + await handleConnect(connector); + return; + } + + setError(err.message ?? 'Unable to connect wallet'); + } finally { + setConnectingConnectorId(null); + } + }; + + const content = ( + + + Connect Wallet + + + Connect your wallet to continue and submit your solution. + + + + {displayedConnectors.map((connector) => { + const isConnectingWallet = connectingConnectorId === connector.id; + + return ( + + + + ); + })} + + + {error && ( + + {error} + + )} + + {!showAllWallets ? ( + + ) : ( + <> + + + + )} + + + ); + + const closeButton = ( + + + + ); + + if (isMobile) { + return ( + + {closeButton} + {content} + + ); + } return ( - - - - - Connect -
your wallet -
- - By connecting a wallet, you agree to HUMAN Protocol Terms of Service - and consent to its Privacy Policy. - -
- - - - - - {connectors.map((connector) => ( - - ))} - - - {error &&
{error.message}
} -
-
-
+ + {closeButton} + {content} + + ); } diff --git a/exchange-oracle/client/src/index.css b/exchange-oracle/client/src/index.css index addebdf..1af7577 100644 --- a/exchange-oracle/client/src/index.css +++ b/exchange-oracle/client/src/index.css @@ -1,4 +1,15 @@ +html, +body, +#root { + min-height: 100%; + background: #0d0433; +} + +body { + margin: 0; +} + a { - color: #320a8d; + color: #c7bdff; text-decoration: none; } diff --git a/exchange-oracle/client/src/pages/Home/index.tsx b/exchange-oracle/client/src/pages/Home/index.tsx index d2f7cc2..299864e 100644 --- a/exchange-oracle/client/src/pages/Home/index.tsx +++ b/exchange-oracle/client/src/pages/Home/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Grid, Typography } from '@mui/material'; +import { Box, Grid, Stack, Typography } from '@mui/material'; import { Outlet } from 'react-router-dom'; import bagImg from '../../assets/bag.png'; import humanImg from '../../assets/human.png'; @@ -8,19 +8,19 @@ import { DefaultHeader } from '../../components/Headers/DefaultHeader'; const Home: React.FC = () => { return ( - + - + { height: '100%', }} > - - bag - user - human - + + {[bagImg, userImg, humanImg].map((image, index) => ( + + + + ))} + HUMAN
Exchange Oracle
- - Solve marketing jobs. + + Review the job details, submit the right social proof, and send + it for validation.
diff --git a/exchange-oracle/client/src/pages/Solution/index.tsx b/exchange-oracle/client/src/pages/Solution/index.tsx index c30c7d2..4702855 100644 --- a/exchange-oracle/client/src/pages/Solution/index.tsx +++ b/exchange-oracle/client/src/pages/Solution/index.tsx @@ -9,25 +9,25 @@ const Solution: React.FC = () => { const { isConnected } = useAccount(); return ( - + diff --git a/exchange-oracle/client/src/providers/WagmiProvider.tsx b/exchange-oracle/client/src/providers/WagmiProvider.tsx index a0d72e5..3d56495 100644 --- a/exchange-oracle/client/src/providers/WagmiProvider.tsx +++ b/exchange-oracle/client/src/providers/WagmiProvider.tsx @@ -7,6 +7,20 @@ import { LOCALHOST } from '../constants/chains'; const projectId = import.meta.env.VITE_APP_WALLETCONNECT_PROJECT_ID; +const connectors = [ + ...(projectId + ? [ + walletConnect({ + showQrModal: true, + projectId, + }), + ] + : []), + coinbaseWallet({ + appName: 'human-job-launcher', + }), +]; + export const wagmiConfig = createConfig({ chains: [ wagmiChains.mainnet, @@ -23,15 +37,7 @@ export const wagmiConfig = createConfig({ wagmiChains.xLayerTestnet, LOCALHOST, ], - connectors: [ - walletConnect({ - showQrModal: true, - projectId: projectId ?? '', - }), - coinbaseWallet({ - appName: 'human-job-launcher', - }), - ], + connectors, transports: { [wagmiChains.mainnet.id]: http(), [wagmiChains.sepolia.id]: http(), diff --git a/exchange-oracle/client/src/services/job.ts b/exchange-oracle/client/src/services/job.ts index 7861141..c00600e 100644 --- a/exchange-oracle/client/src/services/job.ts +++ b/exchange-oracle/client/src/services/job.ts @@ -3,6 +3,25 @@ import { WalletClient } from 'viem'; import { HUMAN_SIGNATURE_KEY } from '../constants'; import api from '../utils/api'; +export type JobType = 'social_media_promotion' | 'social_media_engagement'; + +export type AssignmentDetails = { + assignmentId: string; + escrowAddress: string; + chainId: number; + jobType: JobType; + status: string; + rewardAmount: number; + rewardToken: string; + createdAt: string; + expiresAt: string; + updatedAt?: string; + jobDescription: string; + manifestUrl: string; + platforms: string[]; + requirements: Record; +}; + export const solveJob = async (signer: WalletClient, body: any) => { if (!signer.account) { throw new Error('Account not found'); @@ -16,3 +35,13 @@ export const solveJob = async (signer: WalletClient, body: any) => { headers: { [HUMAN_SIGNATURE_KEY]: signature }, }); }; + +export const getAssignmentDetails = async ( + assignmentId: string, +): Promise => { + const response = await api.get( + `/assignment/${assignmentId}/details`, + ); + + return response.data; +}; diff --git a/exchange-oracle/client/src/theme.ts b/exchange-oracle/client/src/theme.ts index ae150fc..c09c9de 100644 --- a/exchange-oracle/client/src/theme.ts +++ b/exchange-oracle/client/src/theme.ts @@ -2,34 +2,39 @@ import { createTheme } from '@mui/material/styles'; const theme = createTheme({ palette: { + mode: 'dark', primary: { - main: '#320a8d', - light: '#320a8d', - dark: '#4a148c', + main: '#ff1f7a', + light: '#ff5aa1', + dark: '#d81064', }, info: { - main: '#eeeeee', - light: '#f5f5f5', - dark: '#bdbdbd', + main: '#8d83c7', + light: '#c9c2ff', + dark: '#5e538f', }, secondary: { - main: '#858ec6', - light: '#6309ff', - dark: '#00867d', - contrastText: '#000', + main: '#c7bdff', + light: '#eee9ff', + dark: '#7d70b8', + contrastText: '#100735', }, text: { - primary: '#320a8d', - secondary: '#858ec6', + primary: '#ffffff', + secondary: '#9b91d4', + }, + background: { + default: '#0d0433', + paper: '#271f4f', }, success: { - main: '#0E976E', + main: '#23b889', }, warning: { - main: '#FF9800', + main: '#f5b800', }, error: { - main: '#F20D5F', + main: '#ff3b80', }, }, typography: { @@ -37,12 +42,13 @@ const theme = createTheme({ h2: { fontSize: '80px', lineHeight: 1.5, - letterSpacing: '-0.5px', + letterSpacing: 0, fontWeight: 800, }, h4: { - fontSize: '34px', - fontWeight: 600, + fontSize: '28px', + fontWeight: 800, + letterSpacing: 0, }, h6: { fontSize: '20px', @@ -60,19 +66,50 @@ const theme = createTheme({ components: { MuiAlert: { styleOverrides: { + standardWarning: { + color: '#ffffff', + backgroundColor: '#33275f', + border: '1px solid #4f4380', + }, + standardError: { + color: '#ffffff', + backgroundColor: '#3c1942', + border: '1px solid #7e2d63', + }, + standardSuccess: { + color: '#ffffff', + backgroundColor: '#173f3d', + border: '1px solid #246c62', + }, outlinedSuccess: { - color: '#320a8d', - borderColor: '#320a8d', + color: '#ffffff', + borderColor: '#23b889', }, - // icon: { - // color: '#320a8d !important', - // }, }, }, MuiButton: { styleOverrides: { root: { textTransform: 'none', + borderRadius: '6px', + boxShadow: 'none', + fontWeight: 800, + }, + contained: { + color: '#ffffff', + backgroundColor: '#ff1f7a', + '&:hover': { + backgroundColor: '#e9166b', + boxShadow: '0 8px 18px rgba(255, 31, 122, 0.20)', + }, + }, + outlined: { + color: '#ffffff', + borderColor: '#4c3d82', + '&:hover': { + borderColor: '#ff1f7a', + backgroundColor: 'rgba(255, 31, 122, 0.08)', + }, }, sizeLarge: { fontSize: '15px', @@ -85,10 +122,10 @@ const theme = createTheme({ MuiCard: { styleOverrides: { root: { - borderRadius: '16px', - background: '#fff', - boxShadow: - '0px 1px 5px 0px rgba(233, 235, 250, 0.20), 0px 2px 2px 0px rgba(233, 235, 250, 0.50), 0px 3px 1px -2px #E9EBFA', + borderRadius: '8px', + background: '#271f4f', + border: '1px solid #3f3569', + boxShadow: 'none', }, }, }, @@ -103,25 +140,80 @@ const theme = createTheme({ styleOverrides: { root: { textDecoration: 'none', + color: '#c7bdff', }, }, }, MuiOutlinedInput: { styleOverrides: { root: { + color: '#ffffff', + backgroundColor: '#201844', + borderRadius: '8px', '&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderWidth: '1px', + borderColor: '#ff1f7a', }, }, notchedOutline: { - borderColor: '#858ec6', + borderColor: '#514681', + }, + }, + }, + MuiInputLabel: { + styleOverrides: { + root: { + color: '#9b91d4', + '&.Mui-focused': { + color: '#ff5aa1', + }, + }, + }, + }, + MuiFormHelperText: { + styleOverrides: { + root: { + color: '#9b91d4', + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + height: '28px', + borderRadius: '999px', + color: '#ffffff', + backgroundColor: '#342a60', + border: '1px solid #4a3f78', + fontWeight: 700, + }, + colorPrimary: { + color: '#180a3d', + backgroundColor: '#c7bdff', + borderColor: '#c7bdff', + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: '#3f3569', + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + background: '#271f4f', + border: '1px solid #3f3569', + borderRadius: '8px', }, }, }, MuiSelect: { styleOverrides: { icon: { - color: '#320a8d', + color: '#c7bdff', }, }, }, @@ -130,16 +222,16 @@ const theme = createTheme({ root: { textTransform: 'none', borderRadius: '8px', - border: '1px solid #320a8d', - color: '#320a8d', + border: '1px solid #4c3d82', + color: '#ffffff', fontWeight: 600, fontSize: '14px', '&.Mui-selected': { - background: '#320a8d', + background: '#ff1f7a', color: '#fff', }, '&.Mui-selected:hover': { - background: '#320a8d', + background: '#ff1f7a', }, }, }, @@ -148,7 +240,7 @@ const theme = createTheme({ styleOverrides: { root: { '&.Mui-selected': { - backgroundColor: '#f9faff', + backgroundColor: '#33275f', }, }, }, diff --git a/exchange-oracle/client/vite.config.ts b/exchange-oracle/client/vite.config.ts index 846dc0c..ce8efc5 100644 --- a/exchange-oracle/client/vite.config.ts +++ b/exchange-oracle/client/vite.config.ts @@ -20,6 +20,11 @@ export default defineConfig({ commonjsOptions: { include: [/node_modules/], }, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, }, server: { port: 3003, diff --git a/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts b/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts index f67ba86..585344e 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts @@ -94,6 +94,35 @@ describe('assignmentController', () => { }); }); + describe('getAssignmentDetails', () => { + it('should call assignmentService.getAssignmentDetails', async () => { + const assignmentDetails = { + assignmentId: '123', + escrowAddress, + chainId: 80002, + jobType: JobType.SOCIAL_MEDIA_ENGAGEMENT, + status: AssignmentStatus.ACTIVE, + rewardAmount: 1, + rewardToken: 'HMT', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + jobDescription: 'Like the target X post.', + manifestUrl: 'https://example.com/manifest.json', + platforms: ['x'], + requirements: { targetPostUrl: 'https://x.com/test/status/123' }, + }; + jest + .spyOn(assignmentService, 'getAssignmentDetails') + .mockResolvedValue(assignmentDetails); + + const result = await assignmentController.getAssignmentDetails('123'); + + expect(result).toBe(assignmentDetails); + expect(assignmentService.getAssignmentDetails).toHaveBeenCalledWith(123); + }); + }); + describe('resignJob', () => { describe('succeed', () => { it('should call jobService.resignJob', async () => { diff --git a/exchange-oracle/server/src/modules/assignment/assignment.controller.ts b/exchange-oracle/server/src/modules/assignment/assignment.controller.ts index 6f2d76e..1964f00 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.controller.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.controller.ts @@ -5,6 +5,7 @@ import { UseGuards, Request, Get, + Param, Query, } from '@nestjs/common'; import { @@ -18,6 +19,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt.auth'; import { AssignmentService } from './assignment.service'; import { AssignJobResponseDto, + AssignmentDetailsDto, AssignmentDto, CreateAssignmentDto, GetAssignmentsDto, @@ -25,6 +27,7 @@ import { } from './assignment.dto'; import { RequestWithUser } from '../../common/types/jwt'; import { PageDto } from '../../common/pagination/pagination.dto'; +import { Public } from '../../common/decorators'; @ApiTags('Assignment') @Controller('assignment') @@ -102,6 +105,27 @@ export class AssignmentController { ); } + @ApiOperation({ + summary: 'Get Assignment Details', + description: 'Endpoint to retrieve assignment details for the solution UI.', + }) + @ApiResponse({ + status: 200, + description: 'Assignment details retrieved successfully.', + type: AssignmentDetailsDto, + }) + @ApiResponse({ + status: 404, + description: 'Assignment not found.', + }) + @Public() + @Get(':assignmentId/details') + getAssignmentDetails( + @Param('assignmentId') assignmentId: string, + ): Promise { + return this.assignmentService.getAssignmentDetails(Number(assignmentId)); + } + @ApiOperation({ summary: 'Resign Assignment', description: 'Endpoint to resign from a assignment.', diff --git a/exchange-oracle/server/src/modules/assignment/assignment.dto.ts b/exchange-oracle/server/src/modules/assignment/assignment.dto.ts index 1375292..c79ea28 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.dto.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.dto.ts @@ -138,6 +138,46 @@ export class AssignmentDto { } } +export class AssignmentDetailsDto extends AssignmentDto { + @ApiProperty({ name: 'job_description' }) + jobDescription: string; + + @ApiProperty({ name: 'manifest_url' }) + manifestUrl: string; + + @ApiProperty({ isArray: true }) + platforms: string[]; + + @ApiProperty() + requirements: Record; + + constructor( + assignment: AssignmentDto, + jobDescription: string, + manifestUrl: string, + platforms: string[], + requirements: Record, + ) { + super( + assignment.assignmentId, + assignment.escrowAddress, + assignment.chainId, + assignment.jobType, + assignment.status, + assignment.rewardAmount, + assignment.rewardToken, + assignment.createdAt, + assignment.expiresAt, + assignment.updatedAt ?? '', + ); + this.url = assignment.url; + this.jobDescription = jobDescription; + this.manifestUrl = manifestUrl; + this.platforms = platforms; + this.requirements = requirements; + } +} + export class ResignDto { @ApiProperty({ name: 'assignment_id' }) @IsString() diff --git a/exchange-oracle/server/src/modules/assignment/assignment.service.ts b/exchange-oracle/server/src/modules/assignment/assignment.service.ts index d49dfb1..068a4b1 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.service.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.service.ts @@ -16,6 +16,7 @@ import { JobRepository } from '../job/job.repository'; import { JobService } from '../job/job.service'; import { Web3Service } from '../web3/web3.service'; import { + AssignmentDetailsDto, AssignmentDto, CreateAssignmentDto, GetAssignmentsDto, @@ -149,29 +150,37 @@ export class AssignmentService { }); const assignments = await Promise.all( entities.map(async (entity) => { - const assignment = new AssignmentDto( - entity.id.toString(), - entity.job.escrowAddress, - entity.job.chainId, - entity.job.jobType, - entity.status, - entity.rewardAmount, - entity.job.rewardToken, - entity.createdAt.toISOString(), - entity.expiresAt.toISOString(), - entity.updatedAt.toISOString(), - ); - if (entity.status === AssignmentStatus.ACTIVE) - assignment.url = - this.serverConfigService.feURL + - '/assignment/' + - entity.id.toString(); - return assignment; + return this.toAssignmentDto(entity); }), ); return new PageDto(data.page!, data.pageSize!, itemCount, assignments); } + public async getAssignmentDetails( + assignmentId: number, + ): Promise { + const entity = await this.assignmentRepository.findOneById(assignmentId); + + if (!entity) { + throw new ServerError(ErrorAssignment.NotFound); + } + + const manifest = await this.jobService.getManifest( + entity.job.chainId, + entity.job.escrowAddress, + entity.job.manifestUrl, + ); + const assignment = this.toAssignmentDto(entity); + + return new AssignmentDetailsDto( + assignment, + manifest.campaign.description, + entity.job.manifestUrl, + manifest.platforms, + this.getPublicRequirements(manifest.requirements), + ); + } + async resign(assignmentId: number, workerAddress: string): Promise { const assignment = await this.assignmentRepository.findOneById(assignmentId); @@ -190,4 +199,35 @@ export class AssignmentService { assignment.status = AssignmentStatus.CANCELED; await this.assignmentRepository.updateOne(assignment); } + + private toAssignmentDto(entity: AssignmentEntity): AssignmentDto { + const assignment = new AssignmentDto( + entity.id.toString(), + entity.job.escrowAddress, + entity.job.chainId, + entity.job.jobType, + entity.status, + entity.rewardAmount, + entity.job.rewardToken, + entity.createdAt.toISOString(), + entity.expiresAt.toISOString(), + entity.updatedAt.toISOString(), + ); + + if (entity.status === AssignmentStatus.ACTIVE) { + assignment.url = + this.serverConfigService.feURL + '/assignment/' + entity.id.toString(); + } + + return assignment; + } + + private getPublicRequirements(requirements: object): Record { + const publicRequirements = { + ...(requirements as Record), + }; + delete publicRequirements.xApiCredentials; + + return publicRequirements; + } } From 0a688f4022f0cc0369e26e84e681e67d3c38986a Mon Sep 17 00:00:00 2001 From: portuu3 <> Date: Thu, 18 Jun 2026 17:09:26 +0200 Subject: [PATCH 05/11] add end date --- .../src/components/SolutionForm/index.tsx | 27 +++++++++++++++++++ exchange-oracle/client/src/services/job.ts | 1 + .../assignment/assignment.controller.spec.ts | 1 + .../src/modules/assignment/assignment.dto.ts | 5 ++++ .../modules/assignment/assignment.service.ts | 1 + 5 files changed, 35 insertions(+) diff --git a/exchange-oracle/client/src/components/SolutionForm/index.tsx b/exchange-oracle/client/src/components/SolutionForm/index.tsx index ea8cde7..4fec74b 100644 --- a/exchange-oracle/client/src/components/SolutionForm/index.tsx +++ b/exchange-oracle/client/src/components/SolutionForm/index.tsx @@ -216,6 +216,22 @@ const isUrlLikeValue = (value: string): boolean => { ); }; +const formatEndDate = (value?: string): string | null => { + if (!value) { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +}; + const SolutionForm: React.FC = () => { const { assignmentId } = useParams<{ assignmentId: string }>(); @@ -257,6 +273,7 @@ const SolutionForm: React.FC = () => { ? assignment.requirements.targetPostUrl : null; const manifestUrl = assignment?.manifestUrl ?? null; + const endDateLabel = formatEndDate(assignment?.endDate); const isLinkedInEngagementSubmission = assignment?.jobType === 'social_media_engagement' && assignment.platforms?.[0]?.toLowerCase() === 'linkedin'; @@ -377,6 +394,16 @@ const SolutionForm: React.FC = () => { Assignment #{assignmentId} + {endDateLabel && ( + + Actions should be completed by {endDateLabel} + + )} {assignment?.jobType && ( diff --git a/exchange-oracle/client/src/services/job.ts b/exchange-oracle/client/src/services/job.ts index c00600e..ec8cf4c 100644 --- a/exchange-oracle/client/src/services/job.ts +++ b/exchange-oracle/client/src/services/job.ts @@ -15,6 +15,7 @@ export type AssignmentDetails = { rewardToken: string; createdAt: string; expiresAt: string; + endDate: string; updatedAt?: string; jobDescription: string; manifestUrl: string; diff --git a/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts b/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts index 585344e..66232be 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.controller.spec.ts @@ -108,6 +108,7 @@ describe('assignmentController', () => { expiresAt: new Date().toISOString(), updatedAt: new Date().toISOString(), jobDescription: 'Like the target X post.', + endDate: new Date().toISOString(), manifestUrl: 'https://example.com/manifest.json', platforms: ['x'], requirements: { targetPostUrl: 'https://x.com/test/status/123' }, diff --git a/exchange-oracle/server/src/modules/assignment/assignment.dto.ts b/exchange-oracle/server/src/modules/assignment/assignment.dto.ts index c79ea28..b86487a 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.dto.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.dto.ts @@ -142,6 +142,9 @@ export class AssignmentDetailsDto extends AssignmentDto { @ApiProperty({ name: 'job_description' }) jobDescription: string; + @ApiProperty({ name: 'end_date' }) + endDate: string; + @ApiProperty({ name: 'manifest_url' }) manifestUrl: string; @@ -154,6 +157,7 @@ export class AssignmentDetailsDto extends AssignmentDto { constructor( assignment: AssignmentDto, jobDescription: string, + endDate: string, manifestUrl: string, platforms: string[], requirements: Record, @@ -172,6 +176,7 @@ export class AssignmentDetailsDto extends AssignmentDto { ); this.url = assignment.url; this.jobDescription = jobDescription; + this.endDate = endDate; this.manifestUrl = manifestUrl; this.platforms = platforms; this.requirements = requirements; diff --git a/exchange-oracle/server/src/modules/assignment/assignment.service.ts b/exchange-oracle/server/src/modules/assignment/assignment.service.ts index 068a4b1..3fd3dca 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.service.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.service.ts @@ -175,6 +175,7 @@ export class AssignmentService { return new AssignmentDetailsDto( assignment, manifest.campaign.description, + new Date(manifest.endDate).toISOString(), entity.job.manifestUrl, manifest.platforms, this.getPublicRequirements(manifest.requirements), From f956d5b199dea0d30e5f60c7deca7509e3993315 Mon Sep 17 00:00:00 2001 From: portuu3 <> Date: Mon, 22 Jun 2026 11:01:17 +0200 Subject: [PATCH 06/11] Add validation for assignment job end date and update related methods --- .../server/src/common/constant/errors.ts | 1 + .../assignment/assignment.service.spec.ts | 64 +++++++++++++++++++ .../modules/assignment/assignment.service.ts | 18 +++++- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/exchange-oracle/server/src/common/constant/errors.ts b/exchange-oracle/server/src/common/constant/errors.ts index 4d00583..feb91c2 100644 --- a/exchange-oracle/server/src/common/constant/errors.ts +++ b/exchange-oracle/server/src/common/constant/errors.ts @@ -28,6 +28,7 @@ export enum ErrorAssignment { FullyAssigned = 'Fully assigned job', ExpiredEscrow = 'Expired escrow', InsufficientTimeForLiveDuration = 'Not enough time remains before the job end date to satisfy post duration', + InvalidEndDate = 'Invalid assignment job end date', JobNotFound = 'Job not found', ReputationNetworkMismatch = 'Requested job is not in your reputation network', } diff --git a/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts b/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts index 041fb98..7018511 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts @@ -584,6 +584,70 @@ describe('AssignmentService', () => { }); }); + describe('getAssignmentDetails', () => { + const assignmentId = 3; + const assignmentEntity = { + id: assignmentId, + job: { + chainId, + escrowAddress, + manifestUrl: MOCK_MANIFEST_URL, + jobType: JobType.SOCIAL_MEDIA_PROMOTION, + rewardToken: 'HMT', + }, + status: AssignmentStatus.ACTIVE, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2026-01-02T00:00:00.000Z'), + updatedAt: new Date('2026-01-03T00:00:00.000Z'), + rewardAmount: 20, + } as AssignmentEntity; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return details when manifest end date is a millisecond timestamp', async () => { + const endDate = 1782129600000; + const manifest = createManifest({ + endDate, + }); + + jest + .spyOn(assignmentRepository, 'findOneById') + .mockResolvedValue(assignmentEntity); + jest.spyOn(jobService, 'getManifest').mockResolvedValue(manifest); + + const result = await assignmentService.getAssignmentDetails(assignmentId); + + expect(result.endDate).toBe('2026-06-22T12:00:00.000Z'); + expect(result).toEqual( + expect.objectContaining({ + assignmentId: assignmentId.toString(), + chainId, + escrowAddress, + jobDescription: manifest.campaign.description, + manifestUrl: MOCK_MANIFEST_URL, + platforms: manifest.platforms, + }), + ); + }); + + it('should fail with validation error when manifest end date is invalid', async () => { + const manifest = createManifest({ + endDate: 'not-a-date', + } as unknown as Partial); + + jest + .spyOn(assignmentRepository, 'findOneById') + .mockResolvedValue(assignmentEntity); + jest.spyOn(jobService, 'getManifest').mockResolvedValue(manifest); + + await expect( + assignmentService.getAssignmentDetails(assignmentId), + ).rejects.toThrow(ErrorAssignment.InvalidEndDate); + }); + }); + describe('resignJob', () => { describe('succeed', () => { it('should successfully cancel an active assignment', async () => { diff --git a/exchange-oracle/server/src/modules/assignment/assignment.service.ts b/exchange-oracle/server/src/modules/assignment/assignment.service.ts index 3fd3dca..e33bd0a 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.service.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.service.ts @@ -85,7 +85,7 @@ export class AssignmentService { throw new ValidationError(ErrorAssignment.FullyAssigned); } - const jobEndDate = new Date(manifest.endDate); + const jobEndDate = this.parseManifestEndDate(manifest.endDate); const requiredLiveDurationHours = manifest.requestType === JobType.SOCIAL_MEDIA_PROMOTION && 'minLiveDurationHours' in manifest.requirements @@ -175,7 +175,7 @@ export class AssignmentService { return new AssignmentDetailsDto( assignment, manifest.campaign.description, - new Date(manifest.endDate).toISOString(), + this.parseManifestEndDate(manifest.endDate).toISOString(), entity.job.manifestUrl, manifest.platforms, this.getPublicRequirements(manifest.requirements), @@ -231,4 +231,18 @@ export class AssignmentService { return publicRequirements; } + + private parseManifestEndDate(endDate: number | string): Date { + const timestamp = + typeof endDate === 'string' && endDate.trim() !== '' + ? Number(endDate) + : endDate; + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + throw new ValidationError(ErrorAssignment.InvalidEndDate); + } + + return date; + } } From d2760e40b21a37a793e2045910e4d6b52f2bad19 Mon Sep 17 00:00:00 2001 From: portuu3 <> Date: Mon, 22 Jun 2026 11:23:40 +0200 Subject: [PATCH 07/11] Add handling for snake_case end date in assignment manifest --- .../assignment/assignment.service.spec.ts | 17 ++++++++++ .../modules/assignment/assignment.service.ts | 33 +++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts b/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts index 7018511..e73554c 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts @@ -632,6 +632,23 @@ describe('AssignmentService', () => { ); }); + it('should return details when manifest end date is snake case', async () => { + const manifest = { + ...createManifest(), + endDate: undefined, + end_date: 1782129600000, + } as unknown as ManifestDto; + + jest + .spyOn(assignmentRepository, 'findOneById') + .mockResolvedValue(assignmentEntity); + jest.spyOn(jobService, 'getManifest').mockResolvedValue(manifest); + + const result = await assignmentService.getAssignmentDetails(assignmentId); + + expect(result.endDate).toBe('2026-06-22T12:00:00.000Z'); + }); + it('should fail with validation error when manifest end date is invalid', async () => { const manifest = createManifest({ endDate: 'not-a-date', diff --git a/exchange-oracle/server/src/modules/assignment/assignment.service.ts b/exchange-oracle/server/src/modules/assignment/assignment.service.ts index e33bd0a..924ada5 100644 --- a/exchange-oracle/server/src/modules/assignment/assignment.service.ts +++ b/exchange-oracle/server/src/modules/assignment/assignment.service.ts @@ -85,7 +85,9 @@ export class AssignmentService { throw new ValidationError(ErrorAssignment.FullyAssigned); } - const jobEndDate = this.parseManifestEndDate(manifest.endDate); + const jobEndDate = this.parseManifestEndDate( + this.getManifestEndDate(manifest), + ); const requiredLiveDurationHours = manifest.requestType === JobType.SOCIAL_MEDIA_PROMOTION && 'minLiveDurationHours' in manifest.requirements @@ -172,10 +174,14 @@ export class AssignmentService { ); const assignment = this.toAssignmentDto(entity); + console.log(manifest); + return new AssignmentDetailsDto( assignment, manifest.campaign.description, - this.parseManifestEndDate(manifest.endDate).toISOString(), + this.parseManifestEndDate( + this.getManifestEndDate(manifest), + ).toISOString(), entity.job.manifestUrl, manifest.platforms, this.getPublicRequirements(manifest.requirements), @@ -232,7 +238,28 @@ export class AssignmentService { return publicRequirements; } - private parseManifestEndDate(endDate: number | string): Date { + private getManifestEndDate(manifest: unknown): unknown { + if (!manifest || typeof manifest !== 'object') { + return undefined; + } + + const manifestRecord = manifest as Record; + return manifestRecord.endDate ?? manifestRecord.end_date; + } + + private parseManifestEndDate(endDate: unknown): Date { + if (endDate === null || endDate === undefined) { + throw new ValidationError(ErrorAssignment.InvalidEndDate); + } + + if ( + typeof endDate !== 'string' && + typeof endDate !== 'number' && + !(endDate instanceof Date) + ) { + throw new ValidationError(ErrorAssignment.InvalidEndDate); + } + const timestamp = typeof endDate === 'string' && endDate.trim() !== '' ? Number(endDate) From 665ec742901d8a389593c6d9b26d9b99f5d43338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:02:17 +0200 Subject: [PATCH 08/11] Campaign Launcher Client (#22) * Add new campaing launcher client * Add campaign launcher client and update oracles to use on chain manifest instead of cloud storage * Refactor manifestUrl to manifest across job and assignment services, update related tests, and add PGP message handling in storage utilities * Refactor encryption utilities: move isFullPgpMessage and decryptJson to a new file, update imports in job and storage services * Refactor assignment service: rename manifestUrl to manifest in job entity and update related references --- campaign-launcher/client/.env.example | 14 + campaign-launcher/client/.gitignore | 6 + campaign-launcher/client/.yarnrc.yml | 1 + campaign-launcher/client/eslint.config.mjs | 53 + campaign-launcher/client/index.html | 12 + campaign-launcher/client/package.json | 50 + campaign-launcher/client/public/robots.txt | 2 + campaign-launcher/client/src/App.tsx | 35 + .../client/src/components/AccountDropdown.tsx | 99 + .../ConnectWallet/ConnectWalletContent.tsx | 386 + .../src/components/ConnectWallet/index.tsx | 60 + .../client/src/components/Field.tsx | 5 + .../client/src/components/Footer/index.tsx | 110 + .../client/src/components/KeyValue.tsx | 22 + .../components/ResponsiveOverlay/index.tsx | 94 + .../src/components/campaign/CampaignSteps.tsx | 771 ++ .../campaign/LaunchProgressDialog.tsx | 114 + .../client/src/components/layout/Shell.tsx | 142 + .../client/src/constants/chains.ts | 61 + .../client/src/constants/fundingTokens.ts | 124 + .../client/src/constants/index.ts | 16 + .../client/src/hooks/useBreakpoints.ts | 6 + .../client/src/hooks/useConnectWalletModal.ts | 75 + .../client/src/hooks/useMarketingEscrow.ts | 221 + .../client/src/hooks/useReownWalletOptions.ts | 309 + campaign-launcher/client/src/index.css | 23 + campaign-launcher/client/src/main.tsx | 26 + .../client/src/pages/CreateCampaignPage.tsx | 529 ++ .../client/src/pages/HomePage.tsx | 174 + .../src/providers/QueryClientProvider.tsx | 18 + .../client/src/providers/WagmiProvider.tsx | 51 + .../client/src/providers/wagmiConfig.ts | 27 + campaign-launcher/client/src/theme.ts | 128 + campaign-launcher/client/src/types/index.ts | 146 + .../client/src/utils/manifest.ts | 228 + .../client/src/utils/validation.ts | 93 + campaign-launcher/client/src/utils/wallet.ts | 14 + campaign-launcher/client/src/vite-env.d.ts | 21 + campaign-launcher/client/tsconfig.json | 21 + campaign-launcher/client/vite.config.ts | 26 + campaign-launcher/client/yarn.lock | 7836 +++++++++++++++++ .../server/src/common/interfaces/job.ts | 2 +- .../server/src/common/utils/encryption.ts | 25 + ...77010000000-renameManifestUrlToManifest.ts | 17 + .../assignment/assignment.service.spec.ts | 28 +- .../modules/assignment/assignment.service.ts | 8 +- .../server/src/modules/job/job.entity.ts | 2 +- .../src/modules/job/job.service.spec.ts | 90 +- .../server/src/modules/job/job.service.ts | 44 +- .../src/common/utils/encryption.ts | 35 + recording-oracle/src/common/utils/manifest.ts | 55 + ...77010000000-renameManifestUrlToManifest.ts | 17 + .../src/modules/cron-job/cron-job.service.ts | 2 +- .../src/modules/job/fixtures/index.ts | 2 +- .../src/modules/job/job.entity.ts | 2 +- .../src/modules/job/job.service.spec.ts | 96 +- .../src/modules/job/job.service.ts | 22 +- .../modules/storage/storage.service.spec.ts | 44 +- .../src/modules/storage/storage.service.ts | 34 +- scripts/marketing-all-local.sh | 18 + 60 files changed, 12557 insertions(+), 135 deletions(-) create mode 100644 campaign-launcher/client/.env.example create mode 100644 campaign-launcher/client/.gitignore create mode 100644 campaign-launcher/client/.yarnrc.yml create mode 100644 campaign-launcher/client/eslint.config.mjs create mode 100644 campaign-launcher/client/index.html create mode 100644 campaign-launcher/client/package.json create mode 100644 campaign-launcher/client/public/robots.txt create mode 100644 campaign-launcher/client/src/App.tsx create mode 100644 campaign-launcher/client/src/components/AccountDropdown.tsx create mode 100644 campaign-launcher/client/src/components/ConnectWallet/ConnectWalletContent.tsx create mode 100644 campaign-launcher/client/src/components/ConnectWallet/index.tsx create mode 100644 campaign-launcher/client/src/components/Field.tsx create mode 100644 campaign-launcher/client/src/components/Footer/index.tsx create mode 100644 campaign-launcher/client/src/components/KeyValue.tsx create mode 100644 campaign-launcher/client/src/components/ResponsiveOverlay/index.tsx create mode 100644 campaign-launcher/client/src/components/campaign/CampaignSteps.tsx create mode 100644 campaign-launcher/client/src/components/campaign/LaunchProgressDialog.tsx create mode 100644 campaign-launcher/client/src/components/layout/Shell.tsx create mode 100644 campaign-launcher/client/src/constants/chains.ts create mode 100644 campaign-launcher/client/src/constants/fundingTokens.ts create mode 100644 campaign-launcher/client/src/constants/index.ts create mode 100644 campaign-launcher/client/src/hooks/useBreakpoints.ts create mode 100644 campaign-launcher/client/src/hooks/useConnectWalletModal.ts create mode 100644 campaign-launcher/client/src/hooks/useMarketingEscrow.ts create mode 100644 campaign-launcher/client/src/hooks/useReownWalletOptions.ts create mode 100644 campaign-launcher/client/src/index.css create mode 100644 campaign-launcher/client/src/main.tsx create mode 100644 campaign-launcher/client/src/pages/CreateCampaignPage.tsx create mode 100644 campaign-launcher/client/src/pages/HomePage.tsx create mode 100644 campaign-launcher/client/src/providers/QueryClientProvider.tsx create mode 100644 campaign-launcher/client/src/providers/WagmiProvider.tsx create mode 100644 campaign-launcher/client/src/providers/wagmiConfig.ts create mode 100644 campaign-launcher/client/src/theme.ts create mode 100644 campaign-launcher/client/src/types/index.ts create mode 100644 campaign-launcher/client/src/utils/manifest.ts create mode 100644 campaign-launcher/client/src/utils/validation.ts create mode 100644 campaign-launcher/client/src/utils/wallet.ts create mode 100644 campaign-launcher/client/src/vite-env.d.ts create mode 100644 campaign-launcher/client/tsconfig.json create mode 100644 campaign-launcher/client/vite.config.ts create mode 100644 campaign-launcher/client/yarn.lock create mode 100644 exchange-oracle/server/src/common/utils/encryption.ts create mode 100644 exchange-oracle/server/src/database/migrations/1777010000000-renameManifestUrlToManifest.ts create mode 100644 recording-oracle/src/common/utils/encryption.ts create mode 100644 recording-oracle/src/common/utils/manifest.ts create mode 100644 recording-oracle/src/database/migrations/1777010000000-renameManifestUrlToManifest.ts diff --git a/campaign-launcher/client/.env.example b/campaign-launcher/client/.env.example new file mode 100644 index 0000000..63e291a --- /dev/null +++ b/campaign-launcher/client/.env.example @@ -0,0 +1,14 @@ +# General +VITE_APP_WALLETCONNECT_PROJECT_ID=replace_me +VITE_APP_DOCS_URL=https://docs.humanprotocol.org +VITE_APP_STAKING_DASHBOARD_URL=https://staking.humanprotocol.org +VITE_FOOTER_LINK_GITHUB=https://github.com/Hu-Fi/hufi +VITE_FOOTER_LINK_TELEGRAM=https://t.me/Hu_Finance +VITE_FOOTER_LINK_X=https://x.com/Hu_Finance + +# Web3 +VITE_APP_ENVIRONMENT=localhost +VITE_APP_SUPPORTED_CHAINS=1338 +VITE_APP_EXCHANGE_ORACLE_ADDRESS=0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc +VITE_APP_RECORDING_ORACLE_ADDRESS=0x976EA74026E726554dB657fA54763abd0C3a0aa9 +VITE_APP_REPUTATION_ORACLE_ADDRESS=0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 diff --git a/campaign-launcher/client/.gitignore b/campaign-launcher/client/.gitignore new file mode 100644 index 0000000..b25fbaa --- /dev/null +++ b/campaign-launcher/client/.gitignore @@ -0,0 +1,6 @@ +dist +.env +.env.local +.env.*.local +.yarn/install-state.gz +node_modules/.yarn-state.yml diff --git a/campaign-launcher/client/.yarnrc.yml b/campaign-launcher/client/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/campaign-launcher/client/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/campaign-launcher/client/eslint.config.mjs b/campaign-launcher/client/eslint.config.mjs new file mode 100644 index 0000000..1f7556b --- /dev/null +++ b/campaign-launcher/client/eslint.config.mjs @@ -0,0 +1,53 @@ +import eslint from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import reactRefreshPlugin from 'eslint-plugin-react-refresh'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const config = tseslint.config( + { + ignores: ['dist', 'vite.config.ts'], + }, + eslint.configs.recommended, + tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx,js,jsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.es2022, + }, + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + plugins: { + 'react-hooks': reactHooksPlugin, + 'react-refresh': reactRefreshPlugin, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'off', + quotes: [ + 'error', + 'single', + { avoidEscape: true, allowTemplateLiterals: true }, + ], + }, + } +); + +export default config; diff --git a/campaign-launcher/client/index.html b/campaign-launcher/client/index.html new file mode 100644 index 0000000..21c82fc --- /dev/null +++ b/campaign-launcher/client/index.html @@ -0,0 +1,12 @@ + + + + + + Marketing Campaign Launcher + + +
+ + + diff --git a/campaign-launcher/client/package.json b/campaign-launcher/client/package.json new file mode 100644 index 0000000..7bc50e0 --- /dev/null +++ b/campaign-launcher/client/package.json @@ -0,0 +1,50 @@ +{ + "name": "marketing-campaign-launcher-client", + "private": true, + "version": "1.0.0", + "description": "Marketing Campaign Launcher Client", + "author": "Human Protocol", + "license": "MIT", + "packageManager": "yarn@4.10.3", + "scripts": { + "clean": "rm -rf dist", + "lint": "eslint \"**/*.{ts,tsx}\"", + "start": "vite", + "build": "vite build", + "preview": "vite preview", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx}\"" + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@human-protocol/core": "^3.0.1", + "@human-protocol/sdk": "^7.1.0", + "@mui/icons-material": "^5.18.0", + "@mui/material": "^5.16.7", + "@reown/appkit": "^1.8.19", + "@reown/appkit-adapter-wagmi": "^1.8.19", + "@tanstack/react-query": "^5.91.3", + "ethers": "^6.16.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.0", + "viem": "2.x", + "wagmi": "^3.6.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.15.16", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^10.1.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.11", + "globals": "^16.3.0", + "prettier": "^3.8.1", + "typescript": "^5.6.3", + "typescript-eslint": "^8.57.0", + "vite": "^6.2.4", + "vite-plugin-node-polyfills": "^0.25.0" + } +} diff --git a/campaign-launcher/client/public/robots.txt b/campaign-launcher/client/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/campaign-launcher/client/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/campaign-launcher/client/src/App.tsx b/campaign-launcher/client/src/App.tsx new file mode 100644 index 0000000..45b4865 --- /dev/null +++ b/campaign-launcher/client/src/App.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAccount } from 'wagmi'; + +import CreateCampaignPage from '@/pages/CreateCampaignPage'; +import HomePage from '@/pages/HomePage'; + +function App() { + return ( + + } /> + + + + } + /> + } /> + + ); +} + +const ProtectedRoute = ({ children }: { children: ReactNode }) => { + const { isConnected } = useAccount(); + + if (!isConnected) { + return ; + } + + return children; +}; + +export default App; diff --git a/campaign-launcher/client/src/components/AccountDropdown.tsx b/campaign-launcher/client/src/components/AccountDropdown.tsx new file mode 100644 index 0000000..27d28ce --- /dev/null +++ b/campaign-launcher/client/src/components/AccountDropdown.tsx @@ -0,0 +1,99 @@ +import { useState, type FC } from 'react'; + +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import LogoutIcon from '@mui/icons-material/Logout'; +import { + Button, + List, + ListItemButton, + Popover, + Stack, + Typography, +} from '@mui/material'; +import { useAccount, useDisconnect } from 'wagmi'; + +const formatAddress = (address?: string) => { + if (!address) return ''; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +const AccountDropdown: FC = () => { + const [anchorEl, setAnchorEl] = useState(null); + const { address } = useAccount(); + const { disconnect } = useDisconnect(); + const open = Boolean(anchorEl); + + const closePopover = () => setAnchorEl(null); + + const handleDisconnect = () => { + closePopover(); + disconnect(); + }; + + return ( + <> + + + + + + Sign out + + + + + ); +}; + +export default AccountDropdown; diff --git a/campaign-launcher/client/src/components/ConnectWallet/ConnectWalletContent.tsx b/campaign-launcher/client/src/components/ConnectWallet/ConnectWalletContent.tsx new file mode 100644 index 0000000..25816f9 --- /dev/null +++ b/campaign-launcher/client/src/components/ConnectWallet/ConnectWalletContent.tsx @@ -0,0 +1,386 @@ +import { + createElement, + useCallback, + useEffect, + useRef, + useState, + type FC, +} from 'react'; + +import SearchIcon from '@mui/icons-material/Search'; +import { + Box, + Button, + CircularProgress, + Grid, + InputAdornment, + Stack, + TextField, + Typography, +} from '@mui/material'; +import '@reown/appkit-ui/wui-qr-code'; + +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useReownWalletOptions } from '@/hooks/useReownWalletOptions'; + +type AppKitQrCodeElement = HTMLElement & { + alt?: string; + imageSrc?: string; + size?: number; + theme?: 'dark' | 'light'; + uri?: string; +}; + +type AppKitQrCodeProps = { + alt: string; + imageSrc?: string; + uri: string; +}; + +const AppKitQrCode: FC = ({ alt, imageSrc, uri }) => { + const qrCodeRef = useRef(null); + + useEffect(() => { + if (!qrCodeRef.current) return; + + qrCodeRef.current.alt = alt; + qrCodeRef.current.imageSrc = imageSrc; + qrCodeRef.current.size = 500; + qrCodeRef.current.theme = 'light'; + qrCodeRef.current.uri = uri; + }, [alt, imageSrc, uri]); + + return createElement('wui-qr-code', { + ref: qrCodeRef, + style: { display: 'block', height: '100%', width: '100%' }, + }); +}; + +const ConnectWalletContent: FC = () => { + const [search, setSearch] = useState(''); + const [showAllWallets, setShowAllWallets] = useState(false); + + const isMobile = useIsMobile(); + const { + canOpenMobileWallet, + connectingWallet, + connectWallet, + displayedWallets, + fetchMoreWallets, + hasMoreWallets, + isFetchingWallets, + isFetchingWcUri, + openMobileWallet, + resetSearch, + resetWalletConnect, + wcUri, + } = useReownWalletOptions({ search, showAllWallets }); + + const isFetching = isFetchingWallets || isFetchingWcUri; + + const resetState = useCallback(() => { + resetWalletConnect(); + setSearch(''); + resetSearch(); + setShowAllWallets(false); + }, [resetSearch, resetWalletConnect]); + + useEffect(() => { + return () => { + resetState(); + }; + }, [resetState]); + + if (isMobile && wcUri && connectingWallet && canOpenMobileWallet) { + return ( + + + Continue in {connectingWallet.name} + + + + Accept the connection request in your wallet. + + + + + + ); + } + + if (wcUri && connectingWallet) { + return ( + + + Scan with {connectingWallet.name} + + + + + + Open your wallet app and scan this QR code to continue. + + + + ); + } + + return ( + + + Connect wallet + + + Connect your wallet to create marketing campaigns and fund escrows + directly. + + {showAllWallets && ( + setSearch(event.target.value)} + placeholder="Search wallet" + size="small" + fullWidth + sx={{ mb: 2 }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: isFetching ? ( + + + + ) : null, + }} + /> + )} + + {!isFetching && showAllWallets && displayedWallets.length === 0 && ( + + + No wallets found + + + )} + {displayedWallets.length > 0 && ( + + {displayedWallets.map((wallet) => { + const isConnectingWallet = + connectingWallet?.id === wallet.id && isFetchingWcUri; + + return ( + + {isFetching && ( + + )} + + + ); + })} + + )} + + + {!showAllWallets ? ( + + ) : ( + <> + + + + )} + + + ); +}; + +export default ConnectWalletContent; diff --git a/campaign-launcher/client/src/components/ConnectWallet/index.tsx b/campaign-launcher/client/src/components/ConnectWallet/index.tsx new file mode 100644 index 0000000..9d07eda --- /dev/null +++ b/campaign-launcher/client/src/components/ConnectWallet/index.tsx @@ -0,0 +1,60 @@ +import { type FC } from 'react'; + +import WalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import { Button, type ButtonProps } from '@mui/material'; + +import ResponsiveOverlay from '@/components/ResponsiveOverlay'; +import { useConnectWalletModal } from '@/hooks/useConnectWalletModal'; + +import ConnectWalletContent from './ConnectWalletContent'; + +type ConnectWalletProps = { + size?: ButtonProps['size']; +}; + +const ConnectWallet: FC = ({ size = 'large' }) => { + const { + closeConnectWallet, + isConnecting, + isConnectWalletOpen, + openConnectWallet, + } = useConnectWalletModal(); + + return ( + <> + + + + + + ); +}; + +export default ConnectWallet; diff --git a/campaign-launcher/client/src/components/Field.tsx b/campaign-launcher/client/src/components/Field.tsx new file mode 100644 index 0000000..c6fff2e --- /dev/null +++ b/campaign-launcher/client/src/components/Field.tsx @@ -0,0 +1,5 @@ +import { TextField, type TextFieldProps } from '@mui/material'; + +export const Field = (props: TextFieldProps) => { + return ; +}; diff --git a/campaign-launcher/client/src/components/Footer/index.tsx b/campaign-launcher/client/src/components/Footer/index.tsx new file mode 100644 index 0000000..58b73e5 --- /dev/null +++ b/campaign-launcher/client/src/components/Footer/index.tsx @@ -0,0 +1,110 @@ +import type { FC } from 'react'; + +import GitHubIcon from '@mui/icons-material/GitHub'; +import TelegramIcon from '@mui/icons-material/Telegram'; +import XIcon from '@mui/icons-material/X'; +import { + Box, + Container, + IconButton, + Stack, + styled, + Typography, +} from '@mui/material'; + +import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; + +const SocialMediaIconButton = styled(IconButton)(({ theme }) => ({ + padding: 0, + '& svg': { + fill: theme.palette.text.primary, + fillOpacity: 0.7, + }, + + '&:hover': { + background: 'none', + '& svg': { + fill: theme.palette.text.primary, + fillOpacity: 1, + }, + }, +})); + +const handleClickOnSocialButton = (url: string) => { + window.open(url, '_blank'); +}; + +const Footer: FC<{ reserveBottomOffset: boolean }> = ({ + reserveBottomOffset, +}) => { + return ( + + + + + © {new Date().getFullYear()} HuFi powered by HUMAN Protocol + + + + handleClickOnSocialButton( + import.meta.env.VITE_FOOTER_LINK_GITHUB + ) + } + > + + + + handleClickOnSocialButton(import.meta.env.VITE_FOOTER_LINK_X) + } + > + + + + handleClickOnSocialButton( + import.meta.env.VITE_FOOTER_LINK_TELEGRAM + ) + } + > + + + + + + + ); +}; + +export default Footer; diff --git a/campaign-launcher/client/src/components/KeyValue.tsx b/campaign-launcher/client/src/components/KeyValue.tsx new file mode 100644 index 0000000..b15297c --- /dev/null +++ b/campaign-launcher/client/src/components/KeyValue.tsx @@ -0,0 +1,22 @@ +import { Box, Typography } from '@mui/material'; + +type Props = { + label: string; + value: string; +}; + +export const KeyValue = ({ label, value }: Props) => { + return ( + + + {label} + + + {value || '-'} + + + ); +}; diff --git a/campaign-launcher/client/src/components/ResponsiveOverlay/index.tsx b/campaign-launcher/client/src/components/ResponsiveOverlay/index.tsx new file mode 100644 index 0000000..cc222bd --- /dev/null +++ b/campaign-launcher/client/src/components/ResponsiveOverlay/index.tsx @@ -0,0 +1,94 @@ +import { type FC, type PropsWithChildren } from 'react'; + +import CloseIcon from '@mui/icons-material/Close'; +import { + Dialog, + Drawer, + IconButton, + Paper, + type SxProps, + type Theme, +} from '@mui/material'; + +import { useIsMobile } from '@/hooks/useBreakpoints'; + +type Props = { + open: boolean; + onClose: () => void; + desktopSx?: SxProps; + mobileSx?: SxProps; + closeButtonSx?: SxProps; +}; + +const closeButtonBaseSx = { + position: 'absolute', + color: 'text.secondary', + zIndex: 2, +}; + +const ResponsiveOverlay: FC> = ({ + open, + onClose, + desktopSx, + mobileSx, + closeButtonSx, + children, +}) => { + const isMobile = useIsMobile(); + + if (isMobile) { + return ( + + + + + {children} + + ); + } + + return ( + + + + + {children} + + ); +}; + +export default ResponsiveOverlay; diff --git a/campaign-launcher/client/src/components/campaign/CampaignSteps.tsx b/campaign-launcher/client/src/components/campaign/CampaignSteps.tsx new file mode 100644 index 0000000..698b9f1 --- /dev/null +++ b/campaign-launcher/client/src/components/campaign/CampaignSteps.tsx @@ -0,0 +1,771 @@ +import { + Alert, + Box, + Button, + Card, + CardActionArea, + Checkbox, + Divider, + FormControlLabel, + Grid, + MenuItem, + Paper, + Stack, + Typography, +} from '@mui/material'; + +import { Field } from '@/components/Field'; +import { KeyValue } from '@/components/KeyValue'; +import { ORACLE_ADDRESSES } from '@/constants'; +import { + getFundingTokenConfig, + getFundingTokenOptions, +} from '@/constants/fundingTokens'; +import { supportedChains } from '@/providers/wagmiConfig'; +import { + CampaignRequestType, + EscrowFundToken, + SocialPlatform, + type CampaignFormState, + type PreparedManifest, + type RecordingOracleKeyState, +} from '@/types'; +import { shortKeyPreview } from '@/utils/manifest'; + +type JobTypeStepProps = { + form: CampaignFormState; + onSelect: (requestType: CampaignRequestType) => void; +}; + +export const JobTypeStep = ({ form, onSelect }: JobTypeStepProps) => ( + + + Choose job type + + Start with the marketing workflow you want to validate. + + + + + onSelect(CampaignRequestType.SOCIAL_MEDIA_PROMOTION)} + /> + + + onSelect(CampaignRequestType.SOCIAL_MEDIA_ENGAGEMENT)} + /> + + + +); + +type PlatformStepProps = { + form: CampaignFormState; + onSelect: (platform: SocialPlatform) => void; +}; + +export const PlatformStep = ({ form, onSelect }: PlatformStepProps) => { + const isPromotion = + form.requestType === CampaignRequestType.SOCIAL_MEDIA_PROMOTION; + + return ( + + + Choose platform + + LinkedIn is currently available only for engagement campaigns. + + + + + onSelect(SocialPlatform.X)} + /> + + + onSelect(SocialPlatform.LINKEDIN)} + /> + + + {isPromotion && ( + + Social media promotion is limited to X / Twitter for this version. + + )} + + ); +}; + +type GeneralDataStepProps = { + form: CampaignFormState; + updateForm: ( + key: K, + value: CampaignFormState[K], + ) => void; + onChainChange: (chainId: CampaignFormState['chainId']) => void; +}; + +export const GeneralDataStep = ({ + form, + updateForm, + onChainChange, +}: GeneralDataStepProps) => { + const fundingTokenOptions = getFundingTokenOptions(form.chainId); + + return ( + + + General campaign data + + These fields are shared by every marketing manifest. + + + + + + onChainChange( + Number(event.target.value) as CampaignFormState['chainId'], + ) + } + > + {supportedChains.map((chain) => ( + + {chain.name} + + ))} + + + + + updateForm('fundToken', event.target.value as EscrowFundToken) + } + > + {fundingTokenOptions.map((token) => ( + + {token.symbol} + + ))} + + + + updateForm('fundAmount', event.target.value)} + /> + + + updateForm('campaignName', event.target.value)} + /> + + + + updateForm('submissionsRequired', event.target.value) + } + /> + + + + updateForm('campaignDescription', event.target.value) + } + /> + + + updateForm('endDate', event.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + updateForm('qualifications', event.target.value) + } + /> + + + + ); +}; + +type SelectionCardProps = { + selected: boolean; + title: string; + description: string; + disabled?: boolean; + onClick: () => void; +}; + +const SelectionCard = ({ + selected, + title, + description, + disabled, + onClick, +}: SelectionCardProps) => ( + + + + + {title} + + {description} + + + +); + +type PromotionFieldsProps = { + form: CampaignFormState; + updatePromotion: ( + key: K, + value: CampaignFormState['promotion'][K], + ) => void; +}; + +export const PromotionFields = ({ + form, + updatePromotion, +}: PromotionFieldsProps) => ( + + + Promotion details + + Define what a valid social media post must contain. + + + + + + updatePromotion('requiredHashtags', event.target.value) + } + /> + + + + updatePromotion('requiredKeywords', event.target.value) + } + /> + + + + updatePromotion('requiredLink', event.target.value) + } + /> + + + updatePromotion('minLength', event.target.value)} + /> + + + + updatePromotion('minLiveDurationHours', event.target.value) + } + /> + + + + updatePromotion('minFollowers', event.target.value) + } + /> + + + + updatePromotion('minAccountAgeDays', event.target.value) + } + /> + + + updatePromotion('minLikes', event.target.value)} + /> + + + + updatePromotion('minReposts', event.target.value) + } + /> + + + + updatePromotion( + 'allowedAbuseProbability', + event.target + .value as CampaignFormState['promotion']['allowedAbuseProbability'], + ) + } + > + Low + Medium + High + + + + + + updatePromotion('mustBePublic', event.target.checked) + } + /> + } + label="Must be public" + /> + + updatePromotion('requiresMedia', event.target.checked) + } + /> + } + label="Requires media" + /> + + + + +); + +type EngagementFieldsProps = { + form: CampaignFormState; + updateEngagement: ( + key: K, + value: CampaignFormState['engagement'][K], + ) => void; + updateXCredentials: < + K extends keyof CampaignFormState['engagement']['xApiCredentials'], + >( + key: K, + value: CampaignFormState['engagement']['xApiCredentials'][K], + ) => void; +}; + +export const EngagementFields = ({ + form, + updateEngagement, + updateXCredentials, +}: EngagementFieldsProps) => { + const isLinkedIn = form.platform === SocialPlatform.LINKEDIN; + const xLikeRequiresEncryption = + form.platform === SocialPlatform.X && form.engagement.checkLike; + + return ( + + + Engagement details + + Define what interactions workers must perform on the target post. + + + + updateEngagement('targetPostUrl', event.target.value) + } + /> + + + updateEngagement('checkLike', event.target.checked) + } + /> + } + label="Check like" + /> + + updateEngagement('checkComment', event.target.checked) + } + /> + } + label="Check comment" + /> + + updateEngagement('checkRepost', event.target.checked) + } + /> + } + label="Check repost" + /> + + updateEngagement('checkQuote', event.target.checked) + } + /> + } + label="Check quote" + /> + + + {isLinkedIn && ( + + LinkedIn supports like and comment checks only. + + )} + + {xLikeRequiresEncryption && ( + + + X API credentials + + + These fields are included only in the encrypted manifest. + + + + + updateXCredentials('consumerKey', event.target.value) + } + /> + + + + updateXCredentials('consumerSecret', event.target.value) + } + /> + + + + updateXCredentials('accessToken', event.target.value) + } + /> + + + + updateXCredentials('accessTokenSecret', event.target.value) + } + /> + + + + )} + + ); +}; + +type SummaryStepProps = { + form: CampaignFormState; + encryptionRequired: boolean; + recordingOracleKey: RecordingOracleKeyState; + preparedManifest: PreparedManifest | null; + isWrongNetwork: boolean; + isSwitchingChain: boolean; + selectedChainName: string; + manifestPreview: string; + onSwitchChain: () => void; +}; + +export const SummaryStep = ({ + form, + encryptionRequired, + recordingOracleKey, + preparedManifest, + isWrongNetwork, + isSwitchingChain, + selectedChainName, + manifestPreview, + onSwitchChain, +}: SummaryStepProps) => ( + + + Review and launch + + Confirm the exact manifest string and escrow settings before approving + the selected token. + + + + {isWrongNetwork && ( + + Switch + + } + > + Switch your wallet to {selectedChainName} before launching. + + )} + {encryptionRequired && recordingOracleKey.isLoading && ( + Fetching recording oracle public key... + )} + + + + + + + + Manifest preview + + + {manifestPreview || 'Manifest is not ready yet.'} + + + + + + + The launcher will approve {form.fundToken} if allowance is insufficient, + then create the escrow with the prepared manifest. + + +); + +type SummaryPanelProps = { + form: CampaignFormState; + encryptionRequired: boolean; + preparedManifest: PreparedManifest | null; + recordingOracleKey: RecordingOracleKeyState; +}; + +const SummaryPanel = ({ + form, + encryptionRequired, + preparedManifest, + recordingOracleKey, +}: SummaryPanelProps) => { + const selectedChain = supportedChains.find( + (chain) => chain.id === form.chainId, + ); + const selectedToken = getFundingTokenConfig(form.chainId, form.fundToken); + + return ( + + + + Launch summary + + + + + + + + + + + + + + + + + + + + + + + + + + + {encryptionRequired && ( + + + + )} + + + + + {encryptionRequired && ( + <> + + + + + + + + )} + + + ); +}; diff --git a/campaign-launcher/client/src/components/campaign/LaunchProgressDialog.tsx b/campaign-launcher/client/src/components/campaign/LaunchProgressDialog.tsx new file mode 100644 index 0000000..2dad464 --- /dev/null +++ b/campaign-launcher/client/src/components/campaign/LaunchProgressDialog.tsx @@ -0,0 +1,114 @@ +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Step, + StepContent, + StepLabel, + Stepper, + Typography, +} from '@mui/material'; + +type LaunchProgressDialogProps = { + open: boolean; + isApproving: boolean; + isCreating: boolean; + isNotifyingExchange: boolean; + error?: Error; + escrowAddress?: string; + onClose: () => void; +}; + +const getLaunchProgressStep = ({ + isApproving, + isCreating, + isNotifyingExchange, + escrowAddress, +}: Pick< + LaunchProgressDialogProps, + 'isApproving' | 'isCreating' | 'isNotifyingExchange' | 'escrowAddress' +>): number => { + if (escrowAddress) return 3; + if (isNotifyingExchange) return 2; + if (isCreating) return 1; + if (isApproving) return 0; + return 0; +}; + +const LaunchProgressDialog = ({ + open, + isApproving, + isCreating, + isNotifyingExchange, + error, + escrowAddress, + onClose, +}: LaunchProgressDialogProps) => { + const activeStep = getLaunchProgressStep({ + isApproving, + isCreating, + isNotifyingExchange, + escrowAddress, + }); + const canClose = Boolean(escrowAddress) || Boolean(error); + const launchSteps = [ + { + label: 'Approve funds', + description: isApproving + ? 'Waiting for token approval confirmation.' + : 'Token approval confirmed.', + }, + { + label: 'Create escrow', + description: isCreating + ? 'Waiting for escrow creation confirmation.' + : 'Escrow creation transaction confirmed.', + }, + { + label: 'Sign webhook', + description: isNotifyingExchange + ? 'Waiting for webhook signature and exchange oracle notification.' + : 'Exchange oracle webhook acknowledged.', + }, + { + label: 'Escrow completed', + description: escrowAddress + ? `Escrow created: ${escrowAddress}` + : 'Escrow creation is not completed yet.', + }, + ]; + + return ( + + Launch campaign + + + {error && {error.message}} + + {launchSteps.map((step) => ( + + {step.label} + + + {step.description} + + + + ))} + + + + + + + + ); +}; + +export default LaunchProgressDialog; diff --git a/campaign-launcher/client/src/components/layout/Shell.tsx b/campaign-launcher/client/src/components/layout/Shell.tsx new file mode 100644 index 0000000..635be9f --- /dev/null +++ b/campaign-launcher/client/src/components/layout/Shell.tsx @@ -0,0 +1,142 @@ +import { AppBar, Box, Button, Container, Stack, Toolbar } from '@mui/material'; +import type { ReactNode } from 'react'; +import { Link as RouterLink, useLocation } from 'react-router-dom'; +import { useAccount } from 'wagmi'; + +import AccountDropdown from '@/components/AccountDropdown'; +import ConnectWallet from '@/components/ConnectWallet'; + +import Footer from '../Footer'; + +const docsUrl = + import.meta.env.VITE_APP_DOCS_URL || 'https://docs.humanprotocol.org'; +const stakeUrl = + import.meta.env.VITE_APP_STAKING_DASHBOARD_URL || + 'https://dashboard.humanprotocol.org'; + +type NavLinkProps = { + to: string; + active?: boolean; + external?: boolean; + disabled?: boolean; + children: ReactNode; +}; + +const NavLink = ({ + to, + active, + external, + disabled, + children, +}: NavLinkProps) => ( + +); + +type ShellProps = { + children: ReactNode; +}; + +const Shell = ({ children }: ShellProps) => { + const { isConnected } = useAccount(); + const location = useLocation(); + + return ( + + + + + + Marketing + + + + Dashboard + + + Support + + + Stake HMT + + + {isConnected ? ( + + + + ) : ( + + + + )} + + + + + {children} + +