diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 09425ee9..b9bdd194 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -51,12 +51,7 @@ type Mutation { type Mutation { - updateExpiredIDXProfiles : ExpiredPlayerProfiles -} - - -type Mutation { - updateIDXProfile ( + updateCachedProfile ( playerId: uuid ): CacheProcessOutput } diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index dc6d7a81..3c360b6d 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -50,18 +50,11 @@ actions: definition: kind: synchronous handler: '{{ACTION_BASE_ENDPOINT}}/syncSourceCredAccounts' -- name: updateExpiredIDXProfiles - definition: - kind: synchronous - handler: '{{ACTION_BASE_ENDPOINT}}/idxCache/checkExpired' -- name: updateIDXProfile +- name: updateCachedProfile definition: kind: asynchronous - handler: '{{ACTION_BASE_ENDPOINT}}/idxCache/updateSingle' + handler: http://host.do{{ACTION_BASE_ENDPOINT}}/composeDB/updateCachedProfile.internal:3000 forward_client_headers: true - permissions: - - role: player - - role: public - name: updateQuestCompletion definition: kind: synchronous diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml index b18c70c6..5a653ebc 100644 --- a/hasura/metadata/cron_triggers.yaml +++ b/hasura/metadata/cron_triggers.yaml @@ -1,15 +1,3 @@ -- name: queueExpiredIDXCacheEntries - webhook: '{{ACTION_BASE_ENDPOINT}}/idxCache/checkExpired' - schedule: 0 3 * * * - include_in_metadata: true - payload: {} - retry_conf: - num_retries: 0 - timeout_seconds: 600 - tolerance_seconds: 21600 - retry_interval_seconds: 10 - comment: Checks for cache entries more than four days old and queues them to be - recached. - name: syncAllGuildDiscordMembers webhook: '{{ACTION_BASE_ENDPOINT}}/syncAllGuildDiscordMembers' schedule: 31 5 * * * @@ -26,3 +14,10 @@ tolerance_seconds: 21600 retry_interval_seconds: 10 comment: Reads account data from XP Github and upserts users into the Database +- name: refreshPlayersFromComposeDB + webhook: '{{ACTION_BASE_ENDPOINT}}/composeDB/refreshPlayers' + schedule: 0 3 * * * + include_in_metadata: true + payload: {} + comment: Finds any cached player profiles more than four days old and queues them + to be recached from ComposeDB. diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index fdb4bf22..bcf0ac2c 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -611,13 +611,14 @@ set: id: x-hasura-User-Id event_triggers: - - name: cacheIDXProfile + - name: cacheComposeDBProfile definition: enable_manual: true insert: columns: '*' update: columns: + - ceramic_profile_id - ethereum_address retry_conf: num_retries: 0 diff --git a/packages/backend/src/handlers/actions/ceramic/routes.ts b/packages/backend/src/handlers/actions/ceramic/routes.ts deleted file mode 100644 index dd45affb..00000000 --- a/packages/backend/src/handlers/actions/ceramic/routes.ts +++ /dev/null @@ -1,8 +0,0 @@ -import express from 'express'; - -import { asyncHandlerWrapper } from '../../../lib/apiHelpers.js'; -import linkProfileNode from './linkProfileNode.js'; - -export const ceramicRoutes = express.Router(); - -ceramicRoutes.post('/linkProfileNode', asyncHandlerWrapper(linkProfileNode)); diff --git a/packages/backend/src/lib/cacheHelper.ts b/packages/backend/src/handlers/actions/composeDB/cacheHelper.ts similarity index 93% rename from packages/backend/src/lib/cacheHelper.ts rename to packages/backend/src/handlers/actions/composeDB/cacheHelper.ts index 1dec21c6..9e54ecb2 100644 --- a/packages/backend/src/lib/cacheHelper.ts +++ b/packages/backend/src/handlers/actions/composeDB/cacheHelper.ts @@ -1,7 +1,7 @@ import { Maybe } from '@metafam/utils'; import Bottleneck from 'bottleneck'; -import updateCachedProfile from '../handlers/actions/idxCache/updateSingle.js'; +import { updatePlayerFromComposeDB } from './updatePlayerFromComposeDB.js'; let count = 0; @@ -33,7 +33,7 @@ export const queueRecache = async ({ const preRun = count++; const result = await limiter.schedule({ id: playerId, ...opts }, () => - updateCachedProfile(playerId), + updatePlayerFromComposeDB(playerId), ); console.debug({ msg: 'Completed Profile Update', diff --git a/packages/backend/src/handlers/actions/ceramic/linkProfileNode.ts b/packages/backend/src/handlers/actions/composeDB/linkProfileNode/handler.ts similarity index 93% rename from packages/backend/src/handlers/actions/ceramic/linkProfileNode.ts rename to packages/backend/src/handlers/actions/composeDB/linkProfileNode/handler.ts index cccc2d37..00905740 100644 --- a/packages/backend/src/handlers/actions/ceramic/linkProfileNode.ts +++ b/packages/backend/src/handlers/actions/composeDB/linkProfileNode/handler.ts @@ -2,12 +2,12 @@ import { ComposeClient } from '@composedb/client'; import { composeDBDefinition } from '@metafam/utils'; import { Request, Response } from 'express'; -import { CONFIG } from '../../../config.js'; +import { CONFIG } from '../../../../config.js'; import { LinkCeramicProfileNodeResponse, Mutation_RootLinkCeramicProfileNodeArgs, -} from '../../../lib/autogen/hasura-sdk.js'; -import { client } from '../../../lib/hasuraClient.js'; +} from '../../../../lib/autogen/hasura-sdk.js'; +import { client } from '../../../../lib/hasuraClient.js'; export default async (req: Request, res: Response): Promise => { const { input, session_variables: sessionVariables } = req.body; @@ -52,7 +52,6 @@ export default async (req: Request, res: Response): Promise => { controllerEthAddress.toLowerCase() === ethereumAddress.toLowerCase() ) { // We confirmed they indeed control this model, so persist it as theirs - await client.UpdatePlayerById({ playerId, input: { ceramicProfileId: nodeId }, diff --git a/packages/backend/src/handlers/actions/composeDB/routes.ts b/packages/backend/src/handlers/actions/composeDB/routes.ts new file mode 100644 index 00000000..233392a8 --- /dev/null +++ b/packages/backend/src/handlers/actions/composeDB/routes.ts @@ -0,0 +1,13 @@ +import express from 'express'; + +import { asyncHandlerWrapper } from '../../../lib/apiHelpers.js'; +import linkProfileNode from './linkProfileNode/handler.js'; +import updateSingleProfileHandler from './updateSingleProfile/handler.js'; + +export const routes = express.Router(); + +routes.post('/linkProfileNode', asyncHandlerWrapper(linkProfileNode)); +routes.post( + '/updateCachedProfile', + asyncHandlerWrapper(updateSingleProfileHandler), +); diff --git a/packages/backend/src/handlers/actions/composeDB/updatePlayerFromComposeDB.ts b/packages/backend/src/handlers/actions/composeDB/updatePlayerFromComposeDB.ts new file mode 100644 index 00000000..2a8fcdd4 --- /dev/null +++ b/packages/backend/src/handlers/actions/composeDB/updatePlayerFromComposeDB.ts @@ -0,0 +1,154 @@ +import { Caip10Link } from '@ceramicnetwork/stream-caip10-link'; +import { ComposeClient } from '@composedb/client'; +import { + composeDBDefinition, + ComposeDBProfile, + composeDBToHasuraProfile, +} from '@metafam/utils'; + +import { CONFIG } from '../../../config.js'; +import { + Profile_Update_Column, + UpdateIdxProfileResponse, +} from '../../../lib/autogen/hasura-sdk.js'; +import { client } from '../../../lib/hasuraClient.js'; + +const composeDBClient = new ComposeClient({ + ceramic: CONFIG.ceramicURL, + definition: composeDBDefinition, +}); + +export const updatePlayerFromComposeDB = async ( + playerId: string, +): Promise => { + const accountLinks: string[] = []; + const fields: string[] = []; + const { player_by_pk: player } = await client.GetPlayer({ playerId }); + + if (!player) { + throw new Error(`Unknown Player: "${playerId}"`); + } else { + console.debug(`Updating Profile Cache For ${player.ethereumAddress}`); + } + + const { ethereumAddress, ceramicProfileId } = player; + + if (ceramicProfileId == null) { + // they are not yet in ComposeDB; ignore + return { + success: false, + ceramic: CONFIG.ceramicURL, + }; + } + + const modelInstanceDoc = await composeDBClient.context.loadDoc( + ceramicProfileId, + ); + if (modelInstanceDoc == null) { + // their profile document wasn't found, now what? Why would this happen? + // should we dereference the ceramicProfileId from their player record? + return { + success: false, + ceramic: CONFIG.ceramicURL, + }; + } + const values = composeDBToHasuraProfile( + modelInstanceDoc.content as ComposeDBProfile, + ); + + let did = null; + + try { + ({ did } = await Caip10Link.fromAccount( + composeDBClient.context.ceramic, + // mainnet; the site prompts them to switch if necessary + `${ethereumAddress.toLowerCase()}@eip155:1`, + )); + + try { + fields.push(...Object.keys(values)); + values.playerId = playerId; + + await client.UpsertProfile({ + objects: [values], + updateColumns: fields as Profile_Update_Column[], + }); + } catch (err) { + if ( + (err as Error).message.includes( + 'violates unique constraint "profile_username_key"', + ) + ) { + // this is brittle and likely subject to exploit + values.username = `${values.username}-${(did ?? ethereumAddress).slice( + -8, + )}`; + + await client.UpsertProfile({ + objects: [values], + updateColumns: fields as Profile_Update_Column[], + }); + } else { + throw err; + } + } + + // if (did) { + // const alsoKnownAs = ((await store.get('alsoKnownAs', did)) ?? + // {}) as AlsoKnownAs; + // const { accounts = [] } = alsoKnownAs; + + // await Promise.all( + // accounts?.map(async ({ host, id: username }: Account) => { + // const service = host + // ?.replace(/\.com$/, '') + // .toUpperCase() as AccountType_Enum; + // if (!service) { + // console.error(`No hostname for AlsoKnownAs: "${host}"`); + // } else { + // // If the account has been registered previously, this will + // // destructively assign it to the current user removing any + // // other users. + // // + // // ToDo: Examine the JWT to validate that it came from a + // // trusted source. Specifically, either the IdentityLink + // // service backing //self.id or one established by MetaGame + // const { insert_player_account: insert } = + // await client.UpsertAccount({ + // objects: [ + // { + // playerId, + // type: service, + // identifier: username, + // }, + // ], + // }); + // if (insert?.affected_rows === undefined) { + // // eslint-disable-next-line no-console + // console.warn( + // `Unable to insert ${service} user ${username} for playerId ${playerId}.`, + // ); + // } else if (insert.affected_rows > 0) { + // accountLinks.push(service); + // } + // } + // }), + // ); + // } + } catch (err) { + if (!(err as Error).message.includes('No DID')) { + throw err; + } + } + + const ret = { + success: true, + ceramic: CONFIG.ceramicURL, + did, + ethereumAddress, + accountLinks, + fields, + }; + + return ret; +}; diff --git a/packages/backend/src/handlers/actions/idxCache/updateSingleProfile/handler.ts b/packages/backend/src/handlers/actions/composeDB/updateSingleProfile/handler.ts similarity index 91% rename from packages/backend/src/handlers/actions/idxCache/updateSingleProfile/handler.ts rename to packages/backend/src/handlers/actions/composeDB/updateSingleProfile/handler.ts index 7a5bc10f..78aec737 100644 --- a/packages/backend/src/handlers/actions/idxCache/updateSingleProfile/handler.ts +++ b/packages/backend/src/handlers/actions/composeDB/updateSingleProfile/handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; -import { queueRecache } from '../../../../lib/cacheHelper.js'; +import { queueRecache } from '../cacheHelper.js'; export default async (req: Request, res: Response): Promise => { const role = req.body.session_variables['x-hasura-role']; diff --git a/packages/backend/src/handlers/actions/idxCache/routes.ts b/packages/backend/src/handlers/actions/idxCache/routes.ts deleted file mode 100644 index f9461b34..00000000 --- a/packages/backend/src/handlers/actions/idxCache/routes.ts +++ /dev/null @@ -1,17 +0,0 @@ -import express from 'express'; - -import { asyncHandlerWrapper } from '../../../lib/apiHelpers.js'; -import updateExpiredProfilesHandler from './updateExpiredProfiles/handler.js'; -import updateSingleProfileHandler from './updateSingleProfile/handler.js'; - -export const cacheRoutes = express.Router(); - -cacheRoutes.post( - '/updateSingle', - asyncHandlerWrapper(updateSingleProfileHandler), -); - -cacheRoutes.post( - '/checkExpired', - asyncHandlerWrapper(updateExpiredProfilesHandler), -); diff --git a/packages/backend/src/handlers/actions/idxCache/updateExpiredProfiles/handler.ts b/packages/backend/src/handlers/actions/idxCache/updateExpiredProfiles/handler.ts deleted file mode 100644 index db716a62..00000000 --- a/packages/backend/src/handlers/actions/idxCache/updateExpiredProfiles/handler.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Request, Response } from 'express'; - -import { queueRecache } from '../../../../lib/cacheHelper.js'; -import { client } from '../../../../lib/hasuraClient.js'; - -const INVALIDATE_AFTER_DAYS = 4; // number of days after which to recache - -export default async (req: Request, res: Response): Promise => { - const { limiter } = req.app.locals; - const expiration = new Date(); - const invalidateAfterDays = - req.query.invalidate_after_days != null - ? parseInt(req.query.invalidate_after_days as string, 10) - : INVALIDATE_AFTER_DAYS; - expiration.setDate(expiration.getDate() - invalidateAfterDays); - const { profile: players } = await client.GetCacheEntries({ - updatedBefore: expiration, - }); - const ids = ( - await Promise.all( - players.map(async ({ playerId }) => { - const queued = await queueRecache({ playerId, limiter }); - return queued ? playerId : null; - }), - ) - ).filter((id) => !!id); - - res.json({ ids }); -}; diff --git a/packages/backend/src/handlers/actions/idxCache/updateSingle.ts b/packages/backend/src/handlers/actions/idxCache/updateSingle.ts deleted file mode 100644 index 58125545..00000000 --- a/packages/backend/src/handlers/actions/idxCache/updateSingle.ts +++ /dev/null @@ -1,253 +0,0 @@ -import type { CeramicApi } from '@ceramicnetwork/common'; -import { CeramicClient } from '@ceramicnetwork/http-client'; -import { Caip10Link } from '@ceramicnetwork/stream-caip10-link'; -import { - Account, - AlsoKnownAs, - model as alsoKnownAsModel, -} from '@datamodels/identity-accounts-web'; -import { - BasicProfile, - ImageSources, - model as basicProfileModel, -} from '@datamodels/identity-profile-basic'; -import { DataModel } from '@glazed/datamodel'; -import { DIDDataStore } from '@glazed/did-datastore'; -import { - BasicProfileImages, - BasicProfileStrings, - ExtendedProfile, - ExtendedProfileImages, - extendedProfileModel, - ExtendedProfileObjects, - ExtendedProfileStrings, - HasuraProfileProps, - maskFor, - simplifyAliases, - Values, -} from '@metafam/utils'; -import { getLegacy3BoxProfileAsBasicProfile } from '@self.id/3box-legacy'; - -import { CONFIG } from '../../../config.js'; -import { - AccountType_Enum, - Maybe, - Profile_Update_Column, - UpdateIdxProfileResponse, -} from '../../../lib/autogen/hasura-sdk.js'; -import { client } from '../../../lib/hasuraClient.js'; -import { handleMeetWithWalletIntegration } from '../meetwithwallet/handler.js'; - -export default async (playerId: string): Promise => { - const accountLinks: string[] = []; - const fields: string[] = []; - const { player_by_pk: player } = await client.GetPlayer({ playerId }); - const { ethereumAddress } = player ?? {}; - let did = null; - - if (!ethereumAddress) { - throw new Error(`Unknown Player: "${playerId}"`); - } else { - console.debug(`Updating Profile Cache For ${ethereumAddress}`); - } - - const aliases = simplifyAliases([ - basicProfileModel, - extendedProfileModel, - alsoKnownAsModel, - ]); - - try { - const ceramic = new CeramicClient(CONFIG.ceramicURL) as CeramicApi; - const model = new DataModel({ ceramic, aliases }); - const store = new DIDDataStore({ ceramic, model }); - - ({ did } = await Caip10Link.fromAccount( - ceramic, - // mainnet; the site prompts them to switch if necessary - `${ethereumAddress.toLowerCase()}@eip155:1`, - )); - - const values: HasuraProfileProps = {}; - let basicProfile: Maybe = null; - let extendedProfile: Maybe = null; - - if (!did) { - console.debug(`No CAIP-10 Link For ${ethereumAddress}`); - } else { - basicProfile = await store.get('basicProfile', did); - } - - if (!basicProfile) { - basicProfile = await getLegacy3BoxProfileAsBasicProfile(ethereumAddress); - } - - if (!basicProfile) { - console.debug(`No Basic Profile For: ${ethereumAddress} (${did})`); - } else { - Object.entries(BasicProfileStrings).forEach(([hasuraId, ceramicId]) => { - const fromKey = ceramicId as Values; - const toKey = hasuraId as keyof typeof BasicProfileStrings; - if (basicProfile?.[fromKey] != null) { - values[toKey] = (basicProfile[fromKey] as string) ?? null; - } - }); - Object.entries(BasicProfileImages).forEach(([hasuraId, ceramicId]) => { - const fromKey = ceramicId as Values; - const toKey = hasuraId as keyof typeof BasicProfileImages; - values[toKey] = - (basicProfile?.[fromKey] as ImageSources)?.original.src ?? null; - }); - } - - if (did) { - extendedProfile = await store.get('extendedProfile', did); - - if (extendedProfile == null) { - console.debug(`No Extended Profile For: ${ethereumAddress} (${did})`); - } else { - Object.entries(ExtendedProfileStrings).forEach( - ([hasuraId, ceramicId]) => { - const fromKey = ceramicId as Values; - const toKey = hasuraId as keyof typeof ExtendedProfileStrings; - - if (extendedProfile?.[fromKey] != null) { - values[toKey] = (extendedProfile[fromKey] as string) ?? null; - } - }, - ); - if (extendedProfile.meetWithWalletDomain != null) { - await handleMeetWithWalletIntegration( - playerId, - extendedProfile.meetWithWalletDomain, - ); - } - Object.entries(ExtendedProfileImages).forEach( - ([hasuraId, ceramicId]) => { - const fromKey = ceramicId as Values; - const toKey = hasuraId as keyof typeof ExtendedProfileImages; - values[toKey] = - (extendedProfile?.[fromKey] as ImageSources)?.original.src ?? - null; - }, - ); - Object.entries(ExtendedProfileObjects).forEach( - ([hasuraId, ceramicId]) => { - const fromKey = ceramicId as Values; - const toKey = hasuraId as keyof typeof ExtendedProfileObjects; - if (extendedProfile?.[fromKey] != null) { - switch (fromKey) { - case 'availableHours': { - values.availableHours = extendedProfile.availableHours; - break; - } - case 'magicDisposition': { - values.colorMask = - maskFor(extendedProfile.magicDisposition) ?? undefined; - break; - } - default: { - console.info('Unrecognized Key', { fromKey, toKey }); - } - } - } - }, - ); - } - } - - if (!basicProfile && !extendedProfile) { - console.info(`No Profile Information For ${ethereumAddress}.`); - } else { - try { - fields.push(...Object.keys(values)); - values.playerId = playerId; - - await client.UpsertProfile({ - objects: [values], - updateColumns: fields as Profile_Update_Column[], - }); - } catch (err) { - if ( - !(err as Error).message.includes( - 'violates unique constraint "profile_username_key"', - ) - ) { - throw err; - } else { - // this is brittle and likely subject to exploit - values.username = `${values.username}-${( - did ?? ethereumAddress - ).slice(-8)}`; - - await client.UpsertProfile({ - objects: [values], - updateColumns: fields as Profile_Update_Column[], - }); - } - } - } - - if (did) { - const alsoKnownAs = ((await store.get('alsoKnownAs', did)) ?? - {}) as AlsoKnownAs; - const { accounts = [] } = alsoKnownAs; - - await Promise.all( - accounts?.map(async ({ host, id: username }: Account) => { - const service = host - ?.replace(/\.com$/, '') - .toUpperCase() as AccountType_Enum; - if (!service) { - console.error(`No hostname for AlsoKnownAs: "${host}"`); - } else { - // If the account has been registered previously, this will - // destructively assign it to the current user removing any - // other users. - // - // ToDo: Examine the JWT to validate that it came from a - // trusted source. Specifically, either the IdentityLink - // service backing //self.id or one established by MetaGame - const { insert_player_account: insert } = - await client.UpsertAccount({ - objects: [ - { - playerId, - type: service, - identifier: username, - }, - ], - }); - if (insert?.affected_rows === undefined) { - // eslint-disable-next-line no-console - console.warn( - `Unable to insert ${service} user ${username} for playerId ${playerId}.`, - ); - } else if (insert.affected_rows > 0) { - accountLinks.push(service); - } - } - }), - ); - } - } catch (err) { - if (!(err as Error).message.includes('No DID')) { - throw err; - } - } - - const ret = { - success: true, - ceramic: CONFIG.ceramicURL, - did, - ethereumAddress, - accountLinks, - fields, - }; - - if (fields.length === 0) { - Object.assign(ret, { aliases }); - } - - return ret; -}; diff --git a/packages/backend/src/handlers/actions/routes.ts b/packages/backend/src/handlers/actions/routes.ts index 0de60a1e..0a22a90f 100644 --- a/packages/backend/src/handlers/actions/routes.ts +++ b/packages/backend/src/handlers/actions/routes.ts @@ -1,18 +1,16 @@ import express from 'express'; import { asyncHandlerWrapper } from '../../lib/apiHelpers.js'; -import { ceramicRoutes } from './ceramic/routes.js'; +import { routes as composeDBRoutes } from './composeDB/routes.js'; import { guildRoutes } from './guild/routes.js'; import { syncAllGuildDiscordMembers } from './guild/sync.js'; -import { cacheRoutes } from './idxCache/routes.js'; import { questsRoutes } from './quests/routes.js'; import { syncSourceCredAccounts } from './sourcecred/sync.js'; export const actionRoutes = express.Router(); -actionRoutes.use('/ceramic', ceramicRoutes); +actionRoutes.use('/composeDB', composeDBRoutes); actionRoutes.use('/guild', guildRoutes); -actionRoutes.use('/idxCache', cacheRoutes); actionRoutes.post( '/syncSourceCredAccounts', diff --git a/packages/backend/src/handlers/auth-webhook/users.ts b/packages/backend/src/handlers/auth-webhook/users.ts index d4510105..3b8d7ce1 100644 --- a/packages/backend/src/handlers/auth-webhook/users.ts +++ b/packages/backend/src/handlers/auth-webhook/users.ts @@ -1,7 +1,7 @@ import Bottleneck from 'bottleneck'; -import { cacheProfile } from '../../lib/cacheHelper.js'; import { client } from '../../lib/hasuraClient.js'; +import { cacheProfile } from '../actions/composeDB/cacheHelper.js'; async function createPlayer(ethAddress: string, limiter: Bottleneck) { const { insert_profile: insert } = await client.CreatePlayerFromETH({ diff --git a/packages/backend/src/handlers/triggers/cacheIDXProfile.ts b/packages/backend/src/handlers/triggers/cacheComposeDBProfile.ts similarity index 77% rename from packages/backend/src/handlers/triggers/cacheIDXProfile.ts rename to packages/backend/src/handlers/triggers/cacheComposeDBProfile.ts index 12727c6c..a5cfb61c 100644 --- a/packages/backend/src/handlers/triggers/cacheIDXProfile.ts +++ b/packages/backend/src/handlers/triggers/cacheComposeDBProfile.ts @@ -1,11 +1,11 @@ import Bottleneck from 'bottleneck'; import { Player } from '../../lib/autogen/hasura-sdk.js'; -import { queueRecache } from '../../lib/cacheHelper.js'; +import { queueRecache } from '../actions/composeDB/cacheHelper.js'; import { TriggerPayload } from './types.js'; // This trigger is called when new accounts are created. -export const cacheIDXProfile = async ( +export const cacheComposeDBProfile = async ( payload: TriggerPayload, limiter: Bottleneck, ) => { diff --git a/packages/backend/src/handlers/triggers/handler.ts b/packages/backend/src/handlers/triggers/handler.ts index 2ad8d18d..a22a7afb 100644 --- a/packages/backend/src/handlers/triggers/handler.ts +++ b/packages/backend/src/handlers/triggers/handler.ts @@ -3,13 +3,13 @@ import { ParamsDictionary } from 'express-serve-static-core'; import { Guild, Player, Player_Role } from '../../lib/autogen/hasura-sdk.js'; import { syncDiscordGuildMembers } from '../actions/guild/sync.js'; -import { cacheIDXProfile } from './cacheIDXProfile.js'; +import { cacheComposeDBProfile } from './cacheComposeDBProfile.js'; import { playerRankUpdated } from './playerRankUpdated.js'; import { playerRoleChanged } from './playerRoleChanged.js'; import { TriggerPayload } from './types.js'; const TRIGGERS = { - cacheIDXProfile, + cacheComposeDBProfile, playerRankUpdated, playerRoleChanged, syncDiscordGuildMembers, diff --git a/packages/utils/src/composeDBProfileFields.ts b/packages/utils/src/composeDB/fields.ts similarity index 83% rename from packages/utils/src/composeDBProfileFields.ts rename to packages/utils/src/composeDB/fields.ts index edd8b332..f1744a06 100644 --- a/packages/utils/src/composeDBProfileFields.ts +++ b/packages/utils/src/composeDB/fields.ts @@ -1,4 +1,4 @@ -import { Values } from './extendedProfileTypes.js'; +import { Values } from '../extendedProfileTypes.js'; export const composeDBProfileFieldName = 'name'; export const composeDBProfileFieldDescription = 'description'; @@ -99,23 +99,3 @@ export const hasuraImageFields = [ ] as const; export type HasuraImageFieldKey = typeof hasuraImageFields[number]; - -// typesafe Array.includes, see https://fettblog.eu/typescript-array-includes/ -function includes(coll: ReadonlyArray, el: U): el is T { - return coll.includes(el as T); -} - -export function isComposeDBImageField(key: string) { - return includes(composeDBImageFields, key); -} - -export function isHasuraImageField(key: string) { - return includes(hasuraImageFields, key); -} - -export function isImageMetadata(value: ComposeDBPayloadValue) { - const maybeImageMetadata = value as ComposeDBImageMetadata; - return ( - maybeImageMetadata?.url != null && maybeImageMetadata?.mimeType != null - ); -} diff --git a/packages/utils/src/composeDB/utils.ts b/packages/utils/src/composeDB/utils.ts new file mode 100644 index 00000000..bf2c8f04 --- /dev/null +++ b/packages/utils/src/composeDB/utils.ts @@ -0,0 +1,59 @@ +import { maskFor } from '../colorHelpers.js'; +import { + ComposeDBField, + composeDBImageFields, + ComposeDBImageMetadata, + ComposeDBPayloadValue, + ComposeDBProfile, + composeDBProfileFieldFiveColorDisposition, + hasuraImageFields, + profileMapping, +} from './fields.js'; + +// typesafe Array.includes, see https://fettblog.eu/typescript-array-includes/ +function includes(coll: ReadonlyArray, el: U): el is T { + return coll.includes(el as T); +} + +export function isComposeDBImageField(key: string) { + return includes(composeDBImageFields, key); +} + +export function isHasuraImageField(key: string) { + return includes(hasuraImageFields, key); +} + +export function isImageMetadata(value: ComposeDBPayloadValue) { + const maybeImageMetadata = value as ComposeDBImageMetadata; + return ( + maybeImageMetadata?.url != null && maybeImageMetadata?.mimeType != null + ); +} + +export const composeDBToHasuraProfile = ( + composeDBProfile: ComposeDBProfile, +) => { + // todo we should be able to make this typesafe + const hasuraProfile: Record = {}; + + Object.entries(composeDBProfile).forEach(([key, value]) => { + const match = Object.entries(profileMapping).find( + ([, composeDBKey]) => composeDBKey === key, + ) as [keyof typeof profileMapping, ComposeDBField]; + + const hasuraKey = match[0]; + + // Some fields required custom translations + let hasuraValue = value; + if (value && key === composeDBProfileFieldFiveColorDisposition) { + const maskNumber = maskFor(value as string); + if (maskNumber != null) { + hasuraValue = maskNumber; + } + } else if (value && isComposeDBImageField(key)) { + hasuraValue = (value as ComposeDBImageMetadata).url; + } + hasuraProfile[hasuraKey] = hasuraValue; + }); + return hasuraProfile; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c5cc940e..5eef96e3 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -5,7 +5,8 @@ import extendedProfileModel from './ExtendedProfileModel.json' assert { type: 'j export * from './arrayHelpers.js'; export * from './ceramic.js'; export * from './colorHelpers.js'; -export * from './composeDBProfileFields.js'; +export * from './composeDB/fields.js'; +export * from './composeDB/utils.js'; export * as Constants from './constants.js'; export * as did from './did/index.js'; export * as DiscordUtil from './discordHelpers.js'; diff --git a/packages/web/components/EditProfileModal.tsx b/packages/web/components/EditProfileModal.tsx index ebd080f4..18dc7cff 100644 --- a/packages/web/components/EditProfileModal.tsx +++ b/packages/web/components/EditProfileModal.tsx @@ -36,7 +36,11 @@ import { isHasuraImageField, profileMapping, } from '@metafam/utils'; -import { Maybe, Player, Profile } from 'graphql/autogen/types'; +import { + Maybe, + Player, + useInsertCacheInvalidationMutation, +} from 'graphql/autogen/types'; import { getPlayer } from 'graphql/getPlayer'; import { PlayerProfile } from 'graphql/types'; import { useWeb3 } from 'lib/hooks'; @@ -106,6 +110,7 @@ export const EditProfileModal: React.FC = ({ const username = player.profile?.username; const { save } = useSaveToComposeDB(); + const [, invalidateCache] = useInsertCacheInvalidationMutation(); const initialFormValues = useMemo( () => getDefaultFormValues(player), @@ -234,6 +239,11 @@ export const EditProfileModal: React.FC = ({ const payload = hasuraToComposeDBProfile(profile, profileImages); const ceramicStreamID = await save(payload); + if (player) { + setStatus('Invalidating Cache…'); + await invalidateCache({ playerId: player.id }); + } + // if they changed their username, the page will 404 on reload if (player && inputs.username !== username) { window.history.replaceState( diff --git a/packages/web/components/Link.tsx b/packages/web/components/Link.tsx index e2ca83ec..6562a518 100644 --- a/packages/web/components/Link.tsx +++ b/packages/web/components/Link.tsx @@ -9,7 +9,6 @@ export const MetaLink: React.FC = ({ children, href, as, - passHref, replace, scroll = true, shallow, @@ -33,7 +32,8 @@ export const MetaLink: React.FC = ({ scroll, shallow, }} - passHref={passHref || true} + passHref={true} + legacyBehavior={true} > {/* NextLink passes the href */} diff --git a/packages/web/graphql/mutations/idxCache.ts b/packages/web/graphql/mutations/idxCache.ts index 21a9be4c..ddfda905 100644 --- a/packages/web/graphql/mutations/idxCache.ts +++ b/packages/web/graphql/mutations/idxCache.ts @@ -1,5 +1,5 @@ export const InsertCacheInvalidation = /* GraphQL */ ` mutation InsertCacheInvalidation($playerId: uuid!) { - updateIDXProfile(playerId: $playerId) + updateCachedProfile(playerId: $playerId) } `; diff --git a/packages/web/lib/hooks/ceramic/useGetPlayerProfileFromComposeDB.ts b/packages/web/lib/hooks/ceramic/useGetPlayerProfileFromComposeDB.ts index 25df33e1..0d5165ec 100644 --- a/packages/web/lib/hooks/ceramic/useGetPlayerProfileFromComposeDB.ts +++ b/packages/web/lib/hooks/ceramic/useGetPlayerProfileFromComposeDB.ts @@ -1,12 +1,7 @@ import { - ComposeDBField, - ComposeDBImageMetadata, ComposeDBProfile, - composeDBProfileFieldFiveColorDisposition, - isComposeDBImageField, - maskFor, + composeDBToHasuraProfile, Maybe, - profileMapping, } from '@metafam/utils'; import { Player } from 'graphql/autogen/types'; import { buildPlayerProfileQuery } from 'graphql/composeDB/queries/profile'; @@ -82,31 +77,3 @@ export const hydratePlayerProfile = ( } return player; }; - -export const composeDBToHasuraProfile = ( - composeDBProfile: ComposeDBProfile, -) => { - // todo we should be able to make this typesafe - const hasuraProfile: Record = {}; - - Object.entries(composeDBProfile).forEach(([key, value]) => { - const match = Object.entries(profileMapping).find( - ([, composeDBKey]) => composeDBKey === key, - ) as [keyof typeof profileMapping, ComposeDBField]; - - const hasuraKey = match[0]; - - // Some fields required custom translations - let hasuraValue = value; - if (value && key === composeDBProfileFieldFiveColorDisposition) { - const maskNumber = maskFor(value as string); - if (maskNumber != null) { - hasuraValue = maskNumber; - } - } else if (value && isComposeDBImageField(key)) { - hasuraValue = (value as ComposeDBImageMetadata).url; - } - hasuraProfile[hasuraKey] = hasuraValue; - }); - return hasuraProfile; -}; diff --git a/packages/web/lib/hooks/ceramic/usePlayerSetupSaveToComposeDB.ts b/packages/web/lib/hooks/ceramic/usePlayerSetupSaveToComposeDB.ts index 0dfc40b0..6e125e18 100644 --- a/packages/web/lib/hooks/ceramic/usePlayerSetupSaveToComposeDB.ts +++ b/packages/web/lib/hooks/ceramic/usePlayerSetupSaveToComposeDB.ts @@ -12,6 +12,7 @@ import { profileMapping, } from '@metafam/utils'; import { useSetupFlow } from 'contexts/SetupContext'; +import { useInsertCacheInvalidationMutation } from 'graphql/autogen/types'; import { PlayerProfile } from 'graphql/types'; import { CeramicError } from 'lib/errors'; import { ReactElement, useCallback, useEffect, useState } from 'react'; @@ -20,6 +21,7 @@ import { getImageDimensions } from 'utils/imageHelpers'; import { uploadFile } from 'utils/uploadHelpers'; import { FileReaderData } from '../useImageReader'; +import { useUser } from '../useUser'; import { useSaveToComposeDB } from './useSaveToComposeDB'; export type PlayerSetupSaveToComposeDBProps = { @@ -34,18 +36,12 @@ export function usePlayerSetupSaveToComposeDB({ pickedFile: fileData, }: PlayerSetupSaveToComposeDBProps) { const toast = useToast(); + const { user } = useUser(); const { onNextPress } = useSetupFlow(); const [status, setStatus] = useState>(); const { save: saveToComposeDB, status: saveStatus } = useSaveToComposeDB(); - - const persist = useCallback( - (values: Record) => { - setStatus('Saving to Ceramic…'); - return saveToComposeDB(values); - }, - [saveToComposeDB], - ); + const [, invalidateCache] = useInsertCacheInvalidationMutation(); useEffect(() => { if (saveStatus === 'authenticating') { @@ -94,8 +90,13 @@ export function usePlayerSetupSaveToComposeDB({ payload[composeDBProfileFieldAvatar] = cleanImageMetadata; } - setStatus('Saving…'); - nodeId = await persist(payload); + setStatus('Saving to ComposeDB…'); + nodeId = await saveToComposeDB(payload); + + if (user) { + setStatus('Invalidating Cache…'); + await invalidateCache({ playerId: user.id }); + } } if (onComplete) { @@ -116,7 +117,16 @@ export function usePlayerSetupSaveToComposeDB({ setStatus(null); } }, - [fileData, isChanged, onComplete, onNextPress, persist, toast], + [ + fileData, + invalidateCache, + isChanged, + onComplete, + onNextPress, + saveToComposeDB, + toast, + user, + ], ); return { diff --git a/packages/web/lib/hooks/ceramic/useSaveToComposeDB.ts b/packages/web/lib/hooks/ceramic/useSaveToComposeDB.ts index 64f5326c..45f25492 100644 --- a/packages/web/lib/hooks/ceramic/useSaveToComposeDB.ts +++ b/packages/web/lib/hooks/ceramic/useSaveToComposeDB.ts @@ -17,6 +17,7 @@ export const useSaveToComposeDB = () => { // When saving to ComposeDB, it's essential that we have the most recent value for user.ceramicProfileId so that we don't inadvertently create duplicate profile models. Thus, we specify 'network-only' to ALWAYS fetch the latest value of 'user' from Hasura. // Ideally, we should be invalidating the urql cache when persisting the ceramicProfileId below, but that would require switching over to Normalized caching (https://formidable.com/open-source/urql/docs/graphcache/normalized-caching/) which could be a big lift requiring a bunch of testing + // This is likely causing the warning "Cannot update a component while rendering a different component" const { user } = useUser({ requestPolicy: 'network-only' }); const [, linkNode] = useLinkOwnCeramicNodeMutation(); diff --git a/schema.graphql b/schema.graphql index c581d87b..da639fcc 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1076,6 +1076,14 @@ type DiscordGuildAuthResponse { success: Boolean! } +type DiscordGuildsSyncOutput { + name: String! + numDeleted: Int + numInserted: Int + numSkipped: Int + username: String! +} + type DiscordRole { id: String! name: String! @@ -4083,14 +4091,19 @@ type mutation_root { saveGuildLayout(guildLayoutInfo: GuildLayoutInfoInput!): SaveGuildLayoutResponse """ - perform the action: "updateExpiredIDXProfiles" + perform the action: "syncAllGuildDiscordMembers" """ - updateExpiredIDXProfiles: ExpiredPlayerProfiles + syncAllGuildDiscordMembers: [DiscordGuildsSyncOutput] """ - perform the action: "updateIDXProfile" + perform the action: "syncSourceCredAccounts" """ - updateIDXProfile(playerId: uuid): uuid! + syncSourceCredAccounts: SourceCredSyncOutput + + """ + perform the action: "updateCachedProfile" + """ + updateCachedProfile(playerId: uuid): uuid! """ perform the action: "updateQuestCompletion" @@ -8452,14 +8465,14 @@ type query_root { skill_by_pk(id: uuid!): skill """ - retrieve the result of action: "updateIDXProfile" + retrieve the result of action: "updateCachedProfile" """ - updateIDXProfile( + updateCachedProfile( """ - id of the action: "updateIDXProfile" + id of the action: "updateCachedProfile" """ id: uuid! - ): updateIDXProfile + ): updateCachedProfile } """ @@ -10863,6 +10876,13 @@ enum SkillCategory_update_column { name } +type SourceCredSyncOutput { + numInserted: Int! + numSkipped: Int! + numUnclaimed: Int! + numUpdated: Int! +} + """ expression to compare columns of type String. All fields are combined with logical 'AND'. """ @@ -12046,14 +12066,14 @@ type subscription_root { skill_by_pk(id: uuid!): skill """ - retrieve the result of action: "updateIDXProfile" + retrieve the result of action: "updateCachedProfile" """ - updateIDXProfile( + updateCachedProfile( """ - id of the action: "updateIDXProfile" + id of the action: "updateCachedProfile" """ id: uuid! - ): updateIDXProfile + ): updateCachedProfile } scalar timestamptz @@ -12080,9 +12100,9 @@ type TokenBalances { } """ -fields of action: "updateIDXProfile" +fields of action: "updateCachedProfile" """ -type updateIDXProfile { +type updateCachedProfile { """the time at which this action was created""" created_at: timestamptz