diff --git a/src/auth.ts b/src/auth.ts index 67f990f..b6c462c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -116,12 +116,16 @@ export async function managerContext( const teamUuid = (payload as Record).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 { diff --git a/src/schemas/team.ts b/src/schemas/team.ts index 22128f7..f480166 100644 --- a/src/schemas/team.ts +++ b/src/schemas/team.ts @@ -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}`,