significant refactor

This commit is contained in:
2025-08-30 01:42:23 -05:00
parent 7136f646a3
commit 052f53444e
106 changed files with 1994 additions and 1701 deletions

View File

@@ -1,4 +1,4 @@
type LogLevel = 'info' | 'success' | 'warn' | 'error';
type LogLevel = "info" | "success" | "warn" | "error";
interface LoggerOptions {
enabled?: boolean;
@@ -7,50 +7,38 @@ interface LoggerOptions {
colors?: boolean;
}
// Cache for performance - update once per second max
let cachedTimestamp = '';
let cachedTimestamp = "";
let lastTimestampUpdate = 0;
/**
* Get formatted timestamp with caching for performance
* Format: MM/DD HH:mm:ss
*/
function getTimestamp(): string {
const now = Date.now();
// Update cache only if more than 1 second has passed
if (now - lastTimestampUpdate > 1000) {
const date = new Date(now);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
cachedTimestamp = `${month}/${day} ${hours}:${minutes}:${seconds}`;
lastTimestampUpdate = now;
}
return cachedTimestamp;
}
/**
* Get color and emoji for each log level
*/
function getLevelStyle(level: LogLevel): { color: string; label: string } {
const styles = {
info: { color: '#f5f5f5', label: 'INFO' },
success: { color: '#10B981', label: 'SUCCESS' },
warn: { color: '#F59E0B', label: 'WARN' },
error: { color: '#EF4444', label: 'ERROR' },
info: { color: "#f5f5f5", label: "INFO" },
success: { color: "#10B981", label: "SUCCESS" },
warn: { color: "#F59E0B", label: "WARN" },
error: { color: "#EF4444", label: "ERROR" },
};
return styles[level] || styles.info;
}
/**
* Main logger class
*/
class Logger {
private options: LoggerOptions;
private context?: string;
@@ -58,25 +46,21 @@ class Logger {
constructor(context?: string, options: LoggerOptions = {}) {
this.context = context;
this.options = {
enabled: process.env.NODE_ENV !== 'production',
enabled: process.env.NODE_ENV !== "production",
showTimestamp: true,
collapsed: true,
colors: true,
...options
...options,
};
}
/**
* Create a child logger with a specific context
*/
child(context: string, options?: LoggerOptions): Logger {
const childContext = this.context ? `${this.context} > ${context}` : context;
const childContext = this.context
? `${this.context} > ${context}`
: context;
return new Logger(childContext, { ...this.options, ...options });
}
/**
* Core logging method
*/
private log(
level: LogLevel,
label: string,
@@ -86,114 +70,92 @@ class Logger {
if (!this.options.enabled) return;
const style = getLevelStyle(level);
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : "";
const context = this.context ? `${this.context}` : "";
const groupLabel = `${timestamp}${style.label}${context}${label}`;
const group = this.options.collapsed ? console.groupCollapsed : console.group;
if (this.options.colors && typeof window !== 'undefined') {
group(
`%c${groupLabel}`,
`color: ${style.color}; font-weight: bold;`
);
const group = this.options.collapsed
? console.groupCollapsed
: console.group;
if (this.options.colors && typeof window !== "undefined") {
group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`);
} else {
group(groupLabel);
}
if (data !== undefined) {
console.log(data);
}
if (rest.length > 0) {
for (const item of rest) {
console.log(item);
}
}
console.groupEnd();
}
/**
* log level methods
*/
info(label: string, data?: any, ...rest: any[]): void {
this.log('info', label, data, ...rest);
this.log("info", label, data, ...rest);
}
success(label: string, data?: any, ...rest: any[]): void {
this.log('success', label, data, ...rest);
this.log("success", label, data, ...rest);
}
warn(label: string, data?: any, ...rest: any[]): void {
this.log('warn', label, data, ...rest);
this.log("warn", label, data, ...rest);
}
error(label: string, data?: any, ...rest: any[]): void {
this.log('error', label, data, ...rest);
this.log("error", label, data, ...rest);
}
simple(message: string): void {
if (!this.options.enabled) return;
const style = getLevelStyle('info');
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
const style = getLevelStyle("info");
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : "";
const context = this.context ? `${this.context}` : "";
const logMessage = `${timestamp}${style.label}${context}${message}`;
if (this.options.colors && typeof window !== 'undefined') {
if (this.options.colors && typeof window !== "undefined") {
console.log(`%c${logMessage}`, `color: ${style.color};`);
} else {
console.log(logMessage);
}
}
/**
* Measure performance of an operation
*/
async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
const result = await fn();
const duration = (performance.now() - start).toFixed(2);
this.success(`${label} completed`, {
duration: `${duration}ms`,
result
result,
});
return result;
} catch (error) {
const duration = (performance.now() - start).toFixed(2);
this.error(`${label} failed`, {
duration: `${duration}ms`,
error
error,
});
throw error;
}
}
/**
* Create a table log
*/
table(label: string, data: any[]): void {
if (!this.options.enabled) return;
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
console.group(`${timestamp}TABLE${context}${label}`);
console.table(data);
console.groupEnd();
}
}
export const logger = new Logger();
export { Logger };

View File

@@ -4,20 +4,25 @@ import { useMantineColorScheme } from "@mantine/core";
import { useEffect } from "react";
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata: { colorScheme } } = useAuth()
const {
metadata: { colorScheme },
} = useAuth();
const { setColorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
useEffect(() => {
if (!colorScheme) return;
setColorScheme(colorScheme);
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0]);
themeColorMeta.setAttribute(
"content",
colorScheme === "dark" ? theme.colors.dark[8] : theme.colors.gray[0]
);
}
}, [colorScheme]);
return children
}
return children;
};
export default ColorSchemeProvider;

View File

@@ -1,68 +1,71 @@
import { useAuth } from "@/contexts/auth-context";
import { createTheme, MantineProvider as MantineProviderCore } from "@mantine/core";
import {
createTheme,
MantineProvider as MantineProviderCore,
} from "@mantine/core";
import ColorSchemeProvider from "./color-scheme-provider";
import { useState, useEffect } from "react";
const commonInputStyles = {
label: {
padding: 5
padding: 5,
},
root: {
margin: '0'
}
}
margin: "0",
},
};
const theme = createTheme({
defaultRadius: 'sm',
defaultRadius: "sm",
components: {
TextInput: {
styles: commonInputStyles
styles: commonInputStyles,
},
DateTimePicker: {
styles: commonInputStyles
styles: commonInputStyles,
},
Input: {
styles: commonInputStyles
styles: commonInputStyles,
},
Select: {
styles: commonInputStyles
styles: commonInputStyles,
},
Autocomplete: {
styles: commonInputStyles
styles: commonInputStyles,
},
DateTiemPicker: {
styles: {
root: {
zIndex: 1000
zIndex: 1000,
},
input: {
zIndex: 1000,
backgroundColor: 'red'
}
}
}
backgroundColor: "red",
},
},
},
},
});
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata } = useAuth()
const [isHydrated, setIsHydrated] = useState(false)
const { metadata } = useAuth();
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true)
}, [])
setIsHydrated(true);
}, []);
const colorScheme = isHydrated ? (metadata.colorScheme || 'auto') : 'auto'
const primaryColor = isHydrated ? (metadata.accentColor || 'blue') : 'blue'
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
return <MantineProviderCore
defaultColorScheme={colorScheme}
theme={{ ...theme, primaryColor }}
>
<ColorSchemeProvider>
{children}
</ColorSchemeProvider>
</MantineProviderCore>
}
return (
<MantineProviderCore
defaultColorScheme={colorScheme}
theme={{ ...theme, primaryColor }}
>
<ColorSchemeProvider>{children}</ColorSchemeProvider>
</MantineProviderCore>
);
};
export default MantineProvider;

View File

@@ -1,7 +1,7 @@
import PocketBase from 'pocketbase';
import { createPlayersService } from './services/players';
import { createTournamentsService } from './services/tournaments';
import { createTeamsService } from './services/teams';
import PocketBase from "pocketbase";
import { createPlayersService } from "./services/players";
import { createTournamentsService } from "./services/tournaments";
import { createTeamsService } from "./services/teams";
class PocketBaseAdminClient {
private pb: PocketBase;
@@ -11,20 +11,20 @@ class PocketBaseAdminClient {
this.pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
this.pb.beforeSend = (url, options) => {
options.cache = 'no-store';
options.cache = "no-store";
options.headers = {
...options.headers,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
};
return { url, options };
};
this.pb.autoCancellation(false);
this.authPromise = this.authenticate();
this.authPromise.then(() => {
Object.assign(this, createPlayersService(this.pb));
Object.assign(this, createTeamsService(this.pb));
@@ -33,18 +33,20 @@ class PocketBaseAdminClient {
}
private async authenticate() {
await this.pb.collection("_superusers").authWithPassword(
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
);
await this.pb
.collection("_superusers")
.authWithPassword(
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
);
}
}
interface AdminClient extends
PocketBaseAdminClient,
ReturnType<typeof createPlayersService>,
ReturnType<typeof createTeamsService>,
ReturnType<typeof createTournamentsService> {
interface AdminClient
extends PocketBaseAdminClient,
ReturnType<typeof createPlayersService>,
ReturnType<typeof createTeamsService>,
ReturnType<typeof createTournamentsService> {
authPromise: Promise<void>;
}

View File

@@ -1,46 +1,50 @@
import type { Player, PlayerInput, PlayerUpdateInput } from '@/features/players/types';
import { transformPlayer } from '@/lib/pocketbase/util/transform-types';
import PocketBase from 'pocketbase';
import type {
Player,
PlayerInput,
PlayerUpdateInput,
} from "@/features/players/types";
import { transformPlayer } from "@/lib/pocketbase/util/transform-types";
import PocketBase from "pocketbase";
export function createPlayersService(pb: PocketBase) {
return {
async getPlayerByAuthId(authId: string): Promise<Player | null> {
const result = await pb.collection('players').getList<Player>(1, 1, {
filter: `auth_id = "${authId}"`
const result = await pb.collection("players").getList<Player>(1, 1, {
filter: `auth_id = "${authId}"`,
});
return result.items[0] ? transformPlayer(result.items[0]) : null;
},
async getPlayer(id: string): Promise<Player | null> {
const result = await pb.collection('players').getOne(id, {
expand: 'teams'
async getPlayer(id: string): Promise<Player> {
const result = await pb.collection("players").getOne(id, {
expand: "teams",
});
return transformPlayer(result);
},
async listPlayers(): Promise<Player[]> {
const result = await pb.collection('players').getFullList<Player>({
fields: 'id,first_name,last_name'
const result = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name",
});
return result.map(transformPlayer);
},
async createPlayer(data: PlayerInput): Promise<Player> {
const result = await pb.collection('players').create<Player>(data);
const result = await pb.collection("players").create<Player>(data);
return transformPlayer(result);
},
async updatePlayer(id: string, data: PlayerUpdateInput): Promise<Player> {
const result = await pb.collection('players').update<Player>(id, data);
const result = await pb.collection("players").update<Player>(id, data);
return transformPlayer(result);
},
async getUnassociatedPlayers(): Promise<Player[]> {
const result = await pb.collection('players').getFullList<Player>({
const result = await pb.collection("players").getFullList<Player>({
filter: 'auth_id = ""',
fields: 'id,first_name,last_name'
fields: "id,first_name,last_name",
});
return result.map(transformPlayer);
}
},
};
}

View File

@@ -6,15 +6,11 @@ import { Team } from "@/features/teams/types";
export function createTeamsService(pb: PocketBase) {
return {
async getTeam(id: string): Promise<Team | null> {
try {
logger.info('PocketBase | Getting team', id);
const result = await pb.collection('teams').getOne(id, {
expand: 'players, tournaments'
});
return transformTeam(result);
} catch {
return null;
}
logger.info("PocketBase | Getting team", id);
const result = await pb.collection("teams").getOne(id, {
expand: "players, tournaments",
});
return transformTeam(result);
},
};
}

View File

@@ -12,15 +12,11 @@ import { transformTeam } from "@/lib/pocketbase/util/transform-types";
export function createTournamentsService(pb: PocketBase) {
return {
async getTournament(id: string): Promise<Tournament | null> {
try {
logger.info("PocketBase | Getting tournament", id);
const result = await pb.collection("tournaments").getOne(id, {
expand: "teams, teams.players",
});
return transformTournament(result);
} catch {
return null;
}
logger.info("PocketBase | Getting tournament", id);
const result = await pb.collection("tournaments").getOne(id, {
expand: "teams, teams.players",
});
return transformTournament(result);
},
async listTournaments(): Promise<Tournament[]> {
const result = await pb
@@ -51,60 +47,67 @@ export function createTournamentsService(pb: PocketBase) {
tournamentId: string,
teamId: string
): Promise<Tournament> {
const result = await pb.collection("tournaments").update<Tournament>(
tournamentId,
{ "teams+": teamId },
{ expand: "teams, teams.players" }
);
await pb.collection("teams").update(
teamId,
{ "tournaments+": tournamentId }
);
const result = await pb
.collection("tournaments")
.update<Tournament>(
tournamentId,
{ "teams+": teamId },
{ expand: "teams, teams.players" }
);
await pb
.collection("teams")
.update(teamId, { "tournaments+": tournamentId });
return transformTournament(result);
},
async unenrollTeam(
tournamentId: string,
teamId: string
): Promise<Tournament> {
const result = await pb.collection("tournaments").update<Tournament>(
tournamentId,
{ "teams-": teamId },
{ expand: "teams, teams.players" }
);
await pb.collection("teams").update(
teamId,
{ "tournaments-": tournamentId }
);
const result = await pb
.collection("tournaments")
.update<Tournament>(
tournamentId,
{ "teams-": teamId },
{ expand: "teams, teams.players" }
);
await pb
.collection("teams")
.update(teamId, { "tournaments-": tournamentId });
return transformTournament(result);
},
async getUnenrolledTeams(tournamentId: string): Promise<Team[]> {
try {
logger.info("PocketBase | Getting unenrolled teams for tournament", tournamentId);
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
fields: "teams"
});
logger.info(
"PocketBase | Getting unenrolled teams for tournament",
tournamentId
);
const tournament = await pb
.collection("tournaments")
.getOne(tournamentId, {
fields: "teams",
});
const enrolledTeamIds = tournament.teams || [];
if (enrolledTeamIds.length === 0) {
const allTeams = await pb.collection("teams").getFullList({
expand: "players"
expand: "players",
});
return allTeams.map(transformTeam);
}
const filter = enrolledTeamIds
.map((teamId: string) => `id != "${teamId}"`)
.join(" && ");
const availableTeams = await pb.collection("teams").getFullList({
filter,
expand: "players"
expand: "players",
});
return availableTeams.map(transformTeam);
} catch (error) {
logger.error("PocketBase | Error getting unenrolled teams", error);

View File

@@ -32,7 +32,7 @@ export function transformTeam(record: any): Team {
new Date(a.created!) < new Date(b.created!) ? -1 : 0
)
?.map(transformPlayer) ?? [];
const tournaments =
const tournaments =
record.expand?.tournaments
?.sort((a: Tournament, b: Tournament) =>
new Date(a.created!) < new Date(b.created!) ? -1 : 0
@@ -56,7 +56,7 @@ export function transformTeam(record: any): Team {
created: record.created,
updated: record.updated,
players,
tournaments
tournaments,
};
}

View File

@@ -1,9 +1,9 @@
import { toast as sonnerToast } from 'sonner';
import { ToastProps } from './types';
import { Notification } from '@mantine/core';
import { ShieldCheckIcon, WarningCircleIcon } from '@phosphor-icons/react';
import { toast as sonnerToast } from "sonner";
import { ToastProps } from "./types";
import { Notification } from "@mantine/core";
import { ShieldCheckIcon, WarningCircleIcon } from "@phosphor-icons/react";
const makeToast = (toast: Omit<ToastProps, 'id'>) => {
const makeToast = (toast: Omit<ToastProps, "id">) => {
return sonnerToast.custom((id) => (
<Toast
id={id}
@@ -14,24 +14,33 @@ const makeToast = (toast: Omit<ToastProps, 'id'>) => {
color={toast.color}
loading={!!toast.loading}
/>
))
));
};
function success(toast: Omit<ToastProps, "id"> | string) {
const config = typeof toast === "string" ? { description: toast } : toast;
return makeToast({
...config,
icon: <ShieldCheckIcon color="lightgreen" size={48} weight="fill" />,
});
}
function success(toast: Omit<ToastProps, 'id'> | string) {
const config = typeof toast === 'string' ? { description: toast } : toast;
return makeToast({ ...config, icon: <ShieldCheckIcon color='lightgreen' size={48} weight='fill'/> });
}
function error(toast: Omit<ToastProps, 'id'> | string) {
const config = typeof toast === 'string' ? { description: toast } : toast;
return makeToast({ ...config, icon: <WarningCircleIcon color='lightcoral' size={48} weight='fill' /> });
function error(toast: Omit<ToastProps, "id"> | string) {
const config = typeof toast === "string" ? { description: toast } : toast;
return makeToast({
...config,
icon: <WarningCircleIcon color="lightcoral" size={48} weight="fill" />,
});
}
function Toast(props: ToastProps) {
const { title, description, withCloseButton, icon, loading } = props;
return (
<Notification miw='md' color={'rgba(0,0,0,0)'} withBorder
<Notification
miw="md"
color={"rgba(0,0,0,0)"}
withBorder
withCloseButton={!!withCloseButton}
loading={loading}
title={title}
@@ -42,7 +51,7 @@ function Toast(props: ToastProps) {
);
}
export default {
export default {
success,
error,
}
};

View File

@@ -1,8 +1,8 @@
import SuperTokens from 'supertokens-web-js';
import Session from 'supertokens-web-js/recipe/session';
import Passwordless from 'supertokens-web-js/recipe/passwordless';
import { appInfo } from './config';
import { logger } from './';
import SuperTokens from "supertokens-web-js";
import Session from "supertokens-web-js/recipe/session";
import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config";
import { logger } from "./";
export const frontendConfig = () => {
return {
@@ -12,27 +12,27 @@ export const frontendConfig = () => {
Session.init({
tokenTransferMethod: "cookie",
sessionTokenBackendDomain: undefined,
preAPIHook: async (context) => {
context.requestInit.credentials = "include";
return context;
},
})
]
}),
],
};
}
};
let initialized = false;
export function ensureSuperTokensFrontend() {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
if (!initialized) {
SuperTokens.init(frontendConfig());
initialized = true;
logger.info("Initialized");
Session.doesSessionExist().then(exists => {
logger.info(`Session does${exists ? '' : 'NOT'} exist on load!`);
Session.doesSessionExist().then((exists) => {
logger.info(`Session does${exists ? "" : "NOT"} exist on load!`);
});
}
}
}

View File

@@ -1,7 +1,7 @@
import Passwordless from "supertokens-node/recipe/passwordless";
import { logger } from "../";
const init = () => (
const init = () =>
Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
@@ -14,17 +14,17 @@ const init = () => (
throw new Error("No user input code provided to sendSms");
}
logger.info('Sending Code',
'######################',
'## SuperTokens Code ##',
logger.info(
"Sending Code",
"######################",
"## SuperTokens Code ##",
`## ${userInputCode} ##`,
'######################'
"######################"
);
}
}
}
}
})
)
},
};
},
},
});
export default { init };

View File

@@ -2,7 +2,7 @@ import { useSession } from "@tanstack/react-start/server";
import Passwordless from "supertokens-node/recipe/passwordless";
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
const init = () => (
const init = () =>
Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
@@ -18,22 +18,24 @@ const init = () => (
const sid = await sendVerifyCode(phoneNumber, userInputCode);
const session = await useSession({
password: preAuthSessionId
password: preAuthSessionId,
});
await session.update({
twilioSid: sid
twilioSid: sid,
});
}
}
}
},
};
},
},
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
consumeCode: async (input) => {
const session = await useSession({ password: input.preAuthSessionId });
const session = await useSession({
password: input.preAuthSessionId,
});
const twilioSid = session?.data.twilioSid;
if (!twilioSid) {
@@ -46,20 +48,22 @@ const init = () => (
await updateVerify(twilioSid);
await session.update({
twilioSid: undefined,
userId: response?.user.id
})
userId: response?.user.id,
});
} else if (response.status === "INCORRECT_USER_INPUT_CODE_ERROR") {
if (response.failedCodeInputAttemptCount !== response.maximumCodeInputAttempts) {
if (
response.failedCodeInputAttemptCount !==
response.maximumCodeInputAttempts
) {
await updateVerify(twilioSid);
}
}
return response;
}
}
}
}
})
)
},
};
},
},
});
export default { init };

View File

@@ -9,6 +9,14 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
const session = await getSessionForSSR(request);
if (session.hasToken) {
if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) {
return {
hasToken: true,
needsRefresh: true,
error: 'TRY_REFRESH_TOKEN'
}
}
return {
hasToken: true,
accessTokenPayload: session.accessTokenPayload,
@@ -36,13 +44,3 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
throw error;
}
}
export async function verifySession(request: Request, options?: { sessionRequired?: boolean }) {
const session = await getSessionForStart(request, options);
if (!session && options?.sessionRequired !== false) {
throw new Response("Unauthorized", { status: 401 });
}
return session;
}

View File

@@ -9,28 +9,30 @@ import { logger } from "./";
export const backendConfig = (): TypeInput => {
return {
framework: 'custom',
framework: "custom",
supertokens: {
connectionURI: import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
connectionURI:
import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
},
appInfo,
recipeList: [
PasswordlessDevelopmentMode.init(),
Session.init({
cookieSameSite: "lax",
cookieSecure: process.env.NODE_ENV === 'production',
cookieDomain: process.env.NODE_ENV === 'production' ? ".example.com" : undefined,
antiCsrf: process.env.NODE_ENV === 'production' ? "VIA_TOKEN" : "NONE",
cookieSecure: process.env.NODE_ENV === "production",
cookieDomain:
process.env.NODE_ENV === "production" ? ".example.com" : undefined,
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
// Debug only
exposeAccessTokenToFrontendInCookieBasedAuth: true,
}),
Dashboard.init(),
UserRoles.init()
UserRoles.init(),
],
telemetry: process.env.NODE_ENV !== 'production',
telemetry: process.env.NODE_ENV !== "production",
};
}
};
let initialized = false;
export function ensureSuperTokensBackend() {

View File

@@ -0,0 +1,4 @@
export * from './use-optimistic-mutation';
export * from './use-server-mutation';
export * from './use-server-query';
export * from './user-server-suspense-query';

View File

@@ -0,0 +1,37 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "./use-server-mutation";
export function useOptimisticMutation<TData, TVariables = unknown>(
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
queryKey: readonly (string | number)[];
optimisticUpdate?: (oldData: any, variables: TVariables) => any;
}
) {
const queryClient = useQueryClient();
const { queryKey, optimisticUpdate, ...mutationOptions } = options;
return useServerMutation({
...mutationOptions,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
if (optimisticUpdate && previousData) {
queryClient.setQueryData(queryKey, (old: any) => optimisticUpdate(old, variables));
}
return { previousData };
},
onError: (error, variables, context) => {
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
mutationOptions.onError?.(error, variables, context);
},
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({ queryKey });
mutationOptions.onSettled?.(data, error, variables, context);
}
});
}

View File

@@ -0,0 +1,47 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types";
import toast from '@/lib/sonner'
export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
successMessage?: string;
showErrorToast?: boolean;
showSuccessToast?: boolean;
}
) {
const {
mutationFn,
successMessage,
showErrorToast = true,
showSuccessToast = true,
onSuccess,
onError,
...mutationOptions
} = options;
return useMutation({
...mutationOptions,
mutationFn: async (variables: TVariables) => {
const result = await mutationFn(variables);
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
},
onSuccess: (data, variables, context) => {
if (showSuccessToast && successMessage) {
toast.success(successMessage);
}
onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
onError?.(error, variables, context);
}
});
}

View File

@@ -0,0 +1,31 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query";
import { ServerResult } from "../types";
import toast from '@/lib/sonner'
export function useServerQuery<TData>(
options: {
queryKey: QueryKey,
queryFn: () => Promise<ServerResult<TData>>;
options?: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn' | 'queryKey'>
showErrorToast?: boolean;
}
) {
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
return useQuery({
...queryOptions,
queryKey,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}

View File

@@ -1,132 +0,0 @@
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ServerResult } from '@/lib/tanstack-query/types';
export function useServerQuery<TData>(
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
queryFn: () => Promise<ServerResult<TData>>;
showErrorToast?: boolean;
}
) {
const { queryFn, showErrorToast = true, ...queryOptions } = options;
return useQuery({
...queryOptions,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}
export function useServerSuspenseQuery<TData>(
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
queryFn: () => Promise<ServerResult<TData>>;
showErrorToast?: boolean;
}
) {
const { queryFn, showErrorToast = true, ...queryOptions } = options;
return useSuspenseQuery({
...queryOptions,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}
export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
successMessage?: string;
showErrorToast?: boolean;
showSuccessToast?: boolean;
}
) {
const {
mutationFn,
successMessage,
showErrorToast = true,
showSuccessToast = true,
onSuccess,
onError,
...mutationOptions
} = options;
return useMutation({
...mutationOptions,
mutationFn: async (variables: TVariables) => {
const result = await mutationFn(variables);
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
},
onSuccess: (data, variables, context) => {
if (showSuccessToast && successMessage) {
toast.success(successMessage);
}
onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
onError?.(error, variables, context);
}
});
}
export function useOptimisticMutation<TData, TVariables = unknown>(
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
queryKey: readonly (string | number)[];
optimisticUpdate?: (oldData: any, variables: TVariables) => any;
}
) {
const queryClient = useQueryClient();
const { queryKey, optimisticUpdate, ...mutationOptions } = options;
return useServerMutation({
...mutationOptions,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
if (optimisticUpdate && previousData) {
queryClient.setQueryData(queryKey, (old: any) => optimisticUpdate(old, variables));
}
return { previousData };
},
onError: (error, variables, context) => {
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
mutationOptions.onError?.(error, variables, context);
},
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({ queryKey });
mutationOptions.onSettled?.(data, error, variables, context);
}
});
}

View File

@@ -0,0 +1,32 @@
import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { ServerResult } from "../types";
import toast from '@/lib/sonner'
export function useServerSuspenseQuery<TData>(
options: {
queryKey: QueryKey,
queryFn: () => Promise<ServerResult<TData>>;
options?: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn' | 'queryKey'>
showErrorToast?: boolean;
}
) {
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
const queryResult = useSuspenseQuery({
...queryOptions,
queryKey,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
return queryResult;
}

View File

@@ -0,0 +1,23 @@
import { QueryClient, QueryKey } from "@tanstack/react-query";
import { ServerResult } from "../types";
export async function ensureServerQueryData<TData>(
queryClient: QueryClient,
query: {
queryKey: QueryKey;
queryFn: () => Promise<ServerResult<TData>>;
}
): Promise<TData> {
return queryClient.ensureQueryData({
queryKey: query.queryKey,
queryFn: async () => {
const result = await query.queryFn();
if (!result.success) {
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}

View File

@@ -0,0 +1,23 @@
import { QueryClient, QueryKey } from '@tanstack/react-query';
import { ServerResult } from '../types';
export async function prefetchServerQuery<TData>(
queryClient: QueryClient,
query: {
queryKey: QueryKey;
queryFn: () => Promise<ServerResult<TData>>;
}
): Promise<void> {
await queryClient.prefetchQuery({
queryKey: query.queryKey,
queryFn: async () => {
const result = await query.queryFn();
if (!result.success) {
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}

View File

@@ -1,5 +1,5 @@
import { logger } from "../logger";
import { ErrorType, ServerError, ServerResult } from "./types";
import { logger } from "../../logger";
import { ErrorType, ServerError, ServerResult } from "../types";
export const createServerError = (
type: ErrorType,
@@ -15,30 +15,19 @@ export const createServerError = (
context,
});
export const withErrorHandling = async <T>(
operation: () => Promise<T>,
context: string,
customErrorMap?: (error: unknown) => ServerError | null
): Promise<ServerResult<T>> => {
export const toServerResult = async <T>(serverFn: () => Promise<T>): Promise<ServerResult<T>> => {
try {
const data = await operation();
const data = await serverFn();
return { success: true, data };
} catch (error) {
logger.error(`${context} failed`, { error, context });
logger.error('Server Fn Error', error);
if (customErrorMap) {
const customError = customErrorMap(error);
if (customError) {
return { success: false, error: customError };
}
}
const mappedError = mapKnownError(error, context);
const mappedError = mapKnownError(error);
return { success: false, error: mappedError };
}
};
const mapKnownError = (error: unknown, context: string): ServerError => {
const mapKnownError = (error: unknown): ServerError => {
if (error && typeof error === "object" && "status" in error) {
const pbError = error as {
status: number;
@@ -100,7 +89,7 @@ const mapKnownError = (error: unknown, context: string): ServerError => {
error.message,
"An unexpected error occurred. Please try again.",
undefined,
{ stack: error.stack, context }
{ stack: error.stack }
);
}
@@ -109,6 +98,6 @@ const mapKnownError = (error: unknown, context: string): ServerError => {
String(error),
"An unexpected error occurred. Please try again.",
undefined,
{ originalError: error, context }
{ originalError: error }
);
};

View File

@@ -1,19 +1,19 @@
import twilio from "twilio";
const accountSid = import.meta.env.VITE_TWILIO_ACCOUNT_SID
const authToken = import.meta.env.VITE_TWILIO_AUTH_TOKEN
const serviceSid = import.meta.env.VITE_TWILIO_SERVICE_SID
const accountSid = import.meta.env.VITE_TWILIO_ACCOUNT_SID;
const authToken = import.meta.env.VITE_TWILIO_AUTH_TOKEN;
const serviceSid = import.meta.env.VITE_TWILIO_SERVICE_SID;
const client = twilio(accountSid, authToken);
export async function sendVerifyCode(phoneNumber: string, code: string) {
const verification = await client.verify.v2
.services(serviceSid)
.verifications.create({
channel: "sms",
customCode: code,
to: phoneNumber,
});
.services(serviceSid)
.verifications.create({
channel: "sms",
customCode: code,
to: phoneNumber,
});
if (verification.status !== "pending") {
throw new Error("Unknown error sending verification code");