Skip to content

Commit

Permalink
feat(user): implement anonymous user registration
Browse files Browse the repository at this point in the history
refs #247
  • Loading branch information
ygrishajev committed Jul 29, 2024
1 parent 5cea782 commit 0aa2ccb
Show file tree
Hide file tree
Showing 20 changed files with 243 additions and 50 deletions.
9 changes: 5 additions & 4 deletions .env.sandbox.docker-compose-dev
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ Network=sandbox
ActiveChain=akashSandbox

# Deploy Web
API_BASE_URL: http://api:3080
API_BASE_URL: http://api:3000
BASE_API_MAINNET_URL: http://api:3000
PROVIDER_PROXY_URL: http://provider-proxy:3040

# Stats Web
API_MAINNET_BASE_URL: http://api:3080
API_TESTNET_BASE_URL: http://api:3080
API_SANDBOX_BASE_URL: http://api:3080
API_MAINNET_BASE_URL: http://api:3000
API_TESTNET_BASE_URL: http://api:3000
API_SANDBOX_BASE_URL: http://api:3000

# DB
POSTGRES_USER: postgres
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { container } from "tsyringe";
import { HonoErrorHandlerService } from "@src/core/services/hono-error-handler/hono-error-handler.service";
import { HttpLoggerService } from "@src/core/services/http-logger/http-logger.service";
import { LoggerService } from "@src/core/services/logger/logger.service";
import { RequestStorageInterceptor } from "@src/core/services/request-storage/request-storage.interceptor";
import { CurrentUserInterceptor } from "@src/user/services/current-user/current-user.interceptor";
import packageJson from "../package.json";
import { chainDb, syncUserSchema, userDb } from "./db/dbConnection";
import { apiRouter } from "./routers/apiRouter";
Expand Down Expand Up @@ -62,6 +64,8 @@ const scheduler = new Scheduler({
});

appHono.use(container.resolve(HttpLoggerService).intercept());
appHono.use(container.resolve(RequestStorageInterceptor).intercept());
appHono.use(container.resolve(CurrentUserInterceptor).intercept());
appHono.use(
"*",
sentry({
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/core/providers/request.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { container, inject } from "tsyringe";

import { RequestStorageInterceptor } from "@src/core/services/request-storage/request-storage.interceptor";

const REQUEST = "REQUEST";

container.register(REQUEST, {
useFactory: c => {
return c.resolve(RequestStorageInterceptor).context;
}
});

export const Request = () => inject(REQUEST);
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ import { format } from "sql-formatter";
import { LoggerService } from "@src/core/services/logger/logger.service";

export class PostgresLoggerService implements LogWriter {
private readonly logger = new LoggerService({ context: "POSTGRES" });
private readonly logger: LoggerService;

private readonly isDrizzle: boolean;

constructor(options?: { orm: "drizzle" | "sequelize" }) {
const orm = options?.orm || "drizzle";
this.logger = new LoggerService({ context: "POSTGRES", orm });
this.isDrizzle = orm === "drizzle";
}

write(message: string) {
let formatted = message.replace(/^Query: /, "");
let formatted = message.replace(this.isDrizzle ? /^Query: / : /^Executing \(default\):/, "");

if (this.logger.isPretty) {
formatted = format(message, { language: "postgresql" });
formatted = format(formatted, { language: "postgresql" });
}

this.logger.debug(formatted);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Context, Next } from "hono";
import { AsyncLocalStorage } from "node:async_hooks";
import { singleton } from "tsyringe";
import { v4 as uuid } from "uuid";

import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type";

@singleton()
export class RequestStorageInterceptor implements HonoInterceptor {
private readonly CONTEXT_KEY = "CONTEXT";

private readonly storage = new AsyncLocalStorage<Map<string, Context>>();

get context() {
return this.storage.getStore()?.get(this.CONTEXT_KEY);
}

intercept() {
return async (c: Context, next: Next) => {
const requestId = c.req.header("X-Request-Id") || uuid();
c.set("requestId", requestId);

await this.runWithContext(c, next);
};
}

private async runWithContext(context: Context, cb: () => Promise<void>) {
return await new Promise((resolve, reject) => {
this.storage.run(new Map(), () => {
this.storage.getStore().set(this.CONTEXT_KEY, context);
cb().then(resolve).catch(reject);
});
});
}
}
9 changes: 6 additions & 3 deletions apps/api/src/db/dbConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import pg from "pg";
import { Transaction as DbTransaction } from "sequelize";
import { Sequelize } from "sequelize-typescript";

import { PostgresLoggerService } from "@src/core/services/postgres-logger/postgres-logger.service";
import { env } from "@src/utils/env";

function isValidNetwork(network: string): network is keyof typeof csMap {
Expand All @@ -25,10 +26,12 @@ if (!csMap[env.Network]) {
throw new Error(`Missing connection string for network: ${env.Network}`);
}

const logger = new PostgresLoggerService({ orm: "sequelize" });

pg.defaults.parseInt8 = true;
export const chainDb = new Sequelize(csMap[env.Network], {
dialectModule: pg,
logging: false,
logging: (msg: string) => logger.write(msg),
transactionType: DbTransaction.TYPES.IMMEDIATE,
define: {
timestamps: false,
Expand All @@ -44,7 +47,7 @@ export const chainDbs: { [key: string]: Sequelize } = Object.keys(chainDefinitio
...obj,
[chain]: new Sequelize(chainDefinitions[chain].connectionString, {
dialectModule: pg,
logging: false,
logging: (msg: string) => logger.write(msg),
repositoryMode: true,
transactionType: DbTransaction.TYPES.IMMEDIATE,
define: {
Expand All @@ -59,7 +62,7 @@ export const chainDbs: { [key: string]: Sequelize } = Object.keys(chainDefinitio

export const userDb = new Sequelize(env.UserDatabaseCS, {
dialectModule: pg,
logging: false,
logging: (msg: string) => logger.write(msg),
transactionType: DbTransaction.TYPES.IMMEDIATE,
define: {
timestamps: false,
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/routers/userRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,17 @@ userRequiredRouter.delete("/removeAddressName/:address", async c => {

userRequiredRouter.post("/tokenInfo", async c => {
const userId = getCurrentUserId(c);
const anonymousUserId = c.req.header("x-anonymous-user-id");
const { wantedUsername, email, emailVerified, subscribedToNewsletter } = await c.req.json();

const settings = await getSettingsOrInit(userId, wantedUsername, email, !!emailVerified, subscribedToNewsletter);
const settings = await getSettingsOrInit({
anonymousUserId,
userId: userId,
wantedUsername,
email: email,
emailVerified: !!emailVerified,
subscribedToNewsletter: subscribedToNewsletter
});

return c.json(settings);
});
Expand Down
96 changes: 69 additions & 27 deletions apps/api/src/services/db/userDataService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { UserAddressName, UserSetting } from "@akashnetwork/database/dbSchemas/user";
import pick from "lodash/pick";
import { Transaction } from "sequelize";

import { getUserPlan } from "../external/stripeService";
import { LoggerService } from "@src/core";

const logger = new LoggerService({ context: "UserDataService" });

function randomIntFromInterval(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1) + min);
Expand All @@ -13,9 +16,7 @@ export async function checkUsernameAvailable(username: string, dbTransaction?: T
}

async function generateUsername(wantedUsername: string, dbTransaction?: Transaction): Promise<string> {
const sanitized = wantedUsername.replace(/[^a-zA-Z0-9_-]/gi, "");

let baseUsername = sanitized;
let baseUsername = wantedUsername.replace(/[^a-zA-Z0-9_-]/gi, "");

if (baseUsername.length < 3) {
baseUsername = "anonymous";
Expand Down Expand Up @@ -61,41 +62,82 @@ export async function updateSettings(
await settings.save();
}

export async function getSettingsOrInit(userId: string, wantedUsername: string, email: string, emailVerified: boolean, subscribedToNewsletter: boolean) {
const [userSettings, created] = await UserSetting.findCreateFind({
where: { userId: userId },
defaults: {
type UserInput = {
anonymousUserId?: string;
userId: string;
wantedUsername: string;
email: string;
emailVerified: boolean;
subscribedToNewsletter: boolean;
};

export async function getSettingsOrInit({ anonymousUserId, userId, wantedUsername, email, emailVerified, subscribedToNewsletter }: UserInput) {
let userSettings: UserSetting;
let isAnonymous = false;

if (anonymousUserId) {
try {
const updateResult = await UserSetting.update(
{
userId,
username: await generateUsername(wantedUsername),
email: email,
emailVerified: emailVerified,
stripeCustomerId: null,
subscribedToNewsletter: subscribedToNewsletter
},
{ where: { id: anonymousUserId, userId: null }, returning: ["*"] }
);

userSettings = updateResult[1][0];
isAnonymous = !!userSettings;

if (isAnonymous) {
logger.info({ event: "ANONYMOUS_USER_REGISTERED", id: anonymousUserId, userId });
}
} catch (error) {
if (error.name !== "SequelizeUniqueConstraintError") {
throw error;
}

logger.info({ event: "ANONYMOUS_USER_ALREADY_REGISTERED", id: anonymousUserId, userId });
}
}

if (!isAnonymous) {
userSettings = await UserSetting.findOne({ where: { userId: userId } });
logger.debug({ event: "USER_RETRIEVED", id: anonymousUserId, userId });
}

if (!userSettings) {
userSettings = await UserSetting.create({
userId: userId,
username: await generateUsername(wantedUsername),
email: email,
emailVerified: emailVerified,
stripeCustomerId: null,
subscribedToNewsletter: subscribedToNewsletter
}
});
});
logger.info({ event: "USER_REGISTERED", userId });
}

if (created) {
console.log(`Created settings for user ${userId}`);
} else if (userSettings.email !== email || userSettings.emailVerified !== emailVerified) {
if (userSettings.email !== email || userSettings.emailVerified !== emailVerified) {
userSettings.email = email;
userSettings.emailVerified = emailVerified;
await userSettings.save();
}

const planCode = await getUserPlan(userSettings.stripeCustomerId);

return {
username: userSettings.username,
email: userSettings.email,
emailVerified: userSettings.emailVerified,
stripeCustomerId: userSettings.stripeCustomerId,
bio: userSettings.bio,
subscribedToNewsletter: userSettings.subscribedToNewsletter,
youtubeUsername: userSettings.youtubeUsername,
twitterUsername: userSettings.twitterUsername,
githubUsername: userSettings.githubUsername,
planCode: planCode
};
return pick(userSettings, [
"username",
"email",
"emailVerified",
"stripeCustomerId",
"bio",
"subscribedToNewsletter",
"youtubeUsername",
"twitterUsername",
"githubUsername"
]);
}

export async function getAddressNames(userId: string) {
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/user/providers/current-user.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { container, inject } from "tsyringe";

import { RequestStorageInterceptor } from "@src/core/services/request-storage/request-storage.interceptor";
import { CURRENT_USER } from "@src/user/services/current-user/current-user.interceptor";

container.register(CURRENT_USER, {
useFactory: c => {
return c.resolve(RequestStorageInterceptor).context.get(CURRENT_USER);
}
});

export const CurrentUser = () => inject(CURRENT_USER);
export type CurrentUser = {
userId: string;
isAnonymous: boolean;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Context, Next } from "hono";
import { singleton } from "tsyringe";

import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type";
import { getCurrentUserId } from "@src/middlewares/userMiddleware";

export const CURRENT_USER = "CURRENT_USER";

@singleton()
export class CurrentUserInterceptor implements HonoInterceptor {
intercept() {
return async (c: Context, next: Next) => {
const userId = getCurrentUserId(c);
c.set(CURRENT_USER, { userId, isAnonymous: !userId });

return await next();
};
}
}
1 change: 1 addition & 0 deletions apps/deploy-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@akashnetwork/akash-api": "^1.3.0",
"@akashnetwork/akashjs": "^0.10.0",
"@akashnetwork/ui": "*",
"@akashnetwork/http-sdk": "*",
"@auth0/nextjs-auth0": "^3.5.0",
"@chain-registry/types": "^0.41.3",
"@cosmjs/encoding": "^0.32.4",
Expand Down
3 changes: 2 additions & 1 deletion apps/deploy-web/src/components/user/UserProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { UserProvider } from "@auth0/nextjs-auth0/client";
import { UserInitLoader } from "@src/components/user/UserInitLoader";
import { envConfig } from "@src/config/env.config";
import { AnonymousUserProvider } from "@src/context/AnonymousUserProvider/AnonymousUserProvider";
import { authHttpService } from "@src/services/auth/auth-http.service";
import { FCWithChildren } from "@src/types/component";

export const UserProviders: FCWithChildren = ({ children }) =>
envConfig.NEXT_PUBLIC_BILLING_ENABLED ? (
<UserProvider>
<UserProvider fetcher={authHttpService.getProfile}>
<UserInitLoader>
<AnonymousUserProvider>{children}</AnonymousUserProvider>
</UserInitLoader>
Expand Down
1 change: 0 additions & 1 deletion apps/deploy-web/src/hooks/useCustomUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ type UseCustomUser = {

export const useCustomUser = (): UseCustomUser => {
const { user, isLoading, error, checkSession } = useUser();

const completeUser = user ? { ...user, plan: plans.find(x => x.code === user.planCode) } : user;

return {
Expand Down
9 changes: 6 additions & 3 deletions apps/deploy-web/src/hooks/useStoredAnonymousUser.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { useMemo } from "react";
import { useLocalStorage } from "usehooks-ts";

import { envConfig } from "@src/config/env.config";
import { useCustomUser } from "@src/hooks/useCustomUser";
import { useWhen } from "@src/hooks/useWhen";
import { useAnonymousUserQuery, UserOutput } from "@src/queries/useAnonymousUserQuery";
import { ANONYMOUS_USER_KEY } from "@src/utils/constants";

type UseApiUserResult = {
user?: UserOutput;
isLoading: boolean;
};

const storedAnonymousUserStr = typeof window !== "undefined" && localStorage.getItem(ANONYMOUS_USER_KEY);
const storedAnonymousUser: UserOutput | undefined = storedAnonymousUserStr ? JSON.parse(storedAnonymousUserStr) : undefined;

export const useStoredAnonymousUser = (): UseApiUserResult => {
const { user: registeredUser, isLoading: isLoadingRegisteredUser } = useCustomUser();
const [storedAnonymousUser, storeAnonymousUser] = useLocalStorage<UserOutput | undefined>("user", undefined);
const { user, isLoading } = useAnonymousUserQuery(storedAnonymousUser?.id, {
enabled: envConfig.NEXT_PUBLIC_BILLING_ENABLED && !registeredUser && !isLoadingRegisteredUser
});

useWhen(user, () => storeAnonymousUser(user));
useWhen(user, () => localStorage.setItem("anonymous-user", JSON.stringify(user)));
useWhen(storedAnonymousUser && registeredUser, () => localStorage.removeItem(ANONYMOUS_USER_KEY));

return useMemo(
() => ({
Expand Down
Loading

0 comments on commit 0aa2ccb

Please sign in to comment.