all the haul
This commit is contained in:
@@ -12,6 +12,7 @@ import { unifi } from "./optima-api/modules/unifi";
|
||||
import { procurement } from "./optima-api/modules/procurement";
|
||||
import { sales } from "./optima-api/modules/sales";
|
||||
import { cw } from "./optima-api/modules/cw";
|
||||
import { schedule } from "./optima-api/modules/schedule";
|
||||
|
||||
export const optima = {
|
||||
auth,
|
||||
@@ -26,6 +27,7 @@ export const optima = {
|
||||
procurement,
|
||||
sales,
|
||||
cw,
|
||||
schedule,
|
||||
};
|
||||
/**
|
||||
* @TODO
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface CatalogItem {
|
||||
id: string;
|
||||
identifier?: string;
|
||||
cwCatalogId?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
partNumber?: string;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface SalesOpportunity {
|
||||
stage?: { id?: number; name?: string } | null;
|
||||
status?: { id?: number; name?: string } | null;
|
||||
priority?: { id?: number; name?: string } | null;
|
||||
rating?: { id?: number; name?: string } | null;
|
||||
interest?: "HOT" | "WARM" | "COLD" | null;
|
||||
source?: string | null;
|
||||
campaign?: string | null;
|
||||
primarySalesRep?: {
|
||||
@@ -76,6 +76,7 @@ export interface SalesOpportunity {
|
||||
customerPO?: string | null;
|
||||
totalSalesTax?: number | null;
|
||||
expectedSalesTaxRate?: number | null;
|
||||
taxCodeDescription?: string | null;
|
||||
location?: { id?: number; name?: string } | null;
|
||||
department?: { id?: number; name?: string } | null;
|
||||
expectedCloseDate?: string | null;
|
||||
@@ -240,7 +241,7 @@ export const STATUS_ID_TO_KEY: Record<number, WorkflowStatusKey> =
|
||||
Object.entries(WORKFLOW_STATUS_IDS).map(([k, v]) => [
|
||||
v,
|
||||
k as WorkflowStatusKey,
|
||||
]),
|
||||
])
|
||||
) as Record<number, WorkflowStatusKey>;
|
||||
|
||||
/** Human-readable labels for each workflow status */
|
||||
@@ -274,7 +275,7 @@ export const REOPENABLE_STATUSES: ReadonlySet<WorkflowStatusKey> = new Set([
|
||||
|
||||
/** Statuses where the quote has been confirmed (finalize is allowed) */
|
||||
export const QUOTE_CONFIRMED_STATUSES: ReadonlySet<WorkflowStatusKey> = new Set(
|
||||
["ConfirmedQuote", "ReadyToSend", "Active", "PendingWon", "PendingLost"],
|
||||
["ConfirmedQuote", "ReadyToSend", "Active", "PendingWon", "PendingLost"]
|
||||
);
|
||||
|
||||
export interface OpportunityType {
|
||||
@@ -382,7 +383,7 @@ export interface CreateOpportunityBody {
|
||||
name: string;
|
||||
expectedCloseDate: string;
|
||||
notes?: string;
|
||||
rating?: { id: number };
|
||||
interest?: "HOT" | "WARM" | "COLD" | null;
|
||||
type?: { id: number };
|
||||
stage?: { id: number };
|
||||
status?: { id: number };
|
||||
@@ -519,10 +520,11 @@ export interface MemberSalesMetrics {
|
||||
export const sales = {
|
||||
async fetchMetrics(accessToken: string): Promise<MemberSalesMetrics | null> {
|
||||
const response = await api.get("/v1/sales/opportunities/metrics", {
|
||||
params: { scope: "me" },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return response.data?.data?.metrics ?? null;
|
||||
return response.data?.data ?? null;
|
||||
},
|
||||
|
||||
async fetchMe(accessToken: string, includeClosed: boolean = false) {
|
||||
@@ -542,7 +544,7 @@ export const sales = {
|
||||
page: number = 1,
|
||||
search: string = "",
|
||||
rpp: number = 30,
|
||||
includeClosed: boolean = true,
|
||||
includeClosed: boolean = true
|
||||
) {
|
||||
const params: Record<string, unknown> = { page, rpp };
|
||||
if (search) params.search = search;
|
||||
@@ -578,7 +580,7 @@ export const sales = {
|
||||
async fetchOne(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
include?: ("notes" | "contacts" | "products" | "quotes")[],
|
||||
include?: ("notes" | "contacts" | "products" | "quotes")[]
|
||||
) {
|
||||
const params: Record<string, string> = {};
|
||||
if (include && include.length > 0) {
|
||||
@@ -591,43 +593,49 @@ export const sales = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchForecasts(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/forecasts`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/forecasts`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchProducts(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchNotes(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/notes`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/notes`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -635,16 +643,18 @@ export const sales = {
|
||||
async createNote(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
data: { text: string; flagged?: boolean },
|
||||
data: { text: string; flagged?: boolean }
|
||||
) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/notes`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/notes`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -653,40 +663,46 @@ export const sales = {
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
noteId: number,
|
||||
data: { text?: string; flagged?: boolean },
|
||||
data: { text?: string; flagged?: boolean }
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/notes/${noteId}`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/notes/${noteId}`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteNote(accessToken: string, identifier: string, noteId: number) {
|
||||
const response = await api.delete(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/notes/${noteId}`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/notes/${noteId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchContacts(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/contacts`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/contacts`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -694,16 +710,18 @@ export const sales = {
|
||||
async sequenceProducts(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
orderedIds: number[],
|
||||
orderedIds: number[]
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products/sequence`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products/sequence`,
|
||||
{ orderedIds },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -711,16 +729,18 @@ export const sales = {
|
||||
async addProduct(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
body: AddProductBody,
|
||||
body: AddProductBody
|
||||
) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -728,28 +748,32 @@ export const sales = {
|
||||
async addSpecialOrder(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
body: SpecialOrderBody | SpecialOrderBody[],
|
||||
body: SpecialOrderBody | SpecialOrderBody[]
|
||||
) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products/special-order`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products/special-order`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchLaborOptions(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products/labor/options`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products/labor/options`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -761,13 +785,15 @@ export const sales = {
|
||||
|
||||
async addLabor(accessToken: string, identifier: string, body: AddLaborBody) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products/labor`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products/labor`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -776,16 +802,18 @@ export const sales = {
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
productId: number,
|
||||
body: EditOpportunityProductBody,
|
||||
body: EditOpportunityProductBody
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products/${productId}/edit`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products/${productId}/edit`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -794,29 +822,33 @@ export const sales = {
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
productId: number,
|
||||
body: CancelOpportunityProductBody,
|
||||
body: CancelOpportunityProductBody
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products/${productId}/cancel`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products/${productId}/cancel`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async refreshOpportunity(accessToken: string, identifier: string) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/refresh`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/refresh`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -824,7 +856,7 @@ export const sales = {
|
||||
async updateOpportunity(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
body: Record<string, unknown>,
|
||||
body: Record<string, unknown>
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}`,
|
||||
@@ -833,7 +865,7 @@ export const sales = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -844,19 +876,21 @@ export const sales = {
|
||||
options?: {
|
||||
includeRegenData?: boolean;
|
||||
includeRegenParams?: boolean;
|
||||
},
|
||||
}
|
||||
) {
|
||||
const params: Record<string, string> = {};
|
||||
if (options?.includeRegenData) params.includeRegenData = "true";
|
||||
if (options?.includeRegenParams) params.includeRegenParams = "true";
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/quotes`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/quotes`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -873,16 +907,18 @@ export const sales = {
|
||||
lineItemPricing?: boolean;
|
||||
includeQuoteNarrative?: boolean;
|
||||
includeItemNarratives?: boolean;
|
||||
},
|
||||
}
|
||||
) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/quote/commit`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/quote/commit`,
|
||||
body ?? {},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -905,14 +941,65 @@ export const sales = {
|
||||
};
|
||||
},
|
||||
|
||||
async previewQuote(accessToken: string, identifier: string, quoteId: string) {
|
||||
async fetchQuoteNarrative(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/quote/${encodeURIComponent(quoteId)}/preview`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/quotes/narrative`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
data: {
|
||||
quoteNarrative: string | null;
|
||||
};
|
||||
successful?: boolean;
|
||||
};
|
||||
},
|
||||
|
||||
async updateQuoteNarrative(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
quoteNarrative: string | null
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/quotes/narrative`,
|
||||
{
|
||||
quoteNarrative,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
data: {
|
||||
quoteNarrative: string | null;
|
||||
};
|
||||
successful?: boolean;
|
||||
};
|
||||
},
|
||||
|
||||
async previewQuote(accessToken: string, identifier: string, quoteId: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/quote/${encodeURIComponent(quoteId)}/preview`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -929,16 +1016,18 @@ export const sales = {
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
quoteId: string,
|
||||
fetchAction: "download" | "print",
|
||||
fetchAction: "download" | "print"
|
||||
) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/quote/${encodeURIComponent(quoteId)}/download`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/quote/${encodeURIComponent(quoteId)}/download`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: { fetchAction },
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -955,12 +1044,14 @@ export const sales = {
|
||||
|
||||
async fetchQuoteDownloads(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/quotes/downloads`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/quotes/downloads`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -973,15 +1064,17 @@ export const sales = {
|
||||
async deleteProduct(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
productId: number,
|
||||
productId: number
|
||||
) {
|
||||
const response = await api.delete(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/products/${productId}`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/products/${productId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -993,7 +1086,7 @@ export const sales = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -1002,12 +1095,14 @@ export const sales = {
|
||||
|
||||
async fetchWorkflowStatus(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/workflow`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/workflow`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -1021,16 +1116,18 @@ export const sales = {
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
action: WorkflowAction,
|
||||
payload: WorkflowActionPayload,
|
||||
payload: WorkflowActionPayload
|
||||
) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/workflow`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/workflow`,
|
||||
{ action, payload },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
@@ -1043,18 +1140,20 @@ export const sales = {
|
||||
async fetchWorkflowHistory(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
type?: string,
|
||||
type?: string
|
||||
) {
|
||||
const params: Record<string, string> = {};
|
||||
if (type) params.type = type;
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(identifier)}/workflow/history`,
|
||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||
identifier
|
||||
)}/workflow/history`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data as {
|
||||
status?: number;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import api from "../axios";
|
||||
|
||||
export interface ScheduleStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleType {
|
||||
id: string;
|
||||
name: string;
|
||||
displayColor: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleSpan {
|
||||
id: string;
|
||||
scheduleSpanId: number | null;
|
||||
spanDesc: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleEntry {
|
||||
id: string;
|
||||
cwId: number;
|
||||
memberId: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
allDayFlag: boolean;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
hoursScheduled: number | null;
|
||||
status: ScheduleStatus | null;
|
||||
type: ScheduleType | null;
|
||||
scheduleSpan: ScheduleSpan | null;
|
||||
}
|
||||
|
||||
export const schedule = {
|
||||
async fetchMe(
|
||||
accessToken: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<ScheduleEntry[]> {
|
||||
const response = await api.get("/v1/schedule/@me", {
|
||||
params: {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return (response.data?.data as ScheduleEntry[]) ?? [];
|
||||
},
|
||||
};
|
||||
@@ -1,25 +1,23 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockGetRequestEvent, mockRedirect, mockAxiosPost, mockIo, mockApi } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetRequestEvent: vi.fn(),
|
||||
mockRedirect: vi.fn(),
|
||||
mockAxiosPost: vi.fn(),
|
||||
mockIo: vi.fn(),
|
||||
mockApi: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$app/server", () => ({
|
||||
getRequestEvent: mockGetRequestEvent,
|
||||
const { mockRedirect, mockAxiosPost, mockIo, mockApi } = vi.hoisted(() => ({
|
||||
mockRedirect: vi.fn(),
|
||||
mockAxiosPost: vi.fn(),
|
||||
mockIo: vi.fn(),
|
||||
mockApi: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$env/static/public", () => ({
|
||||
PUBLIC_API_URL: "https://api.example.com",
|
||||
}));
|
||||
|
||||
vi.mock("$app/environment", () => ({
|
||||
browser: false,
|
||||
}));
|
||||
|
||||
vi.mock("@sveltejs/kit", () => ({
|
||||
redirect: mockRedirect,
|
||||
}));
|
||||
@@ -45,24 +43,30 @@ describe("user module", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("isLoggedIn returns true when accessToken cookie exists", () => {
|
||||
mockGetRequestEvent.mockReturnValueOnce({
|
||||
it("isLoggedInServer returns true when accessToken cookie exists", () => {
|
||||
const mockEvent = {
|
||||
cookies: {
|
||||
get: vi.fn().mockReturnValue("token"),
|
||||
},
|
||||
});
|
||||
} as any;
|
||||
|
||||
expect(user.isLoggedIn()).toBe(true);
|
||||
expect(user.isLoggedInServer(mockEvent)).toBe(true);
|
||||
});
|
||||
|
||||
it("isLoggedIn returns false when accessToken cookie is missing", () => {
|
||||
mockGetRequestEvent.mockReturnValueOnce({
|
||||
it("isLoggedInServer returns false when accessToken cookie is missing", () => {
|
||||
const mockEvent = {
|
||||
cookies: {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
});
|
||||
} as any;
|
||||
|
||||
expect(user.isLoggedIn()).toBe(false);
|
||||
expect(user.isLoggedInServer(mockEvent)).toBe(false);
|
||||
});
|
||||
|
||||
it("isLoggedIn throws an error when called without event", () => {
|
||||
expect(() => user.isLoggedIn()).toThrow(
|
||||
"user.isLoggedIn() should not be called without a request event. Use isLoggedInServer() instead."
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshSession posts refresh header and returns token payload", async () => {
|
||||
@@ -84,7 +88,7 @@ describe("user module", () => {
|
||||
headers: {
|
||||
"x-refresh-token": "refresh-123",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access",
|
||||
@@ -105,7 +109,7 @@ describe("user module", () => {
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/v1/user/@me/check-permission",
|
||||
{ permissions: ["company.read"] },
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
{ headers: { Authorization: "Bearer token" } }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { getRequestEvent } from "$app/server";
|
||||
import { PUBLIC_API_URL } from "$env/static/public";
|
||||
import { redirect, RequestEvent } from "@sveltejs/kit";
|
||||
import { redirect, type RequestEvent } from "@sveltejs/kit";
|
||||
import axios from "axios";
|
||||
import api from "../axios";
|
||||
import { io } from "socket.io-client";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export const user = {
|
||||
isLoggedIn(): boolean {
|
||||
const event = getRequestEvent();
|
||||
// This function can't reliably check authentication status in client-side code
|
||||
// since it doesn't have access to httpOnly cookies.
|
||||
// It should be used only in server-side contexts like load functions.
|
||||
if (browser) {
|
||||
console.warn(
|
||||
"user.isLoggedIn() called in browser context - this may not work as expected"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// In server context, this would need the request event to be passed in
|
||||
// Rather than using getRequestEvent() which makes this module server-only
|
||||
throw new Error(
|
||||
"user.isLoggedIn() should not be called without a request event. Use isLoggedInServer() instead."
|
||||
);
|
||||
},
|
||||
|
||||
// Server-side version that takes a request event parameter
|
||||
isLoggedInServer(event: RequestEvent): boolean {
|
||||
const authToken = event.cookies.get("accessToken");
|
||||
return !!authToken;
|
||||
},
|
||||
@@ -21,7 +39,7 @@ export const user = {
|
||||
headers: {
|
||||
"x-refresh-token": refreshToken,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
).data.data;
|
||||
|
||||
@@ -55,96 +73,40 @@ export const user = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* @todo Get communication with server working and setup a key system so that the frontend can listen for a specific key from the backend so that nobody can poach off of login events.
|
||||
*
|
||||
* Note: This function no longer mutates SvelteKit request event/cookies asynchronously.
|
||||
* It returns the tokens to the caller so the caller (within the same request lifecycle)
|
||||
* can set cookies using the event object synchronously.
|
||||
* Polls the API until the Microsoft OAuth callback has been processed and
|
||||
* tokens are available, then returns them. Uses a simple HTTP long-poll
|
||||
* pattern instead of socket.io — much more reliable through reverse proxies.
|
||||
*/
|
||||
async awaitAuthCallback(callbackKey: string): Promise<{
|
||||
async awaitAuthCallback(callbackKey: string, socketUrl?: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> {
|
||||
const base = PUBLIC_API_URL || "";
|
||||
const base = socketUrl ?? PUBLIC_API_URL ?? "";
|
||||
const pollUrl = `${base}/v1/auth/callback/${encodeURIComponent(callbackKey)}`;
|
||||
const timeoutMs = 5 * 60 * 1000; // 5 minutes
|
||||
const intervalMs = 600;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const socket = io(`${base}/auth_callback`, {
|
||||
transports: ["websocket"],
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.disconnect();
|
||||
} catch {}
|
||||
reject(new Error("Timed out waiting for auth callback"));
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
); // 5 minutes
|
||||
|
||||
const handlePayload = (payload: any) => {
|
||||
try {
|
||||
const { accessToken, refreshToken } = payload ?? {};
|
||||
if (accessToken && refreshToken) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
socket.disconnect();
|
||||
} catch {}
|
||||
resolve({ accessToken, refreshToken });
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("connect", () => {});
|
||||
// listen for a specific callback key if provided
|
||||
if (callbackKey) {
|
||||
socket.on(`auth:login:callback:${callbackKey}`, handlePayload);
|
||||
} else {
|
||||
socket.on("auth-callback", handlePayload);
|
||||
while (Date.now() < deadline) {
|
||||
const res = await fetch(pollUrl);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json() as { data: { accessToken: string; refreshToken: string } };
|
||||
return body.data;
|
||||
}
|
||||
socket.on("message", console.log);
|
||||
socket.on("connect_error", (err: any) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
socket.disconnect();
|
||||
} catch {}
|
||||
reject(err instanceof Error ? err : new Error("Socket connect_error"));
|
||||
});
|
||||
socket.on("error", (err: any) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
socket.disconnect();
|
||||
} catch {}
|
||||
reject(err instanceof Error ? err : new Error("Socket error"));
|
||||
});
|
||||
socket.on("disconnect", (reason: any) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
"Socket disconnected before auth was received: " + String(reason),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
if (res.status !== 202) {
|
||||
throw new Error(`Unexpected status ${res.status} from auth callback endpoint`);
|
||||
}
|
||||
// 202 = still pending — wait and retry
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error("Timed out waiting for auth callback");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Role } from "./roles";
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
email: string;
|
||||
login: string;
|
||||
image?: string;
|
||||
@@ -38,7 +40,7 @@ export const users = {
|
||||
*/
|
||||
async fetch(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
identifier: string
|
||||
): Promise<{ data: User }> {
|
||||
const response = await api.get(`/v1/user/users/${identifier}`, {
|
||||
headers: {
|
||||
@@ -58,10 +60,12 @@ export const users = {
|
||||
identifier: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
image?: string;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
},
|
||||
}
|
||||
): Promise<{ data: User }> {
|
||||
const response = await api.patch(`/v1/user/users/${identifier}`, updates, {
|
||||
headers: {
|
||||
@@ -77,7 +81,7 @@ export const users = {
|
||||
*/
|
||||
async delete(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
identifier: string
|
||||
): Promise<{ data: User }> {
|
||||
const response = await api.delete(`/v1/user/users/${identifier}`, {
|
||||
headers: {
|
||||
@@ -93,7 +97,7 @@ export const users = {
|
||||
*/
|
||||
async fetchRoles(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
identifier: string
|
||||
): Promise<{ data: Role[] }> {
|
||||
const response = await api.get(`/v1/user/users/${identifier}/roles`, {
|
||||
headers: {
|
||||
@@ -110,7 +114,7 @@ export const users = {
|
||||
async checkPermissions(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
permissions: string[],
|
||||
permissions: string[]
|
||||
): Promise<{ data: { results: PermissionCheckResult[] } }> {
|
||||
const response = await api.post(
|
||||
`/v1/user/users/${identifier}/check-permission`,
|
||||
@@ -119,7 +123,7 @@ export const users = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
+7
-1
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Format an ISO date string into a human-readable locale date.
|
||||
* Returns an empty string (or "—" when `dash` is true) for missing/invalid values.
|
||||
* Date-only ISO strings (e.g. "2025-03-15T00:00:00.000Z") are interpreted as
|
||||
* calendar dates to avoid off-by-one errors west of UTC.
|
||||
*/
|
||||
export function formatDate(
|
||||
dateStr?: string | null,
|
||||
@@ -9,7 +11,11 @@ export function formatDate(
|
||||
const fallback = opts.dash ? "—" : "";
|
||||
if (!dateStr) return fallback;
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
// Strip time portion for date-only fields to prevent timezone shift
|
||||
const dateOnly = dateStr.split("T")[0];
|
||||
const date = new Date(dateOnly + "T00:00:00");
|
||||
if (isNaN(date.getTime())) return fallback;
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
||||
Reference in New Issue
Block a user