Compare commits

...
15 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
12 changed files with 1693 additions and 1126 deletions
+14 -10
View File
@@ -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
View File
@@ -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"]
+1179 -1073
View File
File diff suppressed because it is too large Load Diff
+14 -10
View File
@@ -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"
}
}
+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 {
provider = "postgresql"
url = env("ORDERS_DATABASE_URL")
}
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()
+58 -27
View File
@@ -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,8 +144,7 @@ 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);
@@ -117,9 +152,7 @@ export async function managerContext(
| string
| undefined;
if (!payload.sub || !hasManagerClaim(payload)) {
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHENTICATED" },
});
throw unauthenticated();
}
return {
userId: payload.sub,
@@ -131,8 +164,6 @@ export async function managerContext(
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(", ")}`);
}
}
+9 -1
View File
@@ -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 })
+42 -1
View File
@@ -527,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,
@@ -549,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) {