diff --git a/backend/.env.example b/backend/.env.example index 4eeee58..ecb79fe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,14 +3,19 @@ PORT=8000 -# Napta API — required for live data. Without it, the API serves demo fixtures. -# Base URL + token: see https://app.napta.io (Settings → API). -NAPTA_API_TOKEN= +# Napta API (https://app.napta.io). Auth is Auth0 Machine-to-Machine: the backend +# exchanges client_id + client_secret for a JWT. Without BOTH, the API serves demo +# fixtures. Get an M2M credential from Napta (Settings -> API / your Napta admin). +NAPTA_CLIENT_ID= +NAPTA_CLIENT_SECRET= NAPTA_BASE_URL=https://app.napta.io/api/v1 +# Override only if Napta tells you to: +# NAPTA_AUTH_URL=https://auth.napta.io/oauth/token +# NAPTA_AUDIENCE=backend # Slack bot token (xoxb-...) with scopes: users:read, users:read.email -# Used to resolve each person's profile picture by email. +# Optional — without it, Members show initials Avatars instead of Slack photos. SLACK_BOT_TOKEN= -# SQLite location (avatar cache). Matches the Coolify persistent volume. +# SQLite location (Slack directory cache). Matches the Coolify persistent volume. DB_PATH=data/app.db diff --git a/backend/src/config.ts b/backend/src/config.ts index a84b9a6..28ebf07 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,19 +1,29 @@ export interface Config { port: number - naptaApiToken: string | null + // Napta uses Auth0 Machine-to-Machine (client-credentials), not a static token: + // client_id + client_secret are exchanged for a short-lived JWT (see napta.ts). + naptaClientId: string | null + naptaClientSecret: string | null naptaBaseUrl: string + naptaAuthUrl: string + naptaAudience: string slackBotToken: string | null } export const config: Config = { port: Number(process.env.PORT ?? 8000), - naptaApiToken: process.env.NAPTA_API_TOKEN || null, + naptaClientId: process.env.NAPTA_CLIENT_ID || null, + naptaClientSecret: process.env.NAPTA_CLIENT_SECRET || null, naptaBaseUrl: process.env.NAPTA_BASE_URL || "https://app.napta.io/api/v1", + naptaAuthUrl: process.env.NAPTA_AUTH_URL || "https://auth.napta.io/oauth/token", + naptaAudience: process.env.NAPTA_AUDIENCE || "backend", slackBotToken: process.env.SLACK_BOT_TOKEN || null, } -// Live data needs BOTH a Napta token (to list projects/people) and a Slack -// token (to resolve avatars). Without both, the API serves demo fixtures. -export const hasLiveCredentials: boolean = Boolean( - config.naptaApiToken && config.slackBotToken, +// Listing Projects and Members needs Napta. Without it, the API serves fixtures. +export const hasNaptaCredentials: boolean = Boolean( + config.naptaClientId && config.naptaClientSecret, ) + +// Slack only resolves Avatars; it's optional and degrades to initials when absent. +export const hasSlackCredentials: boolean = Boolean(config.slackBotToken) diff --git a/backend/src/index.ts b/backend/src/index.ts index 137e8a0..639cced 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,35 +1,49 @@ import { serve } from "@hono/node-server" import { Hono } from "hono" import { logger } from "hono/logger" -import { config, hasLiveCredentials } from "./config.ts" +import { config, hasNaptaCredentials } from "./config.ts" import { fixtureMembers, fixtureProjects } from "./fixtures.ts" import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts" import { enrichWithSlackAvatars } from "./slack.ts" import "./db.ts" // initialise the SQLite schema at boot +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + const app = new Hono() app.use("*", logger()) app.get("/api/health", (c) => c.json({ status: "ok" })) app.get("/api/projects", async (c) => { - if (!hasLiveCredentials) { + if (!hasNaptaCredentials) { return c.json({ source: "fixture", projects: fixtureProjects }) } - const projects = await fetchNaptaProjects() - return c.json({ source: "napta", projects }) + try { + const projects = await fetchNaptaProjects() + return c.json({ source: "napta", projects }) + } catch (err) { + console.error("fetchNaptaProjects failed:", err) + return c.json({ error: errorMessage(err) }, 502) + } }) app.get("/api/projects/:id/members", async (c) => { const id = c.req.param("id") - if (!hasLiveCredentials) { + if (!hasNaptaCredentials) { return c.json({ source: "fixture", members: fixtureMembers(id) }) } - const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id)) - return c.json({ source: "napta", members }) + try { + const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id)) + return c.json({ source: "napta", members }) + } catch (err) { + console.error("fetchNaptaProjectMembers failed:", err) + return c.json({ error: errorMessage(err) }, 502) + } }) console.log( - `photofetch backend listening on :${config.port} (live credentials: ${hasLiveCredentials})`, + `photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`, ) serve({ fetch: app.fetch, port: config.port }) diff --git a/backend/src/napta.ts b/backend/src/napta.ts index 85ba989..2d92aed 100644 --- a/backend/src/napta.ts +++ b/backend/src/napta.ts @@ -1,15 +1,144 @@ +import { config } from "./config.ts" import type { Member, Project } from "./types.ts" -// Real Napta integration. Implemented in Step 11 (build-out) once the exact -// Napta API endpoints, auth header, and response shapes are confirmed in the -// walk-with-me design session. Until then these throw, and the route layer -// only calls them when live credentials are present — so the default -// experience (no tokens) serves fixtures instead of erroring. +// --- Auth: Auth0 client-credentials, with an in-memory JWT cache --- + +interface CachedToken { + token: string + expiresAt: number +} + +let tokenCache: CachedToken | null = null + +async function getAccessToken(): Promise { + // Reuse the cached token until ~1 min before it expires. + if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) { + return tokenCache.token + } + const res = await fetch(config.naptaAuthUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: config.naptaClientId, + client_secret: config.naptaClientSecret, + audience: config.naptaAudience, + }), + }) + if (!res.ok) { + throw new Error(`Napta auth failed: ${res.status} ${await res.text()}`) + } + const body = (await res.json()) as { access_token: string; expires_in: number } + tokenCache = { + token: body.access_token, + expiresAt: Date.now() + body.expires_in * 1000, + } + return body.access_token +} + +// --- JSON:API helpers (Napta is JSON:API 1.0 over flask-rest-jsonapi) --- + +interface JsonApiResource { + id: string + type: string + attributes: Record + relationships?: Record< + string, + { data: { id: string; type: string } | { id: string; type: string }[] | null } + > +} + +interface JsonApiResponse { + data: JsonApiResource[] + included?: JsonApiResource[] +} + +interface Filter { + name: string + op: string + val: unknown +} + +interface QueryOpts { + filters?: Filter[] + include?: string[] + pageSize?: number +} + +async function naptaGetPage( + path: string, + opts: QueryOpts, + page: number, +): Promise { + const token = await getAccessToken() + const url = new URL(`${config.naptaBaseUrl}${path}`) + // flask-rest-jsonapi: ?filter=[{"name","op","val"}], page[size], page[number], include. + if (opts.filters?.length) url.searchParams.set("filter", JSON.stringify(opts.filters)) + if (opts.include?.length) url.searchParams.set("include", opts.include.join(",")) + url.searchParams.set("page[size]", String(opts.pageSize ?? 100)) + url.searchParams.set("page[number]", String(page)) + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + if (!res.ok) { + // Surface the exact request + body so a live mismatch (filter syntax, etc.) is obvious. + throw new Error(`Napta GET ${url.pathname}${url.search} -> ${res.status} ${await res.text()}`) + } + return (await res.json()) as JsonApiResponse +} + +// Page through a collection, accumulating `data` and sideloaded `included`. +async function naptaGetAll( + path: string, + opts: QueryOpts, +): Promise<{ data: JsonApiResource[]; included: JsonApiResource[] }> { + const data: JsonApiResource[] = [] + const included: JsonApiResource[] = [] + const pageSize = opts.pageSize ?? 100 + for (let page = 1; page <= 1000; page++) { + const res = await naptaGetPage(path, opts, page) + data.push(...res.data) + if (res.included) included.push(...res.included) + if (res.data.length < pageSize) break // short page => last page + } + return { data, included } +} + +function attr(resource: JsonApiResource, key: string): string { + const v = resource.attributes[key] + return v == null ? "" : String(v) +} + +function relId(resource: JsonApiResource, name: string): string | null { + const rel = resource.relationships?.[name]?.data + if (!rel || Array.isArray(rel)) return null + return rel.id +} + +// --- Projects --- export async function fetchNaptaProjects(): Promise { - throw new Error("Napta integration not implemented yet (Step 11)") + const { data, included } = await naptaGetAll("/project", { + filters: [{ name: "is_archived", op: "eq", val: false }], + include: ["client"], + }) + const clientNameById = new Map() + for (const inc of included) { + if (inc.type === "client") clientNameById.set(inc.id, attr(inc, "name")) + } + return data + .map((p) => { + const clientId = relId(p, "client") + const clientName = clientId ? clientNameById.get(clientId) : undefined + return { + id: p.id, + name: attr(p, "name") || "Untitled project", + clientName: clientName || undefined, + } + }) + .sort((a, b) => a.name.localeCompare(b.name)) } +// --- Members (implemented in Slice 2) --- + export async function fetchNaptaProjectMembers(_projectId: string): Promise { - throw new Error("Napta integration not implemented yet (Step 11)") + throw new Error("Napta member resolution not implemented yet (Slice 2)") } diff --git a/docker-compose.yml b/docker-compose.yml index 0bad8b1..b02956f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,9 @@ services: - PORT=8000 - DB_PATH=/app/data/app.db # Set these as env vars on the Coolify app to switch from demo fixtures - # to live data. Empty -> the backend serves fixtures. - - NAPTA_API_TOKEN=${NAPTA_API_TOKEN:-} + # to live data. Empty Napta creds -> the backend serves fixtures. + - NAPTA_CLIENT_ID=${NAPTA_CLIENT_ID:-} + - NAPTA_CLIENT_SECRET=${NAPTA_CLIENT_SECRET:-} - NAPTA_BASE_URL=${NAPTA_BASE_URL:-https://app.napta.io/api/v1} - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-} volumes: