From 68778bfe6e861a2c8410bbff8d691cafe9b3831a Mon Sep 17 00:00:00 2001 From: aggre Date: Mon, 5 Feb 2024 16:51:45 +0900 Subject: [PATCH 01/13] initial implementation for Invitations --- package.json | 1 + src/plugins/invitations/index.ts | 62 +++++++++++++++++++++++ src/plugins/invitations/redis-schema.ts | 67 +++++++++++++++++++++++++ src/plugins/invitations/redis.ts | 43 ++++++++++++++++ yarn.lock | 5 ++ 5 files changed, 178 insertions(+) create mode 100644 src/plugins/invitations/index.ts create mode 100644 src/plugins/invitations/redis-schema.ts create mode 100644 src/plugins/invitations/redis.ts diff --git a/package.json b/package.json index 8bbaa9704..9a7ed4f49 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "firebase-admin": "^12.0.0", "jsonwebtoken": "^9.0.2", "marked": "10.0.0", + "nanoid": "^5.0.5", "p-queue": "^8.0.0", "postcss": "8.4.33", "ramda": "0.29.1", diff --git a/src/plugins/invitations/index.ts b/src/plugins/invitations/index.ts new file mode 100644 index 000000000..66b81cd31 --- /dev/null +++ b/src/plugins/invitations/index.ts @@ -0,0 +1,62 @@ +import type { + ClubsFunctionGetAdminPaths, + ClubsFunctionGetApiPaths, + ClubsFunctionGetPagePaths, + ClubsFunctionPlugin, + ClubsPluginMeta, +} from '@devprotocol/clubs-core' +import { ClubsPluginCategory, SinglePath } from '@devprotocol/clubs-core' +import { default as Icon } from './assets/icon.svg' +import { Content as Readme } from './README.md' +import Preview1 from './assets/default-theme-1.jpg' +import Preview2 from './assets/default-theme-2.jpg' +import Preview3 from './assets/default-theme-3.jpg' +import { aperture, o } from 'ramda' +import { withCheckingIndex, getDefaultClient } from './redis' + +export const getPagePaths = (async (options, config) => { + return [] +}) satisfies ClubsFunctionGetPagePaths + +export const getApiPaths = (async (options, config) => { + return [ + { + paths: ['invitations', SinglePath], + method: 'GET', + handler: async (req) => { + // Detect the passed invitation ID + const [, id] = + aperture(2, req.url.pathname.split('/')).find( + ([p]) => p === 'invitations', + ) ?? [] + + // Generate a redis client while checking the latest schema is indexing and create/update index if it's not. + const client = await withCheckingIndex(getDefaultClient) + + return new Response() + }, + }, + ] +}) satisfies ClubsFunctionGetApiPaths + +export const getAdminPaths = (async ( + options, + config, +) => []) satisfies ClubsFunctionGetAdminPaths + +export const meta = { + id: 'devprotocol:clubs:plugin:invitations', + displayName: 'Invitations', + category: ClubsPluginCategory.Growth, + icon: Icon.src, + description: `Basic theme with multiple color schemes.`, + previewImages: [Preview1.src, Preview2.src, Preview3.src], + readme: Readme, +} satisfies ClubsPluginMeta + +export default { + getPagePaths, + getApiPaths, + getAdminPaths, + meta, +} satisfies ClubsFunctionPlugin diff --git a/src/plugins/invitations/redis-schema.ts b/src/plugins/invitations/redis-schema.ts new file mode 100644 index 000000000..c0db53924 --- /dev/null +++ b/src/plugins/invitations/redis-schema.ts @@ -0,0 +1,67 @@ +import { encode } from '@devprotocol/clubs-core' +import { SchemaFieldTypes, type RediSearchSchema } from 'redis' +import { keccak256 } from 'ethers' +import { nanoid } from 'nanoid' + +export const Index = 'idx::devprotocol:clubs:invitation' + +export const Prefix = 'doc::devprotocol:clubs:invitation::' + +export const SchemaKey = 'scm::devprotocol:clubs:invitation' + +export type Invitation = { + id: string + disabled?: boolean + conditions?: { + receipient?: string[] + } + membership: { + payload: string + } +} + +export const id = { + '$.id': { + type: SchemaFieldTypes.TAG, + AS: 'id', + }, +} satisfies RediSearchSchema + +export const disabled = { + '$.disabled': { + type: SchemaFieldTypes.TAG, + AS: 'disabled', + }, +} satisfies RediSearchSchema + +export const conditionsReceipient = { + '$.conditions.receipient': { + type: SchemaFieldTypes.TEXT, + AS: 'conditionsReceipient', + }, +} satisfies RediSearchSchema + +export const membershipPayload = { + '$.membership.payload': { + type: SchemaFieldTypes.TAG, + AS: 'membershipPayload', + }, +} satisfies RediSearchSchema + +export const schema = { + ...id, + ...disabled, + ...conditionsReceipient, + ...membershipPayload, +} + +export const schemaId = keccak256(encode(schema)) + +/** + * Generate a new invitation document + * @param base - the invitation item without ID + * @returns the generated new invitation document without ID duplication check + */ +export const invitationDocument = ( + base: Omit, +): Invitation => ({ ...base, id: nanoid(10) }) diff --git a/src/plugins/invitations/redis.ts b/src/plugins/invitations/redis.ts new file mode 100644 index 000000000..bd76a3324 --- /dev/null +++ b/src/plugins/invitations/redis.ts @@ -0,0 +1,43 @@ +import { createClient } from 'redis' +import type { AsyncReturnType } from 'type-fest' +import { Index, Prefix, SchemaKey, schema, schemaId } from './redis-schema' + +export const defaultClient = createClient({ + url: import.meta.env.REDIS_URL, + username: import.meta.env.REDIS_USERNAME ?? '', + password: import.meta.env.REDIS_PASSWORD ?? '', + socket: { + keepAlive: 1, + reconnectStrategy: 1, + }, +}) + +export const getDefaultClient = async () => { + if (defaultClient.isOpen === false) { + await defaultClient.connect() + } + return defaultClient +} + +/** + * Returns a redis client from the given async function with checking the current schema is indexed. + * @param getClient - a function that returns redis client + * @returns the redis client + */ +export const withCheckingIndex = async < + T extends typeof getDefaultClient = typeof getDefaultClient, +>( + getClient: T, +): Promise> => { + const client = (await getClient()) as AsyncReturnType + const currentScm = await client.get(SchemaKey) + const isSchemaIndexed = currentScm === schemaId + return isSchemaIndexed + ? client + : client.ft + .dropIndex(Index) + .then(() => + client.ft.create(Index, schema, { ON: 'JSON', PREFIX: Prefix }), + ) + .then(() => client) +} diff --git a/yarn.lock b/yarn.lock index 89e8d24bc..41b89d71d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8212,6 +8212,11 @@ nanoid@^3.3.7: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +nanoid@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.5.tgz#5112efb5c0caf4fc80680d66d303c65233a79fdd" + integrity sha512-/Veqm+QKsyMY3kqi4faWplnY1u+VuKO3dD2binyPIybP31DRO29bPF+1mszgLnrR2KqSLceFLBNw0zmvDzN1QQ== + napi-build-utils@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" From d0a4a0b72e708e55eaf0e0bb55e56000db764549 Mon Sep 17 00:00:00 2001 From: aggre Date: Mon, 5 Feb 2024 17:15:38 +0900 Subject: [PATCH 02/13] pseudo implementation for GET endpoint --- src/plugins/invitations/index.ts | 45 ++++++++++++++++++++++--- src/plugins/invitations/redis-schema.ts | 7 ++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/plugins/invitations/index.ts b/src/plugins/invitations/index.ts index 66b81cd31..4a0fdd5a5 100644 --- a/src/plugins/invitations/index.ts +++ b/src/plugins/invitations/index.ts @@ -11,8 +11,16 @@ import { Content as Readme } from './README.md' import Preview1 from './assets/default-theme-1.jpg' import Preview2 from './assets/default-theme-2.jpg' import Preview3 from './assets/default-theme-3.jpg' -import { aperture, o } from 'ramda' +import { aperture } from 'ramda' import { withCheckingIndex, getDefaultClient } from './redis' +import { Index, schema, uuidToQuery, type Invitation } from './redis-schema' +import { + isNotError, + whenDefined, + whenNotError, + whenNotErrorAll, + type UndefinedOr, +} from '@devprotocol/util-ts' export const getPagePaths = (async (options, config) => { return [] @@ -25,15 +33,44 @@ export const getApiPaths = (async (options, config) => { method: 'GET', handler: async (req) => { // Detect the passed invitation ID - const [, id] = + const [, givenId] = aperture(2, req.url.pathname.split('/')).find( ([p]) => p === 'invitations', ) ?? [] + const id = + whenDefined(givenId, (_id) => _id) ?? new Error('ID is required') + // Generate a redis client while checking the latest schema is indexing and create/update index if it's not. - const client = await withCheckingIndex(getDefaultClient) + const client = await withCheckingIndex(getDefaultClient).catch( + (err) => err as Error, + ) + + // Try to fetch the mapped invitation. + const data = await whenNotErrorAll([id, client], ([_id, _client]) => + _client.ft.search( + Index, + `@${schema['$.id'].AS}:{${uuidToQuery(_id)}}`, + { + LIMIT: { + from: 0, + size: 1, + }, + }, + ), + ) + + const res = whenNotError( + data, + (d) => + (d.documents.find((x) => x.value) + ?.value as UndefinedOr) ?? + new Error('ID is not found.'), + ) - return new Response() + return new Response(JSON.stringify(res), { + status: isNotError(res) ? 200 : 400, + }) }, }, ] diff --git a/src/plugins/invitations/redis-schema.ts b/src/plugins/invitations/redis-schema.ts index c0db53924..7236e439d 100644 --- a/src/plugins/invitations/redis-schema.ts +++ b/src/plugins/invitations/redis-schema.ts @@ -65,3 +65,10 @@ export const schemaId = keccak256(encode(schema)) export const invitationDocument = ( base: Omit, ): Invitation => ({ ...base, id: nanoid(10) }) + +/** + * Returns string that available for searching as TAG + * @param id - the base string + * @returns the TAG string + */ +export const uuidToQuery = (id: string) => id.replaceAll('-', '\\-') From cc310e5457a223c3dd57c9691206fe05a266245f Mon Sep 17 00:00:00 2001 From: aggre Date: Mon, 5 Feb 2024 18:04:47 +0900 Subject: [PATCH 03/13] add supporting History --- src/plugins/invitations/index.ts | 14 ++++-- src/plugins/invitations/redis-schema.ts | 66 +++++++++++++++++++++++-- src/plugins/invitations/redis.ts | 36 +++++++++++--- 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/src/plugins/invitations/index.ts b/src/plugins/invitations/index.ts index 4a0fdd5a5..8a11e5d0d 100644 --- a/src/plugins/invitations/index.ts +++ b/src/plugins/invitations/index.ts @@ -13,7 +13,15 @@ import Preview2 from './assets/default-theme-2.jpg' import Preview3 from './assets/default-theme-3.jpg' import { aperture } from 'ramda' import { withCheckingIndex, getDefaultClient } from './redis' -import { Index, schema, uuidToQuery, type Invitation } from './redis-schema' +import { + Index, + Prefix, + schemaInvitation, + schemaHistory, + uuidToQuery, + type Invitation, + type History, +} from './redis-schema' import { isNotError, whenDefined, @@ -49,8 +57,8 @@ export const getApiPaths = (async (options, config) => { // Try to fetch the mapped invitation. const data = await whenNotErrorAll([id, client], ([_id, _client]) => _client.ft.search( - Index, - `@${schema['$.id'].AS}:{${uuidToQuery(_id)}}`, + Index.Invitation, + `@${schemaInvitation['$.id'].AS}:{${uuidToQuery(_id)}}`, { LIMIT: { from: 0, diff --git a/src/plugins/invitations/redis-schema.ts b/src/plugins/invitations/redis-schema.ts index 7236e439d..b1947a96c 100644 --- a/src/plugins/invitations/redis-schema.ts +++ b/src/plugins/invitations/redis-schema.ts @@ -3,11 +3,20 @@ import { SchemaFieldTypes, type RediSearchSchema } from 'redis' import { keccak256 } from 'ethers' import { nanoid } from 'nanoid' -export const Index = 'idx::devprotocol:clubs:invitation' +export enum Index { + Invitation = 'idx::devprotocol:clubs:invitation:code', + History = 'idx::devprotocol:clubs:invitation:history', +} -export const Prefix = 'doc::devprotocol:clubs:invitation::' +export enum Prefix { + Invitation = 'doc::devprotocol:clubs:invitation:code::', + History = 'doc::devprotocol:clubs:invitation:history::', +} -export const SchemaKey = 'scm::devprotocol:clubs:invitation' +export enum SchemaKey { + Invitation = 'scm::devprotocol:clubs:invitation:code', + History = 'scm::devprotocol:clubs:invitation:history', +} export type Invitation = { id: string @@ -20,6 +29,13 @@ export type Invitation = { } } +export type History = { + id: string + usedId: string + datetime: number + account: string +} + export const id = { '$.id': { type: SchemaFieldTypes.TAG, @@ -48,14 +64,44 @@ export const membershipPayload = { }, } satisfies RediSearchSchema -export const schema = { +export const usedId = { + '$.usedId': { + type: SchemaFieldTypes.TAG, + AS: 'usedId', + }, +} satisfies RediSearchSchema + +export const datetime = { + '$.datetime': { + type: SchemaFieldTypes.NUMERIC, + AS: 'datetime', + }, +} satisfies RediSearchSchema + +export const account = { + '$.account': { + type: SchemaFieldTypes.TAG, + AS: 'account', + }, +} satisfies RediSearchSchema + +export const schemaInvitation = { ...id, ...disabled, ...conditionsReceipient, ...membershipPayload, } -export const schemaId = keccak256(encode(schema)) +export const schemaHistory = { + ...id, + ...usedId, + ...datetime, + ...account, +} + +export const schemaInvitationId = keccak256(encode(schemaInvitation)) + +export const schemaHistoryId = keccak256(encode(schemaHistory)) /** * Generate a new invitation document @@ -66,6 +112,16 @@ export const invitationDocument = ( base: Omit, ): Invitation => ({ ...base, id: nanoid(10) }) +/** + * Generate a new history document + * @param base - the history item without ID + * @returns the generated new history document without ID duplication check + */ +export const historyDocument = (base: Omit): History => ({ + ...base, + id: nanoid(), +}) + /** * Returns string that available for searching as TAG * @param id - the base string diff --git a/src/plugins/invitations/redis.ts b/src/plugins/invitations/redis.ts index bd76a3324..0c8a2d976 100644 --- a/src/plugins/invitations/redis.ts +++ b/src/plugins/invitations/redis.ts @@ -1,6 +1,14 @@ import { createClient } from 'redis' import type { AsyncReturnType } from 'type-fest' -import { Index, Prefix, SchemaKey, schema, schemaId } from './redis-schema' +import { + Index, + Prefix, + SchemaKey, + schemaHistory, + schemaHistoryId, + schemaInvitation, + schemaInvitationId, +} from './redis-schema' export const defaultClient = createClient({ url: import.meta.env.REDIS_URL, @@ -30,14 +38,28 @@ export const withCheckingIndex = async < getClient: T, ): Promise> => { const client = (await getClient()) as AsyncReturnType - const currentScm = await client.get(SchemaKey) - const isSchemaIndexed = currentScm === schemaId - return isSchemaIndexed + const currentScmI = await client.get(SchemaKey.Invitation) + const currentScmH = await client.get(SchemaKey.History) + const isScmIIndexed = currentScmI === schemaInvitationId + const isScmHIndexed = currentScmH === schemaHistoryId + const ON = 'JSON' + return isScmIIndexed && isScmHIndexed ? client - : client.ft - .dropIndex(Index) + : Promise.all([ + client.ft.dropIndex(Index.Invitation), + client.ft.dropIndex(Index.History), + ]) .then(() => - client.ft.create(Index, schema, { ON: 'JSON', PREFIX: Prefix }), + Promise.all([ + client.ft.create(Index.Invitation, schemaInvitation, { + ON, + PREFIX: Prefix.Invitation, + }), + client.ft.create(Index.History, schemaHistory, { + ON, + PREFIX: Prefix.History, + }), + ]), ) .then(() => client) } From a85f04d239c499a31bab908c61386f04e46b8990 Mon Sep 17 00:00:00 2001 From: aggre Date: Mon, 5 Feb 2024 18:38:33 +0900 Subject: [PATCH 04/13] add check api --- .../handlers/get-invitations-check.ts | 117 ++++++++++++++++++ .../handlers/get-invitations-id.ts | 61 +++++++++ src/plugins/invitations/index.ts | 67 ++-------- 3 files changed, 186 insertions(+), 59 deletions(-) create mode 100644 src/plugins/invitations/handlers/get-invitations-check.ts create mode 100644 src/plugins/invitations/handlers/get-invitations-id.ts diff --git a/src/plugins/invitations/handlers/get-invitations-check.ts b/src/plugins/invitations/handlers/get-invitations-check.ts new file mode 100644 index 000000000..dba9b652f --- /dev/null +++ b/src/plugins/invitations/handlers/get-invitations-check.ts @@ -0,0 +1,117 @@ +import type { APIRoute } from 'astro' +import { aperture } from 'ramda' +import { getDefaultClient } from '../redis' +import { + Index, + Prefix, + schemaInvitation, + schemaHistory, + uuidToQuery, + type Invitation, + type History, +} from '../redis-schema' +import { + isNotError, + whenDefined, + whenDefinedAll, + whenNotError, + whenNotErrorAll, + type UndefinedOr, +} from '@devprotocol/util-ts' +import type { AsyncReturnType } from 'type-fest' +import { withCheckingIndex } from '../redis' + +export const check = async ({ + id, + account, + client, +}: { + id: string + account: string + client: AsyncReturnType +}) => { + // Try to fetch the mapped invitation. + const invitation = await client.ft + .search( + Index.Invitation, + `@${schemaInvitation['$.id'].AS}:{${uuidToQuery(id)}}`, + { + LIMIT: { + from: 0, + size: 1, + }, + }, + ) + .catch((err) => err as Error) + const invItem = whenNotError( + invitation, + (d) => + (d.documents.find((x) => x.value)?.value as UndefinedOr) ?? + new Error('ID is not found.'), + ) + + // Try to fetch the history. + const history = await client.ft + .search( + Index.History, + `@${schemaHistory['$.usedId'].AS}:{${uuidToQuery(id)}} @${schemaHistory['$.account'].AS}:{${uuidToQuery(account)}}`, + { + LIMIT: { + from: 0, + size: 1, + }, + }, + ) + .catch((err) => err as Error) + const historyItem = whenNotError( + history, + (d) => d.documents.find((x) => x.value)?.value as UndefinedOr, + ) + + const valid = whenNotErrorAll( + [invItem, historyItem], + ([_invitation, _history]) => { + return typeof _history === 'undefined' + ? true + : new Error('ID is already used.') + }, + ) + + return valid +} + +const handler: APIRoute = async (req) => { + const body = await req.request + .json() + .then((r) => r as { account?: string }) + .catch((err) => err as Error) + const props = whenNotError( + body, + (_body) => + whenDefinedAll([_body.account], ([account]) => ({ account })) ?? + new Error('Missing parameters.'), + ) + + // Detect the passed invitation ID + const [, givenId] = + aperture(2, req.url.pathname.split('/')).find(([p]) => p === 'check') ?? [] + + const id = whenDefined(givenId, (_id) => _id) ?? new Error('ID is required') + + // Generate a redis client while checking the latest schema is indexing and create/update index if it's not. + const client = await withCheckingIndex(getDefaultClient).catch( + (err) => err as Error, + ) + + const res = await whenNotErrorAll( + [id, client, props], + ([_id, _client, { account }]) => + check({ id: _id, client: _client, account }), + ) + + return new Response(JSON.stringify(res), { + status: isNotError(res) ? 200 : 400, + }) +} + +export default handler diff --git a/src/plugins/invitations/handlers/get-invitations-id.ts b/src/plugins/invitations/handlers/get-invitations-id.ts new file mode 100644 index 000000000..290e55b31 --- /dev/null +++ b/src/plugins/invitations/handlers/get-invitations-id.ts @@ -0,0 +1,61 @@ +import type { APIRoute } from 'astro' +import { aperture } from 'ramda' +import { withCheckingIndex, getDefaultClient } from '../redis' +import { + Index, + Prefix, + schemaInvitation, + schemaHistory, + uuidToQuery, + type Invitation, + type History, +} from '../redis-schema' +import { + isNotError, + whenDefined, + whenNotError, + whenNotErrorAll, + type UndefinedOr, +} from '@devprotocol/util-ts' + +const handler: APIRoute = async (req) => { + // Detect the passed invitation ID + const [, givenId] = + aperture(2, req.url.pathname.split('/')).find( + ([p]) => p === 'invitations', + ) ?? [] + + const id = whenDefined(givenId, (_id) => _id) ?? new Error('ID is required') + + // Generate a redis client while checking the latest schema is indexing and create/update index if it's not. + const client = await withCheckingIndex(getDefaultClient).catch( + (err) => err as Error, + ) + + // Try to fetch the mapped invitation. + const data = await whenNotErrorAll([id, client], ([_id, _client]) => + _client.ft.search( + Index.Invitation, + `@${schemaInvitation['$.id'].AS}:{${uuidToQuery(_id)}}`, + { + LIMIT: { + from: 0, + size: 1, + }, + }, + ), + ) + + const res = whenNotError( + data, + (d) => + (d.documents.find((x) => x.value)?.value as UndefinedOr) ?? + new Error('ID is not found.'), + ) + + return new Response(JSON.stringify(res), { + status: isNotError(res) ? 200 : 400, + }) +} + +export default handler diff --git a/src/plugins/invitations/index.ts b/src/plugins/invitations/index.ts index 8a11e5d0d..c99cf01f3 100644 --- a/src/plugins/invitations/index.ts +++ b/src/plugins/invitations/index.ts @@ -11,24 +11,8 @@ import { Content as Readme } from './README.md' import Preview1 from './assets/default-theme-1.jpg' import Preview2 from './assets/default-theme-2.jpg' import Preview3 from './assets/default-theme-3.jpg' -import { aperture } from 'ramda' -import { withCheckingIndex, getDefaultClient } from './redis' -import { - Index, - Prefix, - schemaInvitation, - schemaHistory, - uuidToQuery, - type Invitation, - type History, -} from './redis-schema' -import { - isNotError, - whenDefined, - whenNotError, - whenNotErrorAll, - type UndefinedOr, -} from '@devprotocol/util-ts' +import getInvitationsId from './handlers/get-invitations-id' +import getInvitationsCheck from './handlers/get-invitations-check' export const getPagePaths = (async (options, config) => { return [] @@ -39,47 +23,12 @@ export const getApiPaths = (async (options, config) => { { paths: ['invitations', SinglePath], method: 'GET', - handler: async (req) => { - // Detect the passed invitation ID - const [, givenId] = - aperture(2, req.url.pathname.split('/')).find( - ([p]) => p === 'invitations', - ) ?? [] - - const id = - whenDefined(givenId, (_id) => _id) ?? new Error('ID is required') - - // Generate a redis client while checking the latest schema is indexing and create/update index if it's not. - const client = await withCheckingIndex(getDefaultClient).catch( - (err) => err as Error, - ) - - // Try to fetch the mapped invitation. - const data = await whenNotErrorAll([id, client], ([_id, _client]) => - _client.ft.search( - Index.Invitation, - `@${schemaInvitation['$.id'].AS}:{${uuidToQuery(_id)}}`, - { - LIMIT: { - from: 0, - size: 1, - }, - }, - ), - ) - - const res = whenNotError( - data, - (d) => - (d.documents.find((x) => x.value) - ?.value as UndefinedOr) ?? - new Error('ID is not found.'), - ) - - return new Response(JSON.stringify(res), { - status: isNotError(res) ? 200 : 400, - }) - }, + handler: getInvitationsId, + }, + { + paths: ['invitations', 'check', SinglePath], + method: 'GET', + handler: getInvitationsCheck, }, ] }) satisfies ClubsFunctionGetApiPaths From d369678e62c62d416cb08aa7a62af04521245869 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Fri, 9 Feb 2024 13:33:55 +0900 Subject: [PATCH 05/13] initial post invitation --- .../invitations/handlers/post-invitation.ts | 32 +++++++++++++++++++ src/plugins/invitations/redis-schema.ts | 10 +++--- 2 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 src/plugins/invitations/handlers/post-invitation.ts diff --git a/src/plugins/invitations/handlers/post-invitation.ts b/src/plugins/invitations/handlers/post-invitation.ts new file mode 100644 index 000000000..1ef9aa01c --- /dev/null +++ b/src/plugins/invitations/handlers/post-invitation.ts @@ -0,0 +1,32 @@ +import type { APIRoute } from 'astro' +import { Prefix, invitationDocument } from '../redis-schema' +import { getDefaultClient } from '../redis' + +export const POST: APIRoute = async ({ request }) => { + const { signature, message, membership, conditions } = + (await request.json()) as { + signature: string + message: string + membership: { + payload: string + } + conditions?: { + recipient?: string[] + } + } + + // Generate a redis client while checking the latest schema is indexing and create/update index if it's not. + const client = await getDefaultClient() + + const invitation = invitationDocument({ + membership, + conditions, + }) + + client.set( + `${Prefix.Invitation}::${invitation.id}`, + JSON.stringify(invitation), + ) + + return new Response(JSON.stringify({ id: invitation.id }), { status: 200 }) +} diff --git a/src/plugins/invitations/redis-schema.ts b/src/plugins/invitations/redis-schema.ts index b1947a96c..a33207c23 100644 --- a/src/plugins/invitations/redis-schema.ts +++ b/src/plugins/invitations/redis-schema.ts @@ -22,7 +22,7 @@ export type Invitation = { id: string disabled?: boolean conditions?: { - receipient?: string[] + recipient?: string[] } membership: { payload: string @@ -50,10 +50,10 @@ export const disabled = { }, } satisfies RediSearchSchema -export const conditionsReceipient = { - '$.conditions.receipient': { +export const conditionsRecipient = { + '$.conditions.recipient': { type: SchemaFieldTypes.TEXT, - AS: 'conditionsReceipient', + AS: 'conditionsRecipient', }, } satisfies RediSearchSchema @@ -88,7 +88,7 @@ export const account = { export const schemaInvitation = { ...id, ...disabled, - ...conditionsReceipient, + ...conditionsRecipient, ...membershipPayload, } From c80fca173d03527e355994154f1cb9311831b517 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Thu, 15 Feb 2024 15:16:31 +0900 Subject: [PATCH 06/13] send-transactions params broken --- .env.example | 2 + .../invitations/handlers/claim-invitation.ts | 71 +++++++++++++++++++ src/plugins/invitations/index.ts | 10 +++ 3 files changed, 83 insertions(+) create mode 100644 src/plugins/invitations/handlers/claim-invitation.ts diff --git a/.env.example b/.env.example index 6dd11a377..8ca2596b1 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,5 @@ SEND_TX_API_KEY= PUBLIC_WALLET_CONNECT_PROJECT_ID= PUBLIC_ONDATO_VERIFICATION_URL= + +SEND_DEVPROTOCOL_API_KEY= diff --git a/src/plugins/invitations/handlers/claim-invitation.ts b/src/plugins/invitations/handlers/claim-invitation.ts new file mode 100644 index 000000000..27076cc44 --- /dev/null +++ b/src/plugins/invitations/handlers/claim-invitation.ts @@ -0,0 +1,71 @@ +import { getDefaultClient } from '../redis' +import { verifyMessage } from 'ethers' +import { check } from './get-invitations-check' + +type HandlerParams = { + rpcUrl: string + chainId: number + property: string +} + +export const handler = + ({ rpcUrl, chainId, property }: HandlerParams) => + async ({ request }: { readonly request: Request }) => { + const { signature, message, invitationId } = (await request.json()) as { + signature: string + message: string + invitationId: string + } + + // get the ethereum address from the signature + // const signer = await getSigne(signature, message) + const address = verifyMessage(message, signature) + const client = await getDefaultClient() + + const available = await check({ + id: invitationId, + account: address, + client, + }) + + if (!available) { + return new Response( + JSON.stringify({ error: 'Invitation not available' }), + { + status: 401, + }, + ) + } + + const sendDevProtocolApiKey = process.env.SEND_DEVPROTOCOL_API_KEY ?? '' + + const res = fetch( + 'https://send.devprotocol.xyz/api/send-transactions/SwapTokensAndStakeDev', + { + method: 'POST', + headers: { + Authorization: `Bearer ${sendDevProtocolApiKey}`, + }, + body: JSON.stringify({ + requestId: invitationId, + rpcUrl, + chainId, + args: { + to, + property, + payload, + gatewayAddress, + amounts: { + token, + input, + fee, + }, + }, + }), + }, + ) + + return new Response(JSON.stringify({ id: invitationId }), { status: 200 }) + } + +export default handler diff --git a/src/plugins/invitations/index.ts b/src/plugins/invitations/index.ts index c99cf01f3..7c6996a1d 100644 --- a/src/plugins/invitations/index.ts +++ b/src/plugins/invitations/index.ts @@ -13,6 +13,7 @@ import Preview2 from './assets/default-theme-2.jpg' import Preview3 from './assets/default-theme-3.jpg' import getInvitationsId from './handlers/get-invitations-id' import getInvitationsCheck from './handlers/get-invitations-check' +import claimInvitation from './handlers/claim-invitation' export const getPagePaths = (async (options, config) => { return [] @@ -30,6 +31,15 @@ export const getApiPaths = (async (options, config) => { method: 'GET', handler: getInvitationsCheck, }, + { + paths: ['invitations', 'claim'], + method: 'POST', + handler: claimInvitation({ + rpcUrl: config.rpcUrl, + chainId: config.chainId, + property: config.propertyAddress, + }), + }, ] }) satisfies ClubsFunctionGetApiPaths From 27e01be40e8a5be2c07cfd1edbe233f04674bd05 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Wed, 21 Feb 2024 16:21:11 +0900 Subject: [PATCH 07/13] fetches related membership and populates send-transaction request --- .../invitations/handlers/claim-invitation.ts | 83 +++++++++++++++++-- src/plugins/invitations/index.ts | 13 ++- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/plugins/invitations/handlers/claim-invitation.ts b/src/plugins/invitations/handlers/claim-invitation.ts index 27076cc44..862bd9a6d 100644 --- a/src/plugins/invitations/handlers/claim-invitation.ts +++ b/src/plugins/invitations/handlers/claim-invitation.ts @@ -1,15 +1,31 @@ import { getDefaultClient } from '../redis' import { verifyMessage } from 'ethers' import { check } from './get-invitations-check' +import type { + ClubsFunctionGetPluginConfigById, + Membership, +} from '@devprotocol/clubs-core' +import { + whenNotErrorAll, + type UndefinedOr, + whenNotError, +} from '@devprotocol/util-ts' +import { + Index, + schemaInvitation, + uuidToQuery, + type Invitation, +} from '../redis-schema' type HandlerParams = { rpcUrl: string chainId: number property: string + getPluginConfigById: ClubsFunctionGetPluginConfigById } export const handler = - ({ rpcUrl, chainId, property }: HandlerParams) => + ({ rpcUrl, chainId, property, getPluginConfigById }: HandlerParams) => async ({ request }: { readonly request: Request }) => { const { signature, message, invitationId } = (await request.json()) as { signature: string @@ -17,10 +33,61 @@ export const handler = invitationId: string } + const client = await getDefaultClient() + + // Try to fetch the mapped invitation. + const data = await whenNotErrorAll( + [invitationId, client], + ([_id, _client]) => + _client.ft.search( + Index.Invitation, + `@${schemaInvitation['$.id'].AS}:{${uuidToQuery(_id)}}`, + { + LIMIT: { + from: 0, + size: 1, + }, + }, + ), + ) + + const invitation = whenNotError( + data, + (d) => + (d.documents.find((x) => x.value)?.value as UndefinedOr) ?? + new Error('ID is not found.'), + ) + + if (invitation instanceof Error) { + return new Response(JSON.stringify({ error: 'ID is not found' }), { + status: 400, + }) + } + + const membershipsPlugin = getPluginConfigById( + 'devprotocol:clubs:simple-memberships', + ) + + if (!membershipsPlugin || !membershipsPlugin[0]?.options) { + return new Response( + JSON.stringify({ error: 'Simple memberships plugin not found' }), + { + status: 500, + }, + ) + } + + const memberships = + (membershipsPlugin[0]?.options?.find(({ key }) => key === 'memberships') + ?.value as UndefinedOr) ?? [] + + const invitationMembership = memberships.find( + (m) => m.payload === invitation.membership.payload, + ) + // get the ethereum address from the signature // const signer = await getSigne(signature, message) const address = verifyMessage(message, signature) - const client = await getDefaultClient() const available = await check({ id: invitationId, @@ -51,14 +118,14 @@ export const handler = rpcUrl, chainId, args: { - to, + to: invitation.conditions?.recipient, property, - payload, - gatewayAddress, + payload: invitationMembership?.payload, + gatewayAddress: invitation.conditions?.recipient, amounts: { - token, - input, - fee, + token: invitationMembership?.currency, + input: invitationMembership?.price, + fee: invitationMembership?.fee, }, }, }), diff --git a/src/plugins/invitations/index.ts b/src/plugins/invitations/index.ts index 7c6996a1d..aec93b387 100644 --- a/src/plugins/invitations/index.ts +++ b/src/plugins/invitations/index.ts @@ -19,7 +19,11 @@ export const getPagePaths = (async (options, config) => { return [] }) satisfies ClubsFunctionGetPagePaths -export const getApiPaths = (async (options, config) => { +export const getApiPaths = (async ( + options, + { rpcUrl, chainId, propertyAddress }, + { getPluginConfigById }, +) => { return [ { paths: ['invitations', SinglePath], @@ -35,9 +39,10 @@ export const getApiPaths = (async (options, config) => { paths: ['invitations', 'claim'], method: 'POST', handler: claimInvitation({ - rpcUrl: config.rpcUrl, - chainId: config.chainId, - property: config.propertyAddress, + rpcUrl, + chainId, + property: propertyAddress, + getPluginConfigById, }), }, ] From 24f1d32a7b77e611ee96699b5d289ddf896eaac8 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Wed, 21 Feb 2024 16:38:02 +0900 Subject: [PATCH 08/13] updates to param --- src/plugins/invitations/handlers/claim-invitation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/invitations/handlers/claim-invitation.ts b/src/plugins/invitations/handlers/claim-invitation.ts index 862bd9a6d..d97f0f26b 100644 --- a/src/plugins/invitations/handlers/claim-invitation.ts +++ b/src/plugins/invitations/handlers/claim-invitation.ts @@ -118,7 +118,7 @@ export const handler = rpcUrl, chainId, args: { - to: invitation.conditions?.recipient, + to: address, property, payload: invitationMembership?.payload, gatewayAddress: invitation.conditions?.recipient, From 632bdbdfb6cd222466e72e5f7bc5549cc07dde23 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Thu, 22 Feb 2024 12:14:05 +0900 Subject: [PATCH 09/13] updates send transaction args --- .../invitations/handlers/claim-invitation.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/plugins/invitations/handlers/claim-invitation.ts b/src/plugins/invitations/handlers/claim-invitation.ts index d97f0f26b..f25e4388a 100644 --- a/src/plugins/invitations/handlers/claim-invitation.ts +++ b/src/plugins/invitations/handlers/claim-invitation.ts @@ -1,5 +1,5 @@ import { getDefaultClient } from '../redis' -import { verifyMessage } from 'ethers' +import { parseUnits, verifyMessage } from 'ethers' import { check } from './get-invitations-check' import type { ClubsFunctionGetPluginConfigById, @@ -106,6 +106,26 @@ export const handler = const sendDevProtocolApiKey = process.env.SEND_DEVPROTOCOL_API_KEY ?? '' + const membershipPrice = invitationMembership?.price + + if (!membershipPrice) { + return new Response( + JSON.stringify({ error: 'Membership price not found' }), + { + status: 500, + }, + ) + } + + const parsedPrice = + invitationMembership?.currency === 'USDC' + ? parseUnits(invitationMembership?.price.toString(), 6) + : parseUnits(invitationMembership?.price.toString(), 18) + + const fee = + (parsedPrice * BigInt(invitationMembership.fee?.percentage ?? 0)) / + BigInt(10) + const res = fetch( 'https://send.devprotocol.xyz/api/send-transactions/SwapTokensAndStakeDev', { @@ -121,11 +141,11 @@ export const handler = to: address, property, payload: invitationMembership?.payload, - gatewayAddress: invitation.conditions?.recipient, + gatewayAddress: invitationMembership?.fee?.beneficiary, amounts: { token: invitationMembership?.currency, - input: invitationMembership?.price, - fee: invitationMembership?.fee, + input: parsedPrice, + fee, }, }, }), From bee3ecd9a2fbcfb6878732608cd0246a8bcfc1c3 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Tue, 27 Feb 2024 12:51:05 +0900 Subject: [PATCH 10/13] checks if id exists before inserting a new invitation --- .../invitations/handlers/post-invitation.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/plugins/invitations/handlers/post-invitation.ts b/src/plugins/invitations/handlers/post-invitation.ts index 1ef9aa01c..2b363f305 100644 --- a/src/plugins/invitations/handlers/post-invitation.ts +++ b/src/plugins/invitations/handlers/post-invitation.ts @@ -2,6 +2,14 @@ import type { APIRoute } from 'astro' import { Prefix, invitationDocument } from '../redis-schema' import { getDefaultClient } from '../redis' +const checkExisting = async ({ invitationId }: { invitationId: string }) => { + const client = await getDefaultClient() + + const keyExists = await client.exists(`${Prefix.Invitation}::${invitationId}`) + + return keyExists === 1 ? true : false +} + export const POST: APIRoute = async ({ request }) => { const { signature, message, membership, conditions } = (await request.json()) as { @@ -23,6 +31,13 @@ export const POST: APIRoute = async ({ request }) => { conditions, }) + if (await checkExisting({ invitationId: invitation.id })) { + return new Response( + JSON.stringify({ error: 'invitation already exists' }), + { status: 400 }, + ) + } + client.set( `${Prefix.Invitation}::${invitation.id}`, JSON.stringify(invitation), From 657c9b3eb4394a1699c5b8dacb22e3d0efb9a6fe Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Thu, 29 Feb 2024 15:32:57 +0900 Subject: [PATCH 11/13] removes message and sig from post params --- .../invitations/handlers/post-invitation.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/plugins/invitations/handlers/post-invitation.ts b/src/plugins/invitations/handlers/post-invitation.ts index 2b363f305..2528e689c 100644 --- a/src/plugins/invitations/handlers/post-invitation.ts +++ b/src/plugins/invitations/handlers/post-invitation.ts @@ -11,17 +11,14 @@ const checkExisting = async ({ invitationId }: { invitationId: string }) => { } export const POST: APIRoute = async ({ request }) => { - const { signature, message, membership, conditions } = - (await request.json()) as { - signature: string - message: string - membership: { - payload: string - } - conditions?: { - recipient?: string[] - } + const { membership, conditions } = (await request.json()) as { + membership: { + payload: string } + conditions?: { + recipient?: string[] + } + } // Generate a redis client while checking the latest schema is indexing and create/update index if it's not. const client = await getDefaultClient() From f15d1edfbba3656570ee0287a8e3a8c24f3db7c7 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Mon, 4 Mar 2024 13:36:14 +0900 Subject: [PATCH 12/13] change from recipients to recipient --- src/plugins/invitations/handlers/post-invitation.ts | 2 +- src/plugins/invitations/redis-schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/invitations/handlers/post-invitation.ts b/src/plugins/invitations/handlers/post-invitation.ts index 2528e689c..c8f147603 100644 --- a/src/plugins/invitations/handlers/post-invitation.ts +++ b/src/plugins/invitations/handlers/post-invitation.ts @@ -16,7 +16,7 @@ export const POST: APIRoute = async ({ request }) => { payload: string } conditions?: { - recipient?: string[] + recipient?: string } } diff --git a/src/plugins/invitations/redis-schema.ts b/src/plugins/invitations/redis-schema.ts index a33207c23..18ca4daae 100644 --- a/src/plugins/invitations/redis-schema.ts +++ b/src/plugins/invitations/redis-schema.ts @@ -22,7 +22,7 @@ export type Invitation = { id: string disabled?: boolean conditions?: { - recipient?: string[] + recipient?: string } membership: { payload: string From 3a677e7520a54b72101b8d202d70bc278fb3fc33 Mon Sep 17 00:00:00 2001 From: Stuart Kuentzel Date: Mon, 4 Mar 2024 14:03:32 +0900 Subject: [PATCH 13/13] bumps version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a7ed4f49..49a5b594b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clubs", - "version": "0.12.2-beta.3", + "version": "0.12.2-beta.4", "private": true, "type": "module", "scripts": {