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 10 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
16 changes: 10 additions & 6 deletions src/db/schemas/expense.model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { splitTypeArr } from "@/common/enums";
import {
pgEnum,
pgTable,
Expand All @@ -9,8 +10,6 @@ import {
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type { z } from "zod";

import { splitTypeArr } from "@/common/enums";

import categoryModel from "./category.model";
import groupModel from "./group.model";
import userModel from "./user.model";
Expand All @@ -22,19 +21,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 +55,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 +80,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
15 changes: 13 additions & 2 deletions src/modules/categories/category.util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";

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

export function categorySortBy(sortBy: string | undefined) {
if (sortBy === "name") {
Expand Down Expand Up @@ -45,3 +45,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;
}
}
132 changes: 132 additions & 0 deletions src/modules/expenses/expense.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { AppRouteHandler } from "@/common/lib/types";

import { ActivityType, AuthRoles } from "@/common/enums";
import { logActivity } from "@/common/helpers/activity-log.helper";
import * as HTTPStatusCodes from "@/common/utils/http-status-codes.util";

import type { TCreateExpenseRoute } from "./expense.routes";

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

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: "You are not authorized, please login",
},
HTTPStatusCodes.UNAUTHORIZED,
);
}

const { payerId } = 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,
);
}
}

const { categoryId } = payload;
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,
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,
);
17 changes: 17 additions & 0 deletions src/modules/expenses/expense.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { TInsertExpenseSchema } from "@/db/schemas/expense.model";

import { db } from "@/db/adapter";
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;
}
86 changes: 86 additions & 0 deletions src/modules/expenses/expense.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 * 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(),
}),
"The validation error(s)",
),
[HTTPStatusCodes.UNAUTHORIZED]: jsonContent(
z.object({
success: z.boolean().default(false),
message: z.string(),
}),
"You are not authorized, please login",
),
[HTTPStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(
createErrorSchema(insertExpenseSchema),
"The validation error(s)",
),
[HTTPStatusCodes.NOT_FOUND]: jsonContent(
z.object({
success: z.boolean().default(false),
message: z.string(),
}),
"The validation error(s)",
),
[HTTPStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
z.object({
success: z.boolean().default(false),
message: z.string(),
}),
"Failed to create expense",
),
},
});

export type TCreateExpenseRoute = typeof createExpenseRoute;
Loading
Loading