ICS React Native App
1import express, { NextFunction, Response } from "express";
2import * as OpenApiValidator from "express-openapi-validator";
3import { json } from "body-parser";
4import { Database } from "sqlite-async";
5import { compare } from "bcryptjs";
6import cors from "cors";
7import z, { ZodError } from "zod";
8import {
9 CORS_ORIGIN,
10 JWT_REFRESH_SECRET,
11 JWT_SECRET,
12 TOKEN_EXPIRY_MINUTES,
13} from "./config";
14import {
15 Account,
16 LoginRequest,
17 TokenPair,
18 User as UserResponse,
19} from "../generated";
20import { readFileSync } from "fs";
21import { generateRefreshToken, generateToken, verifyToken } from "./auth";
22import { User, Request, TransactionsQuerySchema, LoginSchema } from "./schema";
23import { HttpError } from "express-openapi-validator/dist/framework/types";
24import { AccountService } from "./services/accounts.service";
25import { TransactionService } from "./services/transactions.service";
26
27const authenticateToken = async (
28 req: Request,
29 res: Response,
30 next: NextFunction,
31) => {
32 const authHeader = req.headers["authorization"];
33 const token = (authHeader ?? "").split(" ")[1];
34
35 if (!token) {
36 return res
37 .status(401)
38 .json({ message: "Missing authorization token" })
39 .send();
40 }
41
42 try {
43 req.user = await verifyToken(token, JWT_SECRET);
44 next();
45 } catch (err) {
46 return res.status(403).json({ message: "Invalid token" }).send();
47 }
48};
49
50interface AppConfig {
51 db: Database;
52 specPath?: string;
53}
54
55export 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
64 app.use(
65 OpenApiValidator.middleware({
66 apiSpec: specPath,
67 validateRequests: false,
68 validateResponses: true,
69 }),
70 );
71
72 app.get("/", (req: Request, res: Response) => {
73 res.type("text/html");
74 res.send(readFileSync("./src/swagger.html").toString());
75 });
76
77 app.get("/openapi.yaml", (req: Request, res: Response) => {
78 res.type("application/yaml");
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
85 const user = await db.get<User>("SELECT * FROM users WHERE username = ?", [
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();
94
95 const expires = new Date(now.getTime() + TOKEN_EXPIRY_MINUTES * 60 * 1000);
96
97 const response: TokenPair = {
98 expires: expires.toISOString(),
99 accessToken: generateToken(user),
100 refreshToken: generateRefreshToken(user),
101 };
102
103 res.json(response).status(200);
104 });
105
106 app.post("/refresh-token", async ({ body }: Request, res: Response) => {
107 const { refreshToken } = body;
108
109 if (!refreshToken) {
110 return res.status(401).json({ message: "Missing refresh token" });
111 }
112
113 try {
114 const user = await verifyToken(refreshToken, JWT_REFRESH_SECRET);
115
116 const now = new Date();
117 const expires = new Date(
118 now.getTime() + TOKEN_EXPIRY_MINUTES * 60 * 1000,
119 );
120
121 const response: TokenPair = {
122 expires: expires.toISOString(),
123 accessToken: generateToken(user),
124 refreshToken: generateRefreshToken(user),
125 };
126
127 res.json(response);
128 } catch (err) {
129 return res
130 .status(401)
131 .json({ message: `Invalid refresh token: ${err.message}` })
132 .send();
133 }
134 });
135
136 // Protected endpoints
137 app.get("/me", authenticateToken, async (req: Request, res: Response) => {
138 const user = await db.get<User>("SELECT * FROM users WHERE id = ?", [
139 req.user.id,
140 ]);
141
142 const response: UserResponse = {
143 id: user.id,
144 username: user.username,
145 fullname: user.fullname,
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
180 app.get(
181 "/cards",
182 authenticateToken,
183 async ({ user }: Request, res: Response) => {
184 const rows = await db.all("SELECT * FROM cards WHERE user_id = ?", [
185 user.id,
186 ]);
187
188 return res.json(rows ?? []);
189 },
190 );
191
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};