From e47302218bf3881d8362eabccf8ab65ddbdb26a2 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 26 Jun 2026 16:20:51 +0100 Subject: [PATCH] feat(napta): resolve project Members (real+active, role, sorted) Filter user_project by project + simulated=false, include user, drop inactive people, dedupe per user, map user_position -> Role, sort by last name. --- backend/src/napta.ts | 68 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/backend/src/napta.ts b/backend/src/napta.ts index 2d92aed..cedda8e 100644 --- a/backend/src/napta.ts +++ b/backend/src/napta.ts @@ -137,8 +137,68 @@ export async function fetchNaptaProjects(): Promise { .sort((a, b) => a.name.localeCompare(b.name)) } -// --- Members (implemented in Slice 2) --- - -export async function fetchNaptaProjectMembers(_projectId: string): Promise { - throw new Error("Napta member resolution not implemented yet (Slice 2)") +// Prefer a flat `_id` attribute, else the relationship's resource id. +function relatedId(resource: JsonApiResource, name: string): string | null { + const flat = resource.attributes[`${name}_id`] + if (flat != null) return String(flat) + return relId(resource, name) +} + +// --- Positions (Role lookup), cached for the process lifetime --- + +let positionMapCache: Map | null = null + +async function getPositionMap(): Promise> { + if (positionMapCache) return positionMapCache + const { data } = await naptaGetAll("/user_position", {}) + const map = new Map() + for (const p of data) map.set(p.id, attr(p, "name")) + positionMapCache = map + return map +} + +// --- Members --- + +export async function fetchNaptaProjectMembers(projectId: string): Promise { + const [{ data, included }, positions] = await Promise.all([ + naptaGetAll("/user_project", { + // Real staffing on this Project only (drop simulated/forecast scenarios). + filters: [ + { name: "project_id", op: "eq", val: projectId }, + { name: "simulated", op: "eq", val: false }, + ], + include: ["user"], + }), + getPositionMap(), + ]) + + const userById = new Map() + for (const inc of included) { + if (inc.type === "user") userById.set(inc.id, inc) + } + + // One Member per user, even if they have several assignments on the Project. + const byUserId = new Map() + for (const up of data) { + const userId = relatedId(up, "user") + if (!userId || byUserId.has(userId)) continue + const user = userById.get(userId) + if (!user) continue + if (user.attributes.active === false) continue // drop departed people + const positionId = relatedId(user, "user_position") + byUserId.set(userId, { + id: userId, + firstName: attr(user, "first_name"), + lastName: attr(user, "last_name"), + role: (positionId && positions.get(positionId)) || "", + email: attr(user, "email"), + imageUrl: null, + slackMatched: false, + }) + } + + return [...byUserId.values()].sort( + (a, b) => + a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName), + ) }