Allow manager orders access without team claim
Build and deploy Docker image / build (push) Successful in 2m4s

This commit is contained in:
Ruslan Bakiev
2026-06-05 12:29:38 +07:00
parent 5dd28d388f
commit 2a0fb9936d
2 changed files with 75 additions and 37 deletions
+6 -2
View File
@@ -116,12 +116,16 @@ export async function managerContext(
const teamUuid = (payload as Record<string, unknown>).team_uuid as
| string
| undefined;
if (!payload.sub || !hasManagerClaim(payload) || !teamUuid) {
if (!payload.sub || !hasManagerClaim(payload)) {
throw new GraphQLError("Unauthorized", {
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 {
+69 -35
View File
@@ -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,
@@ -734,11 +761,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 +777,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 +909,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 +925,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 +939,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 +956,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 +968,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 +985,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 +1019,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 +1030,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 +1074,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 +1089,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 +1140,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 +1162,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}`,