feat: add time entry manager, controller, and API routes

This commit is contained in:
2026-04-21 00:52:35 +00:00
parent 38654601c9
commit a55850e2c1
39 changed files with 4700 additions and 440 deletions
+7
View File
@@ -0,0 +1,7 @@
import { Hono } from "hono";
import * as timeEntryRoutes from "../time-entries";
const timeEntryRouter = new Hono();
Object.values(timeEntryRoutes).map((r) => timeEntryRouter.route("/", r));
export default timeEntryRouter;
@@ -3,6 +3,7 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { opportunities } from "../../../../../managers/opportunities";
import { timeEntries } from "../../../../../managers/timeEntries";
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
import { ActivityController } from "../../../../../controllers/ActivityController";
import { OptimaType } from "../../../../../workflows/wf.opportunity";
@@ -31,6 +32,9 @@ const QUOTE_ID_FIELD_ID = 48;
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
const CLOSE_DATE_FIELD_ID = 49;
/** Parent_Activity custom field ID. */
const PARENT_ACTIVITY_FIELD_ID = 50;
/**
* Extract the Optima_Type value from a CW activity's custom fields.
* Returns the string value if present, or null.
@@ -70,6 +74,22 @@ function extractCloseDate(
return field.value;
}
/**
* Extract the Parent_Activity custom field value from a CW activity.
* Returns the numeric activity ID or null.
*/
function extractParentActivityId(
customFields: { id: number; value: unknown }[] | undefined,
): number | null {
if (!customFields) return null;
const field = customFields.find(
(f) => f.id === PARENT_ACTIVITY_FIELD_ID || (f as any).caption === "Parent_Activity",
);
if (!field?.value) return null;
const parsed = parseInt(String(field.value), 10);
return isNaN(parsed) ? null : parsed;
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
@@ -96,6 +116,7 @@ export default createRoute(
activity: ReturnType<ActivityController["toJson"]>;
optimaType: string;
quoteId: string | null;
parentActivityId: number | null;
closed: boolean;
closedAt: string | null;
}[] = [];
@@ -109,6 +130,7 @@ export default createRoute(
if (filterType && optimaType !== filterType) continue;
const quoteId = extractQuoteId(raw.customFields);
const parentActivityId = extractParentActivityId(raw.customFields);
const closed = raw.status?.id === 2;
const closedAt = extractCloseDate(raw.customFields);
@@ -116,6 +138,7 @@ export default createRoute(
activity: json,
optimaType,
quoteId,
parentActivityId,
closed,
closedAt,
});
@@ -132,13 +155,26 @@ export default createRoute(
return dateB - dateA;
});
// Attach time entries for each activity in parallel
const activitiesWithTimeEntries = await Promise.all(
workflowActivities.map(async (item) => {
const entries = await timeEntries.fetchByActivity(
item.activity.cwActivityId,
);
return {
...item,
timeEntries: entries.map((e) => e.toJson()),
};
}),
);
const response = apiResponse.successful(
"Workflow history fetched successfully.",
{
opportunityId: opportunity.id,
cwOpportunityId: opportunity.cwOpportunityId,
totalActivities: workflowActivities.length,
activities: workflowActivities,
totalActivities: activitiesWithTimeEntries.length,
activities: activitiesWithTimeEntries,
},
);
return c.json(response, response.status as ContentfulStatusCode);
+2
View File
@@ -16,6 +16,7 @@ import procurementRouter from "./routers/procurementRouter";
import salesRouter from "./routers/salesRouter";
import cwRouter from "./routers/cwRouter";
import scheduleRouter from "./routers/scheduleRouter";
import timeEntryRouter from "./routers/timeEntryRouter";
const app = new Hono();
const v1 = new Hono();
@@ -73,6 +74,7 @@ v1.route("/procurement", procurementRouter);
v1.route("/sales", salesRouter);
v1.route("/cw", cwRouter);
v1.route("/schedule", scheduleRouter);
v1.route("/time-entry", timeEntryRouter);
app.route("/v1", v1);
export default app;
@@ -0,0 +1,22 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { timeEntries } from "../../../managers/timeEntries";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/time-entry/time-entries/:identifier */
export default createRoute(
"get",
["/time-entries/:identifier"],
async (c) => {
const entry = await timeEntries.fetch(c.req.param("identifier"));
const response = apiResponse.successful(
"Time entry fetched successfully!",
entry.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["time-entry.fetch"] }),
);
+22
View File
@@ -0,0 +1,22 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { timeEntries } from "../../managers/timeEntries";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* GET /v1/time-entry/count */
export default createRoute(
"get",
["/count"],
async (c) => {
const count = await timeEntries.count();
const response = apiResponse.successful(
"Time entry count fetched successfully!",
{ count },
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
);
+42
View File
@@ -0,0 +1,42 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { timeEntries } from "../../managers/timeEntries";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* GET /v1/time-entry/time-entries?page=&rpp=&search= */
export default createRoute(
"get",
["/time-entries"],
async (c) => {
const page = new Number(c.req.query("page") ?? 1) as number;
const rpp = new Number(c.req.query("rpp") ?? 30) as number;
const search = c.req.query("search");
const data = search
? await timeEntries.search(search, page, rpp)
: await timeEntries.fetchPages(page, rpp);
const totalRecords = search
? (await timeEntries.search(search, 1, 999999)).length
: await timeEntries.count();
const response = apiResponse.successful(
"Time entries fetched successfully!",
data.map((e) => e.toJson()),
{
pagination: {
previousPage: page == 1 ? null : page - 1,
currentPage: page,
nextPage: page >= totalRecords / rpp ? null : page + 1,
totalPages: Math.ceil(totalRecords / rpp),
totalRecords,
listedRecords: rpp,
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
);
+8
View File
@@ -0,0 +1,8 @@
import { default as fetchAll } from "./fetchAll";
import { default as count } from "./count";
import { default as fetch } from "./[identifier]/fetch";
import { default as fetchByMember } from "./member/fetchByMember";
import { default as fetchByTicket } from "./ticket/fetchByTicket";
import { default as fetchMyTimeEntries } from "./me/fetchMyTimeEntries";
export { count, fetch, fetchAll, fetchByMember, fetchByTicket, fetchMyTimeEntries };
@@ -0,0 +1,69 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { timeEntries } from "../../../managers/timeEntries";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
/* GET /v1/time-entry/@me?start=<ISO>&end=<ISO> */
export default createRoute(
"get",
["/@me"],
async (c) => {
const user = c.get("user");
if (!user?.cwIdentifier) {
throw new GenericError({
name: "BadRequest",
message:
"Your account is not linked to a ConnectWise member. Cannot fetch time entries.",
status: 400,
});
}
const startParam = c.req.query("start");
const endParam = c.req.query("end");
if (!startParam || !endParam) {
throw new GenericError({
name: "BadRequest",
message:
"Query params 'start' and 'end' are required (ISO 8601 date strings).",
status: 400,
});
}
const startDate = new Date(startParam);
const endDate = new Date(endParam);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new GenericError({
name: "BadRequest",
message: "Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00Z).",
status: 400,
});
}
if (startDate >= endDate) {
throw new GenericError({
name: "BadRequest",
message: "'start' must be before 'end'.",
status: 400,
});
}
const data = await timeEntries.fetchByDateRange(
startDate,
endDate,
user.cwIdentifier,
);
const response = apiResponse.successful(
"Time entries fetched successfully!",
data.map((e) => e.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["time-entry.fetch"] }),
);
@@ -0,0 +1,37 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { timeEntries } from "../../../managers/timeEntries";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/time-entry/member/:memberId?page=&rpp= */
export default createRoute(
"get",
["/member/:memberId"],
async (c) => {
const memberId = c.req.param("memberId");
const page = new Number(c.req.query("page") ?? 1) as number;
const rpp = new Number(c.req.query("rpp") ?? 30) as number;
const data = await timeEntries.fetchByMember(memberId, page, rpp);
const totalRecords = await timeEntries.countByMember(memberId);
const response = apiResponse.successful(
"Time entries fetched successfully!",
data.map((e) => e.toJson()),
{
pagination: {
previousPage: page == 1 ? null : page - 1,
currentPage: page,
nextPage: page >= totalRecords / rpp ? null : page + 1,
totalPages: Math.ceil(totalRecords / rpp),
totalRecords,
listedRecords: rpp,
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
);
@@ -0,0 +1,24 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { timeEntries } from "../../../managers/timeEntries";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/time-entry/ticket/:ticketId */
export default createRoute(
"get",
["/ticket/:ticketId"],
async (c) => {
const ticketId = parseInt(c.req.param("ticketId"), 10);
const data = await timeEntries.fetchByTicket(ticketId);
const response = apiResponse.successful(
"Time entries fetched successfully!",
data.map((e) => e.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
);
+131
View File
@@ -0,0 +1,131 @@
import {
TimeEntry,
TimeEntryStatus,
TimeEntryChargeCode,
Company,
ServiceTicket,
Activity,
Contact,
CorporateLocation,
} from "../../generated/prisma/client";
type TimeEntryWithRelations = TimeEntry & {
status?: TimeEntryStatus | null;
chargeCode?: TimeEntryChargeCode | null;
company?: Company | null;
serviceTicket?: ServiceTicket | null;
activity?: Activity | null;
contact?: Contact | null;
location?: CorporateLocation | null;
};
export class TimeEntryController {
public readonly id: number;
public readonly uid: string;
private _data: TimeEntryWithRelations;
constructor(data: TimeEntryWithRelations) {
this.id = data.id;
this.uid = data.uid;
this._data = data;
}
public toJson() {
const d = this._data;
return {
id: d.uid,
cwId: d.id,
memberId: d.memberId,
// ------ Time Fields ------
dateStart: d.dateStart,
timeStart: d.timeStart,
timeEnd: d.timeEnd,
// ------ Notes ------
notes: d.notes,
notesMd: d.notesMd,
internalNote: d.internalNote,
// ------ Hours ------
billableHours: d.billableHours,
actualHours: d.actualHours,
invoicedHours: d.invoicedHours,
deductedHours: d.deductedHours,
// ------ Rates ------
hourlyRate: d.hourlyRate,
effectiveRate: d.effectiveRate,
// ------ Flag Fields ------
issueFlag: d.issueFlag,
mergedFlag: d.mergedFlag,
invoiceFlag: d.invoiceFlag,
billableFlag: d.billableFlag,
documentFlag: d.documentFlag,
teProblemFlag: d.teProblemFlag,
teResolutionFlag: d.teResolutionFlag,
teInternalAnalysisFlag: d.teInternalAnalysisFlag,
// ------ Charge Info ------
chargeToRecId: d.chargeToRecId,
chargeToType: d.chargeToType,
// ------ Relations ------
company: d.company
? { id: d.company.uid, cwId: d.company.id, name: d.company.name }
: null,
serviceTicket: d.serviceTicket
? {
id: d.serviceTicket.uid,
cwId: d.serviceTicket.id,
summary: d.serviceTicket.summary,
}
: null,
activity: d.activity
? {
id: d.activity.uid,
cwId: d.activity.id,
subject: d.activity.subject,
}
: null,
contact: d.contact
? {
id: d.contact.uid,
cwId: d.contact.id,
name: `${d.contact.firstName} ${d.contact.lastName}`.trim(),
}
: null,
location: d.location
? { id: d.location.uid, cwId: d.location.id, name: d.location.name }
: null,
status: d.status
? {
id: d.status.uid,
cwId: d.status.id,
description: d.status.description,
action: d.status.action,
}
: null,
chargeCode: d.chargeCode
? {
id: d.chargeCode.uid,
cwId: d.chargeCode.id,
description: d.chargeCode.description,
expenseFlag: d.chargeCode.expenseFlag,
timeFlag: d.chargeCode.timeFlag,
billableFlag: d.chargeCode.billableFlag,
invoiceFlag: d.chargeCode.invoiceFlag,
}
: null,
// ------ Audit ------
createdById: d.createdById,
updatedById: d.updatedById,
originalAuthorId: d.originalAuthorId,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};
}
}
+147
View File
@@ -0,0 +1,147 @@
import { prisma } from "../constants";
import { TimeEntryController } from "../controllers/TimeEntryController";
const timeEntryIncludes = {
status: true,
chargeCode: true,
company: true,
serviceTicket: true,
activity: true,
contact: true,
location: true,
} as const;
export const timeEntries = {
async fetch(identifier: string | number): Promise<TimeEntryController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const entry = await prisma.timeEntry.findFirst({
where: isNumeric
? { id: Number(identifier) }
: { uid: String(identifier) },
include: timeEntryIncludes,
});
if (!entry) throw new Error("Unknown time entry.");
return new TimeEntryController(entry);
},
async count(): Promise<number> {
return prisma.timeEntry.count();
},
async fetchPages(page: number, rpp: number): Promise<TimeEntryController[]> {
page = page.valueOf();
rpp = rpp.valueOf();
const skip = (page > 1 ? page - 1 : 0) * rpp;
const take = rpp ?? 30;
const data = await prisma.timeEntry.findMany({
skip,
take,
include: timeEntryIncludes,
orderBy: { dateStart: "desc" },
});
return data.map((e) => new TimeEntryController(e));
},
async search(
query: string,
page: number,
rpp: number
): Promise<TimeEntryController[]> {
page = page.valueOf();
rpp = rpp.valueOf();
const skip = (page > 1 ? page - 1 : 0) * rpp;
const take = rpp ?? 30;
const numericQuery = parseInt(query, 10);
const data = await prisma.timeEntry.findMany({
where: {
OR: [
{ notes: { contains: query, mode: "insensitive" } },
{ internalNote: { contains: query, mode: "insensitive" } },
{ uid: { contains: query, mode: "insensitive" } },
...(!isNaN(numericQuery) ? [{ id: numericQuery }] : []),
],
},
skip,
take,
include: timeEntryIncludes,
orderBy: { dateStart: "desc" },
});
return data.map((e) => new TimeEntryController(e));
},
async fetchByMember(
memberId: string,
page: number,
rpp: number
): Promise<TimeEntryController[]> {
page = page.valueOf();
rpp = rpp.valueOf();
const skip = (page > 1 ? page - 1 : 0) * rpp;
const take = rpp ?? 30;
const data = await prisma.timeEntry.findMany({
where: { memberId },
skip,
take,
include: timeEntryIncludes,
orderBy: { dateStart: "desc" },
});
return data.map((e) => new TimeEntryController(e));
},
async countByMember(memberId: string): Promise<number> {
return prisma.timeEntry.count({ where: { memberId } });
},
async fetchByTicket(
serviceTicketId: number
): Promise<TimeEntryController[]> {
const data = await prisma.timeEntry.findMany({
where: { serviceTicketId },
include: timeEntryIncludes,
orderBy: { dateStart: "desc" },
});
return data.map((e) => new TimeEntryController(e));
},
async fetchByActivity(activityId: number): Promise<TimeEntryController[]> {
const data = await prisma.timeEntry.findMany({
where: { activityId },
include: timeEntryIncludes,
orderBy: { dateStart: "desc" },
});
return data.map((e) => new TimeEntryController(e));
},
async fetchByDateRange(
startDate: Date,
endDate: Date,
memberId?: string
): Promise<TimeEntryController[]> {
const data = await prisma.timeEntry.findMany({
where: {
dateStart: { gte: startDate, lte: endDate },
...(memberId ? { memberId } : {}),
},
include: timeEntryIncludes,
orderBy: { dateStart: "asc" },
});
return data.map((e) => new TimeEntryController(e));
},
};
+51 -11
View File
@@ -1616,6 +1616,32 @@ export async function createScheduleEntry(
: undefined;
const dateEnd = payload.endTime ? toCwDateTime(payload.endTime) : undefined;
// Find the currently open workflow activity (OpportunitySetup, OpportunityReview,
// or Revision) to use as the parent for this schedule entry.
let parentActivityCwId: number | null = null;
try {
const existingActivities = await activityCw.fetchByOpportunityDirect(
opportunity.cwOpportunityId,
);
for (const raw of existingActivities) {
if (raw.status?.id === 2) continue; // already closed
const optimaField = raw.customFields?.find(
(f: any) => f.id === OptimaType.FIELD_ID,
);
if (!optimaField?.value) continue;
if (optimaField.value === OptimaType.ScheduleEntry) continue; // skip other schedule entries
if (STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) {
parentActivityCwId = raw.id;
break;
}
}
} catch (err) {
// Non-fatal — schedule entry will be created without a parent
console.warn(
`[Workflow:ScheduleEntry] Could not resolve parent activity: ${err}`,
);
}
const activity = await ActivityController.create({
name: `[Schedule Entry] ${payload.activityTypeValue}${opportunity.name}`,
type: { id: 3 }, // HistoricEntry
@@ -1627,21 +1653,35 @@ export async function createScheduleEntry(
...(dateEnd ? { dateEnd } : {}),
});
// Set Optima_Type to Schedule Entry (stays open)
// Build custom fields: always Optima_Type, plus Parent_Activity when resolved
const customFields: any[] = [
{
id: OptimaType.FIELD_ID,
caption: "Optima_Type",
type: "Text",
entryMethod: "List",
numberOfDecimals: 0,
value: OptimaType.ScheduleEntry,
},
];
if (parentActivityCwId != null) {
customFields.push({
id: PARENT_ACTIVITY_FIELD_ID,
caption: "Parent_Activity",
type: "Text",
entryMethod: "EntryField",
numberOfDecimals: 0,
value: String(parentActivityCwId),
});
}
// Set Optima_Type (+ Parent_Activity) on the new schedule entry
await activity.update([
{
op: "replace",
path: "customFields",
value: [
{
id: OptimaType.FIELD_ID,
caption: "Optima_Type",
type: "Text",
entryMethod: "List",
numberOfDecimals: 0,
value: OptimaType.ScheduleEntry,
},
],
value: customFields,
},
]);