Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create expense without any group attached #68

Merged
merged 19 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ logs

dist
build
.zed
3 changes: 2 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { createApp } from "./common/lib/create-app.lib";
import { activityRouter } from "./modules/activities/activity.index";
import { authRouter } from "./modules/auth/auth.index";
import { categoryRouter } from "./modules/categories/category.index";
import { expenseRouter } from "./modules/expenses/expense.index";
import { groupRouters } from "./modules/group/group.index";
import { healthCheckRouter } from "./modules/health-check/health-check.index";

export const app = createApp();

const routesV1 = [authRouter, categoryRouter, groupRouters, activityRouter];
const routesV1 = [authRouter, categoryRouter, groupRouters, activityRouter, expenseRouter];

configureOpenAPI(app);

Expand Down
13 changes: 9 additions & 4 deletions src/db/schemas/expense.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@ const expenseModel = pgTable("expense", {
.$defaultFn(() => Bun.randomUUIDv7())
.primaryKey()
.notNull(),

payerId: varchar({ length: 60 })
.notNull()
.references(() => userModel.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
categoryId: varchar({ length: 60 })
creatorId: varchar({ length: 60 })
.notNull()
.references(() => categoryModel.id, {
.references(() => userModel.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
categoryId: varchar({ length: 60 }).references(() => categoryModel.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
groupId: varchar({ length: 60 }).references(() => groupModel.id, {
onDelete: "cascade",
onUpdate: "cascade",
Expand All @@ -53,6 +56,8 @@ export const selectExpenseSchema = createSelectSchema(expenseModel, {
id: schema => schema.id.describe("Unique identifier for the expense"),
payerId: schema =>
schema.payerId.describe("Reference to the user who paid for the expense"),
creatorId: schema =>
schema.creatorId.describe("Reference to the user who created the expense"),
categoryId: schema =>
schema.categoryId.describe("Reference to the category of the expense"),
groupId: schema =>
Expand All @@ -76,7 +81,7 @@ export const selectExpenseSchema = createSelectSchema(expenseModel, {
export const insertExpenseSchema = createInsertSchema(expenseModel, {
id: schema => schema.id.describe("Unique identifier for the expense"),
payerId: schema =>
schema.payerId.describe("Reference to the user who paid for the expense"),
schema.payerId.describe("Reference to the user who paid for the expense. (Note: For admin users, this field is required.)"),
categoryId: schema =>
schema.categoryId.describe("Reference to the category of the expense"),
groupId: schema =>
Expand Down
12 changes: 12 additions & 0 deletions src/modules/categories/category.util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";

import { categoryModel } from "@/db/schemas";
import type { TInsertCategorySchema } from "@/db/schemas/category.model";

export function categorySortBy(sortBy: string | undefined) {
if (sortBy === "name") {
Expand Down Expand Up @@ -45,3 +46,14 @@ export function categoryFullTextSearch(search: string) {

return data;
}

/**
* Check if category either belongs to user or should not have any user assigned(global).
*/
export function isCategoryValidForUser(category: TInsertCategorySchema, userId: string) {
if (category.userId === userId || !category.userId) {
return true;
} else {
return false;
}
}
131 changes: 131 additions & 0 deletions src/modules/expenses/expense.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ActivityType, AuthRoles } from "@/common/enums";
import { logActivity } from "@/common/helpers/activity-log.helper";
import type { AppRouteHandler } from "@/common/lib/types";
import { AUTHORIZATION_ERROR_MESSAGE } from "@/common/utils/constants";
import * as HTTPStatusCodes from "@/common/utils/http-status-codes.util";

import { getCategoryRepository } from "../categories/category.repository";
import { getUserByIdRepository } from "../users/user.repository";
import { createExpenseRepository } from "./expense.repository";
import type { TCreateExpenseRoute } from "./expense.routes";

export const createExpense: AppRouteHandler<TCreateExpenseRoute> = async (
c,
) => {
const logger = c.get("logger");
const user = c.get("user");
const payload = c.req.valid("json");

if (!user) {
logger.debug("User is not authorized to create expense");
return c.json(
{
success: false,
message: AUTHORIZATION_ERROR_MESSAGE,
},
HTTPStatusCodes.UNAUTHORIZED,
);
}

const { payerId, categoryId } = payload;

// check if payerId is valid
if (payerId) {
const payerUser = await getUserByIdRepository(payerId);

if (!payerUser) {
logger.debug("Payer not found");
return c.json(
{
success: false,
message: "Payer not found",
},
HTTPStatusCodes.NOT_FOUND,
);
}
}

if (categoryId) {
const category = await getCategoryRepository(categoryId);

const validCategoryUserId = user.role === AuthRoles.ADMIN ? payerId : user.id;
if (category.userId && category.userId !== validCategoryUserId) {
logger.debug("Category does not belong to user");
return c.json(
{
success: false,
message: "Category does not belong to valid category user",
},
HTTPStatusCodes.BAD_REQUEST,
);
}
}

let expense;
switch (user.role) {
case AuthRoles.USER: {
const expensePayload = {
...payload,
payerId: payerId || user.id,
creatorId: user.id,
};

expense = await createExpenseRepository(expensePayload);
break;
}
case AuthRoles.ADMIN: {
// check if payerId exists
if (!payerId) {
logger.debug("Missing payer Id to create expense");
return c.json(
{
success: false,
message: "Missing payerId",
},
HTTPStatusCodes.BAD_REQUEST,
);
}

const expensePayload = {
...payload,
payerId,
creatorId: user.id,
};
expense = await createExpenseRepository(expensePayload);
break;
}
}

if (!expense) {
logger.error("Failed to create expense");
return c.json(
{
success: false,
message: "Failed to create expense",
},
HTTPStatusCodes.INTERNAL_SERVER_ERROR,
);
}

void logActivity({
type: ActivityType.EXPENSE_ADDED,
metadata: {
action: "create",
resourceType: "expense",
actorId: user.id,
targetId: expense.id,
destinationId: expense.id,
actorName: user.fullName || "",
msg: "expense created",
},
});
logger.debug(`Expense created successfully`);
return c.json(
{
success: true,
message: "Expense created successfully",
data: expense,
},
HTTPStatusCodes.CREATED,
);
};
9 changes: 9 additions & 0 deletions src/modules/expenses/expense.index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createRouter } from "@/common/lib/create-app.lib";

import * as handlers from "./expense.handlers";
import * as routes from "./expense.routes";

export const expenseRouter = createRouter().openapi(
routes.createExpenseRoute,
handlers.createExpense,
);
16 changes: 16 additions & 0 deletions src/modules/expenses/expense.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { db } from "@/db/adapter";
import type { TInsertExpenseSchema } from "@/db/schemas/expense.model";
import expenseModel from "@/db/schemas/expense.model";

type TExpensePayload = Omit<TInsertExpenseSchema, "id" | "createdAt" | "updatedAt" | "groupId" >;

export async function createExpenseRepository(
expensePayload: TExpensePayload,
) {
const [expense] = await db
.insert(expenseModel)
.values(expensePayload)
.returning();

return expense;
}
87 changes: 87 additions & 0 deletions src/modules/expenses/expense.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { createRoute } from "@hono/zod-openapi";
import { z } from "zod";

import jsonContentRequired from "@/common/helpers/json-content-required.helper";
import { jsonContent } from "@/common/helpers/json-content.helper";
import {
authMiddleware,
requireAuth,
} from "@/common/middlewares/auth.middleware";
import createErrorSchema from "@/common/schema/create-error.schema";
import { AUTHORIZATION_ERROR_MESSAGE, INTERNAL_SERVER_ERROR_MESSAGE, VALIDATION_ERROR_MESSAGE } from "@/common/utils/constants";
import * as HTTPStatusCodes from "@/common/utils/http-status-codes.util";
import {
insertExpenseSchema,
selectExpenseSchema,
} from "@/db/schemas/expense.model";

const tags = ["Expenses"];

export const createExpenseRoute = createRoute({
tags,
method: "post",
path: "/expenses",
middleware: [
authMiddleware(),
requireAuth(),
] as const,
request: {
body: jsonContentRequired(
insertExpenseSchema
.omit({
id: true,
createdAt: true,
updatedAt: true,
creatorId: true,
})
.partial({
payerId: true,
}),
"Expense creation details",
),
},
responses: {
[HTTPStatusCodes.CREATED]: jsonContent(
z.object({
success: z.boolean().default(true),
message: z.string(),
data: selectExpenseSchema,
}),
"Expense created successfully",
),
[HTTPStatusCodes.BAD_REQUEST]: jsonContent(
z.object({
success: z.boolean().default(false),
message: z.string(),
}),
VALIDATION_ERROR_MESSAGE,
),
[HTTPStatusCodes.UNAUTHORIZED]: jsonContent(
z.object({
success: z.boolean().default(false),
message: z.string(),
}),
AUTHORIZATION_ERROR_MESSAGE,
),
[HTTPStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(
createErrorSchema(insertExpenseSchema),
VALIDATION_ERROR_MESSAGE,
),
[HTTPStatusCodes.NOT_FOUND]: jsonContent(
z.object({
success: z.boolean().default(false),
message: z.string(),
}),
VALIDATION_ERROR_MESSAGE,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not found should not be same as validation error, please check.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shivamvijaywargi i updated error message to Not found error(s). Works?

image

And thank you for getting my attention to "Not Found" status, with this i got to know some mistakes in test cases related to 404. I have fixed them as well. 5ae08b5

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about "Requested resource not found"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya this is good. I will update it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

),
[HTTPStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
z.object({
success: z.boolean().default(false),
message: z.string(),
}),
INTERNAL_SERVER_ERROR_MESSAGE,
),
},
});

export type TCreateExpenseRoute = typeof createExpenseRoute;
Loading
Loading