+2
-2
app/app.json
+2
-2
app/app.json
+78
-15
app/app/(auth)/index.tsx
+78
-15
app/app/(auth)/index.tsx
···
1
import { StyleSheet } from "react-native";
2
3
-
import { ThemedText } from "@/components/themed-text";
4
-
import { ThemedView } from "@/components/themed-view";
5
import { useLogin } from "@/hooks/use-login";
6
-
import { Button } from "@react-navigation/elements";
7
8
-
export default function HomeScreen() {
9
-
const { login } = useLogin();
10
11
return (
12
-
<ThemedView style={styles.titleContainer}>
13
-
<ThemedText type="title">Welcome!</ThemedText>
14
-
<Button
15
-
onPressOut={() =>
16
-
login({ username: "test@test.test", password: "password@123" })
17
-
}
18
-
>
19
-
login
20
-
</Button>
21
-
</ThemedView>
22
);
23
}
24
25
const styles = StyleSheet.create({
26
titleContainer: {
27
paddingTop: 60,
28
flexDirection: "row",
29
alignItems: "center",
30
gap: 8,
31
},
32
});
···
1
+
import { useState } from "react";
2
import { StyleSheet } from "react-native";
3
+
import { useRouter } from "expo-router";
4
5
+
import ContainerView from "@/components/container-view";
6
+
import { LoginRequest } from "@/generated";
7
+
import {
8
+
ThemedText,
9
+
ThemedTextInput,
10
+
ThemedView,
11
+
ThemedButton,
12
+
ThemedErrorBox,
13
+
} from "@/components/theme";
14
import { useLogin } from "@/hooks/use-login";
15
+
import { useThemeColor } from "@/hooks/use-theme-color";
16
17
+
export default function LoginScreen() {
18
+
const { login, loading, error } = useLogin();
19
+
const router = useRouter();
20
+
const borderColor = useThemeColor({}, "text");
21
+
22
+
const [formData, setFormData] = useState<LoginRequest>({
23
+
username: "",
24
+
password: "",
25
+
});
26
27
return (
28
+
<ContainerView style={styles.page}>
29
+
<ThemedView style={[styles.formContainer, { borderColor }]}>
30
+
<ThemedText type="title">Welcome!</ThemedText>
31
+
<ThemedTextInput
32
+
autoComplete="email"
33
+
disabled={loading}
34
+
nextHint="next"
35
+
placeholder="Username"
36
+
value={formData.username}
37
+
error={error?.properties?.username?.errors}
38
+
onChange={(username) =>
39
+
setFormData((state) => ({ ...state, username }))
40
+
}
41
+
/>
42
+
<ThemedTextInput
43
+
autoComplete="current-password"
44
+
disabled={loading}
45
+
nextHint="go"
46
+
placeholder="Password"
47
+
type="password"
48
+
value={formData.password}
49
+
error={error?.properties?.password?.errors}
50
+
onChange={(password) =>
51
+
setFormData((state) => ({ ...state, password }))
52
+
}
53
+
/>
54
+
<ThemedErrorBox errors={error?.errors} />
55
+
<ThemedView style={styles.buttonGroup}>
56
+
<ThemedButton disabled={loading} onPress={() => login(formData)}>
57
+
{loading ? "loading.." : "login"}
58
+
</ThemedButton>
59
+
<ThemedButton
60
+
disabled={loading}
61
+
onPress={() => router.push("./settings")}
62
+
>
63
+
settings
64
+
</ThemedButton>
65
+
</ThemedView>
66
+
</ThemedView>
67
+
</ContainerView>
68
);
69
}
70
71
const styles = StyleSheet.create({
72
+
page: {
73
+
flex: 1,
74
+
justifyContent: "center",
75
+
alignItems: "center",
76
+
flexDirection: "row",
77
+
},
78
titleContainer: {
79
paddingTop: 60,
80
flexDirection: "row",
81
alignItems: "center",
82
gap: 8,
83
+
},
84
+
formContainer: {
85
+
borderRadius: 8,
86
+
borderWidth: 1,
87
+
padding: 16,
88
+
display: "flex",
89
+
},
90
+
buttonGroup: {
91
+
display: "flex",
92
+
flexDirection: "row",
93
+
justifyContent: "space-around",
94
},
95
});
+10
-16
app/app/(auth)/settings.tsx
+10
-16
app/app/(auth)/settings.tsx
···
1
-
import { StyleSheet } from "react-native";
2
3
-
import { ThemedText } from "@/components/themed-text";
4
-
import { ThemedView } from "@/components/themed-view";
5
-
6
-
export default function SettingsScreen() {
7
return (
8
-
<ThemedView style={styles.titleContainer}>
9
-
<ThemedText type="title">Settings</ThemedText>
10
-
</ThemedView>
11
);
12
}
13
-
14
-
const styles = StyleSheet.create({
15
-
titleContainer: {
16
-
flexDirection: "row",
17
-
alignItems: "center",
18
-
gap: 8,
19
-
},
20
-
});
···
1
+
import ContainerView from "@/components/container-view";
2
+
import { ThemedText, ThemedView } from "@/components/theme";
3
+
import { ThemeToggle } from "@/components/shared/theme-toggle";
4
5
+
export default function LoginSettingsScreen() {
6
return (
7
+
<ContainerView>
8
+
<ThemedView>
9
+
<ThemedText type="title">Settings</ThemedText>
10
+
</ThemedView>
11
+
<ThemeToggle />
12
+
</ContainerView>
13
);
14
}
+30
-16
app/app/(tabs)/_layout.tsx
+30
-16
app/app/(tabs)/_layout.tsx
···
1
-
import { Tabs } from 'expo-router';
2
-
import React from 'react';
3
4
-
import { HapticTab } from '@/components/haptic-tab';
5
-
import { IconSymbol } from '@/components/ui/icon-symbol';
6
-
import { Colors } from '@/constants/theme';
7
-
import { useColorScheme } from '@/hooks/use-color-scheme';
8
-
import { useAuthentication } from '@/hooks/use-authentication';
9
10
export default function TabLayout() {
11
const colorScheme = useColorScheme();
···
15
return (
16
<Tabs
17
screenOptions={{
18
-
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
19
headerShown: false,
20
tabBarButton: HapticTab,
21
-
}}>
22
<Tabs.Screen
23
name="index"
24
options={{
25
-
title: 'Home',
26
-
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
27
}}
28
/>
29
<Tabs.Screen
30
-
name="explore"
31
options={{
32
-
title: 'Explore',
33
-
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
34
}}
35
/>
36
<Tabs.Screen
37
name="account"
38
options={{
39
-
title: 'Account',
40
-
tabBarIcon: ({ color }) => <IconSymbol size={28} name="chevron.right" color={color} />,
41
}}
42
/>
43
</Tabs>
···
1
+
import { Tabs } from "expo-router";
2
+
import React from "react";
3
4
+
import { HapticTab } from "@/components/haptic-tab";
5
+
import { IconSymbol } from "@/components/ui/icon-symbol";
6
+
import { Colors } from "@/constants/theme";
7
+
import { useColorScheme } from "@/hooks/use-color-scheme";
8
+
import { useAuthentication } from "@/hooks/use-authentication";
9
10
export default function TabLayout() {
11
const colorScheme = useColorScheme();
···
15
return (
16
<Tabs
17
screenOptions={{
18
+
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
19
headerShown: false,
20
tabBarButton: HapticTab,
21
+
}}
22
+
>
23
<Tabs.Screen
24
name="index"
25
options={{
26
+
title: "Home",
27
+
tabBarIcon: ({ color }) => (
28
+
<IconSymbol size={28} name="house.fill" color={color} />
29
+
),
30
}}
31
/>
32
<Tabs.Screen
33
+
name="cards"
34
options={{
35
+
title: "Cards",
36
+
tabBarIcon: ({ color }) => (
37
+
<IconSymbol size={28} name="creditcard.fill" color={color} />
38
+
),
39
}}
40
/>
41
<Tabs.Screen
42
name="account"
43
options={{
44
+
title: "Settings",
45
+
tabBarIcon: ({ color }) => (
46
+
<IconSymbol size={28} name="shield.fill" color={color} />
47
+
),
48
+
}}
49
+
/>
50
+
<Tabs.Screen
51
+
name="accounts/[accountId]/index"
52
+
options={{
53
+
href: null,
54
+
title: "Transactions",
55
}}
56
/>
57
</Tabs>
+14
-10
app/app/(tabs)/account.tsx
+14
-10
app/app/(tabs)/account.tsx
···
1
import { Image } from "expo-image";
2
import { StyleSheet } from "react-native";
3
import ParallaxScrollView from "@/components/parallax-scroll-view";
4
-
import { ThemedText } from "@/components/themed-text";
5
-
import { ThemedView } from "@/components/themed-view";
6
-
import { Button } from "@react-navigation/elements";
7
import { useCurrentUser } from "@/hooks/use-current-user";
8
import { useLogout } from "@/hooks/use-logout";
9
10
-
export default function ProfileScreen() {
11
const { logout } = useLogout();
12
13
const { user } = useCurrentUser();
···
23
}
24
>
25
<ThemedView style={styles.titleContainer}>
26
-
<ThemedText type="title">{`Welcome ${user?.fullname}`}</ThemedText>
27
-
</ThemedView>
28
-
<ThemedView style={styles.titleContainer}>
29
-
<ThemedText type="subtitle">{`username: ${user?.username}`}</ThemedText>
30
</ThemedView>
31
-
<ThemedView style={styles.titleContainer}>
32
-
<Button onPressOut={() => logout()}>logout</Button>
33
</ThemedView>
34
</ParallaxScrollView>
35
);
36
}
···
38
const styles = StyleSheet.create({
39
titleContainer: {
40
flexDirection: "row",
41
alignItems: "center",
42
gap: 8,
43
},
···
1
import { Image } from "expo-image";
2
import { StyleSheet } from "react-native";
3
import ParallaxScrollView from "@/components/parallax-scroll-view";
4
+
import { ThemedText, ThemedView, ThemedButton } from "@/components/theme";
5
import { useCurrentUser } from "@/hooks/use-current-user";
6
import { useLogout } from "@/hooks/use-logout";
7
+
import { ThemeToggle } from "@/components/shared/theme-toggle";
8
9
+
export default function SettingsScreen() {
10
const { logout } = useLogout();
11
12
const { user } = useCurrentUser();
···
22
}
23
>
24
<ThemedView style={styles.titleContainer}>
25
+
<ThemedText type="title">Settings</ThemedText>
26
</ThemedView>
27
+
<ThemedView style={styles.settingsContainer}>
28
+
<ThemedText>logged in as {user?.username}</ThemedText>
29
+
<ThemedButton onPress={() => logout()}>logout</ThemedButton>
30
</ThemedView>
31
+
<ThemeToggle />
32
</ParallaxScrollView>
33
);
34
}
···
36
const styles = StyleSheet.create({
37
titleContainer: {
38
flexDirection: "row",
39
+
alignItems: "center",
40
+
gap: 8,
41
+
},
42
+
settingsContainer: {
43
+
flexDirection: "row",
44
+
justifyContent: "space-between",
45
alignItems: "center",
46
gap: 8,
47
},
+75
app/app/(tabs)/accounts/[accountId]/index.tsx
+75
app/app/(tabs)/accounts/[accountId]/index.tsx
···
···
1
+
import { useState } from "react";
2
+
import { StyleSheet, TouchableOpacity, Modal } from "react-native";
3
+
import Ionicons from "@expo/vector-icons/Ionicons";
4
+
import { useLocalSearchParams } from "expo-router";
5
+
6
+
import { ThemedText, ThemedView } from "@/components/theme";
7
+
import { Price } from "@/components/ui/price";
8
+
import { TransactionList } from "@/components/transaction/transaction-list";
9
+
import ContainerView from "@/components/container-view";
10
+
import { useToggle } from "@/hooks/use-toggle";
11
+
import { useThemeColor } from "@/hooks/use-theme-color";
12
+
import { TransactionFilter } from "@/components/transaction/transaction-filter";
13
+
import { useAccount } from "@/hooks/use-account";
14
+
import { TransactionFilterOptions } from "@/hooks/use-transactions";
15
+
16
+
export default function AccountScreen() {
17
+
const { isHidden, toggleHidden, setHidden } = useToggle(true);
18
+
const [filter, setFilter] = useState<TransactionFilterOptions>({});
19
+
const color = useThemeColor({}, isHidden ? "text" : "green");
20
+
const { accountId: raw } = useLocalSearchParams<{ accountId: string }>();
21
+
const accountId = Number.parseInt(raw);
22
+
23
+
const { account } = useAccount(accountId);
24
+
25
+
return (
26
+
<ContainerView>
27
+
<Modal
28
+
visible={!isHidden}
29
+
animationType="fade"
30
+
onRequestClose={() => setHidden(false)}
31
+
>
32
+
<ThemedView style={styles.modalContainer}>
33
+
<TransactionFilter
34
+
initialState={filter}
35
+
accountId={accountId}
36
+
onCancel={() => setHidden(true)}
37
+
onSubmit={(value) => {
38
+
setFilter(value);
39
+
setHidden(true);
40
+
}}
41
+
/>
42
+
</ThemedView>
43
+
</Modal>
44
+
<ThemedView style={styles.titleContainer}>
45
+
<ThemedView>
46
+
<ThemedText type="default">{account?.name}</ThemedText>
47
+
<ThemedText type="small">{account?.iban}</ThemedText>
48
+
<Price>{account?.balance ?? 0}</Price>
49
+
</ThemedView>
50
+
<TouchableOpacity onPress={toggleHidden}>
51
+
<ThemedView>
52
+
<ThemedText type="default">
53
+
<Ionicons name="filter" size={32} color={color} />
54
+
</ThemedText>
55
+
</ThemedView>
56
+
</TouchableOpacity>
57
+
</ThemedView>
58
+
<TransactionList filter={filter} accountId={accountId} />
59
+
</ContainerView>
60
+
);
61
+
}
62
+
63
+
const styles = StyleSheet.create({
64
+
titleContainer: {
65
+
flexDirection: "row",
66
+
justifyContent: "space-between",
67
+
alignItems: "center",
68
+
},
69
+
modalContainer: {
70
+
flex: 1,
71
+
justifyContent: "center",
72
+
alignItems: "center",
73
+
backgroundColor: "none",
74
+
},
75
+
});
+75
app/app/(tabs)/cards.tsx
+75
app/app/(tabs)/cards.tsx
···
···
1
+
import { Image } from "expo-image";
2
+
import { StyleSheet } from "react-native";
3
+
4
+
import ParallaxScrollView from "@/components/parallax-scroll-view";
5
+
import { ThemedText, ThemedView } from "@/components/theme";
6
+
import { useQuery } from "@tanstack/react-query";
7
+
import { getCardsOptions } from "@/generated/@tanstack/react-query.gen";
8
+
import { useCurrentUser } from "@/hooks/use-current-user";
9
+
import { AccountNumber } from "@/components/ui/accountnumber";
10
+
11
+
export default function HomeScreen() {
12
+
const { data: cardData } = useQuery(getCardsOptions());
13
+
14
+
const { user } = useCurrentUser();
15
+
16
+
return (
17
+
<ParallaxScrollView
18
+
headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }}
19
+
headerImage={
20
+
<Image
21
+
source={require("@/assets/images/partial-react-logo.png")}
22
+
style={styles.reactLogo}
23
+
/>
24
+
}
25
+
>
26
+
<ThemedView style={styles.titleContainer}>
27
+
<ThemedText type="subtitle">Welcome back, {user?.fullname}!</ThemedText>
28
+
</ThemedView>
29
+
<ThemedView style={styles.stepContainer}>
30
+
{cardData?.map(({ id, number, cvv, expiry }) => (
31
+
<ThemedView style={styles.accountContainer} key={id}>
32
+
<ThemedView style={[styles.accountDetail, { flexGrow: 1 }]}>
33
+
<AccountNumber>{number}</AccountNumber>
34
+
</ThemedView>
35
+
<ThemedView style={styles.accountDetail}>
36
+
<ThemedText>CVV: {cvv}</ThemedText>
37
+
<ThemedText>Expiry: {expiry}</ThemedText>
38
+
</ThemedView>
39
+
</ThemedView>
40
+
))}
41
+
</ThemedView>
42
+
</ParallaxScrollView>
43
+
);
44
+
}
45
+
46
+
const styles = StyleSheet.create({
47
+
titleContainer: {
48
+
flexDirection: "row",
49
+
alignItems: "center",
50
+
gap: 8,
51
+
},
52
+
stepContainer: {
53
+
gap: 8,
54
+
marginBottom: 8,
55
+
},
56
+
reactLogo: {
57
+
height: 178,
58
+
width: 290,
59
+
bottom: 0,
60
+
left: 0,
61
+
position: "absolute",
62
+
},
63
+
accountContainer: {
64
+
display: "flex",
65
+
flexDirection: "row",
66
+
justifyContent: "space-between",
67
+
paddingVertical: 4,
68
+
gap: 4,
69
+
},
70
+
accountDetail: {
71
+
flexDirection: "column",
72
+
justifyContent: "center",
73
+
paddingVertical: 2,
74
+
},
75
+
});
-112
app/app/(tabs)/explore.tsx
-112
app/app/(tabs)/explore.tsx
···
1
-
import { Image } from 'expo-image';
2
-
import { Platform, StyleSheet } from 'react-native';
3
-
4
-
import { Collapsible } from '@/components/ui/collapsible';
5
-
import { ExternalLink } from '@/components/external-link';
6
-
import ParallaxScrollView from '@/components/parallax-scroll-view';
7
-
import { ThemedText } from '@/components/themed-text';
8
-
import { ThemedView } from '@/components/themed-view';
9
-
import { IconSymbol } from '@/components/ui/icon-symbol';
10
-
import { Fonts } from '@/constants/theme';
11
-
12
-
export default function TabTwoScreen() {
13
-
return (
14
-
<ParallaxScrollView
15
-
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
16
-
headerImage={
17
-
<IconSymbol
18
-
size={310}
19
-
color="#808080"
20
-
name="chevron.left.forwardslash.chevron.right"
21
-
style={styles.headerImage}
22
-
/>
23
-
}>
24
-
<ThemedView style={styles.titleContainer}>
25
-
<ThemedText
26
-
type="title"
27
-
style={{
28
-
fontFamily: Fonts.rounded,
29
-
}}>
30
-
Explore
31
-
</ThemedText>
32
-
</ThemedView>
33
-
<ThemedText>This app includes example code to help you get started.</ThemedText>
34
-
<Collapsible title="File-based routing">
35
-
<ThemedText>
36
-
This app has two screens:{' '}
37
-
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
38
-
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
39
-
</ThemedText>
40
-
<ThemedText>
41
-
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
42
-
sets up the tab navigator.
43
-
</ThemedText>
44
-
<ExternalLink href="https://docs.expo.dev/router/introduction">
45
-
<ThemedText type="link">Learn more</ThemedText>
46
-
</ExternalLink>
47
-
</Collapsible>
48
-
<Collapsible title="Android, iOS, and web support">
49
-
<ThemedText>
50
-
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
51
-
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
52
-
</ThemedText>
53
-
</Collapsible>
54
-
<Collapsible title="Images">
55
-
<ThemedText>
56
-
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
57
-
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
58
-
different screen densities
59
-
</ThemedText>
60
-
<Image
61
-
source={require('@/assets/images/react-logo.png')}
62
-
style={{ width: 100, height: 100, alignSelf: 'center' }}
63
-
/>
64
-
<ExternalLink href="https://reactnative.dev/docs/images">
65
-
<ThemedText type="link">Learn more</ThemedText>
66
-
</ExternalLink>
67
-
</Collapsible>
68
-
<Collapsible title="Light and dark mode components">
69
-
<ThemedText>
70
-
This template has light and dark mode support. The{' '}
71
-
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
72
-
what the user's current color scheme is, and so you can adjust UI colors accordingly.
73
-
</ThemedText>
74
-
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
75
-
<ThemedText type="link">Learn more</ThemedText>
76
-
</ExternalLink>
77
-
</Collapsible>
78
-
<Collapsible title="Animations">
79
-
<ThemedText>
80
-
This template includes an example of an animated component. The{' '}
81
-
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
82
-
the powerful{' '}
83
-
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
84
-
react-native-reanimated
85
-
</ThemedText>{' '}
86
-
library to create a waving hand animation.
87
-
</ThemedText>
88
-
{Platform.select({
89
-
ios: (
90
-
<ThemedText>
91
-
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
92
-
component provides a parallax effect for the header image.
93
-
</ThemedText>
94
-
),
95
-
})}
96
-
</Collapsible>
97
-
</ParallaxScrollView>
98
-
);
99
-
}
100
-
101
-
const styles = StyleSheet.create({
102
-
headerImage: {
103
-
color: '#808080',
104
-
bottom: -90,
105
-
left: -35,
106
-
position: 'absolute',
107
-
},
108
-
titleContainer: {
109
-
flexDirection: 'row',
110
-
gap: 8,
111
-
},
112
-
});
···
+16
-36
app/app/(tabs)/index.tsx
+16
-36
app/app/(tabs)/index.tsx
···
1
import { Image } from "expo-image";
2
-
import { StyleSheet, TouchableOpacity } from "react-native";
3
4
import ParallaxScrollView from "@/components/parallax-scroll-view";
5
-
import { ThemedText } from "@/components/themed-text";
6
-
import { ThemedView } from "@/components/themed-view";
7
-
import { useQuery } from "@tanstack/react-query";
8
import { getAccountsOptions } from "@/generated/@tanstack/react-query.gen";
9
import { useCurrentUser } from "@/hooks/use-current-user";
10
-
import { IconSymbol } from "@/components/ui/icon-symbol";
11
-
import { Price } from "@/components/ui/price";
12
-
import { AccountNumber } from "@/components/ui/accountnumber";
13
14
export default function HomeScreen() {
15
const { data: accountData } = useQuery(getAccountsOptions());
16
17
const { user } = useCurrentUser();
18
19
return (
20
<ParallaxScrollView
···
27
}
28
>
29
<ThemedView style={styles.titleContainer}>
30
-
<ThemedText type="default">Welcome back, {user?.fullname}!</ThemedText>
31
</ThemedView>
32
<ThemedView style={styles.stepContainer}>
33
-
{accountData?.map(({ id, name, balance, iban }) => (
34
-
<TouchableOpacity key={id}>
35
-
<ThemedView style={styles.accountContainer}>
36
-
<ThemedView style={[styles.accountDetail, { flexGrow: 1 }]}>
37
-
<ThemedText>{name}</ThemedText>
38
-
<AccountNumber hidden>{iban}</AccountNumber>
39
-
</ThemedView>
40
-
<ThemedView style={styles.accountDetail}>
41
-
<Price>{balance}</Price>
42
-
<ThemedText>+20%</ThemedText>
43
-
</ThemedView>
44
-
<ThemedView style={styles.accountDetail}>
45
-
<IconSymbol size={20} name="chevron.right" color="black" />
46
-
</ThemedView>
47
-
</ThemedView>
48
-
</TouchableOpacity>
49
))}
50
</ThemedView>
51
</ParallaxScrollView>
···
68
bottom: 0,
69
left: 0,
70
position: "absolute",
71
-
},
72
-
accountContainer: {
73
-
display: "flex",
74
-
flexDirection: "row",
75
-
justifyContent: "space-between",
76
-
paddingVertical: 4,
77
-
gap: 4,
78
-
},
79
-
accountDetail: {
80
-
flexDirection: "column",
81
-
justifyContent: "center",
82
-
paddingVertical: 2,
83
},
84
});
···
1
import { Image } from "expo-image";
2
+
import { StyleSheet } from "react-native";
3
+
import { useRouter } from "expo-router";
4
+
import { useQuery } from "@tanstack/react-query";
5
6
import ParallaxScrollView from "@/components/parallax-scroll-view";
7
+
import { ThemedText, ThemedView } from "@/components/theme";
8
import { getAccountsOptions } from "@/generated/@tanstack/react-query.gen";
9
import { useCurrentUser } from "@/hooks/use-current-user";
10
+
import { AccountCard } from "@/components/account-card";
11
12
export default function HomeScreen() {
13
const { data: accountData } = useQuery(getAccountsOptions());
14
15
const { user } = useCurrentUser();
16
+
const router = useRouter();
17
18
return (
19
<ParallaxScrollView
···
26
}
27
>
28
<ThemedView style={styles.titleContainer}>
29
+
<ThemedText type="subtitle">Bank Accounts</ThemedText>
30
+
</ThemedView>
31
+
<ThemedView style={styles.titleContainer}>
32
+
<ThemedText type="small">welcome back, {user?.fullname}</ThemedText>
33
</ThemedView>
34
<ThemedView style={styles.stepContainer}>
35
+
{accountData?.map((account) => (
36
+
<AccountCard
37
+
key={account.id}
38
+
account={account}
39
+
onPress={() => router.push(`./accounts/${account.id}`)}
40
+
/>
41
))}
42
</ThemedView>
43
</ParallaxScrollView>
···
60
bottom: 0,
61
left: 0,
62
position: "absolute",
63
},
64
});
+3
-1
app/app/_layout.tsx
+3
-1
app/app/_layout.tsx
+66
app/components/account-card.tsx
+66
app/components/account-card.tsx
···
···
1
+
import { StyleSheet, TouchableOpacity } from "react-native";
2
+
3
+
import { IconSymbol } from "@/components/ui/icon-symbol";
4
+
import { Price } from "@/components/ui/price";
5
+
import { AccountNumber } from "@/components/ui/accountnumber";
6
+
import { ThemedText, ThemedView } from "@/components/theme";
7
+
import { Account } from "@/generated";
8
+
import { useThemeColor } from "@/hooks/use-theme-color";
9
+
10
+
interface AccountCardProps {
11
+
account: Account;
12
+
onPress?: (account: Account) => void | Promise<void>;
13
+
}
14
+
15
+
export const AccountCard = ({ account, onPress }: AccountCardProps) => {
16
+
const chevronColor = useThemeColor({}, "text");
17
+
18
+
const { id, name, balance, iban } = account;
19
+
const rate = 50 - Number(iban.slice(-2));
20
+
21
+
const color = useThemeColor(
22
+
{},
23
+
Number.isNaN(rate) ? "text" : rate > 0 ? "green" : "red",
24
+
);
25
+
26
+
return (
27
+
<TouchableOpacity key={id} onPress={() => onPress?.(account)}>
28
+
<ThemedView style={styles.accountContainer}>
29
+
<ThemedView style={[styles.accountDetail, { flexGrow: 1 }]}>
30
+
<ThemedText>{name}</ThemedText>
31
+
<AccountNumber hidden size="small">
32
+
{iban}
33
+
</AccountNumber>
34
+
</ThemedView>
35
+
<ThemedView style={[styles.accountDetail, styles.balanceDetail]}>
36
+
<Price>{balance}</Price>
37
+
<ThemedText style={{ color }}>
38
+
{rate > 0 ? "+" : ""}
39
+
{Number.isNaN(rate) ? 0 : rate}%
40
+
</ThemedText>
41
+
</ThemedView>
42
+
<ThemedView style={styles.accountDetail}>
43
+
<IconSymbol size={14} name="chevron.right" color={chevronColor} />
44
+
</ThemedView>
45
+
</ThemedView>
46
+
</TouchableOpacity>
47
+
);
48
+
};
49
+
50
+
const styles = StyleSheet.create({
51
+
accountContainer: {
52
+
display: "flex",
53
+
flexDirection: "row",
54
+
justifyContent: "space-between",
55
+
paddingVertical: 4,
56
+
gap: 4,
57
+
},
58
+
accountDetail: {
59
+
flexDirection: "column",
60
+
justifyContent: "center",
61
+
paddingVertical: 2,
62
+
},
63
+
balanceDetail: {
64
+
alignItems: "flex-end",
65
+
},
66
+
});
+48
app/components/container-view.tsx
+48
app/components/container-view.tsx
···
···
1
+
import type { PropsWithChildren, ReactElement } from "react";
2
+
import { StyleSheet, ViewStyle } from "react-native";
3
+
4
+
import { ThemedView } from "@/components/theme";
5
+
import { useThemeColor } from "@/hooks/use-theme-color";
6
+
7
+
const HEADER_HEIGHT = 250;
8
+
9
+
type Props = PropsWithChildren<{
10
+
headerImage?: ReactElement;
11
+
headerBackgroundColor?: { dark: string; light: string };
12
+
style?: ViewStyle;
13
+
}>;
14
+
15
+
export default function ContainerView({
16
+
children,
17
+
headerImage,
18
+
style = {},
19
+
}: Props) {
20
+
const backgroundColor = useThemeColor({}, "background");
21
+
22
+
return (
23
+
<ThemedView style={[styles.container, { backgroundColor }, style]}>
24
+
{headerImage ? (
25
+
<ThemedView style={styles.header}>{headerImage}</ThemedView>
26
+
) : null}
27
+
<ThemedView style={styles.content}>{children}</ThemedView>
28
+
</ThemedView>
29
+
);
30
+
}
31
+
32
+
const styles = StyleSheet.create({
33
+
container: {
34
+
flex: 1,
35
+
paddingTop: 25,
36
+
justifyContent: "space-between",
37
+
},
38
+
header: {
39
+
height: HEADER_HEIGHT,
40
+
overflow: "hidden",
41
+
},
42
+
content: {
43
+
flex: 1,
44
+
padding: 32,
45
+
gap: 16,
46
+
overflow: "hidden",
47
+
},
48
+
});
+32
-28
app/components/parallax-scroll-view.tsx
+32
-28
app/components/parallax-scroll-view.tsx
···
1
-
import type { PropsWithChildren, ReactElement } from 'react';
2
-
import { StyleSheet } from 'react-native';
3
import Animated, {
4
interpolate,
5
useAnimatedRef,
6
useAnimatedStyle,
7
useScrollOffset,
8
-
} from 'react-native-reanimated';
9
10
-
import { ThemedView } from '@/components/themed-view';
11
-
import { useColorScheme } from '@/hooks/use-color-scheme';
12
-
import { useThemeColor } from '@/hooks/use-theme-color';
13
14
const HEADER_HEIGHT = 250;
15
···
23
headerImage,
24
headerBackgroundColor,
25
}: Props) {
26
-
const backgroundColor = useThemeColor({}, 'background');
27
-
const colorScheme = useColorScheme() ?? 'light';
28
const scrollRef = useAnimatedRef<Animated.ScrollView>();
29
const scrollOffset = useScrollOffset(scrollRef);
30
-
const headerAnimatedStyle = useAnimatedStyle(() => {
31
-
return {
32
-
transform: [
33
-
{
34
-
translateY: interpolate(
35
-
scrollOffset.value,
36
-
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
37
-
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
38
-
),
39
-
},
40
-
{
41
-
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
42
-
},
43
-
],
44
-
};
45
-
});
46
47
return (
48
<Animated.ScrollView
49
ref={scrollRef}
50
style={{ backgroundColor, flex: 1 }}
51
-
scrollEventThrottle={16}>
52
<Animated.View
53
style={[
54
styles.header,
55
{ backgroundColor: headerBackgroundColor[colorScheme] },
56
headerAnimatedStyle,
57
-
]}>
58
{headerImage}
59
</Animated.View>
60
<ThemedView style={styles.content}>{children}</ThemedView>
···
68
},
69
header: {
70
height: HEADER_HEIGHT,
71
-
overflow: 'hidden',
72
},
73
content: {
74
flex: 1,
75
padding: 32,
76
gap: 16,
77
-
overflow: 'hidden',
78
},
79
});
···
1
+
import type { PropsWithChildren, ReactElement } from "react";
2
+
import { StyleSheet } from "react-native";
3
import Animated, {
4
interpolate,
5
useAnimatedRef,
6
useAnimatedStyle,
7
useScrollOffset,
8
+
} from "react-native-reanimated";
9
10
+
import { ThemedView } from "@/components/theme";
11
+
import { useColorScheme } from "@/hooks/use-color-scheme";
12
+
import { useThemeColor } from "@/hooks/use-theme-color";
13
14
const HEADER_HEIGHT = 250;
15
···
23
headerImage,
24
headerBackgroundColor,
25
}: Props) {
26
+
const backgroundColor = useThemeColor({}, "background");
27
+
const colorScheme = useColorScheme() ?? "light";
28
const scrollRef = useAnimatedRef<Animated.ScrollView>();
29
const scrollOffset = useScrollOffset(scrollRef);
30
+
const headerAnimatedStyle = useAnimatedStyle(() => ({
31
+
transform: [
32
+
{
33
+
translateY: interpolate(
34
+
scrollOffset.value,
35
+
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
36
+
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
37
+
),
38
+
},
39
+
{
40
+
scale: interpolate(
41
+
scrollOffset.value,
42
+
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
43
+
[2, 1, 1],
44
+
),
45
+
},
46
+
],
47
+
}));
48
49
return (
50
<Animated.ScrollView
51
ref={scrollRef}
52
style={{ backgroundColor, flex: 1 }}
53
+
scrollEventThrottle={16}
54
+
>
55
<Animated.View
56
style={[
57
styles.header,
58
{ backgroundColor: headerBackgroundColor[colorScheme] },
59
headerAnimatedStyle,
60
+
]}
61
+
>
62
{headerImage}
63
</Animated.View>
64
<ThemedView style={styles.content}>{children}</ThemedView>
···
72
},
73
header: {
74
height: HEADER_HEIGHT,
75
+
overflow: "hidden",
76
},
77
content: {
78
flex: 1,
79
padding: 32,
80
gap: 16,
81
+
overflow: "hidden",
82
},
83
});
+6
app/components/theme/index.tsx
+6
app/components/theme/index.tsx
+26
app/components/theme/themed-error-box.tsx
+26
app/components/theme/themed-error-box.tsx
···
···
1
+
import { StyleSheet } from "react-native";
2
+
import { ThemedView } from "./themed-view";
3
+
import { wrap, MaybeArray } from "@/services/utils";
4
+
import { ThemedError } from "./themed-error";
5
+
6
+
interface ThemedErrorBoxProps {
7
+
errors?: MaybeArray<Error | string>;
8
+
}
9
+
10
+
export const ThemedErrorBox = ({ errors }: ThemedErrorBoxProps) => {
11
+
return errors && wrap(errors).length > 0 ? (
12
+
<ThemedView style={styles.container}>
13
+
{wrap(errors)?.map((error, index) => (
14
+
<ThemedError key={`${error}-${index}`}>
15
+
{error instanceof Error ? error.message : error}
16
+
</ThemedError>
17
+
))}
18
+
</ThemedView>
19
+
) : null;
20
+
};
21
+
22
+
const styles = StyleSheet.create({
23
+
container: {
24
+
padding: 8,
25
+
},
26
+
});
+27
app/components/theme/themed-error.tsx
+27
app/components/theme/themed-error.tsx
···
···
1
+
import { StyleSheet } from "react-native";
2
+
import { ThemedText } from "./themed-text";
3
+
import { useThemeColor } from "@/hooks/use-theme-color";
4
+
5
+
interface ThemedErrorProps {
6
+
children?: string | null;
7
+
}
8
+
9
+
export const ThemedError = ({ children }: ThemedErrorProps) => {
10
+
const textColor = useThemeColor({}, "red");
11
+
12
+
return children ? (
13
+
<ThemedText
14
+
type="small"
15
+
style={styles.text}
16
+
lightColor={textColor}
17
+
darkColor={textColor}
18
+
>
19
+
{children}
20
+
</ThemedText>
21
+
) : null;
22
+
};
23
+
24
+
const styles = StyleSheet.create({
25
+
container: {},
26
+
text: {},
27
+
});
+62
app/components/theme/themed-text-input.tsx
+62
app/components/theme/themed-text-input.tsx
···
···
1
+
import { StyleSheet, TextInput } from "react-native";
2
+
import { ThemedView } from "./themed-view";
3
+
import { ThemedErrorBox } from "./themed-error-box";
4
+
import { useThemeColor } from "@/hooks/use-theme-color";
5
+
6
+
interface ThemedTextInputProps {
7
+
// see https://reactnative.dev/docs/textinput#autocomplete
8
+
autoComplete?: "current-password" | "email";
9
+
// see https://reactnative.dev/docs/textinput#enterKeyHint
10
+
nextHint?: "next" | "done" | "search" | "go";
11
+
onChange: (value: string) => void | Promise<void>;
12
+
placeholder: string;
13
+
value?: string;
14
+
disabled?: boolean;
15
+
type?: "text" | "password";
16
+
error?: string | string[];
17
+
}
18
+
19
+
export const ThemedTextInput = ({
20
+
disabled,
21
+
nextHint,
22
+
onChange,
23
+
placeholder,
24
+
type,
25
+
value,
26
+
error,
27
+
}: ThemedTextInputProps) => {
28
+
const borderColor = useThemeColor({}, error?.length ? "red" : "text");
29
+
30
+
const textColor = useThemeColor({}, "text");
31
+
32
+
return (
33
+
<ThemedView style={[styles.inputContainer]}>
34
+
<TextInput
35
+
autoCapitalize="none"
36
+
enterKeyHint={nextHint}
37
+
onChangeText={onChange}
38
+
placeholder={placeholder}
39
+
placeholderTextColor="#888"
40
+
style={[styles.input, { color: textColor, borderColor }]}
41
+
value={value}
42
+
readOnly={disabled}
43
+
aria-disabled={disabled}
44
+
secureTextEntry={type === "password"}
45
+
/>
46
+
<ThemedErrorBox errors={error} />
47
+
</ThemedView>
48
+
);
49
+
};
50
+
51
+
const styles = StyleSheet.create({
52
+
input: {
53
+
borderRadius: 8,
54
+
padding: 8,
55
+
borderWidth: 1,
56
+
},
57
+
inputContainer: {
58
+
borderRadius: 8,
59
+
padding: 4,
60
+
marginVertical: 4,
61
+
},
62
+
});
+11
-2
app/components/themed-text.tsx
app/components/theme/themed-text.tsx
+11
-2
app/components/themed-text.tsx
app/components/theme/themed-text.tsx
···
2
3
import { useThemeColor } from '@/hooks/use-theme-color';
4
5
export type ThemedTextProps = TextProps & {
6
lightColor?: string;
7
darkColor?: string;
8
-
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
9
};
10
11
export function ThemedText({
12
style,
13
lightColor,
14
darkColor,
15
type = 'default',
16
...rest
17
}: ThemedTextProps) {
18
-
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
19
20
return (
21
<Text
···
26
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
27
type === 'subtitle' ? styles.subtitle : undefined,
28
type === 'link' ? styles.link : undefined,
29
style,
30
]}
31
{...rest}
···
37
default: {
38
fontSize: 16,
39
lineHeight: 24,
40
},
41
defaultSemiBold: {
42
fontSize: 16,
···
2
3
import { useThemeColor } from '@/hooks/use-theme-color';
4
5
+
export type ThemeColor = 'text' | 'red' | 'green'
6
+
7
export type ThemedTextProps = TextProps & {
8
+
color?: ThemeColor;
9
lightColor?: string;
10
darkColor?: string;
11
+
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link' | 'small';
12
};
13
14
export function ThemedText({
15
style,
16
+
color: textColor = 'text',
17
lightColor,
18
darkColor,
19
type = 'default',
20
...rest
21
}: ThemedTextProps) {
22
+
const color = useThemeColor({ light: lightColor, dark: darkColor }, textColor);
23
24
return (
25
<Text
···
30
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
31
type === 'subtitle' ? styles.subtitle : undefined,
32
type === 'link' ? styles.link : undefined,
33
+
type === 'small' ? styles.small: undefined,
34
style,
35
]}
36
{...rest}
···
42
default: {
43
fontSize: 16,
44
lineHeight: 24,
45
+
},
46
+
small: {
47
+
fontSize: 12,
48
+
lineHeight: 18,
49
},
50
defaultSemiBold: {
51
fontSize: 16,
app/components/themed-view.tsx
app/components/theme/themed-view.tsx
app/components/themed-view.tsx
app/components/theme/themed-view.tsx
+63
app/components/transaction/transaction-card.tsx
+63
app/components/transaction/transaction-card.tsx
···
···
1
+
import { StyleSheet, TouchableOpacity } from "react-native";
2
+
3
+
import { ThemedView, ThemedText } from "@/components/theme";
4
+
import { Price } from "@/components/ui/price";
5
+
import { Transaction } from "@/generated";
6
+
import Ionicons from "@expo/vector-icons/Ionicons";
7
+
import { mapTransactionTypeToIcon } from "@/services/utils";
8
+
9
+
interface TransactionCardProps {
10
+
transaction: Transaction;
11
+
onPress?: (transaction: Transaction) => Promise<void> | void;
12
+
}
13
+
14
+
export const TransactionCard = ({
15
+
onPress,
16
+
transaction,
17
+
}: TransactionCardProps) => {
18
+
const { description, amount, date, type } = transaction;
19
+
20
+
return (
21
+
<TouchableOpacity onPressOut={() => onPress?.(transaction)}>
22
+
<ThemedView style={styles.transactionContainer}>
23
+
<ThemedView style={[styles.transactionDetail]}>
24
+
<Ionicons size={20} name={mapTransactionTypeToIcon(type)} />
25
+
</ThemedView>
26
+
<ThemedView
27
+
style={[styles.transactionDetail, styles.descriptionDetail]}
28
+
>
29
+
<ThemedText>{description}</ThemedText>
30
+
<ThemedText type="small">{type}</ThemedText>
31
+
</ThemedView>
32
+
<ThemedView style={[styles.transactionDetail, styles.priceDetail]}>
33
+
<Price>{amount}</Price>
34
+
<ThemedText type="small">
35
+
{new Date(date).toLocaleDateString()}
36
+
</ThemedText>
37
+
</ThemedView>
38
+
</ThemedView>
39
+
</TouchableOpacity>
40
+
);
41
+
};
42
+
43
+
const styles = StyleSheet.create({
44
+
transactionContainer: {
45
+
display: "flex",
46
+
flexDirection: "row",
47
+
justifyContent: "space-between",
48
+
paddingVertical: 4,
49
+
gap: 4,
50
+
},
51
+
transactionDetail: {
52
+
flexDirection: "column",
53
+
justifyContent: "center",
54
+
paddingVertical: 2,
55
+
padding: 4,
56
+
},
57
+
descriptionDetail: {
58
+
flexGrow: 1,
59
+
},
60
+
priceDetail: {
61
+
alignItems: "flex-end",
62
+
},
63
+
});
+141
app/components/transaction/transaction-filter.tsx
+141
app/components/transaction/transaction-filter.tsx
···
···
1
+
import { useState } from "react";
2
+
import { StyleSheet, TouchableOpacity } from "react-native";
3
+
import { useQuery } from "@tanstack/react-query";
4
+
5
+
import {
6
+
ThemedText,
7
+
ThemedButton,
8
+
ThemedView,
9
+
ThemedTextInput,
10
+
} from "@/components/theme";
11
+
import { TransactionFilterOptions } from "@/hooks/use-transactions";
12
+
import { useThemeColor } from "@/hooks/use-theme-color";
13
+
import { useAccount } from "@/hooks/use-account";
14
+
import { getTransactionTypesOptions } from "@/generated/@tanstack/react-query.gen";
15
+
import Ionicons from "@expo/vector-icons/Ionicons";
16
+
import { mapTransactionTypeToIcon } from "@/services/utils";
17
+
18
+
interface TransactionFilterProps {
19
+
accountId?: number;
20
+
initialState?: TransactionFilterOptions;
21
+
onCancel?: () => void | Promise<void>;
22
+
onSubmit?: (filter: TransactionFilterOptions) => void | Promise<void>;
23
+
}
24
+
25
+
const sortDirections = ["asc", "desc"] as const;
26
+
27
+
export const TransactionFilter = ({
28
+
accountId,
29
+
initialState = {},
30
+
onCancel,
31
+
onSubmit,
32
+
}: TransactionFilterProps) => {
33
+
const color = useThemeColor({}, "text");
34
+
const [filter, setFilter] = useState<TransactionFilterOptions>(initialState);
35
+
const { account } = useAccount(accountId ?? 0);
36
+
const { data: transactionTypes } = useQuery(getTransactionTypesOptions());
37
+
38
+
return (
39
+
<ThemedView style={[styles.filterContainer, { borderColor: color }]}>
40
+
<ThemedView>
41
+
<ThemedText>filter transactions for </ThemedText>
42
+
<ThemedText type="defaultSemiBold">{account?.name}</ThemedText>
43
+
</ThemedView>
44
+
<ThemedView>
45
+
<ThemedText type="subtitle">Search by description</ThemedText>
46
+
<ThemedTextInput
47
+
value={filter.search}
48
+
placeholder="search"
49
+
onChange={(search) =>
50
+
setFilter((current: TransactionFilterOptions) => ({
51
+
...current,
52
+
search,
53
+
}))
54
+
}
55
+
/>
56
+
</ThemedView>
57
+
<ThemedView>
58
+
<ThemedText type="subtitle">Filter by transaction type</ThemedText>
59
+
{transactionTypes?.map(({ name, count }) => (
60
+
<TouchableOpacity
61
+
key={name}
62
+
onPress={() => setFilter((current) => ({ ...current, type: name }))}
63
+
>
64
+
<ThemedView
65
+
style={{
66
+
display: "flex",
67
+
flexDirection: "row",
68
+
alignItems: "center",
69
+
gap: 4,
70
+
}}
71
+
>
72
+
<ThemedView>
73
+
<Ionicons name={mapTransactionTypeToIcon(name)} />
74
+
</ThemedView>
75
+
<ThemedView>
76
+
<ThemedText
77
+
type={filter.type === name ? "defaultSemiBold" : "default"}
78
+
>
79
+
{name} ({count})
80
+
</ThemedText>
81
+
</ThemedView>
82
+
</ThemedView>
83
+
</TouchableOpacity>
84
+
))}
85
+
</ThemedView>
86
+
<ThemedView>
87
+
<ThemedText type="subtitle">Sort by</ThemedText>
88
+
{["amount", "date"]?.map((sort) => (
89
+
<TouchableOpacity
90
+
key={sort}
91
+
onPress={() => setFilter((current) => ({ ...current, sort }))}
92
+
>
93
+
<ThemedView>
94
+
<ThemedText
95
+
type={filter.sort === sort ? "defaultSemiBold" : "default"}
96
+
>
97
+
{sort}
98
+
</ThemedText>
99
+
</ThemedView>
100
+
</TouchableOpacity>
101
+
))}
102
+
</ThemedView>
103
+
<ThemedView>
104
+
<ThemedText type="subtitle">Sort direction</ThemedText>
105
+
{sortDirections?.map((order) => (
106
+
<TouchableOpacity
107
+
key={order}
108
+
onPress={() => setFilter((current) => ({ ...current, order }))}
109
+
>
110
+
<ThemedView>
111
+
<ThemedText
112
+
type={filter.order === order ? "defaultSemiBold" : "default"}
113
+
>
114
+
{order}
115
+
</ThemedText>
116
+
</ThemedView>
117
+
</TouchableOpacity>
118
+
))}
119
+
</ThemedView>
120
+
121
+
<ThemedView style={styles.buttonGroup}>
122
+
<ThemedButton onPress={() => onSubmit?.(filter)}>filter</ThemedButton>
123
+
<ThemedButton onPress={() => onSubmit?.({})}>clear</ThemedButton>
124
+
<ThemedButton onPress={() => onCancel?.()}>cancel</ThemedButton>
125
+
</ThemedView>
126
+
</ThemedView>
127
+
);
128
+
};
129
+
130
+
const styles = StyleSheet.create({
131
+
buttonGroup: {
132
+
display: "flex",
133
+
flexDirection: "row",
134
+
justifyContent: "space-around",
135
+
},
136
+
filterContainer: {
137
+
borderRadius: 8,
138
+
borderWidth: 1,
139
+
padding: 8,
140
+
},
141
+
});
+55
app/components/transaction/transaction-list.tsx
+55
app/components/transaction/transaction-list.tsx
···
···
1
+
import { FlashList } from "@shopify/flash-list";
2
+
import { RefreshControl, TouchableOpacity } from "react-native";
3
+
import { ThemedText } from "@/components/theme";
4
+
5
+
import { TransactionCard } from "./transaction-card";
6
+
7
+
import {
8
+
useTransactions,
9
+
TransactionFilterOptions,
10
+
} from "@/hooks/use-transactions";
11
+
12
+
interface TransactionListProps {
13
+
accountId?: number;
14
+
filter?: TransactionFilterOptions;
15
+
}
16
+
17
+
export const TransactionList = ({
18
+
accountId,
19
+
filter,
20
+
}: TransactionListProps) => {
21
+
const { transactions, isRefetching, refetch, hasNextPage, fetchNextPage } =
22
+
useTransactions(accountId, filter);
23
+
24
+
return (
25
+
<FlashList
26
+
nestedScrollEnabled={true}
27
+
data={(transactions?.pages ?? []).flatMap(({ data }) => data)}
28
+
keyExtractor={({ id }) => id.toString()}
29
+
refreshControl={
30
+
<RefreshControl
31
+
tintColor="blue"
32
+
refreshing={isRefetching}
33
+
onRefresh={refetch}
34
+
/>
35
+
}
36
+
ListFooterComponent={
37
+
hasNextPage ? (
38
+
<TouchableOpacity
39
+
onPress={() => fetchNextPage}
40
+
style={{ display: "flex" }}
41
+
>
42
+
<ThemedText>fetch more</ThemedText>
43
+
</TouchableOpacity>
44
+
) : null
45
+
}
46
+
renderItem={({ item }) => (
47
+
<TransactionCard key={`${item.id}`} transaction={item} />
48
+
)}
49
+
onEndReachedThreshold={0.95}
50
+
onEndReached={() => {
51
+
fetchNextPage();
52
+
}}
53
+
/>
54
+
);
55
+
};
+4
-3
app/components/ui/accountnumber.tsx
+4
-3
app/components/ui/accountnumber.tsx
···
1
import { TouchableOpacity } from "react-native";
2
-
import { ThemedText } from "../themed-text";
3
import { useToggle } from "@/hooks/use-toggle";
4
import { blurAccountNumber } from "@/services/blur-accountnumber";
5
import { PropsWithChildren } from "react";
6
-
import { ThemedView } from "../themed-view";
7
8
interface AccountNumberProps {
9
children: string;
10
hidden?: boolean;
11
}
12
13
export const AccountNumber = ({
14
children,
15
hidden = false,
16
}: AccountNumberProps) => {
17
const { isHidden, toggleHidden } = useToggle(true);
18
19
return (
20
<Wrapper onPress={toggleHidden} toggleable={!hidden}>
21
-
<ThemedText>
22
{isHidden ? blurAccountNumber(children.toString()) : children}
23
</ThemedText>
24
</Wrapper>
···
1
import { TouchableOpacity } from "react-native";
2
+
import { ThemedText, ThemedView } from "@/components/theme";
3
import { useToggle } from "@/hooks/use-toggle";
4
import { blurAccountNumber } from "@/services/blur-accountnumber";
5
import { PropsWithChildren } from "react";
6
7
interface AccountNumberProps {
8
children: string;
9
hidden?: boolean;
10
+
size?: "default" | "small";
11
}
12
13
export const AccountNumber = ({
14
children,
15
hidden = false,
16
+
size = "default",
17
}: AccountNumberProps) => {
18
const { isHidden, toggleHidden } = useToggle(true);
19
20
return (
21
<Wrapper onPress={toggleHidden} toggleable={!hidden}>
22
+
<ThemedText type={size}>
23
{isHidden ? blurAccountNumber(children.toString()) : children}
24
</ThemedText>
25
</Wrapper>
+17
-14
app/components/ui/collapsible.tsx
+17
-14
app/components/ui/collapsible.tsx
···
1
-
import { PropsWithChildren, useState } from 'react';
2
-
import { StyleSheet, TouchableOpacity } from 'react-native';
3
4
-
import { ThemedText } from '@/components/themed-text';
5
-
import { ThemedView } from '@/components/themed-view';
6
-
import { IconSymbol } from '@/components/ui/icon-symbol';
7
-
import { Colors } from '@/constants/theme';
8
-
import { useColorScheme } from '@/hooks/use-color-scheme';
9
10
-
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
11
const [isOpen, setIsOpen] = useState(false);
12
-
const theme = useColorScheme() ?? 'light';
13
14
return (
15
<ThemedView>
16
<TouchableOpacity
17
style={styles.heading}
18
onPress={() => setIsOpen((value) => !value)}
19
-
activeOpacity={0.8}>
20
<IconSymbol
21
name="chevron.right"
22
size={18}
23
weight="medium"
24
-
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
25
-
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
26
/>
27
28
<ThemedText type="defaultSemiBold">{title}</ThemedText>
···
34
35
const styles = StyleSheet.create({
36
heading: {
37
-
flexDirection: 'row',
38
-
alignItems: 'center',
39
gap: 6,
40
},
41
content: {
···
1
+
import { PropsWithChildren, useState } from "react";
2
+
import { StyleSheet, TouchableOpacity } from "react-native";
3
4
+
import { ThemedText, ThemedView } from "@/components/theme";
5
+
import { IconSymbol } from "@/components/ui/icon-symbol";
6
+
import { Colors } from "@/constants/theme";
7
+
import { useColorScheme } from "@/hooks/use-color-scheme";
8
9
+
export function Collapsible({
10
+
children,
11
+
title,
12
+
}: PropsWithChildren & { title: string }) {
13
const [isOpen, setIsOpen] = useState(false);
14
+
const theme = useColorScheme() ?? "light";
15
16
return (
17
<ThemedView>
18
<TouchableOpacity
19
style={styles.heading}
20
onPress={() => setIsOpen((value) => !value)}
21
+
activeOpacity={0.8}
22
+
>
23
<IconSymbol
24
name="chevron.right"
25
size={18}
26
weight="medium"
27
+
color={theme === "light" ? Colors.light.icon : Colors.dark.icon}
28
+
style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }}
29
/>
30
31
<ThemedText type="defaultSemiBold">{title}</ThemedText>
···
37
38
const styles = StyleSheet.create({
39
heading: {
40
+
flexDirection: "row",
41
+
alignItems: "center",
42
gap: 6,
43
},
44
content: {
+2
app/components/ui/icon-symbol.tsx
+2
app/components/ui/icon-symbol.tsx
+3
-2
app/components/ui/price.tsx
+3
-2
app/components/ui/price.tsx
···
1
+
import { ThemedText } from "@/components/theme";
2
3
interface PriceProps {
4
children: number;
···
7
8
export const Price = ({ children, currency = "€" }: PriceProps) => (
9
<ThemedText>
10
+
{children < 0 ? "- " : ""}
11
{currency}
12
+
{children.toFixed(2).replaceAll("-", "")}
13
</ThemedText>
14
);
+14
-10
app/constants/theme.ts
+14
-10
app/constants/theme.ts
···
1
import { Platform } from "react-native";
2
3
-
const tintColorLight = "#0a7ea4";
4
-
const tintColorDark = "#fff";
5
6
export const Colors = {
7
light: {
8
-
text: "#11181C",
9
-
background: "#fff",
10
tint: tintColorLight,
11
-
icon: "#687076",
12
-
tabIconDefault: "#687076",
13
tabIconSelected: tintColorLight,
14
},
15
dark: {
16
-
text: "#ECEDEE",
17
-
background: "#151718",
18
tint: tintColorDark,
19
-
icon: "#9BA1A6",
20
-
tabIconDefault: "#9BA1A6",
21
tabIconSelected: tintColorDark,
22
},
23
};
24
···
1
import { Platform } from "react-native";
2
3
+
const tintColorLight = "#1E66F5";
4
+
const tintColorDark = "#89B4FA";
5
6
export const Colors = {
7
light: {
8
+
text: "#4C4F69",
9
+
background: "#EFF1F5",
10
tint: tintColorLight,
11
+
icon: "#9CA0B0",
12
+
tabIconDefault: "#9CA0B0",
13
tabIconSelected: tintColorLight,
14
+
red: "#D20F39",
15
+
green: "#40A02B",
16
},
17
dark: {
18
+
text: "#CDD6F4",
19
+
background: "#1E1E2E",
20
tint: tintColorDark,
21
+
icon: "#6C7086",
22
+
tabIconDefault: "#6C7086",
23
tabIconSelected: tintColorDark,
24
+
red: "#F38BA8",
25
+
green: "#A6E3A1",
26
},
27
};
28
+14
app/hooks/use-account.ts
+14
app/hooks/use-account.ts
···
···
1
+
import { getAccountsByAccountIdOptions } from "@/generated/@tanstack/react-query.gen";
2
+
import { useQuery } from "@tanstack/react-query";
3
+
4
+
export const useAccount = (accountId: number) => {
5
+
const { data } = useQuery(
6
+
getAccountsByAccountIdOptions({
7
+
path: { accountId },
8
+
}),
9
+
);
10
+
11
+
return {
12
+
account: data,
13
+
};
14
+
};
+1
-1
app/hooks/use-authentication.ts
+1
-1
app/hooks/use-authentication.ts
+72
-12
app/hooks/use-login.ts
+72
-12
app/hooks/use-login.ts
···
1
-
import { useRouter } from "expo-router";
2
import { useMutation } from "@tanstack/react-query";
3
-
import { LoginRequest, postLogin } from "@/generated";
4
import { useToken } from "@/providers/token-provider";
5
-
import { useEffect } from "react";
6
7
export const useLogin = () => {
8
-
const { token, setToken } = useToken();
9
const router = useRouter();
10
-
const { mutateAsync } = useMutation({
11
-
mutationFn: (credentials: LoginRequest) => postLogin({ body: credentials }),
12
});
13
14
useEffect(() => {
···
17
}
18
}, [token, router]);
19
20
-
return {
21
-
login: (credentials: LoginRequest) =>
22
-
mutateAsync(credentials).then(({ data }) => {
23
-
if (data) {
24
-
setToken(data);
25
}
26
-
}),
27
};
28
};
···
1
+
import { useState, useEffect, useCallback } from "react";
2
import { useMutation } from "@tanstack/react-query";
3
+
import { LoginRequest, LoginError, postLogin } from "@/generated";
4
+
import { z } from "zod";
5
import { useToken } from "@/providers/token-provider";
6
+
import { useRouter } from "expo-router";
7
+
8
+
const FieldErrorSchema = z.object({
9
+
errors: z.array(z.string()),
10
+
});
11
+
12
+
const LoginErrorSchema = z.object({
13
+
errors: z.array(z.string()),
14
+
properties: z
15
+
.object({
16
+
username: FieldErrorSchema.optional(),
17
+
password: FieldErrorSchema.optional(),
18
+
})
19
+
.optional(),
20
+
});
21
+
22
+
const isLoginError = (e: unknown): e is LoginError => {
23
+
const { success } = LoginErrorSchema.safeParse(e);
24
+
return success;
25
+
};
26
+
27
+
const fromError = (error: unknown): LoginError => ({
28
+
errors: [error instanceof Error ? error.message : "unknown error"],
29
+
properties: {},
30
+
});
31
32
export const useLogin = () => {
33
+
const { setToken, token } = useToken();
34
const router = useRouter();
35
+
const [error, setError] = useState<null | LoginError>(null);
36
+
37
+
const {
38
+
isPending,
39
+
mutateAsync,
40
+
error: mutationError,
41
+
data,
42
+
} = useMutation({
43
+
mutationFn: (body: LoginRequest) => {
44
+
setError(null);
45
+
46
+
return postLogin({ body });
47
+
},
48
});
49
50
useEffect(() => {
···
53
}
54
}, [token, router]);
55
56
+
useEffect(() => {
57
+
if (mutationError) {
58
+
setError(fromError(mutationError));
59
+
}
60
+
}, [mutationError, setError]);
61
+
62
+
const login = useCallback(
63
+
async (request: LoginRequest) => {
64
+
try {
65
+
const { data, error } = await mutateAsync(request);
66
+
67
+
const realError = data && "error" in data ? data.error : error;
68
+
69
+
if (realError || !data) {
70
+
throw realError ?? "no data";
71
}
72
+
73
+
setToken(data);
74
+
router.replace("/(tabs)");
75
+
} catch (e) {
76
+
setError(isLoginError(e) ? e : fromError(e));
77
+
}
78
+
},
79
+
[mutateAsync, setToken, router],
80
+
);
81
+
82
+
return {
83
+
data,
84
+
error,
85
+
loading: isPending,
86
+
login,
87
};
88
};
+7
app/hooks/use-rate-color.ts
+7
app/hooks/use-rate-color.ts
+5
-9
app/hooks/use-theme-color.ts
+5
-9
app/hooks/use-theme-color.ts
···
3
* https://docs.expo.dev/guides/color-schemes/
4
*/
5
6
-
import { Colors } from '@/constants/theme';
7
-
import { useColorScheme } from '@/hooks/use-color-scheme';
8
9
export function useThemeColor(
10
props: { light?: string; dark?: string },
11
-
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
12
) {
13
-
const theme = useColorScheme() ?? 'light';
14
const colorFromProps = props[theme];
15
16
-
if (colorFromProps) {
17
-
return colorFromProps;
18
-
} else {
19
-
return Colors[theme][colorName];
20
-
}
21
}
···
3
* https://docs.expo.dev/guides/color-schemes/
4
*/
5
6
+
import { Colors } from "@/constants/theme";
7
+
import { useColorScheme } from "@/hooks/use-color-scheme";
8
9
export function useThemeColor(
10
props: { light?: string; dark?: string },
11
+
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
12
) {
13
+
const theme = useColorScheme() ?? "light";
14
const colorFromProps = props[theme];
15
16
+
return colorFromProps || Colors[theme][colorName];
17
}
+35
app/hooks/use-transactions.ts
+35
app/hooks/use-transactions.ts
···
···
1
+
import { useInfiniteQuery } from "@tanstack/react-query";
2
+
import { getTransactionsInfiniteOptions } from "@/generated/@tanstack/react-query.gen";
3
+
import { GetTransactionsData } from "@/generated";
4
+
5
+
export type TransactionFilterOptions = Omit<
6
+
Exclude<GetTransactionsData["query"], undefined>,
7
+
"accountId" | "page" | "limit"
8
+
>;
9
+
10
+
export const useTransactions = (
11
+
accountId?: number,
12
+
filter: TransactionFilterOptions = {},
13
+
) => {
14
+
const {
15
+
data: transactions,
16
+
fetchNextPage,
17
+
hasNextPage,
18
+
isRefetching,
19
+
refetch,
20
+
} = useInfiniteQuery({
21
+
...getTransactionsInfiniteOptions({
22
+
query: { accountId, ...filter },
23
+
}),
24
+
getNextPageParam: ({ meta: { hasMore, page } }) =>
25
+
hasMore ? page + 1 : undefined,
26
+
});
27
+
28
+
return {
29
+
fetchNextPage,
30
+
hasNextPage,
31
+
isRefetching,
32
+
refetch,
33
+
transactions,
34
+
};
35
+
};
+12
app/package-lock.json
+12
app/package-lock.json
···
14
"@react-navigation/bottom-tabs": "^7.4.0",
15
"@react-navigation/elements": "^2.6.3",
16
"@react-navigation/native": "^7.1.8",
17
"@tanstack/react-query": "^5.90.2",
18
"expo": "~54.0.10",
19
"expo-constants": "~18.0.9",
···
3679
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
3680
"dev": true,
3681
"license": "MIT"
3682
},
3683
"node_modules/@sinclair/typebox": {
3684
"version": "0.27.8",
···
14
"@react-navigation/bottom-tabs": "^7.4.0",
15
"@react-navigation/elements": "^2.6.3",
16
"@react-navigation/native": "^7.1.8",
17
+
"@shopify/flash-list": "^2.1.0",
18
"@tanstack/react-query": "^5.90.2",
19
"expo": "~54.0.10",
20
"expo-constants": "~18.0.9",
···
3680
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
3681
"dev": true,
3682
"license": "MIT"
3683
+
},
3684
+
"node_modules/@shopify/flash-list": {
3685
+
"version": "2.1.0",
3686
+
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.1.0.tgz",
3687
+
"integrity": "sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg==",
3688
+
"license": "MIT",
3689
+
"peerDependencies": {
3690
+
"@babel/runtime": "*",
3691
+
"react": "*",
3692
+
"react-native": "*"
3693
+
}
3694
},
3695
"node_modules/@sinclair/typebox": {
3696
"version": "0.27.8",
+1
app/package.json
+1
app/package.json
-20
app/providers/auth-provider.tsx
-20
app/providers/auth-provider.tsx
···
1
-
import { postLogin } from "@/generated";
2
-
import { useQuery, } from "@tanstack/react-query";
3
-
import { createContext, PropsWithChildren } from "react"
4
-
5
-
const AuthContext = createContext(null);
6
-
7
-
type AuthProviderProps = PropsWithChildren
8
-
9
-
export const AuthProvider = ({ children }: AuthProviderProps) => {
10
-
11
-
const { data } = useQuery({
12
-
queryKey: ['auth'],
13
-
queryFn: () => postLogin({ body: { username: 'test@test.test', password: 'test@123' } }),
14
-
staleTime: 1000 * 60 * 5, // 5 minutes
15
-
})
16
-
17
-
console.log(data)
18
-
19
-
return <AuthContext.Provider value={null}>{children}</AuthContext.Provider>
20
-
}
···
+4
-7
app/providers/query-client-provider.tsx
+4
-7
app/providers/query-client-provider.tsx
···
15
16
type MiddlewareParams = {
17
token: TokenPair;
18
-
onRefresh: (token: TokenPair | null) => void;
19
};
20
21
const createMiddleware =
22
-
({ token, onRefresh }: MiddlewareParams) =>
23
async (request: Request) => {
24
-
const freshToken = await refreshToken({ token, onRefresh });
25
26
if (freshToken) {
27
// TODO: it would be nice to have an AbortController here
···
32
};
33
34
export const QueryClientProvider = ({ children }: PropsWithChildren) => {
35
-
const { token, setToken } = useToken();
36
37
useEffect(() => {
38
if (!token) {
39
return;
40
}
41
42
-
const id = client.interceptors.request.use(
43
-
createMiddleware({ token, onRefresh: setToken }),
44
-
);
45
46
return () => client.interceptors.request.eject(id);
47
});
···
15
16
type MiddlewareParams = {
17
token: TokenPair;
18
};
19
20
const createMiddleware =
21
+
({ token }: MiddlewareParams) =>
22
async (request: Request) => {
23
+
const freshToken = await refreshToken({ token });
24
25
if (freshToken) {
26
// TODO: it would be nice to have an AbortController here
···
31
};
32
33
export const QueryClientProvider = ({ children }: PropsWithChildren) => {
34
+
const { token } = useToken();
35
36
useEffect(() => {
37
if (!token) {
38
return;
39
}
40
41
+
const id = client.interceptors.request.use(createMiddleware({ token }));
42
43
return () => client.interceptors.request.eject(id);
44
});
+5
-3
app/providers/token-provider.tsx
+5
-3
app/providers/token-provider.tsx
···
50
(async () => {
51
const t = await SecureStore.getItemAsync(tokenSymbol);
52
const state = t ? tokenSchema.parse(JSON.parse(t)) : null;
53
+
const fresh = state ? await refreshToken({ token: state }) : null;
54
+
55
+
if (fresh?.accessToken !== state?.accessToken) {
56
+
setToken(fresh);
57
+
}
58
59
setTokenState(fresh);
60
setReady(true);
-3
app/services/token-service.ts
-3
app/services/token-service.ts
···
4
5
interface TokenServiceParams {
6
token: TokenPair;
7
-
onRefresh: (token: MaybeToken) => void | Promise<void>;
8
}
9
10
export const refreshToken = async ({
11
token,
12
-
onRefresh,
13
}: TokenServiceParams): Promise<MaybeToken> => {
14
const { expires, refreshToken } = token;
15
···
19
20
const refreshed = await postRefreshToken({ body: { refreshToken } });
21
const newToken = refreshed?.data ?? null;
22
-
onRefresh(newToken);
23
24
return newToken;
25
};
···
4
5
interface TokenServiceParams {
6
token: TokenPair;
7
}
8
9
export const refreshToken = async ({
10
token,
11
}: TokenServiceParams): Promise<MaybeToken> => {
12
const { expires, refreshToken } = token;
13
···
17
18
const refreshed = await postRefreshToken({ body: { refreshToken } });
19
const newToken = refreshed?.data ?? null;
20
21
return newToken;
22
};
+18
app/services/utils.ts
+18
app/services/utils.ts
···
···
1
+
import Ionicons from "@expo/vector-icons/Ionicons";
2
+
3
+
export type MaybeArray<T> = T | T[];
4
+
5
+
export const wrap = <T>(value: MaybeArray<T>): T[] =>
6
+
Array.isArray(value) ? value : [value];
7
+
8
+
type IconName = keyof (typeof Ionicons)["glyphMap"];
9
+
10
+
const mapping: Record<string, IconName> = {
11
+
deposit: "bonfire-sharp",
12
+
invoice: "document",
13
+
payment: "compass",
14
+
withdrawal: "caret-down",
15
+
};
16
+
17
+
export const mapTransactionTypeToIcon = (value: string): IconName =>
18
+
mapping[value];
-13
package-lock.json
-13
package-lock.json
+1
-1
server/Dockerfile
+1
-1
server/Dockerfile
+374
-134
server/package-lock.json
+374
-134
server/package-lock.json
···
12
"bcryptjs": "2.4.3",
13
"body-parser": "^1.20.3",
14
"cors": "^2.8.5",
15
-
"express": "4.21.2",
16
"express-openapi-validator": "^5.6.0",
17
"jsonwebtoken": "9.0.2",
18
"sqlite-async": "ndp/sqlite-async#13-typescript",
···
3244
"optional": true
3245
},
3246
"node_modules/accepts": {
3247
-
"version": "1.3.8",
3248
-
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
3249
-
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
3250
"license": "MIT",
3251
"dependencies": {
3252
-
"mime-types": "~2.1.34",
3253
-
"negotiator": "0.6.3"
3254
},
3255
"engines": {
3256
"node": ">= 0.6"
3257
}
···
3470
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
3471
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
3472
"license": "Python-2.0"
3473
-
},
3474
-
"node_modules/array-flatten": {
3475
-
"version": "1.1.1",
3476
-
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
3477
-
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
3478
-
"license": "MIT"
3479
},
3480
"node_modules/asap": {
3481
"version": "2.0.6",
···
4242
"optional": true
4243
},
4244
"node_modules/content-disposition": {
4245
-
"version": "0.5.4",
4246
-
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
4247
-
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
4248
"license": "MIT",
4249
"dependencies": {
4250
"safe-buffer": "5.2.1"
···
4279
}
4280
},
4281
"node_modules/cookie-signature": {
4282
-
"version": "1.0.6",
4283
-
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
4284
-
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
4285
-
"license": "MIT"
4286
},
4287
"node_modules/cookiejar": {
4288
"version": "2.1.4",
···
4795
}
4796
},
4797
"node_modules/express": {
4798
-
"version": "4.21.2",
4799
-
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
4800
-
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
4801
"license": "MIT",
4802
"dependencies": {
4803
-
"accepts": "~1.3.8",
4804
-
"array-flatten": "1.1.1",
4805
-
"body-parser": "1.20.3",
4806
-
"content-disposition": "0.5.4",
4807
-
"content-type": "~1.0.4",
4808
-
"cookie": "0.7.1",
4809
-
"cookie-signature": "1.0.6",
4810
-
"debug": "2.6.9",
4811
-
"depd": "2.0.0",
4812
-
"encodeurl": "~2.0.0",
4813
-
"escape-html": "~1.0.3",
4814
-
"etag": "~1.8.1",
4815
-
"finalhandler": "1.3.1",
4816
-
"fresh": "0.5.2",
4817
-
"http-errors": "2.0.0",
4818
-
"merge-descriptors": "1.0.3",
4819
-
"methods": "~1.1.2",
4820
-
"on-finished": "2.4.1",
4821
-
"parseurl": "~1.3.3",
4822
-
"path-to-regexp": "0.1.12",
4823
-
"proxy-addr": "~2.0.7",
4824
-
"qs": "6.13.0",
4825
-
"range-parser": "~1.2.1",
4826
-
"safe-buffer": "5.2.1",
4827
-
"send": "0.19.0",
4828
-
"serve-static": "1.16.2",
4829
-
"setprototypeof": "1.2.0",
4830
-
"statuses": "2.0.1",
4831
-
"type-is": "~1.6.18",
4832
-
"utils-merge": "1.0.1",
4833
-
"vary": "~1.1.2"
4834
},
4835
"engines": {
4836
-
"node": ">= 0.10.0"
4837
},
4838
"funding": {
4839
"type": "opencollective",
···
4892
"node": ">= 0.8"
4893
}
4894
},
4895
-
"node_modules/express-openapi-validator/node_modules/path-to-regexp": {
4896
-
"version": "8.3.0",
4897
-
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
4898
-
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
4899
"license": "MIT",
4900
-
"funding": {
4901
-
"type": "opencollective",
4902
-
"url": "https://opencollective.com/express"
4903
}
4904
},
4905
-
"node_modules/express-openapi-validator/node_modules/qs": {
4906
"version": "6.14.0",
4907
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
4908
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
···
4917
"url": "https://github.com/sponsors/ljharb"
4918
}
4919
},
4920
"node_modules/faker": {
4921
"version": "6.6.6",
4922
"resolved": "https://registry.npmjs.org/faker/-/faker-6.6.6.tgz",
···
4990
}
4991
},
4992
"node_modules/finalhandler": {
4993
-
"version": "1.3.1",
4994
-
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
4995
-
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
4996
"license": "MIT",
4997
"dependencies": {
4998
-
"debug": "2.6.9",
4999
-
"encodeurl": "~2.0.0",
5000
-
"escape-html": "~1.0.3",
5001
-
"on-finished": "2.4.1",
5002
-
"parseurl": "~1.3.3",
5003
-
"statuses": "2.0.1",
5004
-
"unpipe": "~1.0.0"
5005
},
5006
"engines": {
5007
"node": ">= 0.8"
5008
}
5009
},
5010
"node_modules/find-up": {
5011
"version": "4.1.0",
5012
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
···
5066
}
5067
},
5068
"node_modules/fresh": {
5069
-
"version": "0.5.2",
5070
-
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
5071
-
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
5072
"license": "MIT",
5073
"engines": {
5074
-
"node": ">= 0.6"
5075
}
5076
},
5077
"node_modules/fs-constants": {
···
5760
"engines": {
5761
"node": ">=0.12.0"
5762
}
5763
},
5764
"node_modules/is-stream": {
5765
"version": "2.0.1",
···
6856
}
6857
},
6858
"node_modules/merge-descriptors": {
6859
-
"version": "1.0.3",
6860
-
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
6861
-
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
6862
"license": "MIT",
6863
"funding": {
6864
"url": "https://github.com/sponsors/sindresorhus"
6865
}
···
6875
"version": "1.1.2",
6876
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
6877
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
6878
"license": "MIT",
6879
"engines": {
6880
"node": ">= 0.6"
···
6894
"node": ">=8.6"
6895
}
6896
},
6897
-
"node_modules/mime": {
6898
-
"version": "1.6.0",
6899
-
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
6900
-
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
6901
-
"license": "MIT",
6902
-
"bin": {
6903
-
"mime": "cli.js"
6904
-
},
6905
-
"engines": {
6906
-
"node": ">=4"
6907
-
}
6908
-
},
6909
"node_modules/mime-db": {
6910
"version": "1.52.0",
6911
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
···
7138
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
7139
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
7140
"license": "MIT",
7141
"engines": {
7142
"node": ">= 0.6"
7143
}
···
7634
"license": "MIT"
7635
},
7636
"node_modules/path-to-regexp": {
7637
-
"version": "0.1.12",
7638
-
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
7639
-
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
7640
-
"license": "MIT"
7641
},
7642
"node_modules/picocolors": {
7643
"version": "1.1.1",
···
8083
"url": "https://github.com/sponsors/isaacs"
8084
}
8085
},
8086
"node_modules/safe-buffer": {
8087
"version": "5.2.1",
8088
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
···
8122
}
8123
},
8124
"node_modules/send": {
8125
-
"version": "0.19.0",
8126
-
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
8127
-
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
8128
"license": "MIT",
8129
"dependencies": {
8130
-
"debug": "2.6.9",
8131
-
"depd": "2.0.0",
8132
-
"destroy": "1.2.0",
8133
-
"encodeurl": "~1.0.2",
8134
-
"escape-html": "~1.0.3",
8135
-
"etag": "~1.8.1",
8136
-
"fresh": "0.5.2",
8137
-
"http-errors": "2.0.0",
8138
-
"mime": "1.6.0",
8139
-
"ms": "2.1.3",
8140
-
"on-finished": "2.4.1",
8141
-
"range-parser": "~1.2.1",
8142
-
"statuses": "2.0.1"
8143
},
8144
"engines": {
8145
-
"node": ">= 0.8.0"
8146
}
8147
},
8148
-
"node_modules/send/node_modules/encodeurl": {
8149
-
"version": "1.0.2",
8150
-
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
8151
-
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
8152
"license": "MIT",
8153
"engines": {
8154
-
"node": ">= 0.8"
8155
}
8156
},
8157
"node_modules/send/node_modules/ms": {
···
8161
"license": "MIT"
8162
},
8163
"node_modules/serve-static": {
8164
-
"version": "1.16.2",
8165
-
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
8166
-
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
8167
"license": "MIT",
8168
"dependencies": {
8169
-
"encodeurl": "~2.0.0",
8170
-
"escape-html": "~1.0.3",
8171
-
"parseurl": "~1.3.3",
8172
-
"send": "0.19.0"
8173
},
8174
"engines": {
8175
-
"node": ">= 0.8.0"
8176
}
8177
},
8178
"node_modules/set-blocking": {
···
9139
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
9140
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
9141
"license": "MIT"
9142
-
},
9143
-
"node_modules/utils-merge": {
9144
-
"version": "1.0.1",
9145
-
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
9146
-
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
9147
-
"license": "MIT",
9148
-
"engines": {
9149
-
"node": ">= 0.4.0"
9150
-
}
9151
},
9152
"node_modules/v8-compile-cache-lib": {
9153
"version": "3.0.1",
···
12
"bcryptjs": "2.4.3",
13
"body-parser": "^1.20.3",
14
"cors": "^2.8.5",
15
+
"express": "^5.1.0",
16
"express-openapi-validator": "^5.6.0",
17
"jsonwebtoken": "9.0.2",
18
"sqlite-async": "ndp/sqlite-async#13-typescript",
···
3244
"optional": true
3245
},
3246
"node_modules/accepts": {
3247
+
"version": "2.0.0",
3248
+
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
3249
+
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
3250
"license": "MIT",
3251
"dependencies": {
3252
+
"mime-types": "^3.0.0",
3253
+
"negotiator": "^1.0.0"
3254
},
3255
+
"engines": {
3256
+
"node": ">= 0.6"
3257
+
}
3258
+
},
3259
+
"node_modules/accepts/node_modules/mime-db": {
3260
+
"version": "1.54.0",
3261
+
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
3262
+
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
3263
+
"license": "MIT",
3264
+
"engines": {
3265
+
"node": ">= 0.6"
3266
+
}
3267
+
},
3268
+
"node_modules/accepts/node_modules/mime-types": {
3269
+
"version": "3.0.1",
3270
+
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
3271
+
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
3272
+
"license": "MIT",
3273
+
"dependencies": {
3274
+
"mime-db": "^1.54.0"
3275
+
},
3276
+
"engines": {
3277
+
"node": ">= 0.6"
3278
+
}
3279
+
},
3280
+
"node_modules/accepts/node_modules/negotiator": {
3281
+
"version": "1.0.0",
3282
+
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
3283
+
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
3284
+
"license": "MIT",
3285
"engines": {
3286
"node": ">= 0.6"
3287
}
···
3500
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
3501
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
3502
"license": "Python-2.0"
3503
},
3504
"node_modules/asap": {
3505
"version": "2.0.6",
···
4266
"optional": true
4267
},
4268
"node_modules/content-disposition": {
4269
+
"version": "1.0.0",
4270
+
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
4271
+
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
4272
"license": "MIT",
4273
"dependencies": {
4274
"safe-buffer": "5.2.1"
···
4303
}
4304
},
4305
"node_modules/cookie-signature": {
4306
+
"version": "1.2.2",
4307
+
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
4308
+
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
4309
+
"license": "MIT",
4310
+
"engines": {
4311
+
"node": ">=6.6.0"
4312
+
}
4313
},
4314
"node_modules/cookiejar": {
4315
"version": "2.1.4",
···
4822
}
4823
},
4824
"node_modules/express": {
4825
+
"version": "5.1.0",
4826
+
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
4827
+
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
4828
"license": "MIT",
4829
"dependencies": {
4830
+
"accepts": "^2.0.0",
4831
+
"body-parser": "^2.2.0",
4832
+
"content-disposition": "^1.0.0",
4833
+
"content-type": "^1.0.5",
4834
+
"cookie": "^0.7.1",
4835
+
"cookie-signature": "^1.2.1",
4836
+
"debug": "^4.4.0",
4837
+
"encodeurl": "^2.0.0",
4838
+
"escape-html": "^1.0.3",
4839
+
"etag": "^1.8.1",
4840
+
"finalhandler": "^2.1.0",
4841
+
"fresh": "^2.0.0",
4842
+
"http-errors": "^2.0.0",
4843
+
"merge-descriptors": "^2.0.0",
4844
+
"mime-types": "^3.0.0",
4845
+
"on-finished": "^2.4.1",
4846
+
"once": "^1.4.0",
4847
+
"parseurl": "^1.3.3",
4848
+
"proxy-addr": "^2.0.7",
4849
+
"qs": "^6.14.0",
4850
+
"range-parser": "^1.2.1",
4851
+
"router": "^2.2.0",
4852
+
"send": "^1.1.0",
4853
+
"serve-static": "^2.2.0",
4854
+
"statuses": "^2.0.1",
4855
+
"type-is": "^2.0.1",
4856
+
"vary": "^1.1.2"
4857
},
4858
"engines": {
4859
+
"node": ">= 18"
4860
},
4861
"funding": {
4862
"type": "opencollective",
···
4915
"node": ">= 0.8"
4916
}
4917
},
4918
+
"node_modules/express-openapi-validator/node_modules/qs": {
4919
+
"version": "6.14.0",
4920
+
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
4921
+
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
4922
+
"license": "BSD-3-Clause",
4923
+
"dependencies": {
4924
+
"side-channel": "^1.1.0"
4925
+
},
4926
+
"engines": {
4927
+
"node": ">=0.6"
4928
+
},
4929
+
"funding": {
4930
+
"url": "https://github.com/sponsors/ljharb"
4931
+
}
4932
+
},
4933
+
"node_modules/express/node_modules/body-parser": {
4934
+
"version": "2.2.0",
4935
+
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
4936
+
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
4937
+
"license": "MIT",
4938
+
"dependencies": {
4939
+
"bytes": "^3.1.2",
4940
+
"content-type": "^1.0.5",
4941
+
"debug": "^4.4.0",
4942
+
"http-errors": "^2.0.0",
4943
+
"iconv-lite": "^0.6.3",
4944
+
"on-finished": "^2.4.1",
4945
+
"qs": "^6.14.0",
4946
+
"raw-body": "^3.0.0",
4947
+
"type-is": "^2.0.0"
4948
+
},
4949
+
"engines": {
4950
+
"node": ">=18"
4951
+
}
4952
+
},
4953
+
"node_modules/express/node_modules/debug": {
4954
+
"version": "4.4.3",
4955
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
4956
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
4957
"license": "MIT",
4958
+
"dependencies": {
4959
+
"ms": "^2.1.3"
4960
+
},
4961
+
"engines": {
4962
+
"node": ">=6.0"
4963
+
},
4964
+
"peerDependenciesMeta": {
4965
+
"supports-color": {
4966
+
"optional": true
4967
+
}
4968
}
4969
},
4970
+
"node_modules/express/node_modules/iconv-lite": {
4971
+
"version": "0.6.3",
4972
+
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
4973
+
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
4974
+
"license": "MIT",
4975
+
"dependencies": {
4976
+
"safer-buffer": ">= 2.1.2 < 3.0.0"
4977
+
},
4978
+
"engines": {
4979
+
"node": ">=0.10.0"
4980
+
}
4981
+
},
4982
+
"node_modules/express/node_modules/media-typer": {
4983
+
"version": "1.1.0",
4984
+
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
4985
+
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
4986
+
"license": "MIT",
4987
+
"engines": {
4988
+
"node": ">= 0.8"
4989
+
}
4990
+
},
4991
+
"node_modules/express/node_modules/mime-db": {
4992
+
"version": "1.54.0",
4993
+
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
4994
+
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
4995
+
"license": "MIT",
4996
+
"engines": {
4997
+
"node": ">= 0.6"
4998
+
}
4999
+
},
5000
+
"node_modules/express/node_modules/mime-types": {
5001
+
"version": "3.0.1",
5002
+
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
5003
+
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
5004
+
"license": "MIT",
5005
+
"dependencies": {
5006
+
"mime-db": "^1.54.0"
5007
+
},
5008
+
"engines": {
5009
+
"node": ">= 0.6"
5010
+
}
5011
+
},
5012
+
"node_modules/express/node_modules/ms": {
5013
+
"version": "2.1.3",
5014
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
5015
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
5016
+
"license": "MIT"
5017
+
},
5018
+
"node_modules/express/node_modules/qs": {
5019
"version": "6.14.0",
5020
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
5021
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
···
5030
"url": "https://github.com/sponsors/ljharb"
5031
}
5032
},
5033
+
"node_modules/express/node_modules/raw-body": {
5034
+
"version": "3.0.1",
5035
+
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
5036
+
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
5037
+
"license": "MIT",
5038
+
"dependencies": {
5039
+
"bytes": "3.1.2",
5040
+
"http-errors": "2.0.0",
5041
+
"iconv-lite": "0.7.0",
5042
+
"unpipe": "1.0.0"
5043
+
},
5044
+
"engines": {
5045
+
"node": ">= 0.10"
5046
+
}
5047
+
},
5048
+
"node_modules/express/node_modules/raw-body/node_modules/iconv-lite": {
5049
+
"version": "0.7.0",
5050
+
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
5051
+
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
5052
+
"license": "MIT",
5053
+
"dependencies": {
5054
+
"safer-buffer": ">= 2.1.2 < 3.0.0"
5055
+
},
5056
+
"engines": {
5057
+
"node": ">=0.10.0"
5058
+
},
5059
+
"funding": {
5060
+
"type": "opencollective",
5061
+
"url": "https://opencollective.com/express"
5062
+
}
5063
+
},
5064
+
"node_modules/express/node_modules/type-is": {
5065
+
"version": "2.0.1",
5066
+
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
5067
+
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
5068
+
"license": "MIT",
5069
+
"dependencies": {
5070
+
"content-type": "^1.0.5",
5071
+
"media-typer": "^1.1.0",
5072
+
"mime-types": "^3.0.0"
5073
+
},
5074
+
"engines": {
5075
+
"node": ">= 0.6"
5076
+
}
5077
+
},
5078
"node_modules/faker": {
5079
"version": "6.6.6",
5080
"resolved": "https://registry.npmjs.org/faker/-/faker-6.6.6.tgz",
···
5148
}
5149
},
5150
"node_modules/finalhandler": {
5151
+
"version": "2.1.0",
5152
+
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
5153
+
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
5154
"license": "MIT",
5155
"dependencies": {
5156
+
"debug": "^4.4.0",
5157
+
"encodeurl": "^2.0.0",
5158
+
"escape-html": "^1.0.3",
5159
+
"on-finished": "^2.4.1",
5160
+
"parseurl": "^1.3.3",
5161
+
"statuses": "^2.0.1"
5162
},
5163
"engines": {
5164
"node": ">= 0.8"
5165
}
5166
},
5167
+
"node_modules/finalhandler/node_modules/debug": {
5168
+
"version": "4.4.3",
5169
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
5170
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
5171
+
"license": "MIT",
5172
+
"dependencies": {
5173
+
"ms": "^2.1.3"
5174
+
},
5175
+
"engines": {
5176
+
"node": ">=6.0"
5177
+
},
5178
+
"peerDependenciesMeta": {
5179
+
"supports-color": {
5180
+
"optional": true
5181
+
}
5182
+
}
5183
+
},
5184
+
"node_modules/finalhandler/node_modules/ms": {
5185
+
"version": "2.1.3",
5186
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
5187
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
5188
+
"license": "MIT"
5189
+
},
5190
"node_modules/find-up": {
5191
"version": "4.1.0",
5192
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
···
5246
}
5247
},
5248
"node_modules/fresh": {
5249
+
"version": "2.0.0",
5250
+
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
5251
+
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
5252
"license": "MIT",
5253
"engines": {
5254
+
"node": ">= 0.8"
5255
}
5256
},
5257
"node_modules/fs-constants": {
···
5940
"engines": {
5941
"node": ">=0.12.0"
5942
}
5943
+
},
5944
+
"node_modules/is-promise": {
5945
+
"version": "4.0.0",
5946
+
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
5947
+
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
5948
+
"license": "MIT"
5949
},
5950
"node_modules/is-stream": {
5951
"version": "2.0.1",
···
7042
}
7043
},
7044
"node_modules/merge-descriptors": {
7045
+
"version": "2.0.0",
7046
+
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
7047
+
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
7048
"license": "MIT",
7049
+
"engines": {
7050
+
"node": ">=18"
7051
+
},
7052
"funding": {
7053
"url": "https://github.com/sponsors/sindresorhus"
7054
}
···
7064
"version": "1.1.2",
7065
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
7066
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
7067
+
"dev": true,
7068
"license": "MIT",
7069
"engines": {
7070
"node": ">= 0.6"
···
7084
"node": ">=8.6"
7085
}
7086
},
7087
"node_modules/mime-db": {
7088
"version": "1.52.0",
7089
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
···
7316
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
7317
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
7318
"license": "MIT",
7319
+
"optional": true,
7320
"engines": {
7321
"node": ">= 0.6"
7322
}
···
7813
"license": "MIT"
7814
},
7815
"node_modules/path-to-regexp": {
7816
+
"version": "8.3.0",
7817
+
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
7818
+
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
7819
+
"license": "MIT",
7820
+
"funding": {
7821
+
"type": "opencollective",
7822
+
"url": "https://opencollective.com/express"
7823
+
}
7824
},
7825
"node_modules/picocolors": {
7826
"version": "1.1.1",
···
8266
"url": "https://github.com/sponsors/isaacs"
8267
}
8268
},
8269
+
"node_modules/router": {
8270
+
"version": "2.2.0",
8271
+
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
8272
+
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
8273
+
"license": "MIT",
8274
+
"dependencies": {
8275
+
"debug": "^4.4.0",
8276
+
"depd": "^2.0.0",
8277
+
"is-promise": "^4.0.0",
8278
+
"parseurl": "^1.3.3",
8279
+
"path-to-regexp": "^8.0.0"
8280
+
},
8281
+
"engines": {
8282
+
"node": ">= 18"
8283
+
}
8284
+
},
8285
+
"node_modules/router/node_modules/debug": {
8286
+
"version": "4.4.3",
8287
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
8288
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
8289
+
"license": "MIT",
8290
+
"dependencies": {
8291
+
"ms": "^2.1.3"
8292
+
},
8293
+
"engines": {
8294
+
"node": ">=6.0"
8295
+
},
8296
+
"peerDependenciesMeta": {
8297
+
"supports-color": {
8298
+
"optional": true
8299
+
}
8300
+
}
8301
+
},
8302
+
"node_modules/router/node_modules/ms": {
8303
+
"version": "2.1.3",
8304
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
8305
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
8306
+
"license": "MIT"
8307
+
},
8308
"node_modules/safe-buffer": {
8309
"version": "5.2.1",
8310
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
···
8344
}
8345
},
8346
"node_modules/send": {
8347
+
"version": "1.2.0",
8348
+
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
8349
+
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
8350
"license": "MIT",
8351
"dependencies": {
8352
+
"debug": "^4.3.5",
8353
+
"encodeurl": "^2.0.0",
8354
+
"escape-html": "^1.0.3",
8355
+
"etag": "^1.8.1",
8356
+
"fresh": "^2.0.0",
8357
+
"http-errors": "^2.0.0",
8358
+
"mime-types": "^3.0.1",
8359
+
"ms": "^2.1.3",
8360
+
"on-finished": "^2.4.1",
8361
+
"range-parser": "^1.2.1",
8362
+
"statuses": "^2.0.1"
8363
},
8364
"engines": {
8365
+
"node": ">= 18"
8366
+
}
8367
+
},
8368
+
"node_modules/send/node_modules/debug": {
8369
+
"version": "4.4.3",
8370
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
8371
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
8372
+
"license": "MIT",
8373
+
"dependencies": {
8374
+
"ms": "^2.1.3"
8375
+
},
8376
+
"engines": {
8377
+
"node": ">=6.0"
8378
+
},
8379
+
"peerDependenciesMeta": {
8380
+
"supports-color": {
8381
+
"optional": true
8382
+
}
8383
+
}
8384
+
},
8385
+
"node_modules/send/node_modules/mime-db": {
8386
+
"version": "1.54.0",
8387
+
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
8388
+
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
8389
+
"license": "MIT",
8390
+
"engines": {
8391
+
"node": ">= 0.6"
8392
}
8393
},
8394
+
"node_modules/send/node_modules/mime-types": {
8395
+
"version": "3.0.1",
8396
+
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
8397
+
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
8398
"license": "MIT",
8399
+
"dependencies": {
8400
+
"mime-db": "^1.54.0"
8401
+
},
8402
"engines": {
8403
+
"node": ">= 0.6"
8404
}
8405
},
8406
"node_modules/send/node_modules/ms": {
···
8410
"license": "MIT"
8411
},
8412
"node_modules/serve-static": {
8413
+
"version": "2.2.0",
8414
+
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
8415
+
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
8416
"license": "MIT",
8417
"dependencies": {
8418
+
"encodeurl": "^2.0.0",
8419
+
"escape-html": "^1.0.3",
8420
+
"parseurl": "^1.3.3",
8421
+
"send": "^1.2.0"
8422
},
8423
"engines": {
8424
+
"node": ">= 18"
8425
}
8426
},
8427
"node_modules/set-blocking": {
···
9388
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
9389
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
9390
"license": "MIT"
9391
},
9392
"node_modules/v8-compile-cache-lib": {
9393
"version": "3.0.1",
+1
-1
server/package.json
+1
-1
server/package.json
+96
-7
server/src/openapi.yaml
+96
-7
server/src/openapi.yaml
···
108
expiry: { type: string, example: "12/26" }
109
cvv: { type: string, example: "123" }
110
111
TransactionType:
112
type: object
113
required: [name, count]
114
properties:
115
-
name: { type: string}
116
-
count: { type: integer }
117
118
Transaction:
119
type: object
···
164
message:
165
type: string
166
167
tags:
168
- name: Meta
169
- name: Auth
···
240
content:
241
application/json:
242
schema:
243
-
$ref: "#/components/schemas/Error"
244
"500":
245
$ref: "#/components/responses/Error500"
246
···
290
"500":
291
$ref: "#/components/responses/Error500"
292
293
/cards:
294
get:
295
summary: List stored cards for the authenticated user
···
315
316
/transaction-types:
317
get:
318
-
summary: Find transaction types for the authenticated user
319
tags: [Transactions]
320
security:
321
- BearerAuth: []
···
397
content:
398
application/json:
399
schema:
400
-
type: array
401
-
items:
402
-
$ref: "#/components/schemas/Transaction"
403
"400":
404
$ref: "#/components/responses/Error400"
405
"401":
···
108
expiry: { type: string, example: "12/26" }
109
cvv: { type: string, example: "123" }
110
111
+
PaginationMeta:
112
+
type: object
113
+
required: [page, limit, total, hasMore]
114
+
properties:
115
+
page:
116
+
type: integer
117
+
description: Current page number (1-based)
118
+
limit:
119
+
type: integer
120
+
description: Page size
121
+
hasMore:
122
+
type: boolean
123
+
description: True if there are more pages after the current one
124
+
total:
125
+
type: integer
126
+
description: Total number of matching items
127
+
128
+
PaginatedTransactions:
129
+
type: object
130
+
required: [data, meta]
131
+
properties:
132
+
data:
133
+
type: array
134
+
items:
135
+
$ref: "#/components/schemas/Transaction"
136
+
meta:
137
+
$ref: "#/components/schemas/PaginationMeta"
138
+
139
TransactionType:
140
type: object
141
required: [name, count]
142
properties:
143
+
name: { type: string }
144
+
count: { type: integer }
145
146
Transaction:
147
type: object
···
192
message:
193
type: string
194
195
+
LoginError:
196
+
type: object
197
+
description: Standard validation/error envelope.
198
+
required: [errors]
199
+
properties:
200
+
errors:
201
+
type: array
202
+
description: Non-field/global errors.
203
+
items:
204
+
type: string
205
+
properties:
206
+
type: object
207
+
description: Per-field validation errors.
208
+
properties:
209
+
username:
210
+
$ref: "#/components/schemas/FieldError"
211
+
password:
212
+
$ref: "#/components/schemas/FieldError"
213
+
214
+
example:
215
+
errors: []
216
+
properties:
217
+
username:
218
+
errors: ["Invalid email address"]
219
+
password:
220
+
errors: ["Too small: expected string to have >=8 characters"]
221
+
222
+
FieldError:
223
+
type: object
224
+
required: [errors]
225
+
properties:
226
+
errors:
227
+
type: array
228
+
items:
229
+
type: string
230
+
231
tags:
232
- name: Meta
233
- name: Auth
···
304
content:
305
application/json:
306
schema:
307
+
$ref: "#/components/schemas/LoginError"
308
"500":
309
$ref: "#/components/responses/Error500"
310
···
354
"500":
355
$ref: "#/components/responses/Error500"
356
357
+
/accounts/{accountId}:
358
+
get:
359
+
summary: Retrieves a single account for the authenticated user, including calculated balances
360
+
parameters:
361
+
- in: path
362
+
name: accountId
363
+
schema:
364
+
type: integer
365
+
required: true
366
+
description: Numeric ID of the account to get
367
+
tags: [Accounts]
368
+
security:
369
+
- BearerAuth: []
370
+
responses:
371
+
"200":
372
+
description: Account object
373
+
content:
374
+
application/json:
375
+
schema:
376
+
$ref: "#/components/schemas/Account"
377
+
"401":
378
+
$ref: "#/components/responses/Error401"
379
+
"403":
380
+
$ref: "#/components/responses/Error403"
381
+
"500":
382
+
$ref: "#/components/responses/Error500"
383
+
384
/cards:
385
get:
386
summary: List stored cards for the authenticated user
···
406
407
/transaction-types:
408
get:
409
+
summary: Find transaction types for the authenticated user
410
tags: [Transactions]
411
security:
412
- BearerAuth: []
···
488
content:
489
application/json:
490
schema:
491
+
$ref: "#/components/schemas/PaginatedTransactions"
492
"400":
493
$ref: "#/components/responses/Error400"
494
"401":
+43
-6
server/src/schema.ts
+43
-6
server/src/schema.ts
···
1
import { Request as ExpressRequest } from "express";
2
import { z } from "zod";
3
4
-
export type Request<T extends Record<string, string> = {}> =
5
-
ExpressRequest<T> & {
6
-
user?: User;
7
-
};
8
9
export type User = {
10
id: number;
···
12
fullname: string;
13
password: string;
14
created: Date;
15
};
16
17
export const LoginSchema = z.object({
···
19
password: z.string().min(8),
20
});
21
22
export const TransactionTypesQuerySchema = z.object({
23
accountId: z.preprocess(
24
(val) => (val ? Number(val) : undefined),
···
26
),
27
});
28
29
export const TransactionsQuerySchema = z.object({
30
search: z.string().optional().default(""),
31
sort: z.string().optional().default("date"),
32
order: z.enum(["asc", "desc"]).optional().default("desc"),
33
-
page: z.preprocess((val) => Number(val ?? 1), z.int().min(1)),
34
-
limit: z.preprocess((val) => Number(val ?? 25), z.int().min(1).max(100)),
35
accountId: z.preprocess(
36
(val) => (val ? Number(val) : undefined),
37
z.number().optional(),
38
),
39
type: z.string().optional(),
40
});
···
1
import { Request as ExpressRequest } from "express";
2
import { z } from "zod";
3
4
+
export type Request<T extends {} = {}> = ExpressRequest<T> & {
5
+
user?: User;
6
+
};
7
8
export type User = {
9
id: number;
···
11
fullname: string;
12
password: string;
13
created: Date;
14
+
};
15
+
16
+
export type Account = {
17
+
id: number;
18
+
name: string;
19
+
user_id: number;
20
+
iban: string;
21
+
balance: number;
22
+
};
23
+
24
+
export type Transaction = {
25
+
id: number;
26
+
user_id: number;
27
+
account_id: number;
28
+
amount: number;
29
+
type: string;
30
+
description: string;
31
+
date: string;
32
};
33
34
export const LoginSchema = z.object({
···
36
password: z.string().min(8),
37
});
38
39
+
export type TransactionType = {
40
+
name: string;
41
+
count: number;
42
+
};
43
+
44
export const TransactionTypesQuerySchema = z.object({
45
accountId: z.preprocess(
46
(val) => (val ? Number(val) : undefined),
···
48
),
49
});
50
51
+
export type PaginationRespone<T> = {
52
+
data: T[];
53
+
meta: {
54
+
total: number;
55
+
page: number;
56
+
hasMore: boolean;
57
+
};
58
+
};
59
+
60
+
export type TransactionTypesQuery = z.infer<typeof TransactionTypesQuerySchema>;
61
+
62
export const TransactionsQuerySchema = z.object({
63
search: z.string().optional().default(""),
64
sort: z.string().optional().default("date"),
65
order: z.enum(["asc", "desc"]).optional().default("desc"),
66
+
page: z.preprocess((val) => Number(val ?? 1), z.int().min(1)).default(1),
67
+
limit: z
68
+
.preprocess((val) => Number(val ?? 25), z.int().min(1).max(100))
69
+
.default(25),
70
accountId: z.preprocess(
71
(val) => (val ? Number(val) : undefined),
72
z.number().optional(),
73
),
74
type: z.string().optional(),
75
});
76
+
77
+
export type TransactionsQuery = z.infer<typeof TransactionsQuerySchema>;
+18
-14
server/src/seeder.ts
+18
-14
server/src/seeder.ts
···
106
])
107
).map(({ id }) => id);
108
109
-
const transactions = range(100).map(() => [
110
-
userId,
111
-
accountIds[Math.floor(Math.random() * accountIds.length)],
112
-
faker.finance.amount({ min: -1500, max: 2500, dec: 2 }),
113
-
faker.finance.transactionType(),
114
-
faker.commerce.productName(),
115
-
faker.date.recent({ days: 30 }).toISOString(),
116
-
]);
117
118
-
// Seed transactions
119
-
db.run(
120
-
`INSERT INTO transactions (user_id, account_id, amount, type, description, date) VALUES ${transactions
121
-
.map(() => "(?, ?, ?, ?, ?, ?)")
122
-
.join(", ")}`,
123
-
transactions.flat(),
124
);
125
};
···
106
])
107
).map(({ id }) => id);
108
109
+
await Promise.all(
110
+
accountIds.map(async (accountId) => {
111
+
const transactions = range(1000).map(() => [
112
+
userId,
113
+
accountId,
114
+
faker.finance.amount({ min: -1500, max: 2500, dec: 2 }),
115
+
faker.finance.transactionType(),
116
+
faker.commerce.productName(),
117
+
faker.date.recent({ days: 30 }).toISOString(),
118
+
]);
119
120
+
// Seed transactions
121
+
db.run(
122
+
`INSERT INTO transactions (user_id, account_id, amount, type, description, date) VALUES ${transactions
123
+
.map(() => "(?, ?, ?, ?, ?, ?)")
124
+
.join(", ")}`,
125
+
transactions.flat(),
126
+
);
127
+
}),
128
);
129
};
+63
-102
server/src/server.ts
+63
-102
server/src/server.ts
···
11
JWT_SECRET,
12
TOKEN_EXPIRY_MINUTES,
13
} from "./config";
14
-
import { LoginRequest, TokenPair, User as UserResponse } from "../generated";
15
import { readFileSync } from "fs";
16
import { generateRefreshToken, generateToken, verifyToken } from "./auth";
17
import { User, Request, TransactionsQuerySchema, LoginSchema } from "./schema";
18
import { HttpError } from "express-openapi-validator/dist/framework/types";
19
20
const authenticateToken = async (
21
req: Request,
···
48
export const build = ({ db, specPath = "./src/openapi.yaml" }: AppConfig) => {
49
const app = express();
50
51
app.use(json());
52
app.use(cors<Request>({ origin: CORS_ORIGIN }));
53
···
69
res.send(readFileSync(specPath).toString());
70
});
71
72
-
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
73
-
if (err instanceof ZodError) {
74
-
res.status(400).json(z.treeifyError(err));
75
-
return;
76
-
}
77
-
78
-
if (err instanceof HttpError) {
79
-
res.status(err.status).json({ message: err.message }).send();
80
-
return;
81
-
}
82
-
83
-
res
84
-
.status(500)
85
-
.json({ error: `Internal server error: ${err.message}` })
86
-
.send();
87
-
});
88
-
89
app.post("/login", async ({ body }: Request<LoginRequest>, res: Response) => {
90
const { username, password } = LoginSchema.parse(body);
91
···
93
username,
94
]);
95
96
-
if (!user || !compare(password, user.password)) {
97
-
return res.status(401).json({ message: "Invalid credentials" });
98
}
99
100
const now = new Date();
···
107
refreshToken: generateRefreshToken(user),
108
};
109
110
-
res.json(response);
111
});
112
113
app.post("/refresh-token", async ({ body }: Request, res: Response) => {
···
153
created: new Date(user.created).toISOString(),
154
};
155
156
-
res.json(response);
157
});
158
159
app.get(
160
-
"/accounts",
161
authenticateToken,
162
-
async ({ user }: Request, res: Response) => {
163
-
const accounts = await db.all(
164
-
`
165
-
SELECT accounts.*,
166
-
IFNULL(t.balance, 0) as balance
167
168
-
FROM accounts
169
170
-
LEFT JOIN (
171
-
SELECT account_id, SUM(amount) as balance
172
-
FROM transactions
173
-
WHERE user_id = ?
174
-
GROUP BY account_id
175
-
) AS t ON accounts.id = t.account_id
176
177
-
WHERE accounts.user_id = ?
178
-
`,
179
-
[user.id, user.id],
180
-
);
181
-
182
-
return res.json(accounts ?? []);
183
},
184
);
185
···
198
app.get(
199
"/transaction-types",
200
authenticateToken,
201
-
async (request: Request, res: Response) => {
202
-
const { accountId } = TransactionsQuerySchema.parse(request.query);
203
204
-
let query = `
205
-
SELECT count(*) as count,
206
-
type as name
207
-
FROM transactions
208
-
WHERE user_id = ?
209
-
`;
210
-
211
-
let params: Array<string | number> = [request.user.id];
212
-
213
-
if (accountId) {
214
-
query += " AND (account_id = ?)";
215
-
params.push(accountId);
216
-
}
217
-
218
-
query += " GROUP BY type ORDER BY count DESC";
219
-
220
-
return res.json((await db.all(query, params)) ?? []);
221
},
222
);
223
224
app.get(
225
"/transactions",
226
authenticateToken,
227
-
async (req: Request, res: Response) => {
228
-
const { page, limit, search, sort, order, accountId, type } =
229
-
TransactionsQuerySchema.parse(req.query);
230
231
-
const offset = (page - 1) * limit;
232
-
233
-
let query = "SELECT * FROM transactions WHERE user_id = ?";
234
-
235
-
let params: Array<string | number> = [req.user.id];
236
-
237
-
if (search.length) {
238
-
query += " AND (description LIKE ? OR type LIKE ?)";
239
-
params.push(`%${search}%`, `%${search}%`);
240
-
}
241
242
-
if (accountId) {
243
-
query += " AND (account_id = ?)";
244
-
params.push(accountId);
245
-
}
246
247
-
if (type) {
248
-
query += " AND (type = ?)";
249
-
params.push(type);
250
-
}
251
252
-
query += ` ORDER BY ? ${order} LIMIT ? OFFSET ?`;
253
-
params.push(sort, limit, offset);
254
255
-
return res.json(
256
-
(
257
-
(await db.all<{
258
-
id: number;
259
-
user_id: number;
260
-
account_id: number;
261
-
amount: number;
262
-
type: string;
263
-
description: string;
264
-
date: string;
265
-
}>(query, params)) ?? []
266
-
).map(({ user_id, account_id, ...rest }) => ({
267
-
userId: user_id,
268
-
accountId: account_id,
269
-
...rest,
270
-
})),
271
-
);
272
-
},
273
-
);
274
275
return app;
276
};
···
11
JWT_SECRET,
12
TOKEN_EXPIRY_MINUTES,
13
} from "./config";
14
+
import {
15
+
Account,
16
+
LoginRequest,
17
+
TokenPair,
18
+
User as UserResponse,
19
+
} from "../generated";
20
import { readFileSync } from "fs";
21
import { generateRefreshToken, generateToken, verifyToken } from "./auth";
22
import { User, Request, TransactionsQuerySchema, LoginSchema } from "./schema";
23
import { HttpError } from "express-openapi-validator/dist/framework/types";
24
+
import { AccountService } from "./services/accounts.service";
25
+
import { TransactionService } from "./services/transactions.service";
26
27
const authenticateToken = async (
28
req: Request,
···
55
export const build = ({ db, specPath = "./src/openapi.yaml" }: AppConfig) => {
56
const app = express();
57
58
+
const accountService = new AccountService(db);
59
+
const transactionService = new TransactionService(db);
60
+
61
app.use(json());
62
app.use(cors<Request>({ origin: CORS_ORIGIN }));
63
···
79
res.send(readFileSync(specPath).toString());
80
});
81
82
app.post("/login", async ({ body }: Request<LoginRequest>, res: Response) => {
83
const { username, password } = LoginSchema.parse(body);
84
···
86
username,
87
]);
88
89
+
if (!user || !(await compare(password, user.password))) {
90
+
return res.status(401).json({ errors: ["Invalid credentials"] });
91
}
92
93
const now = new Date();
···
100
refreshToken: generateRefreshToken(user),
101
};
102
103
+
res.json(response).status(200);
104
});
105
106
app.post("/refresh-token", async ({ body }: Request, res: Response) => {
···
146
created: new Date(user.created).toISOString(),
147
};
148
149
+
res?.json(response);
150
});
151
152
app.get(
153
+
"/accounts/:id",
154
authenticateToken,
155
+
async (
156
+
{ user, params: { id } }: Request<{ id: number }>,
157
+
res: Response,
158
+
) => {
159
+
const account: Account = await accountService.getAccountById(user, id);
160
161
+
if (!account) {
162
+
return res
163
+
.status(404)
164
+
.json({ message: `Account with id ${id} not found` })
165
+
.send();
166
+
}
167
168
+
res.json(account);
169
+
},
170
+
);
171
172
+
app.get(
173
+
"/accounts",
174
+
authenticateToken,
175
+
async ({ user }: Request, res: Response) => {
176
+
res.json(await accountService.getAccountsForUser(user));
177
},
178
);
179
···
192
app.get(
193
"/transaction-types",
194
authenticateToken,
195
+
async ({ query, user }: Request, res: Response) => {
196
+
const { accountId } = TransactionsQuerySchema.parse(query);
197
198
+
return res.json(
199
+
await transactionService.getTransactionTypes(user.id, accountId),
200
+
);
201
},
202
);
203
204
app.get(
205
"/transactions",
206
authenticateToken,
207
+
async ({ query, user: { id: userId } }: Request, res: Response) => {
208
+
const queryParams = TransactionsQuerySchema.parse(query);
209
210
+
const response = await transactionService.paginatedTransactions(
211
+
userId,
212
+
queryParams,
213
+
);
214
215
+
res.json(response);
216
+
},
217
+
);
218
219
+
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
220
+
if (err instanceof ZodError) {
221
+
res.status(400).json(z.treeifyError(err));
222
+
return;
223
+
}
224
225
+
if (err instanceof HttpError) {
226
+
res.status(err.status).json({ message: err.message }).send();
227
+
return;
228
+
}
229
230
+
res
231
+
.status(500)
232
+
.json({ error: `Internal server error: ${err.message}` })
233
+
.send();
234
+
});
235
236
return app;
237
};
+36
server/src/services/accounts.service.ts
+36
server/src/services/accounts.service.ts
···
···
1
+
import { Database } from "sqlite-async";
2
+
import { Account, User } from "../schema";
3
+
4
+
export class AccountService {
5
+
constructor(private db: Database) {}
6
+
7
+
getAccountsForUser({ id }: User): Promise<Account[]> {
8
+
return this.db.all<Account>(this.getBaseQuery(), [id, id]);
9
+
}
10
+
11
+
getAccountById({ id }: User, accountId: number): Promise<Account> {
12
+
return this.db.get<Account>(this.getBaseQuery() + "AND id = ?", [
13
+
id,
14
+
id,
15
+
accountId,
16
+
]);
17
+
}
18
+
19
+
private getBaseQuery(): string {
20
+
return `
21
+
SELECT accounts.*,
22
+
IFNULL(t.balance, 0) as balance
23
+
24
+
FROM accounts
25
+
26
+
LEFT JOIN (
27
+
SELECT account_id, SUM(amount) as balance
28
+
FROM transactions
29
+
WHERE user_id = ?
30
+
GROUP BY account_id
31
+
) AS t ON accounts.id = t.account_id
32
+
33
+
WHERE accounts.user_id = ?
34
+
`;
35
+
}
36
+
}
+116
server/src/services/transactions.service.ts
+116
server/src/services/transactions.service.ts
···
···
1
+
import { Database } from "sqlite-async";
2
+
import { Transaction, TransactionsQuery, TransactionType } from "../schema";
3
+
import { PaginatedTransactions } from "../../generated";
4
+
5
+
export class TransactionService {
6
+
constructor(private db: Database) {}
7
+
8
+
getTransactions(
9
+
userId: number,
10
+
filters: TransactionsQuery,
11
+
): Promise<Transaction[]> {
12
+
const [query, params] = this.buildTransactionsQuery(userId, filters);
13
+
14
+
const { page, order, limit, sort } = filters;
15
+
const offset = (page - 1) * limit;
16
+
17
+
params.push(limit, offset);
18
+
19
+
const finalQuery = query.concat(
20
+
` ORDER BY ${sort} ${order} LIMIT ? OFFSET ?`,
21
+
);
22
+
23
+
return this.db.all<Transaction>(finalQuery, params);
24
+
}
25
+
26
+
async countTransactions(
27
+
userId: number,
28
+
filters: TransactionsQuery,
29
+
): Promise<number> {
30
+
const [query, params] = this.buildTransactionsQuery(userId, filters);
31
+
32
+
const { count } = await this.db.get<{ count: number }>(
33
+
query.replace("SELECT *", "SELECT COUNT(*) as count"),
34
+
params,
35
+
);
36
+
37
+
return count;
38
+
}
39
+
40
+
async paginatedTransactions(
41
+
userId: number,
42
+
filters: TransactionsQuery,
43
+
): Promise<PaginatedTransactions> {
44
+
const [transactions, total] = await Promise.all([
45
+
this.getTransactions(userId, filters),
46
+
this.countTransactions(userId, filters),
47
+
]);
48
+
49
+
const { page, limit } = filters;
50
+
51
+
return {
52
+
data: transactions.map(({ user_id, account_id, ...rest }) => ({
53
+
userId: user_id,
54
+
accountId: account_id,
55
+
...rest,
56
+
})),
57
+
meta: {
58
+
total,
59
+
page,
60
+
limit,
61
+
hasMore: total > (page - 1) * limit + transactions.length,
62
+
},
63
+
};
64
+
}
65
+
66
+
private buildTransactionsQuery(
67
+
userId: number,
68
+
filters: TransactionsQuery,
69
+
): [string, Array<string | number>] {
70
+
const { search, type, accountId } = filters;
71
+
72
+
let query = "SELECT * FROM transactions WHERE user_id = ?";
73
+
74
+
let params: Array<string | number> = [userId];
75
+
76
+
if (search.length) {
77
+
query += " AND (description LIKE ? OR type LIKE ?)";
78
+
params.push(`%${search}%`, `%${search}%`);
79
+
}
80
+
81
+
if (accountId) {
82
+
query += " AND (account_id = ?)";
83
+
params.push(accountId);
84
+
}
85
+
86
+
if (type) {
87
+
query += " AND (type = ?)";
88
+
params.push(type);
89
+
}
90
+
91
+
return [query, params];
92
+
}
93
+
94
+
getTransactionTypes(
95
+
userId: number,
96
+
accountId?: number,
97
+
): Promise<TransactionType[]> {
98
+
let query = `
99
+
SELECT count(*) as count,
100
+
type as name
101
+
FROM transactions
102
+
WHERE user_id = ?
103
+
`;
104
+
105
+
let params: Array<string | number> = [userId];
106
+
107
+
if (accountId) {
108
+
query += " AND (account_id = ?)";
109
+
params.push(accountId);
110
+
}
111
+
112
+
query += " GROUP BY type ORDER BY count DESC";
113
+
114
+
return this.db.all<TransactionType>(query, params);
115
+
}
116
+
}
+23
-6
server/tests/api.spec.ts
+23
-6
server/tests/api.spec.ts
···
3
import { Database } from "sqlite-async";
4
import { DATABASE_URL } from "../src/config";
5
import { seed, testUser } from "../src/seeder";
6
-
import { Transaction } from "../generated";
7
8
type App = ReturnType<typeof build>;
9
let app: App | null = null;
···
31
});
32
});
33
34
describe("GET /transactions", () => {
35
it("it can fetch transactions", async () => {
36
const auth = await request(app).post("/login").send(testUser);
···
65
.get(`/transactions?accountId=${account.body[0].id}`)
66
.set("Authorization", `Bearer ${auth.body.accessToken}`)
67
.expect(200)
68
-
.expect(({ body }) => expect(body).not.toHaveLength(0))
69
-
.expect(({ body }: { body: Transaction[] }) => {
70
-
body.forEach((transaction) => {
71
expect(transaction.accountId).toBe(account.body[0].id);
72
});
73
});
···
86
.get(`/transactions?type=${transactionType.body[0].name}`)
87
.set("Authorization", `Bearer ${auth.body.accessToken}`)
88
.expect(200)
89
-
.expect(({ body }) => expect(body).not.toHaveLength(0))
90
.expect(({ body }) => {
91
-
body.forEach(({ type }: Transaction) => {
92
expect(type).toBe(transactionType.body[0].name);
93
});
94
});
···
3
import { Database } from "sqlite-async";
4
import { DATABASE_URL } from "../src/config";
5
import { seed, testUser } from "../src/seeder";
6
+
import { PaginatedTransactions, Transaction } from "../generated";
7
8
type App = ReturnType<typeof build>;
9
let app: App | null = null;
···
31
});
32
});
33
34
+
describe("GET /accounts/:id", () => {
35
+
it("it can fetch a single accouns", async () => {
36
+
const auth = await request(app).post("/login").send(testUser);
37
+
38
+
const accounts = await request(app)
39
+
.get("/accounts")
40
+
.set("Authorization", `Bearer ${auth.body.accessToken}`)
41
+
.expect(({ body }) => expect(body).not.toHaveLength(0));
42
+
43
+
await request(app)
44
+
.get(`/accounts/${accounts.body[0].id}`)
45
+
.set("Authorization", `Bearer ${auth.body.accessToken}`)
46
+
.expect(200)
47
+
.expect(({ body }) => expect(body.id).toBe(accounts.body[0].id));
48
+
});
49
+
});
50
+
51
describe("GET /transactions", () => {
52
it("it can fetch transactions", async () => {
53
const auth = await request(app).post("/login").send(testUser);
···
82
.get(`/transactions?accountId=${account.body[0].id}`)
83
.set("Authorization", `Bearer ${auth.body.accessToken}`)
84
.expect(200)
85
+
.expect(({ body }) => expect(body.data).not.toHaveLength(0))
86
+
.expect(({ body }: { body: PaginatedTransactions }) => {
87
+
body.data.forEach((transaction) => {
88
expect(transaction.accountId).toBe(account.body[0].id);
89
});
90
});
···
103
.get(`/transactions?type=${transactionType.body[0].name}`)
104
.set("Authorization", `Bearer ${auth.body.accessToken}`)
105
.expect(200)
106
+
.expect(({ body }) => expect(body.data).not.toHaveLength(0))
107
.expect(({ body }) => {
108
+
body.data.forEach(({ type }: Transaction) => {
109
expect(type).toBe(transactionType.body[0].name);
110
});
111
});