Compare commits

..
16 Commits
Author SHA1 Message Date
Ruslan Bakiev 615f6615e0 Authorize default client team context
Build and deploy Docker image / build (push) Successful in 2m50s
2026-08-20 18:51:14 +07:00
Ruslan Bakiev 10cc93104a ci: deploy through Earth Dokploy
Build and deploy Docker image / build (push) Successful in 2m2s
2026-08-19 18:24:44 +07:00
Ruslan Bakiev ae6b3e8b9e Reduce demo order seed count
Build and deploy Docker image / build (push) Successful in 2m20s
2026-06-08 11:30:02 +07:00
Ruslan Bakiev 7abba6296b Expose order route stages
Build and deploy Docker image / build (push) Successful in 2m37s
2026-06-08 06:41:51 +07:00
Ruslan Bakiev c198b6c566 Normalize demo quotation notes
Build and deploy Docker image / build (push) Successful in 3m7s
2026-06-07 13:16:53 +07:00
Ruslan Bakiev 7b5c44876c Limit demo orders
Build and deploy Docker image / build (push) Successful in 54s
2026-06-07 11:17:57 +07:00
Ruslan Bakiev 022dac41f7 Fix demo seed runtime
Build and deploy Docker image / build (push) Successful in 3m51s
2026-06-07 11:09:55 +07:00
Ruslan Bakiev b743eb13e6 Align backend dependencies
Build and deploy Docker image / build (push) Successful in 3m10s
2026-06-07 10:52:19 +07:00
Ruslan Bakiev 8d2da0ee06 Add demo seed command
Build and deploy Docker image / build (push) Successful in 2m17s
2026-06-07 10:43:12 +07:00
Ruslan Bakiev 6912d15063 Handle orders auth errors explicitly
Build and deploy Docker image / build (push) Successful in 2m31s
2026-06-06 13:40:54 +07:00
Ruslan Bakiev 2cc18940d6 Isolate Docker auth in CI
Build and deploy Docker image / build (push) Successful in 47s
2026-06-05 13:19:22 +07:00
Ruslan Bakiev d81fc10b81 Require Logto team claim for orders auth
Build and deploy Docker image / build (push) Failing after 1m47s
2026-06-05 12:53:04 +07:00
Ruslan Bakiev 1750e8052c Clean local Docker images after CI push
Build and deploy Docker image / build (push) Has been cancelled
2026-06-05 12:51:45 +07:00
Ruslan Bakiev 7a92dcc71a Resolve orders team from Teams profile
Build and deploy Docker image / build (push) Successful in 41s
2026-06-05 12:43:44 +07:00
Ruslan Bakiev 154adb2cd1 Create missing orders tables
Build and deploy Docker image / build (push) Successful in 44s
2026-06-05 12:37:09 +07:00
Ruslan Bakiev 2a0fb9936d Allow manager orders access without team claim
Build and deploy Docker image / build (push) Successful in 2m4s
2026-06-05 12:29:38 +07:00
12 changed files with 1768 additions and 1163 deletions
+14 -10
View File
@@ -1,6 +1,7 @@
name: Build and deploy Docker image name: Build and deploy Docker image
on: on:
workflow_dispatch:
push: push:
branches: [main] branches: [main]
@@ -12,18 +13,21 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Login to Gitea Registry - name: Build and publish
uses: docker/login-action@v3
with:
registry: gitea.dsrptlab.com
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
run: | run: |
set -euo pipefail
docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" . docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" .
docker push "$IMAGE:latest" docker push "$IMAGE:latest"
docker push "$IMAGE:${{ gitea.sha }}" docker push "$IMAGE:${{ gitea.sha }}"
- name: Deploy to Dokploy - name: Remove local image tags
run: curl -fsS -X POST "https://ind.dsrptlab.com/api/deploy/8KU24cMi_nHB4S16dhDB3" 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
View File
@@ -7,12 +7,13 @@ RUN npm ci
FROM deps AS builder FROM deps AS builder
COPY prisma.config.ts ./
COPY prisma ./prisma COPY prisma ./prisma
RUN npx prisma generate RUN ORDERS_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres npx prisma generate
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY src ./src 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 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 ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY prisma.config.ts ./
COPY prisma ./prisma COPY prisma ./prisma
COPY scripts ./scripts COPY scripts ./scripts
EXPOSE 8000 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"]
+1179 -1073
View File
File diff suppressed because it is too large Load Diff
+14 -10
View File
@@ -6,22 +6,26 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "prisma generate && tsc", "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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@prisma/client": "^6.5.0", "@prisma/adapter-pg": "^7.8.0",
"@sentry/node": "^9.5.0", "@prisma/client": "^7.8.0",
"@sentry/node": "^10.56.0",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"graphql": "^16.10.0", "graphql": "^16.14.1",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"jose": "^6.0.11", "jose": "^6.2.3",
"mercurius": "^16.9.0" "mercurius": "^16.9.0",
"pg": "^8.21.0",
"tsx": "^4.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.0", "@types/node": "^25.9.2",
"prisma": "^6.5.0", "@types/pg": "^8.20.0",
"tsx": "^4.19.0", "prisma": "^7.8.0",
"typescript": "^5.7.0" "typescript": "^6.0.3"
} }
} }
+12
View File
@@ -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';
-1
View File
@@ -4,7 +4,6 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("ORDERS_DATABASE_URL")
} }
model TariffReference { model TariffReference {
+194
View File
@@ -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
View File
@@ -1,5 +1,13 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import {
import { GraphQLError } from "graphql"; createRemoteJWKSet,
errors,
jwtVerify,
type JWTPayload,
type JWTVerifyOptions,
} from "jose";
import mercurius, {
type ErrorWithProps as MercuriusErrorWithProps,
} from "mercurius";
import type { FastifyRequest } from "fastify"; import type { FastifyRequest } from "fastify";
const LOGTO_JWKS_URL = const LOGTO_JWKS_URL =
@@ -9,6 +17,7 @@ const LOGTO_ORDERS_AUDIENCE =
process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru"; process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru";
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)); const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
const { ErrorWithProps } = mercurius;
export interface AuthContext { export interface AuthContext {
userId?: string; userId?: string;
@@ -19,19 +28,41 @@ export interface AuthContext {
function getBearerToken(req: FastifyRequest): string { function getBearerToken(req: FastifyRequest): string {
const auth = req.headers.authorization || ""; const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) { if (!auth.startsWith("Bearer ")) {
throw new GraphQLError("Missing Bearer token", { throw unauthenticated("Missing Bearer token");
extensions: { code: "UNAUTHENTICATED" },
});
} }
const token = auth.slice(7); const token = auth.slice(7);
if (!token || token === "undefined") { if (!token || token === "undefined") {
throw new GraphQLError("Empty Bearer token", { throw unauthenticated("Empty Bearer token");
extensions: { code: "UNAUTHENTICATED" },
});
} }
return 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 { function optionalBearerToken(req: FastifyRequest): string | null {
const auth = req.headers.authorization || ""; const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) return null; if (!auth.startsWith("Bearer ")) return null;
@@ -48,6 +79,12 @@ function scopesFromPayload(payload: JWTPayload): string[] {
return []; return [];
} }
function clientScopes(payload: JWTPayload): string[] {
return [
...new Set([...scopesFromPayload(payload), "teams:user", "teams:member"]),
];
}
function claimList(payload: JWTPayload, key: string): string[] { function claimList(payload: JWTPayload, key: string): string[] {
const value = (payload as Record<string, unknown>)[key]; const value = (payload as Record<string, unknown>)[key];
if (typeof value === "string") return value.split(" "); 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> { export async function userContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req); const token = getBearerToken(req);
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); const payload = await verifyLogtoJwt(token);
return { return {
userId: payload.sub, userId: payload.sub,
scopes: scopesFromPayload(payload), scopes: scopesFromPayload(payload),
@@ -80,20 +117,19 @@ export async function userContext(req: FastifyRequest): Promise<AuthContext> {
export async function teamContext(req: FastifyRequest): Promise<AuthContext> { export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req); const token = getBearerToken(req);
const { payload } = await jwtVerify(token, jwks, { const payload = await verifyLogtoJwt(token, {
issuer: LOGTO_ISSUER,
audience: LOGTO_ORDERS_AUDIENCE, audience: LOGTO_ORDERS_AUDIENCE,
}); });
const teamUuid = (payload as Record<string, unknown>).team_uuid as const claimedTeamUuid = (payload as Record<string, unknown>).team_uuid;
| string const teamUuid =
| undefined; typeof claimedTeamUuid === "string" && claimedTeamUuid.length > 0
const scopes = scopesFromPayload(payload); ? claimedTeamUuid
: payload.sub;
const scopes = clientScopes(payload);
if (!teamUuid || !scopes.includes("teams:member")) { if (!payload.sub || !teamUuid) {
throw new GraphQLError("Unauthorized", { throw unauthenticated();
extensions: { code: "UNAUTHENTICATED" },
});
} }
return { return {
@@ -108,27 +144,26 @@ export async function managerContext(
): Promise<AuthContext> { ): Promise<AuthContext> {
const token = optionalBearerToken(req); const token = optionalBearerToken(req);
if (token === null) return { scopes: [] }; if (token === null) return { scopes: [] };
const { payload } = await jwtVerify(token, jwks, { const payload = await verifyLogtoJwt(token, {
issuer: LOGTO_ISSUER,
audience: LOGTO_ORDERS_AUDIENCE, audience: LOGTO_ORDERS_AUDIENCE,
}); });
const scopes = scopesFromPayload(payload); const scopes = scopesFromPayload(payload);
const teamUuid = (payload as Record<string, unknown>).team_uuid as const teamUuid = (payload as Record<string, unknown>).team_uuid as
| string | string
| undefined; | undefined;
if (!payload.sub || !hasManagerClaim(payload) || !teamUuid) { if (!payload.sub || !hasManagerClaim(payload)) {
throw new GraphQLError("Unauthorized", { throw unauthenticated();
extensions: { code: "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 { export function requireScopes(ctx: AuthContext, ...required: string[]): void {
const missing = required.filter((s) => !ctx.scopes.includes(s)); const missing = required.filter((s) => !ctx.scopes.includes(s));
if (missing.length > 0) { if (missing.length > 0) {
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, { throw forbidden(`Missing required scopes: ${missing.join(", ")}`);
extensions: { code: "FORBIDDEN" },
});
} }
} }
+9 -1
View File
@@ -1,3 +1,11 @@
import { PrismaClient } from '@prisma/client' 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
View File
@@ -401,7 +401,18 @@ function isoString(value: Date | null | undefined): string | null {
return value ? value.toISOString() : 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') requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid || !ctx.userId) { if (!ctx.teamUuid || !ctx.userId) {
throw new GraphQLError('User not authenticated') throw new GraphQLError('User not authenticated')
@@ -410,9 +421,25 @@ function assertTeamAccess(ctx: AuthContext): { teamUuid: string; userId: string
return { return {
teamUuid: ctx.teamUuid, teamUuid: ctx.teamUuid,
userId: ctx.userId, 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) { function mapTariffReference(item: PrismaTariffReference) {
return { return {
uuid: item.uuid, uuid: item.uuid,
@@ -500,6 +527,9 @@ function mapQuotation(item: QuotationWithRelations) {
} }
function mapLocalOrder(item: OrderWithRelations) { 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 { return {
uuid: item.uuid, uuid: item.uuid,
quotationUuid: item.quotation?.uuid ?? null, quotationUuid: item.quotation?.uuid ?? null,
@@ -522,10 +552,48 @@ function mapLocalOrder(item: OrderWithRelations) {
updatedAt: item.updatedAt.toISOString(), updatedAt: item.updatedAt.toISOString(),
notes: item.notes ?? '', notes: item.notes ?? '',
orderLines: [], 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 { function requiredString(value: unknown, label: string): string {
const next = normalizeString(value) const next = normalizeString(value)
if (!next) { 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({ const item = await prisma.quotation.findFirst({
where: { where: {
uuid: quotationUuid, uuid: quotationUuid,
teamUuid, ...teamAccessWhere({ teamUuid }),
}, },
include: quotationInclude, include: quotationInclude,
}) })
@@ -750,11 +818,11 @@ async function loadQuotationOrThrow(quotationUuid: string, teamUuid: string) {
return item return item
} }
async function loadTariffOrThrow(tariffReferenceUuid: string, teamUuid: string) { async function loadTariffOrThrow(tariffReferenceUuid: string, teamUuid?: string) {
const item = await prisma.tariffReference.findFirst({ const item = await prisma.tariffReference.findFirst({
where: { where: {
uuid: tariffReferenceUuid, uuid: tariffReferenceUuid,
teamUuid, ...teamAccessWhere({ teamUuid }),
}, },
}) })
@@ -882,11 +950,11 @@ async function refreshTeamQuotations(teamUuid: string, actor: { userId: string;
export const teamResolvers = { export const teamResolvers = {
Query: { Query: {
getTeamOrders: async (_: unknown, __: unknown, ctx: AuthContext) => { getTeamOrders: async (_: unknown, __: unknown, ctx: AuthContext) => {
const { teamUuid } = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const localOrders = await prisma.order.findMany({ const localOrders = await prisma.order.findMany({
where: { where: {
teamUuid, ...teamAccessWhere(access),
}, },
include: orderInclude, include: orderInclude,
orderBy: { orderBy: {
@@ -898,12 +966,12 @@ export const teamResolvers = {
}, },
getOrder: async (_: unknown, args: { orderUuid: string }, ctx: AuthContext) => { getOrder: async (_: unknown, args: { orderUuid: string }, ctx: AuthContext) => {
const { teamUuid } = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const order = await prisma.order.findFirst({ const order = await prisma.order.findFirst({
where: { where: {
uuid: args.orderUuid, uuid: args.orderUuid,
teamUuid, ...teamAccessWhere(access),
}, },
include: orderInclude, include: orderInclude,
}) })
@@ -912,11 +980,11 @@ export const teamResolvers = {
}, },
quotations: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { quotations: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => {
const { teamUuid } = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const items = await prisma.quotation.findMany({ const items = await prisma.quotation.findMany({
where: { where: {
teamUuid, ...teamAccessWhere(access),
status: normalizeString(args.status) ?? undefined, status: normalizeString(args.status) ?? undefined,
}, },
include: quotationInclude, include: quotationInclude,
@@ -929,11 +997,11 @@ export const teamResolvers = {
}, },
quotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { quotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
const { teamUuid } = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const item = await prisma.quotation.findFirst({ const item = await prisma.quotation.findFirst({
where: { where: {
uuid: args.quotationUuid, uuid: args.quotationUuid,
teamUuid, ...teamAccessWhere(access),
}, },
include: quotationInclude, include: quotationInclude,
}) })
@@ -941,11 +1009,11 @@ export const teamResolvers = {
}, },
tariffReferences: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { tariffReferences: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => {
const { teamUuid } = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const items = await prisma.tariffReference.findMany({ const items = await prisma.tariffReference.findMany({
where: { where: {
teamUuid, ...teamAccessWhere(access),
status: normalizeString(args.status) ?? undefined, status: normalizeString(args.status) ?? undefined,
}, },
orderBy: [ orderBy: [
@@ -958,22 +1026,22 @@ export const teamResolvers = {
}, },
tariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => { tariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => {
const { teamUuid } = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const item = await prisma.tariffReference.findFirst({ const item = await prisma.tariffReference.findFirst({
where: { where: {
uuid: args.tariffReferenceUuid, uuid: args.tariffReferenceUuid,
teamUuid, ...teamAccessWhere(access),
}, },
}) })
return item ? mapTariffReference(item) : null return item ? mapTariffReference(item) : null
}, },
quotationTariffMatches: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { quotationTariffMatches: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
const { teamUuid } = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const quotation = await loadQuotationOrThrow(args.quotationUuid, teamUuid) const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
const tariffs = await prisma.tariffReference.findMany({ const tariffs = await prisma.tariffReference.findMany({
where: { where: {
teamUuid, teamUuid: quotation.teamUuid,
status: 'active', status: 'active',
}, },
}) })
@@ -992,7 +1060,7 @@ export const teamResolvers = {
Mutation: { Mutation: {
createTariffReference: async (_: unknown, args: { input: CreateTariffReferenceInput }, ctx: AuthContext) => { 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 actor = { userId: access.userId, label: actorLabel(access) }
const item = await prisma.tariffReference.create({ const item = await prisma.tariffReference.create({
data: buildCreateTariffData(args.input, access), data: buildCreateTariffData(args.input, access),
@@ -1003,33 +1071,35 @@ export const teamResolvers = {
updateTariffReference: async (_: unknown, args: { tariffReferenceUuid: string; input: UpdateTariffReferenceInput }, ctx: AuthContext) => { updateTariffReference: async (_: unknown, args: { tariffReferenceUuid: string; input: UpdateTariffReferenceInput }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const actor = { userId: access.userId, label: actorLabel(access) }
const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid) 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({ const updated = await prisma.tariffReference.update({
where: { where: {
id: item.id, id: item.id,
}, },
data: buildUpdateTariffData(args.input), data: buildUpdateTariffData(args.input),
}) })
await refreshTeamQuotations(access.teamUuid, actor) await refreshTeamQuotations(item.teamUuid, actor)
return mapTariffReference(updated) return mapTariffReference(updated)
}, },
deleteTariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => { deleteTariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const actor = { userId: access.userId, label: actorLabel(access) }
const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid) 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({ await prisma.tariffReference.delete({
where: { where: {
id: item.id, id: item.id,
}, },
}) })
await refreshTeamQuotations(access.teamUuid, actor) await refreshTeamQuotations(item.teamUuid, actor)
return true return true
}, },
createQuotation: async (_: unknown, args: { input: CreateQuotationInput }, ctx: AuthContext) => { 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 actor = { userId: access.userId, label: actorLabel(access) }
const created = await prisma.quotation.create({ const created = await prisma.quotation.create({
data: buildCreateQuotationData(args.input, access), data: buildCreateQuotationData(args.input, access),
@@ -1045,8 +1115,9 @@ export const teamResolvers = {
updateQuotation: async (_: unknown, args: { quotationUuid: string; input: UpdateQuotationInput }, ctx: AuthContext) => { updateQuotation: async (_: unknown, args: { quotationUuid: string; input: UpdateQuotationInput }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const actor = { userId: access.userId, label: actorLabel(access) }
const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) 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({ await prisma.quotation.update({
where: { where: {
id: current.id, id: current.id,
@@ -1059,22 +1130,25 @@ export const teamResolvers = {
updatedFields: Object.keys(args.input), 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) return mapQuotation(refreshed)
}, },
refreshQuotationTariff: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { refreshQuotationTariff: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const actor = { userId: access.userId, label: actorLabel(access) } const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
const refreshed = await refreshQuotationSelection(args.quotationUuid, access.teamUuid, actor) 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) return mapQuotation(refreshed)
}, },
selectQuotationTariff: async (_: unknown, args: { quotationUuid: string; tariffReferenceUuid: string }, ctx: AuthContext) => { selectQuotationTariff: async (_: unknown, args: { quotationUuid: string; tariffReferenceUuid: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const actor = { userId: access.userId, label: actorLabel(access) }
const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) 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) const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0)
await prisma.quotation.update({ await prisma.quotation.update({
@@ -1107,13 +1181,14 @@ export const teamResolvers = {
createOrderFromQuotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { createOrderFromQuotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const actor = { userId: access.userId, label: actorLabel(access) }
const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) 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({ const existingOrder = await prisma.order.findFirst({
where: { where: {
quotationId: quotation.id, quotationId: quotation.id,
teamUuid: access.teamUuid, teamUuid: quotation.teamUuid,
}, },
include: orderInclude, include: orderInclude,
}) })
@@ -1128,7 +1203,7 @@ export const teamResolvers = {
const order = await prisma.order.create({ const order = await prisma.order.create({
data: { data: {
teamUuid: access.teamUuid, teamUuid: quotation.teamUuid,
quotationId: quotation.id, quotationId: quotation.id,
createdByUserId: access.userId, createdByUserId: access.userId,
name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`, name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`,