Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71fe36c0b8 | |||
| e0d575454e | |||
| 32bba31e72 | |||
| 1233535b20 | |||
| 2c737b22f1 | |||
| a3bfe9f374 | |||
| a106bb15a8 | |||
| d9a431d99a | |||
| 83377a7d0d |
@@ -130,6 +130,17 @@ jobs:
|
||||
ghcr.io/horizonstacksoftware/optima-api-migrate:latest
|
||||
ghcr.io/horizonstacksoftware/optima-api-migrate:${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Build and push the dalpuri sync image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: api/Dockerfile
|
||||
push: true
|
||||
target: dalpuri-sync
|
||||
tags: |
|
||||
ghcr.io/horizonstacksoftware/optima-dalpuri-sync:latest
|
||||
ghcr.io/horizonstacksoftware/optima-dalpuri-sync:${{ github.event.release.tag_name }}
|
||||
|
||||
build-worker:
|
||||
name: Build - Worker
|
||||
needs: [test-api, test-dalpuri, test-ui]
|
||||
@@ -276,6 +287,55 @@ jobs:
|
||||
files: |
|
||||
ui/out/make/**/*.exe
|
||||
|
||||
# Runs a full CW → API data sync as a Kubernetes Job (the CW MSSQL and
|
||||
# API Postgres addresses are internal to the cluster and unreachable from
|
||||
# GitHub-hosted runners). Waits for both images to be built first and
|
||||
# must succeed before either the API or worker deploys.
|
||||
sync-cw-to-api:
|
||||
name: Sync - CW to API
|
||||
needs: [migrate-api, build-worker]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set the Kubernetes context
|
||||
uses: azure/k8s-set-context@v2
|
||||
with:
|
||||
method: kubeconfig
|
||||
kubeconfig: ${{ secrets.KUBECONFIG }}
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Delete previous sync job if exists
|
||||
run: kubectl delete job -n optima -l app=dalpuri-sync --ignore-not-found
|
||||
|
||||
- name: Apply sync job
|
||||
run: |
|
||||
TAG=${{ github.event.release.tag_name }}
|
||||
sed "s/RELEASE_TAG/${TAG}/g" dalpuri/kubernetes/sync-job.yaml | kubectl apply -f -
|
||||
|
||||
- name: Wait for sync to complete
|
||||
run: |
|
||||
TAG=${{ github.event.release.tag_name }}
|
||||
JOB="job/dalpuri-sync-${TAG}"
|
||||
|
||||
kubectl wait --for=condition=complete --timeout=7200s -n optima "$JOB" &
|
||||
WAIT_COMPLETE=$!
|
||||
kubectl wait --for=condition=failed --timeout=7200s -n optima "$JOB" &
|
||||
WAIT_FAILED=$!
|
||||
|
||||
wait -n $WAIT_COMPLETE $WAIT_FAILED
|
||||
|
||||
echo "--- Sync job logs ---"
|
||||
kubectl logs -n optima "$JOB" --tail=500 || true
|
||||
|
||||
if kubectl get -n optima "$JOB" -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' | grep -q "True"; then
|
||||
echo "Sync completed successfully."
|
||||
exit 0
|
||||
else
|
||||
echo "Sync FAILED."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ==========================================================================
|
||||
# Deploy jobs
|
||||
# ==========================================================================
|
||||
@@ -332,7 +392,7 @@ jobs:
|
||||
|
||||
deploy-api:
|
||||
name: Deploy - API
|
||||
needs: [migrate-api]
|
||||
needs: [migrate-api, sync-cw-to-api]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set the Kubernetes context
|
||||
@@ -402,7 +462,7 @@ jobs:
|
||||
|
||||
deploy-worker:
|
||||
name: Deploy - Worker
|
||||
needs: [build-worker]
|
||||
needs: [build-worker, sync-cw-to-api]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set the Kubernetes context
|
||||
|
||||
@@ -134,3 +134,33 @@ RUN chmod +x /app/api/prisma/migrate-entrypoint.sh
|
||||
|
||||
WORKDIR /app/api
|
||||
CMD ["sh", "prisma/migrate-entrypoint.sh"]
|
||||
|
||||
# ---- Stage 7: Dalpuri CW-to-API sync runner ----
|
||||
FROM oven/bun:1.3.11 AS dalpuri-sync
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
COPY api/package.json ./api/package.json
|
||||
COPY dalpuri/package.json ./dalpuri/package.json
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY dalpuri/src/ ./dalpuri/src/
|
||||
COPY dalpuri/prisma/ ./dalpuri/prisma/
|
||||
COPY dalpuri/prisma.config.ts ./dalpuri/prisma.config.ts
|
||||
|
||||
COPY api/prisma/ ./api/prisma/
|
||||
COPY api/prisma.config.ts ./api/prisma.config.ts
|
||||
|
||||
WORKDIR /app/dalpuri
|
||||
RUN DATABASE_URL="sqlserver://localhost:1433;database=dummy;user=dummy;password=dummy;trustServerCertificate=true" \
|
||||
bunx prisma generate
|
||||
|
||||
WORKDIR /app/api
|
||||
RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate
|
||||
|
||||
WORKDIR /app/dalpuri
|
||||
CMD ["bun", "run", "src/sync.ts"]
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Server } from "socket.io";
|
||||
import { events, EventTypes } from "../globalEvents";
|
||||
import { WorkerQueue } from "./queues";
|
||||
import { reserveWorkerId } from "../../workert";
|
||||
|
||||
function emitGlobalEvent<K extends keyof EventTypes>(
|
||||
name: K,
|
||||
|
||||
@@ -6,5 +6,17 @@ import { WorkerQueue } from "./queues";
|
||||
* Called on an interval from the main API process so it survives worker restarts.
|
||||
*/
|
||||
export async function enqueueIncrementalSync(): Promise<void> {
|
||||
await getBoss().send(WorkerQueue.DALPURI_INCREMENTAL_SYNC, {});
|
||||
const jobId = await getBoss().send(
|
||||
WorkerQueue.DALPURI_INCREMENTAL_SYNC,
|
||||
{
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
singletonKey: "dalpuri-incremental-sync",
|
||||
}
|
||||
);
|
||||
|
||||
if (!jobId) {
|
||||
console.debug("[interval] DALPURI_INCREMENTAL_SYNC already pending or active");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export async function createWorkerJob<T>(
|
||||
queueType: WorkerQueue,
|
||||
workFn: (workerSocket: Socket) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const managerUrl = process.env.MANAGER_SOCKET_URL ?? "http://localhost:8671";
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Request a worker ID and namespace from the manager
|
||||
socket.emit(
|
||||
@@ -53,7 +55,7 @@ export async function createWorkerJob<T>(
|
||||
}
|
||||
|
||||
// Connect to the worker-specific namespace
|
||||
const workerSocket = io(`http://localhost:8671/worker-${workerId}`, {
|
||||
const workerSocket = io(`${managerUrl}/worker-${workerId}`, {
|
||||
reconnection: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ if (import.meta.main) {
|
||||
// Register job handler for DALPURI_FULL_SYNC
|
||||
const { enqueueDalpuriFullSync } = await import("./modules/workers/sync-manager");
|
||||
const { executeIncrementalSync } = await import("./modules/workers/dalpuri-sync");
|
||||
const { enqueueIncrementalSync } = await import("./modules/workers/incremental-sync");
|
||||
await boss.work(WorkerQueue.DALPURI_FULL_SYNC, async () => {
|
||||
const socket = await ensureManagerSocketReady();
|
||||
await enqueueDalpuriFullSync(socket);
|
||||
@@ -170,10 +171,37 @@ if (import.meta.main) {
|
||||
console.log("[worker] Registered DALPURI_FULL_SYNC job handler");
|
||||
|
||||
await boss.work(WorkerQueue.DALPURI_INCREMENTAL_SYNC, async () => {
|
||||
const startedAt = Date.now();
|
||||
console.log("[worker] DALPURI_INCREMENTAL_SYNC started");
|
||||
try {
|
||||
await executeIncrementalSync();
|
||||
console.log(
|
||||
`[worker] DALPURI_INCREMENTAL_SYNC completed in ${Date.now() - startedAt}ms`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[worker] DALPURI_INCREMENTAL_SYNC failed in ${Date.now() - startedAt}ms`,
|
||||
err
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
console.log("[worker] Registered DALPURI_INCREMENTAL_SYNC job handler");
|
||||
|
||||
const enqueueIncrementalWithLogging = () => {
|
||||
enqueueIncrementalSync().catch((err) => {
|
||||
console.error(
|
||||
`[worker] interval enqueueIncrementalSync failed: ${err?.message ?? err}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Keep a worker-local 5s scheduler so incremental sync continues even when
|
||||
// API interval scheduling is unavailable.
|
||||
enqueueIncrementalWithLogging();
|
||||
setInterval(enqueueIncrementalWithLogging, 5_000);
|
||||
console.log("[worker] Started 5-second incremental enqueue interval");
|
||||
|
||||
// Register job handler for REFRESH_SALES_METRICS
|
||||
const { executeSalesMetricsRefresh } = await import("./modules/workers/sales-metrics");
|
||||
await boss.work(WorkerQueue.REFRESH_SALES_METRICS, async (jobs) => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: dalpuri-sync-RELEASE_TAG
|
||||
namespace: optima
|
||||
labels:
|
||||
app: dalpuri-sync
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
ttlSecondsAfterFinished: 86400
|
||||
activeDeadlineSeconds: 7200
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dalpuri-sync
|
||||
spec:
|
||||
containers:
|
||||
- name: sync
|
||||
image: ghcr.io/horizonstacksoftware/optima-dalpuri-sync:RELEASE_TAG
|
||||
env:
|
||||
- name: CW_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-env-secret
|
||||
key: CW_DATABASE_URL
|
||||
- name: API_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-env-secret
|
||||
key: DATABASE_URL
|
||||
restartPolicy: Never
|
||||
imagePullSecrets:
|
||||
- name: github-container-registry
|
||||
@@ -294,6 +294,22 @@ const refreshContextFromApi = async (
|
||||
}
|
||||
}
|
||||
|
||||
const cwMembers = await apiPrisma.cwMember.findMany({
|
||||
select: { cwMemberId: true, identifier: true },
|
||||
});
|
||||
for (const member of cwMembers) {
|
||||
if (
|
||||
member.cwMemberId != null &&
|
||||
member.identifier &&
|
||||
!context.userIdentifiersByMemberRecId.has(member.cwMemberId)
|
||||
) {
|
||||
context.userIdentifiersByMemberRecId.set(
|
||||
member.cwMemberId,
|
||||
member.identifier
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const board of boards) {
|
||||
context.serviceTicketBoardUidsById.set(board.id, board.uid);
|
||||
}
|
||||
@@ -426,6 +442,12 @@ const sanitizeModelData = (
|
||||
) {
|
||||
sanitized.statusId = null;
|
||||
}
|
||||
if (
|
||||
sanitized.locationId != null &&
|
||||
!context.corporateLocationIds.has(sanitized.locationId as number)
|
||||
) {
|
||||
sanitized.locationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetModel === "schedule") {
|
||||
@@ -734,6 +756,11 @@ const getConfigForTable = (table: string): SyncTableConfig | null => {
|
||||
secondarySalesFlag: true,
|
||||
},
|
||||
},
|
||||
soOppStatus: {
|
||||
select: {
|
||||
closedFlag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+42
-10
@@ -323,6 +323,22 @@ const refreshContextFromApi = async (
|
||||
}
|
||||
}
|
||||
|
||||
const cwMembers = await apiPrisma.cwMember.findMany({
|
||||
select: { cwMemberId: true, identifier: true },
|
||||
});
|
||||
for (const member of cwMembers) {
|
||||
if (
|
||||
member.cwMemberId != null &&
|
||||
member.identifier &&
|
||||
!context.userIdentifiersByMemberRecId.has(member.cwMemberId)
|
||||
) {
|
||||
context.userIdentifiersByMemberRecId.set(
|
||||
member.cwMemberId,
|
||||
member.identifier
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const board of boards) {
|
||||
context.serviceTicketBoardUidsById.set(board.id, board.uid);
|
||||
}
|
||||
@@ -636,6 +652,13 @@ const sanitizeModelData = (
|
||||
) {
|
||||
sanitized.stageId = null;
|
||||
}
|
||||
// Nullify locationId if the corporate location doesn't exist
|
||||
if (
|
||||
sanitized.locationId != null &&
|
||||
!context.corporateLocationIds.has(sanitized.locationId as number)
|
||||
) {
|
||||
sanitized.locationId = null;
|
||||
}
|
||||
// Nullify taxCodeId if the tax code hasn't synced yet
|
||||
if (
|
||||
sanitized.taxCodeId != null &&
|
||||
@@ -1328,6 +1351,15 @@ export const executeFullDalpuriSync = async (options?: {
|
||||
const isTimedOut = () => Date.now() - syncStartTime > timeoutMs;
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
name: "CW Members",
|
||||
sourceModel: "member",
|
||||
targetModel: "cwMember",
|
||||
translation: cwMemberTranslation as unknown as AnyTranslation,
|
||||
uniqueField: "cwMemberId",
|
||||
sourceIdField: "memberRecId",
|
||||
sourceUpdatedField: "lastUpdatedUtc",
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
sourceModel: "member",
|
||||
@@ -1342,15 +1374,6 @@ export const executeFullDalpuriSync = async (options?: {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CW Members",
|
||||
sourceModel: "member",
|
||||
targetModel: "cwMember",
|
||||
translation: cwMemberTranslation as unknown as AnyTranslation,
|
||||
uniqueField: "cwMemberId",
|
||||
sourceIdField: "memberRecId",
|
||||
sourceUpdatedField: "lastUpdatedUtc",
|
||||
},
|
||||
{
|
||||
name: "Companies",
|
||||
sourceModel: "company",
|
||||
@@ -1585,6 +1608,11 @@ export const executeFullDalpuriSync = async (options?: {
|
||||
secondarySalesFlag: true,
|
||||
},
|
||||
},
|
||||
soOppStatus: {
|
||||
select: {
|
||||
closedFlag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1860,7 +1888,11 @@ export const executeForcedIncrementalDalpuriSync = async (options?: {
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
executeFullDalpuriSync().catch((error) => {
|
||||
executeFullDalpuriSync()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("CW -> API sync failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Opportunity as CwOpportunity,
|
||||
OpportunityMember as CwOpportunityMember,
|
||||
SoOppStatus as CwSoOppStatus,
|
||||
} from "../../generated/prisma/client";
|
||||
import { OpportunityInterest } from "../../../api/generated/prisma/client";
|
||||
import { Translation, skipRow } from "./types";
|
||||
@@ -30,6 +31,7 @@ type ApiOpportunityRecord = {
|
||||
dateBecameLead?: Date | null;
|
||||
closedDate?: Date | null;
|
||||
closedFlag: boolean;
|
||||
locationId?: number | null;
|
||||
closedById?: string | null;
|
||||
updatedBy: string;
|
||||
eneteredBy: string;
|
||||
@@ -42,6 +44,7 @@ type CwOpportunityWithMembers = CwOpportunity & {
|
||||
CwOpportunityMember,
|
||||
"memberRecId" | "primarySalesFlag" | "secondarySalesFlag"
|
||||
>[];
|
||||
soOppStatus?: Pick<CwSoOppStatus, "closedFlag"> | null;
|
||||
};
|
||||
|
||||
const toInterest = (value: number | null): OpportunityInterest | null => {
|
||||
@@ -119,13 +122,19 @@ export const opportunityTranslation: Translation<
|
||||
},
|
||||
{ from: "companyRecId", to: "companyId" },
|
||||
{ from: "contactRecId", to: "contactId" },
|
||||
{ from: "ownerLevelRecId", to: "locationId" },
|
||||
{ from: "companyAddressRecId", to: "siteId" },
|
||||
{ from: "poNumber", to: "customerPO" },
|
||||
{ from: "dateCloseExpected", to: "expectedCloseDate" },
|
||||
{ from: "datePipelineChange", to: "pipelineChangeDate" },
|
||||
{ from: "dateBecameLead", to: "dateBecameLead" },
|
||||
{ from: "dateClosed", to: "closedDate" },
|
||||
{ from: "oldCloseFlag", to: "closedFlag" },
|
||||
{
|
||||
from: "oldCloseFlag",
|
||||
to: "closedFlag",
|
||||
process: (_value, _context, row) =>
|
||||
row.soOppStatus?.closedFlag ?? row.oldCloseFlag ?? false,
|
||||
},
|
||||
{ from: "closedBy", to: "closedById" },
|
||||
{
|
||||
from: "updatedBy",
|
||||
|
||||
Reference in New Issue
Block a user