Compare commits
16
Commits
5dd28d388f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
615f6615e0 | ||
|
|
10cc93104a | ||
|
|
ae6b3e8b9e | ||
|
|
7abba6296b | ||
|
|
c198b6c566 | ||
|
|
7b5c44876c | ||
|
|
022dac41f7 | ||
|
|
b743eb13e6 | ||
|
|
8d2da0ee06 | ||
|
|
6912d15063 | ||
|
|
2cc18940d6 | ||
|
|
d81fc10b81 | ||
|
|
1750e8052c | ||
|
|
7a92dcc71a | ||
|
|
154adb2cd1 | ||
|
|
2a0fb9936d |
+14
-10
@@ -1,6 +1,7 @@
|
||||
name: Build and deploy Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -12,18 +13,21 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.dsrptlab.com
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" .
|
||||
docker push "$IMAGE:latest"
|
||||
docker push "$IMAGE:${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy to Dokploy
|
||||
run: curl -fsS -X POST "https://ind.dsrptlab.com/api/deploy/8KU24cMi_nHB4S16dhDB3"
|
||||
- name: Remove local image tags
|
||||
run: docker image rm -f "$IMAGE:latest" "$IMAGE:${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy in Dokploy
|
||||
env:
|
||||
DOKPLOY_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$DOKPLOY_DEPLOY_WEBHOOK"
|
||||
curl -fsS -X POST "$DOKPLOY_DEPLOY_WEBHOOK"
|
||||
|
||||
|
||||
+5
-3
@@ -7,12 +7,13 @@ RUN npm ci
|
||||
|
||||
FROM deps AS builder
|
||||
|
||||
COPY prisma.config.ts ./
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
RUN ORDERS_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres npx prisma generate
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
RUN ORDERS_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres npm run build
|
||||
|
||||
FROM deps AS runtime-deps
|
||||
|
||||
@@ -28,9 +29,10 @@ COPY --from=runtime-deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY prisma.config.ts ./
|
||||
COPY prisma ./prisma
|
||||
COPY scripts ./scripts
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && set +e && npx prisma migrate resolve --applied 0_init 2>/dev/null; set -e && npx prisma migrate deploy && node dist/index.js"]
|
||||
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node dist/index.js"]
|
||||
|
||||
Generated
+1179
-1073
File diff suppressed because it is too large
Load Diff
+14
-10
@@ -6,22 +6,26 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "prisma generate && tsc",
|
||||
"start": "prisma migrate deploy && node dist/index.js"
|
||||
"start": "prisma migrate deploy && node dist/index.js",
|
||||
"seed:demo": "tsx scripts/seed-demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"@sentry/node": "^9.5.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@sentry/node": "^10.56.0",
|
||||
"fastify": "^5.8.5",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql": "^16.14.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jose": "^6.0.11",
|
||||
"mercurius": "^16.9.0"
|
||||
"jose": "^6.2.3",
|
||||
"mercurius": "^16.9.0",
|
||||
"pg": "^8.21.0",
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.0",
|
||||
"prisma": "^6.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'dotenv/config'
|
||||
import { defineConfig, env } from 'prisma/config'
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url: env('ORDERS_DATABASE_URL'),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "orders_tariff_reference" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"team_uuid" VARCHAR(100) NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
"operation_code" VARCHAR(50),
|
||||
"incoterms_code" VARCHAR(20),
|
||||
"transport_type_code" VARCHAR(50),
|
||||
"tare_type_code" VARCHAR(50),
|
||||
"source_country_code" VARCHAR(10),
|
||||
"destination_country_code" VARCHAR(10),
|
||||
"source_hub_uuid" VARCHAR(100),
|
||||
"destination_hub_uuid" VARCHAR(100),
|
||||
"min_weight_kg" DECIMAL(12,2),
|
||||
"max_weight_kg" DECIMAL(12,2),
|
||||
"min_volume_cbm" DECIMAL(12,3),
|
||||
"max_volume_cbm" DECIMAL(12,3),
|
||||
"min_distance_km" INTEGER,
|
||||
"max_distance_km" INTEGER,
|
||||
"amount_usd" DECIMAL(12,2) NOT NULL,
|
||||
"min_price_usd" DECIMAL(12,2),
|
||||
"eta_days" INTEGER,
|
||||
"priority" INTEGER NOT NULL DEFAULT 100,
|
||||
"currency" VARCHAR(10) NOT NULL DEFAULT 'USD',
|
||||
"dmn_expression" TEXT,
|
||||
"notes" TEXT,
|
||||
"created_by_user_id" VARCHAR(255),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "orders_tariff_reference_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "orders_quotation" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"team_uuid" VARCHAR(100) NOT NULL,
|
||||
"created_by_user_id" VARCHAR(255),
|
||||
"title" VARCHAR(255) NOT NULL DEFAULT 'Quotation',
|
||||
"status" VARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||
"operation_code" VARCHAR(50),
|
||||
"incoterms_code" VARCHAR(20),
|
||||
"transport_type_code" VARCHAR(50),
|
||||
"tare_type_code" VARCHAR(50),
|
||||
"source_country_code" VARCHAR(10),
|
||||
"source_hub_uuid" VARCHAR(100),
|
||||
"source_location_uuid" VARCHAR(100),
|
||||
"source_location_name" VARCHAR(255),
|
||||
"source_latitude" DOUBLE PRECISION,
|
||||
"source_longitude" DOUBLE PRECISION,
|
||||
"destination_country_code" VARCHAR(10),
|
||||
"destination_hub_uuid" VARCHAR(100),
|
||||
"destination_location_uuid" VARCHAR(100),
|
||||
"destination_location_name" VARCHAR(255),
|
||||
"destination_latitude" DOUBLE PRECISION,
|
||||
"destination_longitude" DOUBLE PRECISION,
|
||||
"chargeable_weight_kg" DECIMAL(12,2),
|
||||
"gross_weight_kg" DECIMAL(12,2),
|
||||
"volume_cbm" DECIMAL(12,3),
|
||||
"units_count" INTEGER,
|
||||
"route_distance_km" INTEGER,
|
||||
"selected_tariff_id" INTEGER,
|
||||
"tariff_match_summary" TEXT,
|
||||
"tariff_snapshot" TEXT,
|
||||
"total_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
"currency" VARCHAR(10) NOT NULL DEFAULT 'USD',
|
||||
"eta_days" INTEGER,
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "orders_quotation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "orders_quotation_change" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"quotation_id" INTEGER NOT NULL,
|
||||
"actor_user_id" VARCHAR(255),
|
||||
"actor_label" VARCHAR(255),
|
||||
"source" VARCHAR(50) NOT NULL,
|
||||
"summary" TEXT,
|
||||
"payload_json" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "orders_quotation_change_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "orders_order" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"team_uuid" VARCHAR(100) NOT NULL,
|
||||
"quotation_id" INTEGER,
|
||||
"created_by_user_id" VARCHAR(255),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"status" VARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||
"total_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
"currency" VARCHAR(10) NOT NULL DEFAULT 'USD',
|
||||
"source_location_uuid" VARCHAR(100),
|
||||
"source_location_name" VARCHAR(255),
|
||||
"source_country_code" VARCHAR(10),
|
||||
"source_latitude" DOUBLE PRECISION,
|
||||
"source_longitude" DOUBLE PRECISION,
|
||||
"destination_location_uuid" VARCHAR(100),
|
||||
"destination_location_name" VARCHAR(255),
|
||||
"destination_country_code" VARCHAR(10),
|
||||
"destination_latitude" DOUBLE PRECISION,
|
||||
"destination_longitude" DOUBLE PRECISION,
|
||||
"eta_days" INTEGER,
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "orders_order_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "orders_tariff_reference_uuid_key" ON "orders_tariff_reference"("uuid");
|
||||
CREATE INDEX IF NOT EXISTS "orders_tariff_reference_team_uuid_status_priority_idx" ON "orders_tariff_reference"("team_uuid", "status", "priority");
|
||||
CREATE INDEX IF NOT EXISTS "orders_tariff_reference_team_uuid_source_country_code_desti_idx" ON "orders_tariff_reference"("team_uuid", "source_country_code", "destination_country_code");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "orders_quotation_uuid_key" ON "orders_quotation"("uuid");
|
||||
CREATE INDEX IF NOT EXISTS "orders_quotation_team_uuid_status_created_at_idx" ON "orders_quotation"("team_uuid", "status", "created_at");
|
||||
CREATE INDEX IF NOT EXISTS "orders_quotation_team_uuid_source_country_code_destination__idx" ON "orders_quotation"("team_uuid", "source_country_code", "destination_country_code");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "orders_quotation_change_uuid_key" ON "orders_quotation_change"("uuid");
|
||||
CREATE INDEX IF NOT EXISTS "orders_quotation_change_quotation_id_created_at_idx" ON "orders_quotation_change"("quotation_id", "created_at");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "orders_order_uuid_key" ON "orders_order"("uuid");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "orders_order_quotation_id_key" ON "orders_order"("quotation_id");
|
||||
CREATE INDEX IF NOT EXISTS "orders_order_team_uuid_created_at_idx" ON "orders_order"("team_uuid", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'orders_quotation_selected_tariff_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "orders_quotation"
|
||||
ADD CONSTRAINT "orders_quotation_selected_tariff_id_fkey"
|
||||
FOREIGN KEY ("selected_tariff_id") REFERENCES "orders_tariff_reference"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'orders_quotation_change_quotation_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "orders_quotation_change"
|
||||
ADD CONSTRAINT "orders_quotation_change_quotation_id_fkey"
|
||||
FOREIGN KEY ("quotation_id") REFERENCES "orders_quotation"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'orders_order_quotation_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "orders_order"
|
||||
ADD CONSTRAINT "orders_order_quotation_id_fkey"
|
||||
FOREIGN KEY ("quotation_id") REFERENCES "orders_quotation"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE "orders_quotation"
|
||||
SET "notes" = '{"seed":"Demo seed quotation"}'
|
||||
WHERE "notes" = 'Demo seed quotation';
|
||||
@@ -4,7 +4,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("ORDERS_DATABASE_URL")
|
||||
}
|
||||
|
||||
model TariffReference {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '../dist/db.js'
|
||||
|
||||
const DEMO_TEAM_UUID = process.env.DEMO_TEAM_UUID ?? '11111111-1111-4111-8111-111111111111'
|
||||
const DEMO_ORDER_COUNT = Number(process.env.DEMO_ORDER_COUNT ?? '3')
|
||||
const DEMO_QUOTATION_NOTES = JSON.stringify({ seed: 'Demo seed quotation' })
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
function location(index: number, side: 'source' | 'destination') {
|
||||
const source = {
|
||||
uuid: `11111111-2222-4${String(index).padStart(3, '0').slice(-3)}-8${String(index).padStart(3, '0').slice(-3)}-111111${String(index).padStart(6, '0')}`,
|
||||
name: `Demo address ${index + 1}`,
|
||||
countryCode: 'KZ',
|
||||
latitude: 43.238949 + Math.floor(index / 60) * 0.018 + (index % 5) * 0.002,
|
||||
longitude: 76.889709 + (index % 60) * 0.021 + (Math.floor(index / 60) % 5) * 0.002,
|
||||
}
|
||||
const destination = {
|
||||
uuid: `22222222-3333-4${String(index).padStart(3, '0').slice(-3)}-8${String(index).padStart(3, '0').slice(-3)}-222222${String(index).padStart(6, '0')}`,
|
||||
name: `Client destination ${index + 1}`,
|
||||
countryCode: index % 2 === 0 ? 'RU' : 'CN',
|
||||
latitude: index % 2 === 0 ? 55.755826 + (index % 10) * 0.01 : 43.825592 + (index % 10) * 0.012,
|
||||
longitude: index % 2 === 0 ? 37.6173 + (index % 10) * 0.01 : 87.616848 + (index % 10) * 0.012,
|
||||
}
|
||||
return side === 'source' ? source : destination
|
||||
}
|
||||
|
||||
const userId = requiredEnv('DEMO_LOGTO_USER_ID')
|
||||
const quotationUuids = Array.from({ length: DEMO_ORDER_COUNT }, (_, index) => (
|
||||
`33333333-4444-4${String(index).padStart(3, '0').slice(-3)}-8${String(index).padStart(3, '0').slice(-3)}-333333${String(index).padStart(6, '0')}`
|
||||
))
|
||||
const orderUuids = Array.from({ length: DEMO_ORDER_COUNT }, (_, index) => (
|
||||
`44444444-5555-4${String(index).padStart(3, '0').slice(-3)}-8${String(index).padStart(3, '0').slice(-3)}-444444${String(index).padStart(6, '0')}`
|
||||
))
|
||||
|
||||
const tariffs = [
|
||||
{
|
||||
uuid: '11111111-aaaa-4111-8111-111111111111',
|
||||
name: 'Demo auto tariff KZ-RU',
|
||||
sourceCountryCode: 'KZ',
|
||||
destinationCountryCode: 'RU',
|
||||
transportTypeCode: 'AUTO',
|
||||
amountUsd: new Prisma.Decimal(1800),
|
||||
minPriceUsd: new Prisma.Decimal(1200),
|
||||
etaDays: 7,
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
uuid: '11111111-aaaa-4111-8111-222222222222',
|
||||
name: 'Demo rail tariff KZ-CN',
|
||||
sourceCountryCode: 'KZ',
|
||||
destinationCountryCode: 'CN',
|
||||
transportTypeCode: 'RAIL',
|
||||
amountUsd: new Prisma.Decimal(2400),
|
||||
minPriceUsd: new Prisma.Decimal(1600),
|
||||
etaDays: 10,
|
||||
priority: 20,
|
||||
},
|
||||
]
|
||||
|
||||
for (const tariff of tariffs) {
|
||||
await prisma.tariffReference.upsert({
|
||||
where: { uuid: tariff.uuid },
|
||||
create: {
|
||||
...tariff,
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
createdByUserId: userId,
|
||||
status: 'active',
|
||||
currency: 'USD',
|
||||
notes: 'Demo seed tariff',
|
||||
},
|
||||
update: {
|
||||
...tariff,
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
createdByUserId: userId,
|
||||
status: 'active',
|
||||
currency: 'USD',
|
||||
notes: 'Demo seed tariff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.order.deleteMany({
|
||||
where: {
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
notes: 'Demo seed order',
|
||||
uuid: { notIn: orderUuids },
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.quotation.deleteMany({
|
||||
where: {
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
uuid: { notIn: quotationUuids },
|
||||
OR: [
|
||||
{ notes: 'Demo seed quotation' },
|
||||
{ notes: DEMO_QUOTATION_NOTES },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
for (let index = 0; index < DEMO_ORDER_COUNT; index += 1) {
|
||||
const source = location(index, 'source')
|
||||
const destination = location(index, 'destination')
|
||||
const tariff = tariffs[index % tariffs.length]
|
||||
const totalAmount = new Prisma.Decimal(1500 + (index % 17) * 125)
|
||||
const quotationUuid = quotationUuids[index]
|
||||
const orderUuid = orderUuids[index]
|
||||
const selectedTariff = await prisma.tariffReference.findUniqueOrThrow({ where: { uuid: tariff.uuid } })
|
||||
|
||||
const quotation = await prisma.quotation.upsert({
|
||||
where: { uuid: quotationUuid },
|
||||
create: {
|
||||
uuid: quotationUuid,
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
createdByUserId: userId,
|
||||
title: `Demo quotation ${index + 1}`,
|
||||
status: 'converted',
|
||||
operationCode: 'IMPORT',
|
||||
incotermsCode: index % 2 === 0 ? 'DAP' : 'FOB',
|
||||
transportTypeCode: tariff.transportTypeCode,
|
||||
sourceCountryCode: source.countryCode,
|
||||
sourceLocationUuid: source.uuid,
|
||||
sourceLocationName: source.name,
|
||||
sourceLatitude: source.latitude,
|
||||
sourceLongitude: source.longitude,
|
||||
destinationCountryCode: destination.countryCode,
|
||||
destinationLocationUuid: destination.uuid,
|
||||
destinationLocationName: destination.name,
|
||||
destinationLatitude: destination.latitude,
|
||||
destinationLongitude: destination.longitude,
|
||||
grossWeightKg: new Prisma.Decimal(18000 + index * 25),
|
||||
volumeCbm: new Prisma.Decimal(70 + (index % 9) * 3),
|
||||
unitsCount: 1 + (index % 4),
|
||||
routeDistanceKm: 2800 + index * 11,
|
||||
selectedTariffId: selectedTariff.id,
|
||||
totalAmount,
|
||||
currency: 'USD',
|
||||
etaDays: selectedTariff.etaDays,
|
||||
notes: DEMO_QUOTATION_NOTES,
|
||||
},
|
||||
update: {
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
createdByUserId: userId,
|
||||
status: 'converted',
|
||||
selectedTariffId: selectedTariff.id,
|
||||
totalAmount,
|
||||
currency: 'USD',
|
||||
notes: DEMO_QUOTATION_NOTES,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.order.upsert({
|
||||
where: { uuid: orderUuid },
|
||||
create: {
|
||||
uuid: orderUuid,
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
quotationId: quotation.id,
|
||||
createdByUserId: userId,
|
||||
name: `Demo order ${index + 1}`,
|
||||
status: index % 5 === 0 ? 'in_transit' : 'created',
|
||||
totalAmount,
|
||||
currency: 'USD',
|
||||
sourceLocationUuid: source.uuid,
|
||||
sourceLocationName: source.name,
|
||||
sourceCountryCode: source.countryCode,
|
||||
sourceLatitude: source.latitude,
|
||||
sourceLongitude: source.longitude,
|
||||
destinationLocationUuid: destination.uuid,
|
||||
destinationLocationName: destination.name,
|
||||
destinationCountryCode: destination.countryCode,
|
||||
destinationLatitude: destination.latitude,
|
||||
destinationLongitude: destination.longitude,
|
||||
etaDays: selectedTariff.etaDays,
|
||||
notes: 'Demo seed order',
|
||||
},
|
||||
update: {
|
||||
teamUuid: DEMO_TEAM_UUID,
|
||||
quotationId: quotation.id,
|
||||
createdByUserId: userId,
|
||||
status: index % 5 === 0 ? 'in_transit' : 'created',
|
||||
totalAmount,
|
||||
currency: 'USD',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Seeded orders demo data for ${userId}: team ${DEMO_TEAM_UUID}, orders ${DEMO_ORDER_COUNT}`)
|
||||
|
||||
await prisma.$disconnect()
|
||||
+64
-29
@@ -1,5 +1,13 @@
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||
import { GraphQLError } from "graphql";
|
||||
import {
|
||||
createRemoteJWKSet,
|
||||
errors,
|
||||
jwtVerify,
|
||||
type JWTPayload,
|
||||
type JWTVerifyOptions,
|
||||
} from "jose";
|
||||
import mercurius, {
|
||||
type ErrorWithProps as MercuriusErrorWithProps,
|
||||
} from "mercurius";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
|
||||
const LOGTO_JWKS_URL =
|
||||
@@ -9,6 +17,7 @@ const LOGTO_ORDERS_AUDIENCE =
|
||||
process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru";
|
||||
|
||||
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
|
||||
const { ErrorWithProps } = mercurius;
|
||||
|
||||
export interface AuthContext {
|
||||
userId?: string;
|
||||
@@ -19,19 +28,41 @@ export interface AuthContext {
|
||||
function getBearerToken(req: FastifyRequest): string {
|
||||
const auth = req.headers.authorization || "";
|
||||
if (!auth.startsWith("Bearer ")) {
|
||||
throw new GraphQLError("Missing Bearer token", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
throw unauthenticated("Missing Bearer token");
|
||||
}
|
||||
const token = auth.slice(7);
|
||||
if (!token || token === "undefined") {
|
||||
throw new GraphQLError("Empty Bearer token", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
throw unauthenticated("Empty Bearer token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function unauthenticated(message = "Unauthorized"): MercuriusErrorWithProps {
|
||||
return new ErrorWithProps(message, { code: "UNAUTHENTICATED" }, 401);
|
||||
}
|
||||
|
||||
function forbidden(message: string): MercuriusErrorWithProps {
|
||||
return new ErrorWithProps(message, { code: "FORBIDDEN" }, 403);
|
||||
}
|
||||
|
||||
async function verifyLogtoJwt(
|
||||
token: string,
|
||||
options: Omit<JWTVerifyOptions, "issuer"> = {},
|
||||
): Promise<JWTPayload> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer: LOGTO_ISSUER,
|
||||
...options,
|
||||
});
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error instanceof errors.JOSEError) {
|
||||
throw unauthenticated();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function optionalBearerToken(req: FastifyRequest): string | null {
|
||||
const auth = req.headers.authorization || "";
|
||||
if (!auth.startsWith("Bearer ")) return null;
|
||||
@@ -48,6 +79,12 @@ function scopesFromPayload(payload: JWTPayload): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function clientScopes(payload: JWTPayload): string[] {
|
||||
return [
|
||||
...new Set([...scopesFromPayload(payload), "teams:user", "teams:member"]),
|
||||
];
|
||||
}
|
||||
|
||||
function claimList(payload: JWTPayload, key: string): string[] {
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
if (typeof value === "string") return value.split(" ");
|
||||
@@ -71,7 +108,7 @@ export async function publicContext(): Promise<AuthContext> {
|
||||
|
||||
export async function userContext(req: FastifyRequest): Promise<AuthContext> {
|
||||
const token = getBearerToken(req);
|
||||
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER });
|
||||
const payload = await verifyLogtoJwt(token);
|
||||
return {
|
||||
userId: payload.sub,
|
||||
scopes: scopesFromPayload(payload),
|
||||
@@ -80,20 +117,19 @@ export async function userContext(req: FastifyRequest): Promise<AuthContext> {
|
||||
|
||||
export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
|
||||
const token = getBearerToken(req);
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer: LOGTO_ISSUER,
|
||||
const payload = await verifyLogtoJwt(token, {
|
||||
audience: LOGTO_ORDERS_AUDIENCE,
|
||||
});
|
||||
|
||||
const teamUuid = (payload as Record<string, unknown>).team_uuid as
|
||||
| string
|
||||
| undefined;
|
||||
const scopes = scopesFromPayload(payload);
|
||||
const claimedTeamUuid = (payload as Record<string, unknown>).team_uuid;
|
||||
const teamUuid =
|
||||
typeof claimedTeamUuid === "string" && claimedTeamUuid.length > 0
|
||||
? claimedTeamUuid
|
||||
: payload.sub;
|
||||
const scopes = clientScopes(payload);
|
||||
|
||||
if (!teamUuid || !scopes.includes("teams:member")) {
|
||||
throw new GraphQLError("Unauthorized", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
if (!payload.sub || !teamUuid) {
|
||||
throw unauthenticated();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -108,27 +144,26 @@ export async function managerContext(
|
||||
): Promise<AuthContext> {
|
||||
const token = optionalBearerToken(req);
|
||||
if (token === null) return { scopes: [] };
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer: LOGTO_ISSUER,
|
||||
const payload = await verifyLogtoJwt(token, {
|
||||
audience: LOGTO_ORDERS_AUDIENCE,
|
||||
});
|
||||
const scopes = scopesFromPayload(payload);
|
||||
const teamUuid = (payload as Record<string, unknown>).team_uuid as
|
||||
| string
|
||||
| undefined;
|
||||
if (!payload.sub || !hasManagerClaim(payload) || !teamUuid) {
|
||||
throw new GraphQLError("Unauthorized", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
if (!payload.sub || !hasManagerClaim(payload)) {
|
||||
throw unauthenticated();
|
||||
}
|
||||
return { userId: payload.sub, teamUuid, scopes: ["teams:member", "manager"] };
|
||||
return {
|
||||
userId: payload.sub,
|
||||
teamUuid,
|
||||
scopes: [...new Set([...scopes, "manager"])],
|
||||
};
|
||||
}
|
||||
|
||||
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
||||
const missing = required.filter((s) => !ctx.scopes.includes(s));
|
||||
if (missing.length > 0) {
|
||||
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, {
|
||||
extensions: { code: "FORBIDDEN" },
|
||||
});
|
||||
throw forbidden(`Missing required scopes: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
|
||||
export const prisma = new PrismaClient()
|
||||
const connectionString = process.env.ORDERS_DATABASE_URL
|
||||
if (!connectionString) {
|
||||
throw new Error('ORDERS_DATABASE_URL is required')
|
||||
}
|
||||
|
||||
const adapter = new PrismaPg({ connectionString })
|
||||
|
||||
export const prisma = new PrismaClient({ adapter })
|
||||
|
||||
+111
-36
@@ -401,7 +401,18 @@ function isoString(value: Date | null | undefined): string | null {
|
||||
return value ? value.toISOString() : null
|
||||
}
|
||||
|
||||
function assertTeamAccess(ctx: AuthContext): { teamUuid: string; userId: string } {
|
||||
function assertTeamAccess(ctx: AuthContext): { teamUuid?: string; userId: string; isManager: boolean } {
|
||||
if (ctx.scopes.includes('manager')) {
|
||||
if (!ctx.userId) {
|
||||
throw new GraphQLError('User not authenticated')
|
||||
}
|
||||
return {
|
||||
teamUuid: ctx.teamUuid,
|
||||
userId: ctx.userId,
|
||||
isManager: true,
|
||||
}
|
||||
}
|
||||
|
||||
requireScopes(ctx, 'teams:member')
|
||||
if (!ctx.teamUuid || !ctx.userId) {
|
||||
throw new GraphQLError('User not authenticated')
|
||||
@@ -410,9 +421,25 @@ function assertTeamAccess(ctx: AuthContext): { teamUuid: string; userId: string
|
||||
return {
|
||||
teamUuid: ctx.teamUuid,
|
||||
userId: ctx.userId,
|
||||
isManager: false,
|
||||
}
|
||||
}
|
||||
|
||||
function assertScopedTeamAccess(ctx: AuthContext): { teamUuid: string; userId: string } {
|
||||
const access = assertTeamAccess(ctx)
|
||||
if (!access.teamUuid) {
|
||||
throw new GraphQLError('Team context is required')
|
||||
}
|
||||
return {
|
||||
teamUuid: access.teamUuid,
|
||||
userId: access.userId,
|
||||
}
|
||||
}
|
||||
|
||||
function teamAccessWhere(access: { teamUuid?: string }): { teamUuid?: string } {
|
||||
return access.teamUuid ? { teamUuid: access.teamUuid } : {}
|
||||
}
|
||||
|
||||
function mapTariffReference(item: PrismaTariffReference) {
|
||||
return {
|
||||
uuid: item.uuid,
|
||||
@@ -500,6 +527,9 @@ function mapQuotation(item: QuotationWithRelations) {
|
||||
}
|
||||
|
||||
function mapLocalOrder(item: OrderWithRelations) {
|
||||
const progress = orderLocationProgress(item.status)
|
||||
const locationLatitude = interpolateCoordinate(item.sourceLatitude, item.destinationLatitude, progress)
|
||||
const locationLongitude = interpolateCoordinate(item.sourceLongitude, item.destinationLongitude, progress)
|
||||
return {
|
||||
uuid: item.uuid,
|
||||
quotationUuid: item.quotation?.uuid ?? null,
|
||||
@@ -522,10 +552,48 @@ function mapLocalOrder(item: OrderWithRelations) {
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
notes: item.notes ?? '',
|
||||
orderLines: [],
|
||||
stages: [],
|
||||
stages: [
|
||||
{
|
||||
uuid: `${item.uuid}-route`,
|
||||
name: item.name,
|
||||
sequence: 1,
|
||||
stageType: 'route',
|
||||
transportType: item.quotation?.transportTypeCode ?? null,
|
||||
sourceLocationName: item.sourceLocationName,
|
||||
sourceLatitude: item.sourceLatitude,
|
||||
sourceLongitude: item.sourceLongitude,
|
||||
destinationLocationName: item.destinationLocationName,
|
||||
destinationLatitude: item.destinationLatitude,
|
||||
destinationLongitude: item.destinationLongitude,
|
||||
locationName: orderLocationName(item, progress),
|
||||
locationLatitude,
|
||||
locationLongitude,
|
||||
selectedCompany: null,
|
||||
trips: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function orderLocationProgress(status: string | null): number {
|
||||
const normalized = (status ?? '').toLowerCase().replaceAll('-', '_').trim()
|
||||
if (normalized === 'delivered' || normalized === 'completed' || normalized === 'done') return 1
|
||||
if (normalized === 'in_transit' || normalized === 'active') return 0.55
|
||||
if (normalized === 'processing' || normalized === 'accepted') return 0.25
|
||||
return 0.05
|
||||
}
|
||||
|
||||
function interpolateCoordinate(start: number | null, end: number | null, progress: number): number | null {
|
||||
if (start === null || end === null) return start ?? end
|
||||
return start + (end - start) * progress
|
||||
}
|
||||
|
||||
function orderLocationName(item: OrderWithRelations, progress: number): string | null {
|
||||
if (progress >= 1) return item.destinationLocationName ?? 'Груз доставлен'
|
||||
if (progress <= 0.05) return item.sourceLocationName ?? 'Груз принят'
|
||||
return 'Груз в пути'
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
const next = normalizeString(value)
|
||||
if (!next) {
|
||||
@@ -734,11 +802,11 @@ function computeTariffMatches(
|
||||
})
|
||||
}
|
||||
|
||||
async function loadQuotationOrThrow(quotationUuid: string, teamUuid: string) {
|
||||
async function loadQuotationOrThrow(quotationUuid: string, teamUuid?: string) {
|
||||
const item = await prisma.quotation.findFirst({
|
||||
where: {
|
||||
uuid: quotationUuid,
|
||||
teamUuid,
|
||||
...teamAccessWhere({ teamUuid }),
|
||||
},
|
||||
include: quotationInclude,
|
||||
})
|
||||
@@ -750,11 +818,11 @@ async function loadQuotationOrThrow(quotationUuid: string, teamUuid: string) {
|
||||
return item
|
||||
}
|
||||
|
||||
async function loadTariffOrThrow(tariffReferenceUuid: string, teamUuid: string) {
|
||||
async function loadTariffOrThrow(tariffReferenceUuid: string, teamUuid?: string) {
|
||||
const item = await prisma.tariffReference.findFirst({
|
||||
where: {
|
||||
uuid: tariffReferenceUuid,
|
||||
teamUuid,
|
||||
...teamAccessWhere({ teamUuid }),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -882,11 +950,11 @@ async function refreshTeamQuotations(teamUuid: string, actor: { userId: string;
|
||||
export const teamResolvers = {
|
||||
Query: {
|
||||
getTeamOrders: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||
const { teamUuid } = assertTeamAccess(ctx)
|
||||
const access = assertTeamAccess(ctx)
|
||||
|
||||
const localOrders = await prisma.order.findMany({
|
||||
where: {
|
||||
teamUuid,
|
||||
...teamAccessWhere(access),
|
||||
},
|
||||
include: orderInclude,
|
||||
orderBy: {
|
||||
@@ -898,12 +966,12 @@ export const teamResolvers = {
|
||||
},
|
||||
|
||||
getOrder: async (_: unknown, args: { orderUuid: string }, ctx: AuthContext) => {
|
||||
const { teamUuid } = assertTeamAccess(ctx)
|
||||
const access = assertTeamAccess(ctx)
|
||||
|
||||
const order = await prisma.order.findFirst({
|
||||
where: {
|
||||
uuid: args.orderUuid,
|
||||
teamUuid,
|
||||
...teamAccessWhere(access),
|
||||
},
|
||||
include: orderInclude,
|
||||
})
|
||||
@@ -912,11 +980,11 @@ export const teamResolvers = {
|
||||
},
|
||||
|
||||
quotations: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => {
|
||||
const { teamUuid } = assertTeamAccess(ctx)
|
||||
const access = assertTeamAccess(ctx)
|
||||
|
||||
const items = await prisma.quotation.findMany({
|
||||
where: {
|
||||
teamUuid,
|
||||
...teamAccessWhere(access),
|
||||
status: normalizeString(args.status) ?? undefined,
|
||||
},
|
||||
include: quotationInclude,
|
||||
@@ -929,11 +997,11 @@ export const teamResolvers = {
|
||||
},
|
||||
|
||||
quotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
|
||||
const { teamUuid } = assertTeamAccess(ctx)
|
||||
const access = assertTeamAccess(ctx)
|
||||
const item = await prisma.quotation.findFirst({
|
||||
where: {
|
||||
uuid: args.quotationUuid,
|
||||
teamUuid,
|
||||
...teamAccessWhere(access),
|
||||
},
|
||||
include: quotationInclude,
|
||||
})
|
||||
@@ -941,11 +1009,11 @@ export const teamResolvers = {
|
||||
},
|
||||
|
||||
tariffReferences: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => {
|
||||
const { teamUuid } = assertTeamAccess(ctx)
|
||||
const access = assertTeamAccess(ctx)
|
||||
|
||||
const items = await prisma.tariffReference.findMany({
|
||||
where: {
|
||||
teamUuid,
|
||||
...teamAccessWhere(access),
|
||||
status: normalizeString(args.status) ?? undefined,
|
||||
},
|
||||
orderBy: [
|
||||
@@ -958,22 +1026,22 @@ export const teamResolvers = {
|
||||
},
|
||||
|
||||
tariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => {
|
||||
const { teamUuid } = assertTeamAccess(ctx)
|
||||
const access = assertTeamAccess(ctx)
|
||||
const item = await prisma.tariffReference.findFirst({
|
||||
where: {
|
||||
uuid: args.tariffReferenceUuid,
|
||||
teamUuid,
|
||||
...teamAccessWhere(access),
|
||||
},
|
||||
})
|
||||
return item ? mapTariffReference(item) : null
|
||||
},
|
||||
|
||||
quotationTariffMatches: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
|
||||
const { teamUuid } = assertTeamAccess(ctx)
|
||||
const quotation = await loadQuotationOrThrow(args.quotationUuid, teamUuid)
|
||||
const access = assertTeamAccess(ctx)
|
||||
const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
|
||||
const tariffs = await prisma.tariffReference.findMany({
|
||||
where: {
|
||||
teamUuid,
|
||||
teamUuid: quotation.teamUuid,
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
@@ -992,7 +1060,7 @@ export const teamResolvers = {
|
||||
|
||||
Mutation: {
|
||||
createTariffReference: async (_: unknown, args: { input: CreateTariffReferenceInput }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const access = assertScopedTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const item = await prisma.tariffReference.create({
|
||||
data: buildCreateTariffData(args.input, access),
|
||||
@@ -1003,33 +1071,35 @@ export const teamResolvers = {
|
||||
|
||||
updateTariffReference: async (_: unknown, args: { tariffReferenceUuid: string; input: UpdateTariffReferenceInput }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid)
|
||||
const teamAccess = { teamUuid: item.teamUuid, userId: access.userId }
|
||||
const actor = { userId: access.userId, label: actorLabel(teamAccess) }
|
||||
const updated = await prisma.tariffReference.update({
|
||||
where: {
|
||||
id: item.id,
|
||||
},
|
||||
data: buildUpdateTariffData(args.input),
|
||||
})
|
||||
await refreshTeamQuotations(access.teamUuid, actor)
|
||||
await refreshTeamQuotations(item.teamUuid, actor)
|
||||
return mapTariffReference(updated)
|
||||
},
|
||||
|
||||
deleteTariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid)
|
||||
const teamAccess = { teamUuid: item.teamUuid, userId: access.userId }
|
||||
const actor = { userId: access.userId, label: actorLabel(teamAccess) }
|
||||
await prisma.tariffReference.delete({
|
||||
where: {
|
||||
id: item.id,
|
||||
},
|
||||
})
|
||||
await refreshTeamQuotations(access.teamUuid, actor)
|
||||
await refreshTeamQuotations(item.teamUuid, actor)
|
||||
return true
|
||||
},
|
||||
|
||||
createQuotation: async (_: unknown, args: { input: CreateQuotationInput }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const access = assertScopedTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const created = await prisma.quotation.create({
|
||||
data: buildCreateQuotationData(args.input, access),
|
||||
@@ -1045,8 +1115,9 @@ export const teamResolvers = {
|
||||
|
||||
updateQuotation: async (_: unknown, args: { quotationUuid: string; input: UpdateQuotationInput }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
|
||||
const teamAccess = { teamUuid: current.teamUuid, userId: access.userId }
|
||||
const actor = { userId: access.userId, label: actorLabel(teamAccess) }
|
||||
await prisma.quotation.update({
|
||||
where: {
|
||||
id: current.id,
|
||||
@@ -1059,22 +1130,25 @@ export const teamResolvers = {
|
||||
updatedFields: Object.keys(args.input),
|
||||
})
|
||||
|
||||
const refreshed = await refreshQuotationSelection(current.uuid, access.teamUuid, actor)
|
||||
const refreshed = await refreshQuotationSelection(current.uuid, current.teamUuid, actor)
|
||||
return mapQuotation(refreshed)
|
||||
},
|
||||
|
||||
refreshQuotationTariff: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const refreshed = await refreshQuotationSelection(args.quotationUuid, access.teamUuid, actor)
|
||||
const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
|
||||
const teamAccess = { teamUuid: current.teamUuid, userId: access.userId }
|
||||
const actor = { userId: access.userId, label: actorLabel(teamAccess) }
|
||||
const refreshed = await refreshQuotationSelection(current.uuid, current.teamUuid, actor)
|
||||
return mapQuotation(refreshed)
|
||||
},
|
||||
|
||||
selectQuotationTariff: async (_: unknown, args: { quotationUuid: string; tariffReferenceUuid: string }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
|
||||
const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid)
|
||||
const teamAccess = { teamUuid: quotation.teamUuid, userId: access.userId }
|
||||
const actor = { userId: access.userId, label: actorLabel(teamAccess) }
|
||||
const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, quotation.teamUuid)
|
||||
|
||||
const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0)
|
||||
await prisma.quotation.update({
|
||||
@@ -1107,13 +1181,14 @@ export const teamResolvers = {
|
||||
|
||||
createOrderFromQuotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
|
||||
const access = assertTeamAccess(ctx)
|
||||
const actor = { userId: access.userId, label: actorLabel(access) }
|
||||
const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
|
||||
const teamAccess = { teamUuid: quotation.teamUuid, userId: access.userId }
|
||||
const actor = { userId: access.userId, label: actorLabel(teamAccess) }
|
||||
|
||||
const existingOrder = await prisma.order.findFirst({
|
||||
where: {
|
||||
quotationId: quotation.id,
|
||||
teamUuid: access.teamUuid,
|
||||
teamUuid: quotation.teamUuid,
|
||||
},
|
||||
include: orderInclude,
|
||||
})
|
||||
@@ -1128,7 +1203,7 @@ export const teamResolvers = {
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
teamUuid: access.teamUuid,
|
||||
teamUuid: quotation.teamUuid,
|
||||
quotationId: quotation.id,
|
||||
createdByUserId: access.userId,
|
||||
name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`,
|
||||
|
||||
Reference in New Issue
Block a user