diff --git a/libs/adapters/src/trpc/builder.ts b/libs/adapters/src/trpc/builder.ts new file mode 100644 index 00000000000..d1d86cca39a --- /dev/null +++ b/libs/adapters/src/trpc/builder.ts @@ -0,0 +1,130 @@ +import { + logger, + stats, + type AuthStrategies, + type User, +} from '@hicommonwealth/core'; +import { TRPCError, initTRPC } from '@trpc/server'; +import type { Request } from 'express'; +import passport from 'passport'; +import type { OpenApiMeta } from 'trpc-swagger'; +import { ZodSchema, z } from 'zod'; +import { config } from '../config'; +import type { BuildProcOptions, Context, Metadata } from './types'; + +const log = logger(import.meta); + +const trpc = initTRPC.meta().context().create(); +export const router = trpc.router; +export const procedure = trpc.procedure; + +const isSecure = ( + md: Metadata, +) => md.secure !== false || (md.auth ?? []).length > 0; + +const authenticate = async ( + req: Request, + rawInput: z.infer, + authStrategy: AuthStrategies = { type: 'jwt' }, +) => { + // Bypass when user is already authenticated via JWT or token + // Authentication overridden at router level e.g. external-router.ts + if (req.user && authStrategy.type !== 'custom') return; + + try { + if (authStrategy.type === 'authtoken') { + switch (req.headers['authorization']) { + case config.NOTIFICATIONS.KNOCK_AUTH_TOKEN: + req.user = { + id: authStrategy.userId, + email: 'hello@knock.app', + }; + break; + case config.LOAD_TESTING.AUTH_TOKEN: + req.user = { + id: authStrategy.userId, + email: 'info@grafana.com', + }; + break; + default: + throw new Error('Not authenticated'); + } + } else if (authStrategy.type === 'custom') { + req.user = await authStrategy.userResolver(rawInput, req.user as User); + } else { + await passport.authenticate(authStrategy.type, { session: false }); + } + if (!req.user) throw new Error('Not authenticated'); + } catch (error) { + throw new TRPCError({ + message: error instanceof Error ? error.message : (error as string), + code: 'UNAUTHORIZED', + }); + } +}; + +/** + * tRPC procedure factory with authentication, traffic stats, and analytics middleware + */ +export const buildproc = ({ + method, + name, + md, + tag, + outMiddlewares, + forceSecure, +}: BuildProcOptions) => { + const secure = forceSecure ?? isSecure(md); + return trpc.procedure + .use(async ({ ctx, rawInput, next }) => { + if (secure) await authenticate(ctx.req, rawInput, md.authStrategy); + return next({ + ctx: { + ...ctx, + actor: { + user: ctx.req.user as User, + address: ctx.req.headers['address'] as string, + }, + }, + }); + }) + .use(async ({ ctx, rawInput, next }) => { + const start = Date.now(); + const result = await next(); + const latency = Date.now() - start; + try { + const path = `${ctx.req.method.toUpperCase()} ${ctx.req.path}`; + stats().increment('cw.path.called', { path }); + stats().histogram(`cw.path.latency`, latency, { + path, + statusCode: ctx.res.statusCode.toString(), + }); + } catch (err) { + err instanceof Error && log.error(err.message, err); + } + if (result.ok && outMiddlewares?.length) { + for (const omw of outMiddlewares) { + await omw(rawInput, result.data, ctx); + } + } + return result; + }) + .meta({ + openapi: { + method, + path: `/${name}`, + tags: [tag], + headers: [ + { + in: 'header', + name: 'address', + required: false, + schema: { type: 'string' }, + }, + ], + protect: secure, + }, + }) + .input(md.input) + .output(md.output); +}; diff --git a/libs/adapters/src/trpc/handlers.ts b/libs/adapters/src/trpc/handlers.ts index f086e7a6eaf..278ed1086bb 100644 --- a/libs/adapters/src/trpc/handlers.ts +++ b/libs/adapters/src/trpc/handlers.ts @@ -15,7 +15,8 @@ import { import { Events } from '@hicommonwealth/schemas'; import { TRPCError } from '@trpc/server'; import { ZodSchema, ZodUndefined, z } from 'zod'; -import { Commit, Tag, Track, buildproc, procedure } from './middleware'; +import { buildproc, procedure } from './builder'; +import { Tag, type OutputMiddleware } from './types'; const log = logger(import.meta); @@ -48,11 +49,7 @@ const trpcerror = (error: unknown): TRPCError => { * Builds tRPC command POST endpoint * @param factory command factory * @param tag command tag used for OpenAPI spec grouping - * @param track analytics tracking middleware as: - * - tuple of `[event, output mapper]` - * - or `(input,output) => Promise<[event, data]|undefined>` - * @param commit output middleware (best effort), mainly used to commit actions to canvas - * - `(input,output,ctx) => Promise> | undefined | void` + * @param outMiddlewares output middlewares (best effort), mainly used to commit actions to canvas * @returns tRPC mutation procedure */ export const command = < @@ -62,8 +59,7 @@ export const command = < >( factory: () => Metadata, tag: Tag, - track?: Track, - commit?: Commit, + outMiddlewares?: Array>, ) => { const md = factory(); return buildproc({ @@ -71,8 +67,7 @@ export const command = < name: factory.name, md, tag, - track, - commit, + outMiddlewares, }).mutation(async ({ ctx, input }) => { try { return await coreCommand( @@ -94,8 +89,7 @@ export const command = < * @param factory query factory * @param tag query tag used for OpenAPI spec grouping * @param options An object with security and caching related configuration - * @param commit output middleware (best effort), mainly used to update statistics - * - `(input,output,ctx) => Promise> | undefined | void` + * @param outMiddlewares output middlewares (best effort), mainly used to update statistics * @returns tRPC query procedure */ export const query = < @@ -109,7 +103,7 @@ export const query = < forceSecure?: boolean; ttlSecs?: number; }, - commit?: Commit, + outMiddlewares?: Array>, ) => { const md = factory(); return buildproc({ @@ -117,7 +111,7 @@ export const query = < name: factory.name, md, tag, - commit, + outMiddlewares, forceSecure: options?.forceSecure, }).query(async ({ ctx, input }) => { try { diff --git a/libs/adapters/src/trpc/index.ts b/libs/adapters/src/trpc/index.ts index ea63542a1b1..b8395bcee99 100644 --- a/libs/adapters/src/trpc/index.ts +++ b/libs/adapters/src/trpc/index.ts @@ -1,3 +1,5 @@ +export * from './builder'; export * from './handlers'; export * from './middleware'; +export * from './types'; export * from './utils'; diff --git a/libs/adapters/src/trpc/middleware.ts b/libs/adapters/src/trpc/middleware.ts index 21fd4bb8594..bd98da7fb13 100644 --- a/libs/adapters/src/trpc/middleware.ts +++ b/libs/adapters/src/trpc/middleware.ts @@ -1,92 +1,27 @@ -import { - analytics, - logger, - stats, - type Actor, - type AuthStrategies, - type User, -} from '@hicommonwealth/core'; -import { TRPCError, initTRPC } from '@trpc/server'; -import { Request, Response } from 'express'; -import passport from 'passport'; -import { OpenApiMeta } from 'trpc-swagger'; +import { analytics, logger } from '@hicommonwealth/core'; +import type { Request } from 'express'; import { ZodSchema, z } from 'zod'; import { config } from '../config'; +import type { Context, OutputMiddleware, Track } from './types'; const log = logger(import.meta); -type Metadata = { - readonly input: Input; - readonly output: Output; - auth: unknown[]; - secure?: boolean; - authStrategy?: AuthStrategies; -}; - -const isSecure = ( - md: Metadata, -) => md.secure !== false || (md.auth ?? []).length > 0; - -export interface Context { - req: Request; - res: Response; - actor: Actor; -} - -const trpc = initTRPC.meta().context().create(); -export const router = trpc.router; -export const procedure = trpc.procedure; - -export enum Tag { - User = 'User', - Community = 'Community', - Thread = 'Thread', - Comment = 'Comment', - Reaction = 'Reaction', - Integration = 'Integration', - Subscription = 'Subscription', - LoadTest = 'LoadTest', - Wallet = 'Wallet', - Webhook = 'Webhook', - SuperAdmin = 'SuperAdmin', - DiscordBot = 'DiscordBot', - Token = 'Token', - Contest = 'Contest', - Poll = 'Poll', -} - -export type Commit = ( - input: z.infer, - output: z.infer, - ctx: Context, -) => Promise<[string, Record] | undefined | void>; - /** - * Supports two options to track analytics - * 1. A declarative tuple with [event name, optional output mapper] - * 2. A "general" async mapper that derives the tuple of [event name, data] from input/output + * Fire and forget wrapper for output middleware */ -export type Track = - | [string, mapper?: (output: z.infer) => Record] - | (( - input: z.infer, - output: z.infer, - ) => Promise<[string, Record] | undefined>); - -async function evalTrack( - track: Track, - input: z.infer, - output: z.infer, -) { - if (typeof track === 'function') { - const tuple = await track(input, output); - return tuple - ? { event: tuple[0], data: tuple[1] } - : { event: undefined, data: undefined }; - } - return { - event: track[0], - data: track[1] ? track[1](output) : {}, +export function fireAndForget< + Input extends ZodSchema, + Output extends ZodSchema, +>( + fn: ( + input: z.infer, + output: z.infer, + ctx: Context, + ) => Promise, +): OutputMiddleware { + return (input: z.infer, output: z.infer, ctx: Context) => { + void fn(input, output, ctx).catch(log.error); + return Promise.resolve(); }; } @@ -112,171 +47,43 @@ function getRequestBrowserInfo( return info; } -async function trackAnalytics< - Input extends ZodSchema, - Output extends ZodSchema, ->( - track: Track, +export function getAnalyticsPayload( ctx: Context, - input: z.infer, - output: z.infer, + data: Record, ) { - try { - const host = ctx.req.headers.host; - const { event, data } = await evalTrack(track, input, output); - if (event) { - const payload = { - ...data, - ...getRequestBrowserInfo(ctx.req), - ...(host && { isCustomDomain: config.SERVER_URL.includes(host) }), - userId: ctx.actor.user.id, - isPWA: ctx.req.headers?.['isPWA'] === 'true', - }; - analytics().track(event, payload); - } - } catch (err) { - err instanceof Error && log.error(err.message, err); - } + const host = ctx.req.headers.host; + return { + ...data, + ...getRequestBrowserInfo(ctx.req), + ...(host && { isCustomDomain: config.SERVER_URL.includes(host) }), + userId: ctx.actor.user.id, + isPWA: ctx.req.headers?.['isPWA'] === 'true', + }; } -export type BuildProcOptions< - Input extends ZodSchema, - Output extends ZodSchema, -> = { - method: 'GET' | 'POST'; - name: string; - md: Metadata; - tag: Tag; - track?: Track; - commit?: Commit; - forceSecure?: boolean; -}; - -const authenticate = async ( - req: Request, - rawInput: z.infer, - authStrategy: AuthStrategies = { type: 'jwt' }, -) => { - // Bypass when user is already authenticated via JWT or token - // Authentication overridden at router level e.g. external-router.ts - if (req.user && authStrategy.type !== 'custom') return; - - try { - if (authStrategy.type === 'authtoken') { - switch (req.headers['authorization']) { - case config.NOTIFICATIONS.KNOCK_AUTH_TOKEN: - req.user = { - id: authStrategy.userId, - email: 'hello@knock.app', - }; - break; - case config.LOAD_TESTING.AUTH_TOKEN: - req.user = { - id: authStrategy.userId, - email: 'info@grafana.com', - }; - break; - default: - throw new Error('Not authenticated'); - } - } else if (authStrategy.type === 'custom') { - req.user = await authStrategy.userResolver(rawInput, req.user as User); - } else { - await passport.authenticate(authStrategy.type, { session: false }); - } - if (!req.user) throw new Error('Not authenticated'); - } catch (error) { - throw new TRPCError({ - message: error instanceof Error ? error.message : (error as string), - code: 'UNAUTHORIZED', - }); - } -}; +async function resolveTrack( + track: Track, + input: z.infer, + output: z.infer, +): Promise<[string | undefined, Record]> { + if (typeof track === 'function') + return (await track(input, output)) ?? [undefined, {}]; + return [track[0], track[1] ? track[1](output) : {}]; +} /** - * tRPC procedure factory with authentication, traffic stats, and analytics middleware + * Output middleware that tracks analytics in fire-and-forget mode */ -export const buildproc = ({ - method, - name, - md, - tag, - track, - commit, - forceSecure, -}: BuildProcOptions) => { - const secure = forceSecure ?? isSecure(md); - return trpc.procedure - .use(async ({ ctx, rawInput, next }) => { - if (secure) await authenticate(ctx.req, rawInput, md.authStrategy); - return next({ - ctx: { - ...ctx, - actor: { - user: ctx.req.user as User, - address: ctx.req.headers['address'] as string, - }, - }, - }); - }) - .use(async ({ ctx, rawInput, next }) => { - const start = Date.now(); - const result = await next(); - const latency = Date.now() - start; - - // TODO: this is a Friday night hack, let's rethink output middleware - if ( - md.authStrategy?.type === 'custom' && - md.authStrategy?.name === 'SignIn' && - result.ok && - result.data - ) { - const data = result.data as z.infer; - await new Promise((resolve, reject) => { - ctx.req.login(data.User, (err) => { - if (err) { - // TODO: track Mixpanel login failure - reject(err); - } - resolve(true); - }); - }); - } - - try { - const path = `${ctx.req.method.toUpperCase()} ${ctx.req.path}`; - stats().increment('cw.path.called', { path }); - stats().histogram(`cw.path.latency`, latency, { - path, - statusCode: ctx.res.statusCode.toString(), - }); - } catch (err) { - err instanceof Error && log.error(err.message, err); - } - track && - result.ok && - void trackAnalytics(track, ctx, rawInput, result.data).catch(log.error); - commit && - result.ok && - void commit(rawInput, result.data, ctx).catch(log.error); - return result; - }) - .meta({ - openapi: { - method, - path: `/${name}`, - tags: [tag], - headers: [ - { - in: 'header', - name: 'address', - required: false, - schema: { type: 'string' }, - }, - ], - protect: secure, - }, - }) - .input(md.input) - .output(md.output); -}; +export function trackAnalytics< + Input extends ZodSchema, + Output extends ZodSchema, +>(track: Track): OutputMiddleware { + return (input, output, ctx) => { + void resolveTrack(track, input, output) + .then(([event, data]) => { + event && analytics().track(event, getAnalyticsPayload(ctx, data)); + }) + .catch(log.error); + return Promise.resolve(); + }; +} diff --git a/libs/adapters/src/trpc/types.ts b/libs/adapters/src/trpc/types.ts new file mode 100644 index 00000000000..5981b34ac1b --- /dev/null +++ b/libs/adapters/src/trpc/types.ts @@ -0,0 +1,83 @@ +import type { Actor, AuthStrategies } from '@hicommonwealth/core'; +import type { Request, Response } from 'express'; +import { ZodSchema, z } from 'zod'; + +/** + * tRPC request context + */ +export interface Context { + req: Request; + res: Response; + actor: Actor; +} + +/** + * tRPC API Tags + */ +export enum Tag { + User = 'User', + Community = 'Community', + Thread = 'Thread', + Comment = 'Comment', + Reaction = 'Reaction', + Integration = 'Integration', + Subscription = 'Subscription', + LoadTest = 'LoadTest', + Wallet = 'Wallet', + Webhook = 'Webhook', + SuperAdmin = 'SuperAdmin', + DiscordBot = 'DiscordBot', + Token = 'Token', + Contest = 'Contest', + Poll = 'Poll', +} + +/** + * Middleware applied to the output before it is returned to the client. + * - This is useful for things like logging, analytics, and other side effects. + * - Applied in the order it is defined in the array. + * - Use `fireAndForget` wrapper for I/O operations like committing to a canvas, tracking analytics, etc. + */ +export type OutputMiddleware< + Input extends ZodSchema, + Output extends ZodSchema, +> = ( + input: z.infer, + output: z.infer, + ctx: Context, +) => Promise; + +/** + * Overrides for the default metadata for tRPC to work + */ +export type Metadata = { + readonly input: Input; + readonly output: Output; + auth: unknown[]; + secure?: boolean; + authStrategy?: AuthStrategies; +}; + +export type BuildProcOptions< + Input extends ZodSchema, + Output extends ZodSchema, +> = { + method: 'GET' | 'POST'; + name: string; + md: Metadata; + tag: Tag; + outMiddlewares?: Array>; + forceSecure?: boolean; +}; + +/** + * Supports two options to track analytics + * 1. A declarative tuple with [event name, optional output mapper] + * 2. A "general" async mapper that derives the tuple of [event name, data] from input/output + */ +export type Track = + | [string, mapper?: (output: z.infer) => Record] + | (( + input: z.infer, + output: z.infer, + ) => Promise<[string, Record] | undefined>); diff --git a/libs/model/src/user/SignIn.command.ts b/libs/model/src/user/SignIn.command.ts index 81d9e73cca2..94f11e1d68f 100644 --- a/libs/model/src/user/SignIn.command.ts +++ b/libs/model/src/user/SignIn.command.ts @@ -52,15 +52,15 @@ export function SignIn(): Command { }, }, body: async ({ actor, payload }) => { - if (!actor.user.auth) throw Error('Invalid address'); + if (!actor.user.id || !actor.user.auth) throw Error('Invalid address'); const { community_id, wallet_id, referrer_address, session, block_info } = payload; const { base, encodedAddress, ss58Prefix, hex, existingHexUserId } = actor .user.auth as VerifiedAddress; - let user_id = - (actor.user?.id ?? 0) > 0 ? actor.user.id : (existingHexUserId ?? null); + const was_signed_in = actor.user.id > 0; + let user_id = was_signed_in ? actor.user.id : (existingHexUserId ?? null); await verifySessionSignature( deserializeCanvas(session), @@ -199,6 +199,7 @@ export function SignIn(): Command { ...addr.toJSON(), community_base: base, community_ss58_prefix: ss58Prefix, + was_signed_in, user_created, address_created, first_community, diff --git a/libs/model/test/user/signin-lifecycle.spec.ts b/libs/model/test/user/signin-lifecycle.spec.ts index 98b2def3d32..7b1be408a54 100644 --- a/libs/model/test/user/signin-lifecycle.spec.ts +++ b/libs/model/test/user/signin-lifecycle.spec.ts @@ -125,6 +125,7 @@ describe('SignIn Lifecycle', async () => { expect(addr!.verification_token).to.be.not.null; expect(addr!.verified).to.be.not.null; + expect(addr!.was_signed_in).to.be.false; expect(addr!.first_community).to.be.true; expect(addr!.user_created).to.be.true; expect(addr!.address_created).to.be.true; @@ -160,6 +161,7 @@ describe('SignIn Lifecycle', async () => { expect(addr!).to.not.be.null; expect(addr!.User).to.be.not.null; + expect(addr!.was_signed_in).to.be.true; expect(addr!.first_community).to.be.false; expect(addr!.user_created).to.be.false; expect(addr!.address_created).to.be.false; @@ -315,6 +317,7 @@ describe('SignIn Lifecycle', async () => { }, }); expect(transferred).to.not.be.null; + expect(transferred!.was_signed_in).to.be.true; expect(transferred!.address).to.be.equal(ref.address); // check that user 2 now owns 2 addresses from user 1 diff --git a/libs/schemas/src/commands/comment.schemas.ts b/libs/schemas/src/commands/comment.schemas.ts index 83436704416..176bb769f11 100644 --- a/libs/schemas/src/commands/comment.schemas.ts +++ b/libs/schemas/src/commands/comment.schemas.ts @@ -25,6 +25,8 @@ export const UpdateComment = { input: z.object({ comment_id: PG_INT, body: z.string().min(1), + canvas_signed_data: z.string().optional(), + canvas_msg_id: z.string().optional(), }), output: Comment.extend({ community_id: z.string() }), context: CommentContext, diff --git a/libs/schemas/src/commands/user.schemas.ts b/libs/schemas/src/commands/user.schemas.ts index 8b5b14506f2..4aca0073d33 100644 --- a/libs/schemas/src/commands/user.schemas.ts +++ b/libs/schemas/src/commands/user.schemas.ts @@ -15,6 +15,7 @@ export const SignIn = { output: Address.extend({ community_base: z.nativeEnum(ChainBase), community_ss58_prefix: z.number().nullish(), + was_signed_in: z.boolean().describe('True when user was already signed in'), user_created: z .boolean() .describe( diff --git a/packages/commonwealth/server/api/comment.ts b/packages/commonwealth/server/api/comment.ts index de9660019a7..83de1aad3f9 100644 --- a/packages/commonwealth/server/api/comment.ts +++ b/packages/commonwealth/server/api/comment.ts @@ -1,32 +1,35 @@ import { trpc } from '@hicommonwealth/adapters'; import { Comment } from '@hicommonwealth/model'; import { MixpanelCommunityInteractionEvent } from '../../shared/analytics/types'; -import { applyCanvasSignedDataMiddleware } from '../federation'; +import { applyCanvasSignedData } from '../federation'; export const trpcRouter = trpc.router({ - createComment: trpc.command( - Comment.CreateComment, - trpc.Tag.Comment, - [ + createComment: trpc.command(Comment.CreateComment, trpc.Tag.Comment, [ + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + trpc.trackAnalytics([ MixpanelCommunityInteractionEvent.CREATE_COMMENT, (output) => ({ community: output.community_id }), - ], - applyCanvasSignedDataMiddleware, - ), - updateComment: trpc.command( - Comment.UpdateComment, - trpc.Tag.Comment, - undefined, - applyCanvasSignedDataMiddleware, - ), + ]), + ]), + updateComment: trpc.command(Comment.UpdateComment, trpc.Tag.Comment, [ + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + ]), createCommentReaction: trpc.command( Comment.CreateCommentReaction, trpc.Tag.Reaction, [ - MixpanelCommunityInteractionEvent.CREATE_REACTION, - (output) => ({ community: output.community_id }), + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.CREATE_REACTION, + (output) => ({ community: output.community_id }), + ]), ], - applyCanvasSignedDataMiddleware, ), searchComments: trpc.query(Comment.SearchComments, trpc.Tag.Comment), getComments: trpc.query(Comment.GetComments, trpc.Tag.Comment), diff --git a/packages/commonwealth/server/api/community.ts b/packages/commonwealth/server/api/community.ts index 6626718db2e..e91c3ce97b8 100644 --- a/packages/commonwealth/server/api/community.ts +++ b/packages/commonwealth/server/api/community.ts @@ -8,16 +8,16 @@ import { export const trpcRouter = trpc.router({ createCommunity: trpc.command(Community.CreateCommunity, trpc.Tag.Community, [ - MixpanelCommunityCreationEvent.NEW_COMMUNITY_CREATION, - (result) => ({ - chainBase: result.community?.base, - community: result.community?.id, - }), + trpc.trackAnalytics([ + MixpanelCommunityCreationEvent.NEW_COMMUNITY_CREATION, + (output) => ({ + chainBase: output.community?.base, + community: output.community?.id, + }), + ]), ]), - updateCommunity: trpc.command( - Community.UpdateCommunity, - trpc.Tag.Community, - async (input, output) => { + updateCommunity: trpc.command(Community.UpdateCommunity, trpc.Tag.Community, [ + trpc.trackAnalytics(async (input, output) => { const { directory_page_enabled } = input; if (directory_page_enabled === undefined) return undefined; const event = directory_page_enabled @@ -37,8 +37,8 @@ export const trpcRouter = trpc.router({ communitySelected: dirnode?.id, }, ]; - }, - ), + }), + ]), getCommunities: trpc.query(Community.GetCommunities, trpc.Tag.Community), getCommunity: trpc.query(Community.GetCommunity, trpc.Tag.Community), getStake: trpc.query(Community.GetCommunityStake, trpc.Tag.Community), @@ -48,14 +48,8 @@ export const trpcRouter = trpc.router({ trpc.Tag.Community, ), setStake: trpc.command(Community.SetCommunityStake, trpc.Tag.Community), - createGroup: trpc.command( - Community.CreateGroup, - trpc.Tag.Community, - [ - MixpanelCommunityInteractionEvent.CREATE_GROUP, - (result) => ({ community: result.id }), - ], - async (_, output, ctx) => { + createGroup: trpc.command(Community.CreateGroup, trpc.Tag.Community, [ + trpc.fireAndForget(async (_, output, ctx) => { await command(Community.RefreshCommunityMemberships(), { actor: ctx.actor, payload: { @@ -63,16 +57,14 @@ export const trpcRouter = trpc.router({ group_id: output.groups?.at(0)?.id, }, }); - }, - ), - updateGroup: trpc.command( - Community.UpdateGroup, - trpc.Tag.Community, - [ - MixpanelCommunityInteractionEvent.UPDATE_GROUP, - (result) => ({ community: result.community_id }), - ], - async (input, output, ctx) => { + }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.CREATE_GROUP, + (output) => ({ community: output.id }), + ]), + ]), + updateGroup: trpc.command(Community.UpdateGroup, trpc.Tag.Community, [ + trpc.fireAndForget(async (input, output, ctx) => { if (input.requirements?.length || input.metadata?.required_requirements) await command(Community.RefreshCommunityMemberships(), { actor: ctx.actor, @@ -81,8 +73,12 @@ export const trpcRouter = trpc.router({ group_id: output.id, }, }); - }, - ), + }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.UPDATE_GROUP, + (output) => ({ community: output.community_id }), + ]), + ]), getMembers: trpc.query(Community.GetMembers, trpc.Tag.Community), createStakeTransaction: trpc.command( Community.CreateStakeTransaction, @@ -98,18 +94,22 @@ export const trpcRouter = trpc.router({ ), getTopics: trpc.query(Community.GetTopics, trpc.Tag.Community), createTopic: trpc.command(Community.CreateTopic, trpc.Tag.Community, [ - MixpanelCommunityInteractionEvent.CREATE_TOPIC, - (result) => ({ - community: result.topic.community_id, - userId: result.user_id, - }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.CREATE_TOPIC, + (output) => ({ + community: output.topic.community_id, + userId: output.user_id, + }), + ]), ]), updateTopic: trpc.command(Community.UpdateTopic, trpc.Tag.Community, [ - MixpanelCommunityInteractionEvent.UPDATE_TOPIC, - (result) => ({ - community: result.topic.community_id, - userId: result.user_id, - }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.UPDATE_TOPIC, + (output) => ({ + community: output.topic.community_id, + userId: output.user_id, + }), + ]), ]), toggleArchiveTopic: trpc.command( Community.ToggleArchiveTopic, @@ -122,10 +122,10 @@ export const trpcRouter = trpc.router({ trpc.Tag.Community, ), joinCommunity: trpc.command(Community.JoinCommunity, trpc.Tag.Community, [ - MixpanelCommunityInteractionEvent.JOIN_COMMUNITY, - (result) => ({ - community: result.community_id, - }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.JOIN_COMMUNITY, + (output) => ({ community: output.community_id }), + ]), ]), banAddress: trpc.command(Community.BanAddress, trpc.Tag.Community), getPinnedTokens: trpc.query(Community.GetPinnedTokens, trpc.Tag.Community), diff --git a/packages/commonwealth/server/api/poll.ts b/packages/commonwealth/server/api/poll.ts index 2235fe61b49..43a1356d09d 100644 --- a/packages/commonwealth/server/api/poll.ts +++ b/packages/commonwealth/server/api/poll.ts @@ -4,7 +4,9 @@ import { MixpanelCommunityInteractionEvent } from '../../shared/analytics/types' export const trpcRouter = trpc.router({ createPollVote: trpc.command(Poll.CreatePollVote, trpc.Tag.Poll, [ - MixpanelCommunityInteractionEvent.SUBMIT_VOTE, - ({ community_id }) => ({ community_id }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.SUBMIT_VOTE, + ({ community_id }) => ({ community_id }), + ]), ]), }); diff --git a/packages/commonwealth/server/api/thread.ts b/packages/commonwealth/server/api/thread.ts index d00b2fa28f4..5a34cd2c718 100644 --- a/packages/commonwealth/server/api/thread.ts +++ b/packages/commonwealth/server/api/thread.ts @@ -1,65 +1,76 @@ import { trpc } from '@hicommonwealth/adapters'; -import { CacheNamespaces, cache } from '@hicommonwealth/core'; -import { Reaction, Thread } from '@hicommonwealth/model'; +import { CacheNamespaces, cache, logger } from '@hicommonwealth/core'; +import { Reaction, Thread, models } from '@hicommonwealth/model'; import { MixpanelCommunityInteractionEvent } from '../../shared/analytics/types'; -import { applyCanvasSignedDataMiddleware } from '../federation'; -import { incrementThreadViewCount } from '../util/incrementThreadViewCount'; +import { applyCanvasSignedData } from '../federation'; + +const log = logger(import.meta); export const trpcRouter = trpc.router({ - createThread: trpc.command( - Thread.CreateThread, - trpc.Tag.Thread, - [ + createThread: trpc.command(Thread.CreateThread, trpc.Tag.Thread, [ + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + trpc.trackAnalytics([ MixpanelCommunityInteractionEvent.CREATE_THREAD, ({ community_id }) => ({ community: community_id }), - ], - applyCanvasSignedDataMiddleware, - ), - updateThread: trpc.command( - Thread.UpdateThread, - trpc.Tag.Thread, - (input) => + ]), + ]), + updateThread: trpc.command(Thread.UpdateThread, trpc.Tag.Thread, [ + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + trpc.trackAnalytics((input) => Promise.resolve( input.stage !== undefined ? [MixpanelCommunityInteractionEvent.UPDATE_STAGE, {}] : undefined, ), - applyCanvasSignedDataMiddleware, - ), + ), + ]), createThreadReaction: trpc.command( Thread.CreateThreadReaction, trpc.Tag.Reaction, [ - MixpanelCommunityInteractionEvent.CREATE_REACTION, - ({ community_id }) => ({ community: community_id }), + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + trpc.trackAnalytics([ + MixpanelCommunityInteractionEvent.CREATE_REACTION, + ({ community_id }) => ({ community: community_id }), + ]), ], - applyCanvasSignedDataMiddleware, ), - deleteThread: trpc.command( - Thread.DeleteThread, - trpc.Tag.Thread, - async () => { - // Using track output middleware to invalidate global activity cache - // TODO: Generalize output middleware to cover (analytics, gac invalidation, canvas, etc) - void cache().deleteKey( + deleteThread: trpc.command(Thread.DeleteThread, trpc.Tag.Thread, [ + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + trpc.fireAndForget(async () => { + await cache().deleteKey( CacheNamespaces.Query_Response, 'GetGlobalActivity_{}', // this is the global activity cache key ); - return Promise.resolve(undefined); - }, - applyCanvasSignedDataMiddleware, - ), - deleteReaction: trpc.command( - Reaction.DeleteReaction, - trpc.Tag.Reaction, - undefined, - applyCanvasSignedDataMiddleware, - ), + }), + ]), + deleteReaction: trpc.command(Reaction.DeleteReaction, trpc.Tag.Reaction, [ + trpc.fireAndForget(async (input, _, ctx) => { + await applyCanvasSignedData(ctx.req.path, input.canvas_signed_data); + }), + ]), getThreads: trpc.query(Thread.GetThreads, trpc.Tag.Thread), getThreadsByIds: trpc.query( Thread.GetThreadsByIds, trpc.Tag.Thread, undefined, - incrementThreadViewCount, + [ + trpc.fireAndForget(async (input) => { + log.trace('incrementing thread view count', { ids: input.thread_ids }); + const ids = input.thread_ids.split(',').map((x) => parseInt(x, 10)); + await models.Thread.increment( + { view_count: 1 }, + { where: { id: ids } }, + ); + }), + ], ), }); diff --git a/packages/commonwealth/server/api/user.ts b/packages/commonwealth/server/api/user.ts index 8cb8df81de3..264f0ce725b 100644 --- a/packages/commonwealth/server/api/user.ts +++ b/packages/commonwealth/server/api/user.ts @@ -1,4 +1,5 @@ import { trpc } from '@hicommonwealth/adapters'; +import { analytics } from '@hicommonwealth/core'; import { User, incrementProfileCount } from '@hicommonwealth/model'; import { MixpanelLoginEvent, @@ -6,26 +7,51 @@ import { } from 'shared/analytics/types'; export const trpcRouter = trpc.router({ - signIn: trpc.command( - User.SignIn, - trpc.Tag.User, - (_, output) => + signIn: trpc.command(User.SignIn, trpc.Tag.User, [ + async (input, output, ctx) => { + await new Promise((resolve, reject) => { + // no need to login if we're already signed in + if (output.was_signed_in) return resolve(true); + + // complete passport login + ctx.req.login(output.User as Express.User, (err) => { + if (err) { + analytics().track( + 'Login Failed', + trpc.getAnalyticsPayload(ctx, { + community_id: input.community_id, + address: input.address, + }), + ); + reject(err); + } + resolve(true); + }); + }); + }, + trpc.trackAnalytics((_, output) => Promise.resolve( output.user_created ? [ MixpanelUserSignupEvent.NEW_USER_SIGNUP, { community_id: output.community_id }, ] - : [ - MixpanelLoginEvent.LOGIN_COMPLETED, - { - community_id: output.community_id, - userId: output.user_id, - }, - ], + : !output.was_signed_in + ? [ + MixpanelLoginEvent.LOGIN_COMPLETED, + { + community_id: output.community_id, + userId: output.user_id, + }, + ] + : undefined, ), - (_, output) => incrementProfileCount(output.community_id, output.user_id!), - ), + ), + trpc.fireAndForget(async (_, output) => { + if (output.user_created) + await incrementProfileCount(output.community_id, output.user_id!); + }), + ]), updateUser: trpc.command(User.UpdateUser, trpc.Tag.User), getNewContent: trpc.query(User.GetNewContent, trpc.Tag.User), createApiKey: trpc.command(User.CreateApiKey, trpc.Tag.User), diff --git a/packages/commonwealth/server/federation/index.ts b/packages/commonwealth/server/federation/index.ts index 2a08d279de7..493645a23fa 100644 --- a/packages/commonwealth/server/federation/index.ts +++ b/packages/commonwealth/server/federation/index.ts @@ -16,18 +16,20 @@ if (libp2p) { ); } -export const applyCanvasSignedDataMiddleware: ( - input, - output, -) => Promise = async (input, output) => { - if (output.canvas_signed_data) - await applyCanvasSignedData(parse(output.canvas_signed_data)); -}; +export const applyCanvasSignedData = async ( + path: string, + canvas_signed_data?: string, +) => { + if (!canvas_signed_data) return; + const data = parse(canvas_signed_data) as CanvasSignedData; -export const applyCanvasSignedData = async (data: CanvasSignedData) => { let appliedSessionId: string | null = null; let appliedActionId: string | null = null; + log.trace('applying canvas signed data', { + path, + publicKey: data.sessionMessage.payload.publicKey, + }); try { const encodedSessionMessage = canvas.messageLog.encode( data.sessionMessageSignature, diff --git a/packages/commonwealth/server/util/incrementThreadViewCount.ts b/packages/commonwealth/server/util/incrementThreadViewCount.ts deleted file mode 100644 index 820556a6c5e..00000000000 --- a/packages/commonwealth/server/util/incrementThreadViewCount.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { models } from '@hicommonwealth/model'; - -export const incrementThreadViewCount = async ( - input: { thread_id: number } | { thread_ids: string }, -) => { - let id: number | Array = []; - if ('thread_ids' in input) { - const parsedThreadIds = input.thread_ids - .split(',') - .map((x) => parseInt(x, 10)); - id = parsedThreadIds.length === 1 ? parsedThreadIds[0] : parsedThreadIds; - } else { - id = input.thread_id; - } - - await models.Thread.increment({ view_count: 1 }, { where: { id } }); -};