import fetchHelper from './fetchHelper' import { decodeJwt } from './chatAccess' /** * Ticket-visibility helpers for the request/ticket routes. * * Why: `r_requestupdate` rows carry the AD_Org_ID they were POSTed with — for a * long time that was the STAFF member's currently active org (LogYou), not the * ticket's org. A limited merchant role ('c') only has org access to its own org, * so iDempiere's row-level security silently hid every such staff reply from the * merchant (they saw only their own messages). AD_Org_ID is not updateable on * R_Request / R_RequestUpdate / R_RequestAction, so the rows can't be re-stamped. * * Pattern (same as `[id]/author-orgs.get.ts` and `merchantPartner.ts`): * 1. Access to the TICKET is always proven with the CALLER's own token first * (`assertTicketVisible`) — iDempiere security decides, exactly as before. * 2. Only then are the ticket's child rows read with the SERVICE token * (`config.api.idempieretoken`), and only for a limited viewer. * 3. Rows a merchant must never see (internal notes 'I', [Zeitabrechnung] * billing blocks) are dropped server-side (`isHiddenForLimited`) — the * client keeps its own filter as well. * * Role resolution is cached per role id (10 min). Any failure resolves to "not * limited", which keeps today's caller-token behaviour — never wider access. */ const TTL_MS = 10 * 60 * 1000 const g = globalThis as any const roleMenuCache: Map = g.__ticketRoleMenuCache ?? (g.__ticketRoleMenuCache = new Map()) const readMenuValue = (frontendMenu: any): string | null => { if (!frontendMenu) return null if (typeof frontendMenu === 'string') return frontendMenu return frontendMenu.id ?? frontendMenu.identifier ?? null } /** The iDempiere service (SuperUser) token, or undefined when not configured. */ export const getServiceToken = (): string | undefined => { const config = useRuntimeConfig() return (config.api as any)?.idempieretoken || undefined } /** JWT AD_Role_ID of the caller (the session token), or null. */ export const resolveOwnRoleId = (event: any): number | null => { const token = getCookie(event, 'logship_it') if (!token) return null try { const roleId = Number(decodeJwt(token)?.AD_Role_ID) return Number.isFinite(roleId) && roleId > 0 ? roleId : null } catch { return null } } /** * The caller role's FrontendMenu value ('c' = limited Fulfillment Customer), * resolved with the service token and cached. `null` when unknown. */ export const getRoleMenuType = async (event: any): Promise => { const roleId = resolveOwnRoleId(event) if (!roleId) return null const hit = roleMenuCache.get(roleId) if (hit && hit.expires > Date.now()) return hit.menu const serviceToken = getServiceToken() if (!serviceToken) return null try { const role: any = await fetchHelper(event, `models/ad_role/${roleId}?$select=FrontendMenu`, 'GET', serviceToken, null) const menu = readMenuValue(role?.FrontendMenu) roleMenuCache.set(roleId, { menu, expires: Date.now() + TTL_MS }) return menu } catch { // Not cached — a transient failure must not stick for the process lifetime. return null } } /** True only when the caller is PROVEN to be a limited merchant role. */ export const isLimitedViewer = async (event: any): Promise => (await getRoleMenuType(event)) === 'c' /** * Access check with the CALLER's token: throws (iDempiere 401/403/404) when the * caller may not read the ticket. Returns the ticket's id + org id. */ export const assertTicketVisible = async (event: any, token: string, id: string | number): Promise<{ id: number, orgId: number | null }> => { if (!id || !/^\d+$/.test(String(id))) { throw createError({ statusCode: 400, statusMessage: 'Invalid ticket id' }) } const ticket: any = await fetchHelper(event, `models/r_request/${id}?$select=AD_Org_ID`, 'GET', token, null) if (!ticket?.id) throw createError({ statusCode: 404, statusMessage: 'Ticket not found' }) const orgId = Number(ticket?.AD_Org_ID?.id ?? ticket?.AD_Org_ID) return { id: Number(ticket.id), orgId: Number.isFinite(orgId) ? orgId : null } } /** * Server-side twin of MyTicketDetail.vue's `isHiddenForLimited`: an entry a * limited merchant role must never receive — an internal note ('I') or any text * carrying a [Zeitabrechnung] billing block. */ export const isHiddenForLimited = (row: any): boolean => { const conf = row?.ConfidentialTypeEntry?.id ?? row?.ConfidentialTypeEntry if (conf === 'I') return true const text = String(row?.Result ?? '') + ' ' + String(row?.Summary ?? '') return /\[Zeitabrechnung\]/.test(text) } /** Drops hidden rows from an iDempiere list response (records + counts). */ export const filterListForLimited = (res: any): any => { if (!res?.records || !Array.isArray(res.records)) return res const records = res.records.filter((r: any) => !isHiddenForLimited(r)) return { ...res, records, 'row-count': records.length, 'records-size': records.length } }