diff --git a/src/server/api/index.ts b/src/server/api/index.ts index 7e919c248..a8448b335 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -3,6 +3,7 @@ import { middlewareServices } from "./middlewares/services"; import { registerCompanyRoutes } from "./routes/company"; import { registerShareRoutes } from "./routes/share"; import { registerStakeholderRoutes } from "./routes/stakeholder"; +import { registerOptionRoutes } from "./routes/stock-option"; const api = PublicAPI(); @@ -12,5 +13,6 @@ api.use("*", middlewareServices()); registerCompanyRoutes(api); registerShareRoutes(api); registerStakeholderRoutes(api); +registerOptionRoutes(api); export default api; diff --git a/src/server/api/routes/stock-option/create.ts b/src/server/api/routes/stock-option/create.ts new file mode 100644 index 000000000..f5c4706c5 --- /dev/null +++ b/src/server/api/routes/stock-option/create.ts @@ -0,0 +1,146 @@ +import { generatePublicId } from "@/common/id"; +import { + CreateOptionSchema, + type TOptionSchema, +} from "@/server/api/schema/option"; +import { + authMiddleware, + withAuthApiV1, +} from "@/server/api/utils/endpoint-creator"; +import { Audit } from "@/server/audit"; +import { z } from "@hono/zod-openapi"; + +type AuditPromise = ReturnType; + +const ResponseSchema = z.object({ + message: z.string(), + data: CreateOptionSchema, +}); + +const ParamsSchema = z.object({ + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +export const create = withAuthApiV1 + .createRoute({ + method: "post", + path: "/v1/{companyId}/options", + summary: "Create options", + description: "Issue options to a stakeholder in a company.", + tags: ["Options"], + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + body: { + content: { + "application/json": { + schema: CreateOptionSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation of options issued with relevant details.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client; + + const { documents, ...rest } = c.req.valid("json"); + + const option = await db.$transaction(async (tx) => { + const option = await tx.option.create({ + data: { ...rest, companyId: membership.companyId }, + }); + + let auditPromises: AuditPromise[] = []; + + if (documents && documents.length > 0) { + const bulkDocuments = documents.map((doc) => ({ + companyId: membership.companyId, + uploaderId: membership.memberId, + publicId: generatePublicId(), + name: doc.name, + bucketId: doc.bucketId, + optionId: option.id, + })); + + const docs = await tx.document.createManyAndReturn({ + data: bulkDocuments, + skipDuplicates: true, + select: { + id: true, + name: true, + }, + }); + + auditPromises = docs.map((doc) => + Audit.create( + { + action: "document.created", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "document", id: doc.id }], + summary: `${membership.user.name} created a document while issuing a stock option : ${doc.name}`, + }, + tx, + ), + ); + } + + auditPromises.push( + audit.create( + { + action: "option.created", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "option", id: option.id }], + summary: `${membership.user.name} added option for stakeholder ${option.stakeholderId}`, + }, + tx, + ), + ); + + await Promise.all(auditPromises); + + return option; + }); + + const data: TOptionSchema = { + ...option, + createdAt: option.createdAt.toISOString(), + updatedAt: option.updatedAt.toISOString(), + issueDate: option.issueDate.toISOString(), + expirationDate: option.expirationDate.toISOString(), + rule144Date: option.rule144Date.toISOString(), + vestingStartDate: option.vestingStartDate.toISOString(), + boardApprovalDate: option.boardApprovalDate.toISOString(), + }; + + return c.json({ message: "Option successfully created.", data }, 200); + }); diff --git a/src/server/api/routes/stock-option/delete.ts b/src/server/api/routes/stock-option/delete.ts new file mode 100644 index 000000000..1ce9b88fd --- /dev/null +++ b/src/server/api/routes/stock-option/delete.ts @@ -0,0 +1,111 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Option ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + message: z.string().openapi({ + description: + "A text providing details about the API request result, including success, error, or warning messages.", + }), +}); + +export const deleteOne = withAuthApiV1 + .createRoute({ + method: "delete", + path: "/v1/{companyId}/options/{id}", + summary: "Delete a option", + description: "Remove an issued option by its ID.", + tags: ["Options"], + middleware: [authMiddleware()], + request: { params: ParamsSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation that the issued option has been removed.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client as { + requestIp: string; + userAgent: string; + }; + + const { id } = c.req.valid("param"); + + await db.$transaction(async (tx) => { + const option = await tx.option.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + select: { + id: true, + stakeholderId: true, + }, + }); + + if (!option) { + throw new ApiError({ + code: "NOT_FOUND", + message: `Option with ID ${id} not found`, + }); + } + + await tx.option.delete({ + where: { + id: option.id, + }, + }); + + await audit.create( + { + action: "option.deleted", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "option", id }], + summary: `${membership.user.name} deleted the option for stakeholder ${option.stakeholderId}`, + }, + tx, + ); + }); + + return c.json( + { + message: "Option deleted successfully", + }, + 200, + ); + }); diff --git a/src/server/api/routes/stock-option/getMany.ts b/src/server/api/routes/stock-option/getMany.ts new file mode 100644 index 000000000..b43fa89fa --- /dev/null +++ b/src/server/api/routes/stock-option/getMany.ts @@ -0,0 +1,84 @@ +import { OptionSchema } from "@/server/api/schema/option"; +import { z } from "@hono/zod-openapi"; +import { + PaginationQuerySchema, + PaginationResponseSchema, +} from "../../schema/pagination"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ResponseSchema = z.object({ + data: z.array(OptionSchema), + meta: PaginationResponseSchema, +}); + +const ParamsSchema = z.object({ + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +export const getMany = withAuthApiV1 + .createRoute({ + summary: "List options", + description: "Retrieve a list of issued options for the company.", + tags: ["Options"], + method: "get", + path: "/v1/{companyId}/options", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + query: PaginationQuerySchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "A list of issued options with their details.", + }, + }, + }) + .handler(async (c) => { + const { membership } = c.get("session"); + const { db } = c.get("services"); + const query = c.req.valid("query"); + + // To get rid of parseCursor in each getMany route + //@TODO(Better to have parsing at server/db in root ) + const [data, meta] = await db.option + .paginate({ where: { companyId: membership.companyId } }) + .withCursor({ + limit: query.limit, + after: query.cursor, + getCursor({ id }) { + return id; + }, + parseCursor(cursor) { + return { id: cursor }; + }, + }); + + const response: z.infer = { + meta, + data: data.map((i) => ({ + ...i, + createdAt: i.createdAt.toISOString(), + updatedAt: i.updatedAt.toISOString(), + issueDate: i.issueDate.toISOString(), + expirationDate: i.expirationDate.toISOString(), + rule144Date: i.rule144Date.toISOString(), + vestingStartDate: i.vestingStartDate.toISOString(), + boardApprovalDate: i.boardApprovalDate.toISOString(), + })), + }; + return c.json(response, 200); + }); diff --git a/src/server/api/routes/stock-option/getOne.ts b/src/server/api/routes/stock-option/getOne.ts new file mode 100644 index 000000000..6b2b10428 --- /dev/null +++ b/src/server/api/routes/stock-option/getOne.ts @@ -0,0 +1,93 @@ +import { OptionSchema, type TOptionSchema } from "@/server/api/schema/option"; +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; + +import { + authMiddleware, + withAuthApiV1, +} from "@/server/api/utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Option ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + data: OptionSchema, +}); + +export const getOne = withAuthApiV1 + .createRoute({ + summary: "Get an option", + description: "Fetch a single issued option record by its ID.", + tags: ["Options"], + method: "get", + path: "/v1/{companyId}/options/{id}", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Details of the requested issued option.", + }, + }, + }) + .handler(async (c) => { + const { db } = c.get("services"); + const { membership } = c.get("session"); + const { id } = c.req.valid("param"); + + const option = await db.option.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + }); + + if (!option) { + throw new ApiError({ + code: "NOT_FOUND", + message: `No option with the provided Id ${id}`, + }); + } + + const data: TOptionSchema = { + ...option, + createdAt: option.createdAt.toISOString(), + updatedAt: option.updatedAt.toISOString(), + issueDate: option.issueDate.toISOString(), + expirationDate: option.expirationDate.toISOString(), + rule144Date: option.rule144Date.toISOString(), + vestingStartDate: option.vestingStartDate.toISOString(), + boardApprovalDate: option.boardApprovalDate.toISOString(), + }; + + return c.json( + { + data, + }, + 200, + ); + }); diff --git a/src/server/api/routes/stock-option/index.ts b/src/server/api/routes/stock-option/index.ts new file mode 100644 index 000000000..0f7f16260 --- /dev/null +++ b/src/server/api/routes/stock-option/index.ts @@ -0,0 +1,14 @@ +import type { PublicAPI } from "@/server/api/hono"; +import { create } from "./create"; +import { deleteOne } from "./delete"; +import { getMany } from "./getMany"; +import { getOne } from "./getOne"; +import { update } from "./update"; + +export const registerOptionRoutes = (api: PublicAPI) => { + api.openapi(create.route, create.handler); + api.openapi(getOne.route, getOne.handler); + api.openapi(getMany.route, getMany.handler); + api.openapi(update.route, update.handler); + api.openapi(deleteOne.route, deleteOne.handler); +}; diff --git a/src/server/api/routes/stock-option/update.ts b/src/server/api/routes/stock-option/update.ts new file mode 100644 index 000000000..2b354f8a2 --- /dev/null +++ b/src/server/api/routes/stock-option/update.ts @@ -0,0 +1,136 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; + +import { + OptionSchema, + type TOptionSchema, + UpdateOptionSchema, +} from "@/server/api/schema/option"; + +import { + authMiddleware, + withAuthApiV1, +} from "@/server/api/utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Option ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + message: z.string(), + data: OptionSchema, +}); + +export const update = withAuthApiV1 + .createRoute({ + summary: "Update Issued Options", + description: "Update details of an issued option by its ID.", + tags: ["Options"], + method: "patch", + path: "/v1/{companyId}/options/{id}", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + body: { + content: { + "application/json": { + schema: UpdateOptionSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation of updated issued option details.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client; + const { id } = c.req.valid("param"); + + const body = c.req.valid("json"); + + const updatedOption = await db.$transaction(async (tx) => { + const option = await db.option.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + }); + + if (!option) { + throw new ApiError({ + code: "NOT_FOUND", + message: `No option with the provided Id ${id}`, + }); + } + + const updatedOption = await tx.option.update({ + where: { + id: option.id, + }, + data: body, + }); + + await audit.create( + { + action: "option.updated", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + userAgent: userAgent, + requestIp: requestIp, + }, + target: [{ type: "option", id: option.id }], + summary: `${membership.user.name} updated option the option ID ${updatedOption.id}`, + }, + tx, + ); + + return updatedOption; + }); + + const data: TOptionSchema = { + ...updatedOption, + createdAt: updatedOption.createdAt.toISOString(), + updatedAt: updatedOption.updatedAt.toISOString(), + issueDate: updatedOption.issueDate.toISOString(), + expirationDate: updatedOption.expirationDate.toISOString(), + rule144Date: updatedOption.rule144Date.toISOString(), + vestingStartDate: updatedOption.vestingStartDate.toISOString(), + boardApprovalDate: updatedOption.boardApprovalDate.toISOString(), + }; + + return c.json( + { + message: "Option updated successfully", + data, + }, + 200, + ); + }); diff --git a/src/server/api/schema/option.ts b/src/server/api/schema/option.ts new file mode 100644 index 000000000..9d874fde0 --- /dev/null +++ b/src/server/api/schema/option.ts @@ -0,0 +1,158 @@ +import { OptionStatusEnum, OptionTypeEnum } from "@/prisma/enums"; +import { z } from "zod"; + +const OptionTypeArray = Object.values(OptionTypeEnum) as [ + OptionTypeEnum, + ...OptionTypeEnum[], +]; +const OptionStatusArray = Object.values(OptionStatusEnum) as [ + OptionStatusEnum, + ...OptionStatusEnum[], +]; + +export const OptionSchema = z + .object({ + id: z.string().optional().openapi({ + description: "Stock Option ID", + example: "abc123", + }), + + grantId: z.string().openapi({ + description: "Grant ID", + example: "grant123", + }), + + quantity: z.coerce.number().min(0).openapi({ + description: "Quantity of Stock Options", + example: 100, + }), + + exercisePrice: z.coerce.number().min(0).openapi({ + description: "Exercise Price per Stock Option", + example: 10.5, + }), + + type: z.enum(OptionTypeArray).openapi({ + description: "Type of Stock Option", + example: "ISO", + }), + + status: z.enum(OptionStatusArray).openapi({ + description: "Status of Stock Option", + example: "DRAFT", + }), + + cliffYears: z.coerce.number().min(0).openapi({ + description: "Cliff Years", + example: 1, + }), + + vestingYears: z.coerce.number().min(0).openapi({ + description: "Vesting Years", + example: 4, + }), + + issueDate: z.string().datetime().openapi({ + description: "Issue Date", + example: "2024-01-01", + }), + + expirationDate: z.string().datetime().openapi({ + description: "Expiration Date", + example: "2024-01-01T00:00:00.000Z", + }), + + vestingStartDate: z.string().datetime().openapi({ + description: "Vesting Start Date", + example: "2024-01-01T00:00:00.000Z", + }), + + boardApprovalDate: z.string().datetime().openapi({ + description: "Board Approval Date", + example: "2024-01-01T00:00:00.000Z", + }), + + rule144Date: z.string().datetime().openapi({ + description: "Rule 144 Date", + example: "2024-01-01T00:00:00.000Z", + }), + + stakeholderId: z.string().openapi({ + description: "Stakeholder ID", + example: "clz5vr0bd0001tqroiuc7lw1b", + }), + + companyId: z.string().cuid().openapi({ + description: "Company ID", + example: "clyvb28ak0000f1ngcn2i0p2m", + }), + + equityPlanId: z.string().openapi({ + description: "Equity Plan ID", + example: "clz5vtipf0003tqrovvrpepp8", + }), + + documents: z + .array( + z.object({ + bucketId: z.string(), + name: z.string(), + }), + ) + .optional() + .openapi({ + description: "Documents", + example: [ + { bucketId: "clyvb28ak0000f1ngcn2i0p2m", name: "Esign docs" }, + ], + }), + + createdAt: z.string().date().nullish().openapi({ + description: "Option Created at", + example: "2024-01-01T00:00:00.000Z", + }), + + updatedAt: z.string().date().nullish().openapi({ + description: "Option Updated at", + example: "2024-01-01T00:00:00.000Z", + }), + }) + .openapi({ + description: "Get a single option by ID", + }); + +export const CreateOptionSchema = OptionSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + companyId: true, +}) + .strict() + .openapi({ + description: "Issue options to a stakeholder in a company.", + }); + +export const UpdateOptionSchema = OptionSchema.omit({ + id: true, + documents: true, + companyId: true, + createdAt: true, + updatedAt: true, +}) + .partial() + .strict() + .refine( + (data) => { + return Object.values(data).some((value) => value !== undefined); + }, + { + message: "At least one field must be provided to update.", + }, + ) + .openapi({ + description: "Update an option by ID", + }); + +export type TOptionSchema = z.infer; +export type TCreateOptionSchema = z.infer; +export type TUpdateOptionSchema = z.infer; diff --git a/src/server/audit/schema.ts b/src/server/audit/schema.ts index d952ead73..6c2b29dde 100644 --- a/src/server/audit/schema.ts +++ b/src/server/audit/schema.ts @@ -36,6 +36,7 @@ export const AuditSchema = z.object({ "document.deleted", "option.created", + "option.updated", "option.deleted", "share.created",