Skip to content

Commit

Permalink
Merge pull request #137 from letehaha/feat/accounts-grouping
Browse files Browse the repository at this point in the history
feat: Add accounts grouping
  • Loading branch information
letehaha authored Nov 10, 2024
2 parents 75970fb + 8654036 commit c944f5c
Show file tree
Hide file tree
Showing 35 changed files with 1,177 additions and 2 deletions.
1 change: 1 addition & 0 deletions shared-types/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export enum API_ERROR_CODES {
// general
tooManyRequests = 'TOO_MANY_REQUESTS',
notFound = 'NOT_FOUND',
notAllowed = 'NOT_ALLOWED',
unexpected = 'UNEXPECTED',
validationError = 'VALIDATION_ERROR',
conflict = 'CONFLICT',
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import modelsCurrenciesRoutes from './routes/currencies.route';
import monobankRoutes from './routes/banks/monobank.route';
import binanceRoutes from './routes/crypto/binance.route';
import statsRoutes from './routes/stats.route';
import accountGroupsRoutes from './routes/account-groups';

import { supportedLocales } from './translations';

Expand Down Expand Up @@ -78,6 +79,7 @@ app.use(`${apiPrefix}/models/currencies`, modelsCurrenciesRoutes);
app.use(`${apiPrefix}/banks/monobank`, monobankRoutes);
app.use(`${apiPrefix}/crypto/binance`, binanceRoutes);
app.use(`${apiPrefix}/stats`, statsRoutes);
app.use(`${apiPrefix}/account-group`, accountGroupsRoutes);

// Cause some tests can be parallelized, the port might be in use, so we need to allow dynamic port
export const serverInstance = app.listen(
Expand Down
24 changes: 24 additions & 0 deletions src/common/lib/zod/custom-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from 'zod';

export const recordId = () => z.coerce.number().int().positive().finite();

/**
* Used for the case when array is expected to be received like 1,2,3.
* For example GET queries
*/
export const commaSeparatedRecordIds = z.string().transform((str, ctx) => {
const idSchema = recordId();
const ids = str.split(',').map((id) => {
const result = idSchema.safeParse(id);
return result.success ? result.data : null;
});

if (ids.some((id) => id === null)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Not all values are valid record IDs',
});
return z.NEVER;
}
return ids as number[];
});
30 changes: 30 additions & 0 deletions src/controllers/account-groups/add-account-to-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { z } from 'zod';
import type { CustomResponse } from '@common/types';
import { API_RESPONSE_STATUS } from 'shared-types';
import { recordId } from '@common/lib/zod/custom-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const addAccountToGroup = async (req, res: CustomResponse) => {
try {
const { accountId, groupId }: AddAccountToGroupParams['params'] = req.validated.params;

const grouping = await accountGroupService.addAccountToGroup({ accountId, groupId });

return res.status(201).json({
status: API_RESPONSE_STATUS.success,
response: grouping,
});
} catch (err) {
errorHandler(res, err);
}
};

export const addAccountToGroupSchema = z.object({
params: z.object({
accountId: recordId(),
groupId: recordId(),
}),
});

type AddAccountToGroupParams = z.infer<typeof addAccountToGroupSchema>;
33 changes: 33 additions & 0 deletions src/controllers/account-groups/create-account-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { z } from 'zod';
import type { CustomResponse } from '@common/types';
import { API_RESPONSE_STATUS } from 'shared-types';
import { recordId } from '@common/lib/zod/custom-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const createAccountGroup = async (req, res: CustomResponse) => {
try {
const { id: userId } = req.user;
const { name, parentGroupId }: CreateAccountGroupParams['body'] = req.validated.body;

const group = await accountGroupService.createAccountGroup({ userId, name, parentGroupId });

return res.status(201).json({
status: API_RESPONSE_STATUS.success,
response: group,
});
} catch (err) {
errorHandler(res, err);
}
};

export const createAccountGroupSchema = z.object({
body: z
.object({
name: z.string().min(1),
parentGroupId: recordId().nullable().optional(),
})
.strict(),
});

type CreateAccountGroupParams = z.infer<typeof createAccountGroupSchema>;
27 changes: 27 additions & 0 deletions src/controllers/account-groups/delete-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from 'zod';
import type { CustomResponse } from '@common/types';
import { recordId } from '@common/lib/zod/custom-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const deleteAccountGroup = async (req, res: CustomResponse) => {
try {
const { id: userId } = req.user;
const { groupId }: DeleteAccountGroupParams['params'] = req.validated.params;

await accountGroupService.deleteAccountGroup({
groupId,
userId,
});

return res.status(204).send();
} catch (err) {
errorHandler(res, err);
}
};

export const deleteAccountGroupSchema = z.object({
params: z.object({ groupId: recordId() }),
});

type DeleteAccountGroupParams = z.infer<typeof deleteAccountGroupSchema>;
29 changes: 29 additions & 0 deletions src/controllers/account-groups/get-accounts-in-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { z } from 'zod';
import type { CustomResponse } from '@common/types';
import { API_RESPONSE_STATUS } from 'shared-types';
import { recordId } from '@common/lib/zod/custom-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const getAccountsInGroup = async (req, res: CustomResponse) => {
try {
const { groupId }: GetAccountsInGroupParams['params'] = req.validated.params;

const accounts = await accountGroupService.getAccountsInGroup({
groupId,
});

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
response: accounts,
});
} catch (err) {
errorHandler(res, err);
}
};

export const getAccountsInGroupSchema = z.object({
params: z.object({ groupId: recordId() }),
});

type GetAccountsInGroupParams = z.infer<typeof getAccountsInGroupSchema>;
28 changes: 28 additions & 0 deletions src/controllers/account-groups/get-groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from 'zod';
import type { CustomResponse } from '@common/types';
import { commaSeparatedRecordIds } from '@common/lib/zod/custom-types';
import { API_RESPONSE_STATUS } from 'shared-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const getAccountGroups = async (req, res: CustomResponse) => {
try {
const { id: userId } = req.user;
const { accountIds }: GetAccountGroupsParams['query'] = req.validated.query;

const groups = await accountGroupService.getAccountGroups({ userId, accountIds });

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
response: groups,
});
} catch (err) {
errorHandler(res, err);
}
};

export const getAccountGroupsSchema = z.object({
query: z.object({ accountIds: commaSeparatedRecordIds.optional() }),
});

type GetAccountGroupsParams = z.infer<typeof getAccountGroupsSchema>;
8 changes: 8 additions & 0 deletions src/controllers/account-groups/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * from './add-account-to-group';
export * from './create-account-group';
export * from './delete-group';
export * from './get-accounts-in-group';
export * from './get-groups';
export * from './move-account-to-group';
export * from './remove-account-from-group';
export * from './update-group';
38 changes: 38 additions & 0 deletions src/controllers/account-groups/move-account-to-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod';
import type { CustomResponse } from '@common/types';
import { API_RESPONSE_STATUS } from 'shared-types';
import { recordId } from '@common/lib/zod/custom-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const moveAccountGroup = async (req, res: CustomResponse) => {
try {
const { id: userId } = req.user;
const { groupId }: MoveAccountGroupParams['params'] = req.validated.params;
const { newParentGroupId }: MoveAccountGroupParams['body'] = req.validated.body;

const [updatedCount, updatedGroups] = await accountGroupService.moveAccountGroup({
groupId,
newParentGroupId,
userId,
});

if (updatedCount === 0) {
return res.status(404).json({ status: API_RESPONSE_STATUS.error });
}

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
response: updatedGroups[0],
});
} catch (err) {
errorHandler(res, err);
}
};

export const moveAccountGroupSchema = z.object({
params: z.object({ groupId: recordId() }),
body: z.object({ newParentGroupId: recordId().nullable() }),
});

type MoveAccountGroupParams = z.infer<typeof moveAccountGroupSchema>;
32 changes: 32 additions & 0 deletions src/controllers/account-groups/remove-account-from-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { API_RESPONSE_STATUS } from 'shared-types';
import type { CustomResponse } from '@common/types';
import { recordId } from '@common/lib/zod/custom-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const removeAccountFromGroup = async (req, res: CustomResponse) => {
try {
const { accountId, groupId }: RemoveAccountFromGroupParams['params'] = req.validated.params;

await accountGroupService.removeAccountFromGroup({
accountId,
groupId,
});

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
});
} catch (err) {
errorHandler(res, err);
}
};

export const removeAccountFromGroupSchema = z.object({
params: z.object({
accountId: recordId(),
groupId: recordId(),
}),
});

type RemoveAccountFromGroupParams = z.infer<typeof removeAccountFromGroupSchema>;
40 changes: 40 additions & 0 deletions src/controllers/account-groups/update-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from 'zod';
import type { CustomResponse } from '@common/types';
import { API_RESPONSE_STATUS } from 'shared-types';
import { recordId } from '@common/lib/zod/custom-types';
import { errorHandler } from '@controllers/helpers';
import * as accountGroupService from '@services/account-groups';

export const updateAccountGroup = async (req, res: CustomResponse) => {
try {
const { id: userId } = req.user;
const { groupId }: UpdateAccountGroupParams['params'] = req.validated.params;
const updates: UpdateAccountGroupParams['body'] = req.validated.body;

const updatedGroups = await accountGroupService.updateAccountGroup({
groupId,
userId,
...updates,
});

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
response: updatedGroups,
});
} catch (err) {
errorHandler(res, err);
}
};

export const updateAccountGroupSchema = z.object({
params: z.object({ groupId: recordId() }),
body: z
.object({
name: z.string().min(1),
parentGroupId: recordId().nullable().optional(),
})
.strict()
.partial(),
});

type UpdateAccountGroupParams = z.infer<typeof updateAccountGroupSchema>;
13 changes: 13 additions & 0 deletions src/js/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum ERROR_CODES {
Unauthorized = 401,
Forbidden = 403,
NotFoundError = 404,
NotAllowed = 405,
ConflictError = 409,
ValidationError = 422,
TooManyRequests = 429,
Expand Down Expand Up @@ -61,6 +62,18 @@ export class NotFoundError extends CustomError {
}
}

export class NotAllowedError extends CustomError {
constructor({
code = API_ERROR_CODES.notAllowed,
message,
}: {
code?: API_ERROR_CODES;
message: string;
}) {
super(ERROR_CODES.NotAllowed, code, message);
}
}

export class ConflictError extends CustomError {
constructor({
code = API_ERROR_CODES.conflict,
Expand Down
2 changes: 1 addition & 1 deletion src/js/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const removeUndefinedKeys = <T>(obj: T): T => {
obj[key] === undefined ||
(typeof obj[key] === 'number' && isNaN(obj[key] as number)) ||
// Test for Invalid Date object
(obj[key] instanceof Date && isNaN(obj[key] as number))
(obj[key] instanceof Date && isNaN(obj[key] as unknown as number))
) {
delete obj[key];
}
Expand Down
Loading

0 comments on commit c944f5c

Please sign in to comment.