Compare commits
8 Commits
645bd6ff27
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b871f4cbf | ||
|
|
2b4d056ab8 | ||
|
|
30b96ecf64 | ||
|
|
09238df7e3 | ||
|
|
de98bdeef7 | ||
|
|
e47302218b | ||
|
|
9ded10f68e | ||
|
|
fc039ac0fc |
67
CONTEXT.md
Normal file
67
CONTEXT.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# photofetch
|
||||
|
||||
Pick a Napta **Project** and see a grid of its **Members** — each shown with their
|
||||
Slack **Avatar**, name, and **Role**. The shared vocabulary below is the ubiquitous
|
||||
language: it must appear verbatim in conversation, code, tests, commits, and docs.
|
||||
|
||||
## Language
|
||||
|
||||
**Project**:
|
||||
A Napta project that people are staffed on; the unit you select to see its team.
|
||||
_Avoid_: mission, engagement.
|
||||
|
||||
**Person**:
|
||||
An employee in Napta (a Napta `user`), carrying an email, first/last name, and Position.
|
||||
_Avoid_: collaborator, resource, user (ambiguous — there are no app accounts).
|
||||
|
||||
**Staffing Assignment**:
|
||||
A Napta `user_project` row linking one Person to one Project; may be real or `simulated`.
|
||||
_Avoid_: allocation, booking.
|
||||
|
||||
**Member**:
|
||||
A Person with a real (non-`simulated`) Staffing Assignment on the selected Project who is still an active employee — one cell in the grid.
|
||||
_Avoid_: teammate, staff.
|
||||
|
||||
**Role**:
|
||||
A Member's Napta job Position (`user_position`), shown under their name.
|
||||
_Avoid_: title, grade, seniority.
|
||||
|
||||
**Match**:
|
||||
The link from a Member to their Slack profile, made by equal email. A Member is _matched_ or _unmatched_.
|
||||
_Avoid_: lookup, mapping.
|
||||
|
||||
**Avatar**:
|
||||
A matched Member's Slack profile picture. Unmatched Members fall back to an initials Avatar.
|
||||
_Avoid_: photo, picture, image.
|
||||
|
||||
**Selection**:
|
||||
The set of one or more Projects currently chosen, whose teams are shown together.
|
||||
_Avoid_: filter.
|
||||
|
||||
**Project Group**:
|
||||
One selected Project plus its Members — a section in the grid and a folder in an Export.
|
||||
_Avoid_: section, bucket.
|
||||
|
||||
**Export**:
|
||||
A downloadable zip of Member Avatars, organized as one folder per Project Group (folder named after the Project).
|
||||
_Avoid_: download, dump.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Project** has many **Staffing Assignments**; each Assignment links one **Person**.
|
||||
- A **Member** is a **Person** with a qualifying **Staffing Assignment** on a selected **Project**.
|
||||
- A **Member** has exactly one **Role** and at most one **Avatar** (via an email **Match**).
|
||||
- A **Selection** contains one or more **Projects**; each renders as a **Project Group**.
|
||||
- A Person staffed on several selected **Projects** appears in each of those **Project Groups**.
|
||||
- An **Export** writes one folder per **Project Group**, holding its Members' **Avatar** files.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "When I pick a **Project**, do I show every **Person** who has a **Staffing Assignment** on it?"
|
||||
> **Domain expert:** "Only real ones — drop the `simulated` assignments, and drop people who've left."
|
||||
> **Dev:** "And if a **Member**'s email has no **Match** in Slack?"
|
||||
> **Domain expert:** "Still show them — initials **Avatar**, real name and **Role**. A missing photo isn't a missing person."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "user" is avoided: Napta calls a Person a `user`, but photofetch has no login/accounts, so the word would mislead. A Person on the selected Project is a **Member**.
|
||||
110
DESIGN.md
Normal file
110
DESIGN.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# photofetch — Design (QFD)
|
||||
|
||||
How photofetch turns "pick a Project, see its team as faces" into engineering
|
||||
functions and components. Vocabulary is defined in [CONTEXT.md](./CONTEXT.md);
|
||||
the Slack-matching decision is recorded in [ADR-0001](./docs/adr/0001-slack-directory-cache.md).
|
||||
This is the tree-only QFD variant (cascade + budget + tradeoffs) — the goal/function
|
||||
counts don't justify the upkeep of full QFD matrices.
|
||||
|
||||
Strength weights used below: **9** strong, **3** medium, **1** weak, blank none.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals — the WHATs
|
||||
|
||||
| ID | Goal | Weight | Source |
|
||||
| --- | ----------------------------------------------------------------------- | :----: | ------------ |
|
||||
| G1 | Pick a Project and immediately see who's on it, as faces | 10 | user request |
|
||||
| G2 | Each Member shows the correct name + Role | 8 | user request |
|
||||
| G3 | Find a specific Project fast, even among hundreds | 6 | [Q3] |
|
||||
| G4 | Stay useful when Slack is down or a Member has no Slack | 6 | [Q6] |
|
||||
| G5 | Keep the roster private to the team | 7 | [Q7] |
|
||||
| G6 | Select several Projects, see their teams grouped, and export the photos | 8 | user request |
|
||||
|
||||
## 2. Functions — the HOWs
|
||||
|
||||
| ID | Function | Dir | Target (now) | Target (future) |
|
||||
| --- | ----------------------------------------------------------- | :-: | -------------------------------------- | ------------------------ |
|
||||
| F1 | Resolve a Project's Members from Napta (real + active only) | → | correct set | — |
|
||||
| F2 | Render a Project's grid end-to-end | ↓ | ≤ 2 s p95 (warm cache) | ≤ 1 s |
|
||||
| F3 | Match Members → Slack Avatars | ↑ | in-memory vs cached directory | — |
|
||||
| F4 | Authenticate to Napta (Auth0 M2M) | → | cached JWT, refresh on 401/expiry | — |
|
||||
| F5 | Keep the Slack directory cache fresh | → | 30-day TTL + manual Refresh | — |
|
||||
| F6 | Filter/search the Project picker (multi-select) | ↓ | instant on ≤ few hundred (client-side) | server-side if thousands |
|
||||
| F7 | Gate access to the whole site | → | Basic Auth at nginx | SSO |
|
||||
| F8 | Build the Export zip (folder per Project Group) | ↓ | ≤ ~15 s for a few hundred photos | stream + progress |
|
||||
|
||||
## 3. Cascade — Goals → Functions → How → Components
|
||||
|
||||
- **G1** Pick a Project, see its team _(W10)_
|
||||
- **F1** Resolve Members from Napta
|
||||
- **How**: `GET /user_project?filter[project_id]` (drop `simulated`) → resolve `user_id`s → `GET /user` (drop inactive) → resolve `user_position_id` → Role
|
||||
- **Component**: `backend/src/napta.ts` (client + token + queries)
|
||||
- **F4** Authenticate to Napta
|
||||
- **How**: client-credentials → JWT at `auth.napta.io/oauth/token` (aud `backend`), cache until expiry, refresh on 401
|
||||
- **Component**: `backend/src/napta.ts` (token cache)
|
||||
- **F2** Render the grid
|
||||
- **How**: one backend call `GET /api/projects/:id/members` returns ready-to-render Members; SPA shows loading skeleton → grid
|
||||
- **Component**: `src/App.vue`, `src/components/MemberGrid.vue`, `MemberCard.vue`
|
||||
- **G2** Correct name + Role _(W8)_
|
||||
- **F1** (Role = Napta Position) — see above
|
||||
- **F3** Match Members → Avatars
|
||||
- **How**: `users.list` sweep → `email → {imageUrl, slackId}` directory in SQLite; match each Member's email in memory; unmatched → initials Avatar
|
||||
- **Component**: `backend/src/slack.ts`, `backend/src/db.ts` (directory table)
|
||||
- **G3** Find a Project fast _(W6)_
|
||||
- **F6** Client-side searchable picker
|
||||
- **How**: fetch all non-archived Projects once; typeahead filters by name + client
|
||||
- **Component**: `src/components/ProjectPicker.vue`
|
||||
- **G4** Useful when Slack is down / no match _(W6)_
|
||||
- **F7→degrade**: if the directory sweep fails, return Members anyway with `slackMatched:false` + a `slackError` flag; UI shows initials + dismissible warning
|
||||
- **Component**: `backend/src/slack.ts`, `MemberGrid.vue`
|
||||
- **G5** Private to the team _(W7)_
|
||||
- **F7** Basic Auth at the edge
|
||||
- **How**: nginx `auth_basic` over the whole site incl. `/api`; htpasswd generated at container start from `BASIC_AUTH_USER`/`BASIC_AUTH_PASSWORD`; no-op when unset (local dev)
|
||||
- **Component**: `nginx.conf`, web image entrypoint
|
||||
- **G6** Multi-select + grouped view + photo Export _(W8)_
|
||||
- **F6** Multi-select searchable picker; chosen Projects render as Project Groups (a Person on several appears in each)
|
||||
- **Component**: `src/components/ProjectPicker.vue`, `src/App.vue`, `ProjectGroup.vue`
|
||||
- **F8** Export zip
|
||||
- **How**: `POST /api/export {projectIds[]}` → per Project Group fetch Members, fetch each Avatar's bytes (dedup within the export, bounded concurrency), write `<project>/<lastname-firstname>.<ext>`; unmatched Members get a generated **initials SVG**; stream the zip (`fflate`)
|
||||
- **Component**: `backend/src/export.ts`, `backend/src/avatar.ts` (initials SVG), `backend/src/index.ts`
|
||||
|
||||
## 7. Critical performance budget
|
||||
|
||||
| Rank | Function | Target | Watched on | If we miss it |
|
||||
| ---- | -------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 1 | F2 grid render | ≤ 2 s p95 (warm) | backend request logs | parallelize Napta calls; cache Positions table; batch `user` fetch by id |
|
||||
| 2 | F5 directory sweep | ≤ ~10 s for full workspace | sweep duration log | paginate + serve stale snapshot while refreshing in background |
|
||||
| 3 | F1 Member resolution | correct set, not slow path | spot-check vs Napta UI | add `staffed_days > 0` filter if "assigned but never staffed" noise appears |
|
||||
| 4 | F8 Export build | ≤ ~15 s, a few hundred photos | export duration log | bounded-concurrency avatar fetch; dedup repeated avatars; cap selection size with a warning |
|
||||
|
||||
## 8. Tradeoffs — Got / Paid / ADR
|
||||
|
||||
| ID | Tradeoff | Got | Paid | ADR |
|
||||
| --- | -------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| T1 | `users.list` whole-workspace sweep over per-email lookup | few Slack calls, rate-limit-safe, in-memory matching | fetch entire directory; up to 30-day staleness (mitigated by Refresh) | [ADR-0001](./docs/adr/0001-slack-directory-cache.md) |
|
||||
| T2 | Napta Auth0 M2M cached JWT over a static token | matches Napta's real auth; survives token expiry | token-exchange code + refresh-on-401 | — |
|
||||
| T3 | HTTP Basic Auth over SSO | strangers kept out with ~zero build | one shared credential; no per-user identity/audit; easily swapped later | — |
|
||||
| T4 | Email as the only Napta↔Slack join key | one reliable key, no fuzzy matching | Members whose Slack email differs go unmatched (shown with initials) | — |
|
||||
| T5 | Display-only grid for v1 | ships the core ask fastest | no click-to-Slack / copy-email yet | — |
|
||||
| T6 | Initials Avatars exported as SVG, not rasterized PNG | zero native deps, clean Alpine image, scalable | mixed extensions in folders; SVG unsuitable where only raster embeds | — |
|
||||
| T7 | Server-side zip (`fflate`), built in memory | no Slack-CDN CORS, has avatar bytes; simple | whole zip held in memory; large Selections need streaming (F8 future) | — |
|
||||
|
||||
### Tensions being watched (unresolved by design)
|
||||
|
||||
- **Project count.** Client-side search assumes ≤ a few hundred active Projects. **Trigger to revisit:** the all-Projects fetch gets slow or Napta paginates it past one page → move search server-side (F6 future).
|
||||
|
||||
## 9. Inconsistencies spotted and fixed
|
||||
|
||||
- **Static token assumption.** Scaffold used `NAPTA_API_TOKEN`; Napta actually uses Auth0 M2M client-credentials. → Config becomes `NAPTA_CLIENT_ID` + `NAPTA_CLIENT_SECRET` with a token-exchange step.
|
||||
- **Wrong cache shape.** Scaffold `db.ts` modeled a per-email avatar cache (`email → url`, null = no-match), which fits `lookupByEmail`, not the chosen `users.list` sweep. → Reworked to a directory snapshot table + a `refreshed_at` timestamp.
|
||||
- **Host port publishing.** `docker-compose.yml` published `80`/`8000`, which collided on the Coolify host and failed the first deploy. → Switched to `expose:` (already fixed and deployed).
|
||||
|
||||
---
|
||||
|
||||
## How to keep this honest
|
||||
|
||||
- New ADR lands → reference it from §8 and (if matrices ever added) §6.
|
||||
- Spike/measurement returns numbers → update §7 `Target` / `Watched on`.
|
||||
- WHATs (§1) change rarely; HOWs (§2) change per release; revisit §3 cascade when either side moves.
|
||||
- Delete any section that becomes empty — empty sections lie.
|
||||
@@ -6,6 +6,11 @@ COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
# apache2-utils provides htpasswd; auth_enabled.conf is included by nginx.conf
|
||||
# and (re)written at start by the entrypoint hook — default empty = no auth.
|
||||
RUN apk add --no-cache apache2-utils && touch /etc/nginx/auth_enabled.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker-entrypoint.d/40-basic-auth.sh /docker-entrypoint.d/40-basic-auth.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-basic-auth.sh
|
||||
EXPOSE 80
|
||||
|
||||
26
README.md
26
README.md
@@ -2,9 +2,14 @@
|
||||
|
||||
Deployed at https://photofetch.apoena.dev
|
||||
|
||||
Pick a **Napta project** and get a grid of the people staffed on it — each with
|
||||
their **Slack profile picture**, first name, last name, and role. People are
|
||||
matched between Napta and Slack by **email**.
|
||||
Pick one or more **Napta projects** and get a grid of the people staffed on each —
|
||||
every person shown with their **Slack profile picture**, first name, last name, and
|
||||
role. People are matched between Napta and Slack by **email**. Selected projects are
|
||||
shown as separate groups, and you can **export** everyone's photos as a zip with one
|
||||
folder per project (people without a Slack photo get a generated initials image).
|
||||
|
||||
See [`CONTEXT.md`](./CONTEXT.md) for the vocabulary and [`DESIGN.md`](./DESIGN.md)
|
||||
for the design decisions.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -40,14 +45,15 @@ cd backend && pnpm typecheck
|
||||
|
||||
Set on the Coolify app (or in `backend/.env` locally — see `backend/.env.example`):
|
||||
|
||||
| Var | Purpose |
|
||||
|---|---|
|
||||
| `NAPTA_API_TOKEN` | Napta API token (lists projects + their people) |
|
||||
| `NAPTA_BASE_URL` | Napta API base (default `https://app.napta.io/api/v1`) |
|
||||
| `SLACK_BOT_TOKEN` | Slack bot token, scopes `users:read` + `users:read.email` |
|
||||
| Var | Purpose |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `NAPTA_CLIENT_ID` / `NAPTA_CLIENT_SECRET` | Napta Auth0 M2M credentials (lists projects + staffing). Without **both** → demo fixtures. |
|
||||
| `NAPTA_BASE_URL` | Napta API base (default `https://app.napta.io/api/v1`) |
|
||||
| `SLACK_BOT_TOKEN` | Slack bot token, scopes `users:read` + `users:read.email`. Optional — without it, Members show initials. |
|
||||
| `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` | Set **both** (on the `web` service) to require a shared login for the whole site. Unset → open. |
|
||||
|
||||
Without **both** tokens the API serves demo fixtures and the UI shows a
|
||||
"demo data" banner.
|
||||
Without Napta credentials the API serves demo fixtures and the UI shows a
|
||||
"demo data" banner. `BASIC_AUTH_*` go on the **web** service; the rest on **api**.
|
||||
|
||||
## Deploy
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.0.6",
|
||||
"fflate": "^0.8.3",
|
||||
"hono": "^4.12.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
8
backend/pnpm-lock.yaml
generated
8
backend/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@hono/node-server':
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6(hono@4.12.27)
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
hono:
|
||||
specifier: ^4.12.27
|
||||
version: 4.12.27
|
||||
@@ -33,6 +36,9 @@ packages:
|
||||
'@types/node@26.0.1':
|
||||
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
hono@4.12.27:
|
||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
@@ -55,6 +61,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
hono@4.12.27: {}
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
30
backend/src/avatar.ts
Normal file
30
backend/src/avatar.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Deterministic initials Avatar as an SVG string — used in the Export for
|
||||
// Members with no Slack photo, mirroring the in-app initials fallback.
|
||||
|
||||
const COLORS = ["#4A154B", "#1264A3", "#2EB67D", "#E01E5A", "#ECB22E", "#611F69", "#36C5F0"]
|
||||
|
||||
function hashCode(s: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
||||
return Math.abs(h)
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"&": "&",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
}
|
||||
return s.replace(/[<>&'"]/g, (c) => map[c] ?? c)
|
||||
}
|
||||
|
||||
export function initialsAvatarSvg(firstName: string, lastName: string): string {
|
||||
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || "?"
|
||||
const color = COLORS[hashCode(`${firstName} ${lastName}`) % COLORS.length] ?? COLORS[0]
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="${color}"/>
|
||||
<text x="256" y="256" dy="0.08em" fill="#ffffff" font-family="Inter, system-ui, sans-serif" font-size="220" font-weight="600" text-anchor="middle" dominant-baseline="central">${escapeXml(initials)}</text>
|
||||
</svg>`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
23
backend/src/data.ts
Normal file
23
backend/src/data.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { hasNaptaCredentials } from "./config.ts"
|
||||
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
|
||||
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
|
||||
import { type EnrichResult, enrichWithSlackAvatars } from "./slack.ts"
|
||||
import type { DataSource, Project } from "./types.ts"
|
||||
|
||||
// Single source of truth for "fixtures vs live", shared by the API routes and
|
||||
// the Export so the gating logic isn't duplicated.
|
||||
|
||||
export async function getProjects(): Promise<{ source: DataSource; projects: Project[] }> {
|
||||
if (!hasNaptaCredentials) return { source: "fixture", projects: fixtureProjects }
|
||||
return { source: "napta", projects: await fetchNaptaProjects() }
|
||||
}
|
||||
|
||||
export async function getProjectMembers(
|
||||
projectId: string,
|
||||
): Promise<{ source: DataSource } & EnrichResult> {
|
||||
if (!hasNaptaCredentials) {
|
||||
return { source: "fixture", members: fixtureMembers(projectId), slack: "unconfigured" }
|
||||
}
|
||||
const enriched = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(projectId))
|
||||
return { source: "napta", ...enriched }
|
||||
}
|
||||
@@ -7,41 +7,63 @@ mkdirSync(dirname(dbPath), { recursive: true })
|
||||
|
||||
export const db = new DatabaseSync(dbPath)
|
||||
|
||||
// Cache of email -> Slack avatar URL, so we don't hit Slack on every request.
|
||||
// image_url is nullable: a NULL row means "looked up, no Slack match" (also
|
||||
// worth caching, to avoid re-querying people who aren't in Slack).
|
||||
// A snapshot of the Slack workspace directory: one row per Slack user that has
|
||||
// an email, plus a single meta row recording when the snapshot was last swept.
|
||||
// An unmatched Member is simply one whose email isn't in this table (see ADR-0001).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS slack_avatar_cache (
|
||||
CREATE TABLE IF NOT EXISTS slack_directory (
|
||||
email TEXT PRIMARY KEY,
|
||||
image_url TEXT,
|
||||
cached_at INTEGER NOT NULL
|
||||
)
|
||||
slack_id TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS slack_directory_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
refreshed_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
interface CacheRow {
|
||||
image_url: string | null
|
||||
cached_at: number
|
||||
export interface DirectoryEntry {
|
||||
email: string
|
||||
imageUrl: string | null
|
||||
slackId: string
|
||||
}
|
||||
|
||||
// Returns the cached URL (which may be null = known-no-match), or undefined
|
||||
// when there is no fresh cache entry and the caller should query Slack.
|
||||
export function getCachedAvatar(email: string): string | null | undefined {
|
||||
const row = db
|
||||
.prepare("SELECT image_url, cached_at FROM slack_avatar_cache WHERE email = ?")
|
||||
.get(email.toLowerCase()) as CacheRow | undefined
|
||||
if (!row) return undefined
|
||||
if (Date.now() - row.cached_at > CACHE_TTL_MS) return undefined
|
||||
return row.image_url
|
||||
export function getDirectoryRefreshedAt(): number | null {
|
||||
const row = db.prepare("SELECT refreshed_at FROM slack_directory_meta WHERE id = 1").get() as
|
||||
| { refreshed_at: number }
|
||||
| undefined
|
||||
return row ? row.refreshed_at : null
|
||||
}
|
||||
|
||||
export function setCachedAvatar(email: string, imageUrl: string | null): void {
|
||||
db.prepare(
|
||||
`INSERT INTO slack_avatar_cache (email, image_url, cached_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
image_url = excluded.image_url,
|
||||
cached_at = excluded.cached_at`,
|
||||
).run(email.toLowerCase(), imageUrl, Date.now())
|
||||
// Atomically replace the whole directory snapshot and stamp refreshed_at.
|
||||
export function replaceDirectory(entries: DirectoryEntry[]): void {
|
||||
const insert = db.prepare(
|
||||
"INSERT OR REPLACE INTO slack_directory (email, image_url, slack_id) VALUES (?, ?, ?)",
|
||||
)
|
||||
const stamp = db.prepare(
|
||||
"INSERT OR REPLACE INTO slack_directory_meta (id, refreshed_at) VALUES (1, ?)",
|
||||
)
|
||||
db.exec("BEGIN")
|
||||
try {
|
||||
db.exec("DELETE FROM slack_directory")
|
||||
for (const e of entries) insert.run(e.email.toLowerCase(), e.imageUrl, e.slackId)
|
||||
stamp.run(Date.now())
|
||||
db.exec("COMMIT")
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK")
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function getDirectoryMap(): Map<string, DirectoryEntry> {
|
||||
const rows = db.prepare("SELECT email, image_url, slack_id FROM slack_directory").all() as {
|
||||
email: string
|
||||
image_url: string | null
|
||||
slack_id: string
|
||||
}[]
|
||||
const map = new Map<string, DirectoryEntry>()
|
||||
for (const r of rows) {
|
||||
map.set(r.email, { email: r.email, imageUrl: r.image_url, slackId: r.slack_id })
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
98
backend/src/export.ts
Normal file
98
backend/src/export.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { strToU8, zipSync } from "fflate"
|
||||
import { getProjectMembers, getProjects } from "./data.ts"
|
||||
import { initialsAvatarSvg } from "./avatar.ts"
|
||||
import type { Member } from "./types.ts"
|
||||
|
||||
const CONCURRENCY = 8
|
||||
|
||||
// Keep folder/file names filesystem-safe; collapse whitespace.
|
||||
function sanitize(s: string): string {
|
||||
const cleaned = s
|
||||
.replace(/[^\p{L}\p{N} ._-]/gu, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
return cleaned || "untitled"
|
||||
}
|
||||
|
||||
function fileBase(m: Member): string {
|
||||
const base = sanitize(`${m.lastName} ${m.firstName}`).toLowerCase().replace(/\s+/g, "-")
|
||||
return base || `member-${m.id}`
|
||||
}
|
||||
|
||||
function extFromUrl(url: string): string {
|
||||
const path = url.split("?")[0] ?? ""
|
||||
const m = path.match(/\.(jpe?g|png|gif|webp)$/i)
|
||||
return m ? m[1]!.toLowerCase().replace("jpeg", "jpg") : "jpg"
|
||||
}
|
||||
|
||||
async function fetchImage(url: string): Promise<Uint8Array> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`image ${res.status}`)
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
async function runPool<T>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let i = 0
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (i < items.length) {
|
||||
const item = items[i++]!
|
||||
await fn(item)
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
// Build a zip: one folder per Project (folder = project name), each holding its
|
||||
// Members' photos named `<lastname-firstname>.<ext>`. Members with no Slack photo
|
||||
// get a generated initials SVG. Image fetches are deduped across the whole export.
|
||||
export async function buildExportZip(projectIds: string[]): Promise<Uint8Array> {
|
||||
const { projects } = await getProjects()
|
||||
const nameById = new Map(projects.map((p) => [p.id, p.name]))
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const folderCounts = new Map<string, number>()
|
||||
const imageCache = new Map<string, Uint8Array>()
|
||||
|
||||
for (const pid of projectIds) {
|
||||
const baseFolder = sanitize(nameById.get(pid) ?? pid)
|
||||
const seen = folderCounts.get(baseFolder) ?? 0
|
||||
folderCounts.set(baseFolder, seen + 1)
|
||||
const folder = seen === 0 ? baseFolder : `${baseFolder} (${seen + 1})`
|
||||
|
||||
const { members } = await getProjectMembers(pid)
|
||||
|
||||
// Assign unique file bases within this folder before fetching.
|
||||
const usedNames = new Set<string>()
|
||||
const planned = members.map((m) => {
|
||||
let candidate = fileBase(m)
|
||||
let n = 1
|
||||
while (usedNames.has(candidate)) candidate = `${fileBase(m)}-${++n}`
|
||||
usedNames.add(candidate)
|
||||
return { member: m, base: candidate }
|
||||
})
|
||||
|
||||
await runPool(planned, CONCURRENCY, async ({ member, base }) => {
|
||||
if (member.imageUrl) {
|
||||
let bytes = imageCache.get(member.imageUrl)
|
||||
if (!bytes) {
|
||||
try {
|
||||
bytes = await fetchImage(member.imageUrl)
|
||||
imageCache.set(member.imageUrl, bytes)
|
||||
} catch {
|
||||
bytes = undefined
|
||||
}
|
||||
}
|
||||
if (bytes) {
|
||||
files[`${folder}/${base}.${extFromUrl(member.imageUrl)}`] = bytes
|
||||
return
|
||||
}
|
||||
}
|
||||
files[`${folder}/${base}.svg`] = strToU8(initialsAvatarSvg(member.firstName, member.lastName))
|
||||
})
|
||||
}
|
||||
|
||||
return zipSync(files, { level: 6 })
|
||||
}
|
||||
@@ -10,12 +10,7 @@ export const fixtureProjects: Project[] = [
|
||||
{ id: "demo-zephyr", name: "Zephyr Mobile App", clientName: "Globex" },
|
||||
]
|
||||
|
||||
function member(
|
||||
id: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
role: string,
|
||||
): Member {
|
||||
function member(id: string, firstName: string, lastName: string, role: string): Member {
|
||||
return {
|
||||
id,
|
||||
firstName,
|
||||
|
||||
@@ -1,35 +1,72 @@
|
||||
import { serve } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { logger } from "hono/logger"
|
||||
import { config, hasLiveCredentials } from "./config.ts"
|
||||
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
|
||||
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
|
||||
import { enrichWithSlackAvatars } from "./slack.ts"
|
||||
import { config, hasNaptaCredentials, hasSlackCredentials } from "./config.ts"
|
||||
import { getProjectMembers, getProjects } from "./data.ts"
|
||||
import { refreshDirectory } from "./slack.ts"
|
||||
import { buildExportZip } from "./export.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) {
|
||||
return c.json({ source: "fixture", projects: fixtureProjects })
|
||||
try {
|
||||
return c.json(await getProjects())
|
||||
} catch (err) {
|
||||
console.error("getProjects failed:", err)
|
||||
return c.json({ error: errorMessage(err) }, 502)
|
||||
}
|
||||
const projects = await fetchNaptaProjects()
|
||||
return c.json({ source: "napta", projects })
|
||||
})
|
||||
|
||||
app.get("/api/projects/:id/members", async (c) => {
|
||||
const id = c.req.param("id")
|
||||
if (!hasLiveCredentials) {
|
||||
return c.json({ source: "fixture", members: fixtureMembers(id) })
|
||||
try {
|
||||
return c.json(await getProjectMembers(c.req.param("id")))
|
||||
} catch (err) {
|
||||
console.error("getProjectMembers failed:", err)
|
||||
return c.json({ error: errorMessage(err) }, 502)
|
||||
}
|
||||
})
|
||||
|
||||
// Force a re-sweep of the Slack directory (the manual cache invalidation).
|
||||
app.post("/api/slack/refresh", async (c) => {
|
||||
if (!hasSlackCredentials) return c.json({ error: "Slack is not configured" }, 400)
|
||||
try {
|
||||
return c.json({ refreshed: await refreshDirectory() })
|
||||
} catch (err) {
|
||||
console.error("slack refresh failed:", err)
|
||||
return c.json({ error: errorMessage(err) }, 502)
|
||||
}
|
||||
})
|
||||
|
||||
// Export the selected Projects' photos as a zip (one folder per Project).
|
||||
app.post("/api/export", async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { projectIds?: unknown }
|
||||
const ids = Array.isArray(body.projectIds)
|
||||
? body.projectIds.filter((x): x is string => typeof x === "string")
|
||||
: []
|
||||
if (ids.length === 0) return c.json({ error: "projectIds required" }, 400)
|
||||
try {
|
||||
const zip = await buildExportZip(ids)
|
||||
return new Response(zip, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="photofetch-${ids.length}-projects.zip"`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("export failed:", err)
|
||||
return c.json({ error: errorMessage(err) }, 502)
|
||||
}
|
||||
const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id))
|
||||
return c.json({ source: "napta", members })
|
||||
})
|
||||
|
||||
console.log(
|
||||
`photofetch backend listening on :${config.port} (live credentials: ${hasLiveCredentials})`,
|
||||
`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials}, slack: ${hasSlackCredentials})`,
|
||||
)
|
||||
serve({ fetch: app.fetch, port: config.port })
|
||||
|
||||
@@ -1,15 +1,199 @@
|
||||
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<string> {
|
||||
// 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<string, unknown>
|
||||
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<JsonApiResponse> {
|
||||
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<Project[]> {
|
||||
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<string, string>()
|
||||
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))
|
||||
}
|
||||
|
||||
export async function fetchNaptaProjectMembers(_projectId: string): Promise<Member[]> {
|
||||
throw new Error("Napta integration not implemented yet (Step 11)")
|
||||
// Prefer a flat `<name>_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<string, string> | null = null
|
||||
|
||||
async function getPositionMap(): Promise<Map<string, string>> {
|
||||
if (positionMapCache) return positionMapCache
|
||||
const { data } = await naptaGetAll("/user_position", {})
|
||||
const map = new Map<string, string>()
|
||||
for (const p of data) map.set(p.id, attr(p, "name"))
|
||||
positionMapCache = map
|
||||
return map
|
||||
}
|
||||
|
||||
// --- Members ---
|
||||
|
||||
export async function fetchNaptaProjectMembers(projectId: string): Promise<Member[]> {
|
||||
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<string, JsonApiResource>()
|
||||
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<string, Member>()
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,98 @@
|
||||
import type { Member } from "./types.ts"
|
||||
import { config, hasSlackCredentials } from "./config.ts"
|
||||
import type { Member, SlackStatus } from "./types.ts"
|
||||
import {
|
||||
type DirectoryEntry,
|
||||
getDirectoryMap,
|
||||
getDirectoryRefreshedAt,
|
||||
replaceDirectory,
|
||||
} from "./db.ts"
|
||||
|
||||
// Given Napta members (which carry emails), fill in imageUrl from Slack by
|
||||
// matching on email — caching results in slack_avatar_cache (see db.ts).
|
||||
// Implemented in Step 11; for now it's a no-op pass-through so the pipeline
|
||||
// is wired end-to-end and members render with initials fallbacks.
|
||||
export async function enrichWithSlackAvatars(members: Member[]): Promise<Member[]> {
|
||||
return members
|
||||
const TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
|
||||
interface SlackUser {
|
||||
id: string
|
||||
deleted?: boolean
|
||||
is_bot?: boolean
|
||||
profile?: {
|
||||
email?: string
|
||||
image_512?: string
|
||||
image_192?: string
|
||||
image_72?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface UsersListResponse {
|
||||
ok: boolean
|
||||
error?: string
|
||||
members?: SlackUser[]
|
||||
response_metadata?: { next_cursor?: string }
|
||||
}
|
||||
|
||||
async function slackUsersList(cursor?: string): Promise<UsersListResponse> {
|
||||
const url = new URL("https://slack.com/api/users.list")
|
||||
url.searchParams.set("limit", "200")
|
||||
if (cursor) url.searchParams.set("cursor", cursor)
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${config.slackBotToken}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Slack users.list HTTP ${res.status}`)
|
||||
const body = (await res.json()) as UsersListResponse
|
||||
if (!body.ok) throw new Error(`Slack users.list error: ${body.error}`)
|
||||
return body
|
||||
}
|
||||
|
||||
// Sweep the whole workspace (paginated) and replace the cached directory.
|
||||
// Skips deleted users and bots. Returns the number of entries stored.
|
||||
export async function refreshDirectory(): Promise<number> {
|
||||
const entries: DirectoryEntry[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await slackUsersList(cursor)
|
||||
for (const u of page.members ?? []) {
|
||||
if (u.deleted || u.is_bot || u.id === "USLACKBOT") continue
|
||||
const email = u.profile?.email
|
||||
if (!email) continue
|
||||
const imageUrl = u.profile?.image_512 || u.profile?.image_192 || u.profile?.image_72 || null
|
||||
entries.push({ email, imageUrl, slackId: u.id })
|
||||
}
|
||||
cursor = page.response_metadata?.next_cursor || undefined
|
||||
} while (cursor)
|
||||
replaceDirectory(entries)
|
||||
return entries.length
|
||||
}
|
||||
|
||||
async function ensureDirectoryFresh(): Promise<void> {
|
||||
const refreshedAt = getDirectoryRefreshedAt()
|
||||
if (refreshedAt == null || Date.now() - refreshedAt > TTL_MS) {
|
||||
await refreshDirectory()
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnrichResult {
|
||||
members: Member[]
|
||||
slack: SlackStatus
|
||||
}
|
||||
|
||||
// Fill each Member's Avatar from the cached Slack directory, matched by email.
|
||||
// Degrades gracefully: a Slack failure never hides the roster.
|
||||
export async function enrichWithSlackAvatars(members: Member[]): Promise<EnrichResult> {
|
||||
if (!hasSlackCredentials) {
|
||||
return { members, slack: "unconfigured" }
|
||||
}
|
||||
let slack: SlackStatus = "ok"
|
||||
try {
|
||||
await ensureDirectoryFresh()
|
||||
} catch (err) {
|
||||
console.error("Slack directory refresh failed:", err)
|
||||
slack = "degraded" // fall through and match against whatever snapshot exists
|
||||
}
|
||||
const dir = getDirectoryMap()
|
||||
if (dir.size === 0) {
|
||||
return { members, slack: "degraded" }
|
||||
}
|
||||
const matched = members.map((m) => {
|
||||
const hit = dir.get(m.email.toLowerCase())
|
||||
return hit?.imageUrl ? { ...m, imageUrl: hit.imageUrl, slackMatched: true } : m
|
||||
})
|
||||
return { members: matched, slack }
|
||||
}
|
||||
|
||||
@@ -15,3 +15,7 @@ export interface Member {
|
||||
}
|
||||
|
||||
export type DataSource = "napta" | "fixture"
|
||||
|
||||
// ok = matched against a fresh directory; degraded = Slack unreachable, matched
|
||||
// against a stale/empty snapshot; unconfigured = no Slack token set.
|
||||
export type SlackStatus = "ok" | "degraded" | "unconfigured"
|
||||
|
||||
@@ -6,6 +6,10 @@ services:
|
||||
# would collide with Coolify's own :80 / other apps on the host.
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
# Set both on the Coolify app to require a shared login for the whole site.
|
||||
- BASIC_AUTH_USER=${BASIC_AUTH_USER:-}
|
||||
- BASIC_AUTH_PASSWORD=${BASIC_AUTH_PASSWORD:-}
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
@@ -15,8 +19,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:
|
||||
|
||||
16
docker-entrypoint.d/40-basic-auth.sh
Normal file
16
docker-entrypoint.d/40-basic-auth.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Toggle HTTP Basic Auth for the whole site from env at container start.
|
||||
# Runs via nginx:alpine's /docker-entrypoint.d/ hook, before nginx starts.
|
||||
set -e
|
||||
|
||||
if [ -n "$BASIC_AUTH_USER" ] && [ -n "$BASIC_AUTH_PASSWORD" ]; then
|
||||
htpasswd -bc /etc/nginx/.htpasswd "$BASIC_AUTH_USER" "$BASIC_AUTH_PASSWORD" >/dev/null 2>&1
|
||||
cat > /etc/nginx/auth_enabled.conf <<EOF
|
||||
auth_basic "photofetch";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
EOF
|
||||
echo "[entrypoint] basic auth ENABLED (user: $BASIC_AUTH_USER)"
|
||||
else
|
||||
: > /etc/nginx/auth_enabled.conf
|
||||
echo "[entrypoint] basic auth disabled (set BASIC_AUTH_USER + BASIC_AUTH_PASSWORD to enable)"
|
||||
fi
|
||||
22
docs/adr/0001-slack-directory-cache.md
Normal file
22
docs/adr/0001-slack-directory-cache.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Match Members to Slack via a cached whole-workspace directory
|
||||
|
||||
We resolve Slack **Avatars** by sweeping the entire workspace once with `users.list`
|
||||
and caching an `email → {imageUrl, slackId}` directory in SQLite, rather than calling
|
||||
`users.lookupByEmail` per Member. A **Match** is then an in-memory lookup, and an
|
||||
unmatched Member is simply one whose email isn't in the directory.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **`users.lookupByEmail` per Member** — only fetches who you view, but makes N calls
|
||||
per uncached Project view and is more exposed to per-method rate limits.
|
||||
- **`users.list` directory sweep (chosen)** — one paginated sweep covers everyone;
|
||||
repeated Project views cost zero Slack calls; matching is in-memory.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Cache is a directory snapshot + a single `refreshed_at`, with a **30-day TTL** and a
|
||||
manual **Refresh** (`POST /api/slack/refresh`) — Avatars change rarely, so staleness is
|
||||
cheap and the button covers the exceptions.
|
||||
- If the sweep fails, we still return Members (unmatched) and the UI degrades to initials
|
||||
Avatars + a warning — a Slack outage never hides the roster.
|
||||
- Deleted/bot Slack users are filtered out of the directory.
|
||||
@@ -4,6 +4,10 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Optional HTTP Basic Auth for the whole site (incl. /api). Written at
|
||||
# container start from BASIC_AUTH_USER/PASSWORD; empty file => no auth.
|
||||
include /etc/nginx/auth_enabled.conf;
|
||||
|
||||
# Proxy API calls to the backend service (docker-compose service name "api").
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
|
||||
180
src/App.vue
180
src/App.vue
@@ -1,50 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue"
|
||||
import type { DataSource, Member, Project } from "@/types"
|
||||
import { fetchMembers, fetchProjects } from "@/api"
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import type { DataSource, Member, Project, SlackStatus } from "@/types"
|
||||
import { exportPhotos, fetchMembers, fetchProjects, refreshSlackDirectory } from "@/api"
|
||||
import ProjectPicker from "@/components/ProjectPicker.vue"
|
||||
import MemberGrid from "@/components/MemberGrid.vue"
|
||||
import ProjectGroup from "@/components/ProjectGroup.vue"
|
||||
|
||||
interface GroupState {
|
||||
project: Project
|
||||
members: Member[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const selectedProjectId = ref<string>("")
|
||||
const members = ref<Member[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
const groups = ref<GroupState[]>([])
|
||||
const source = ref<DataSource>("napta")
|
||||
|
||||
const slackStatus = ref<SlackStatus | null>(null)
|
||||
const projectsError = ref<string | null>(null)
|
||||
const membersError = ref<string | null>(null)
|
||||
const loadingMembers = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const exporting = ref(false)
|
||||
const exportError = ref<string | null>(null)
|
||||
|
||||
function errMsg(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
const projectById = computed(() => {
|
||||
const m = new Map<string, Project>()
|
||||
for (const p of projects.value) m.set(p.id, p)
|
||||
return m
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetchProjects()
|
||||
projects.value = res.projects
|
||||
source.value = res.source
|
||||
if (res.projects.length > 0) {
|
||||
selectedProjectId.value = res.projects[0].id
|
||||
}
|
||||
} catch (err) {
|
||||
projectsError.value = err instanceof Error ? err.message : String(err)
|
||||
projectsError.value = errMsg(err)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedProjectId, async (id) => {
|
||||
if (!id) {
|
||||
members.value = []
|
||||
return
|
||||
}
|
||||
loadingMembers.value = true
|
||||
membersError.value = null
|
||||
// Reassign the array element by id so reactivity fires reliably.
|
||||
function setGroup(projectId: string, patch: Partial<GroupState>) {
|
||||
const i = groups.value.findIndex((g) => g.project.id === projectId)
|
||||
if (i >= 0) groups.value[i] = { ...groups.value[i], ...patch }
|
||||
}
|
||||
|
||||
async function loadGroup(projectId: string) {
|
||||
setGroup(projectId, { loading: true, error: null })
|
||||
try {
|
||||
const res = await fetchMembers(id)
|
||||
members.value = res.members
|
||||
const res = await fetchMembers(projectId)
|
||||
setGroup(projectId, { members: res.members, loading: false })
|
||||
slackStatus.value = res.slack
|
||||
source.value = res.source
|
||||
} catch (err) {
|
||||
membersError.value = err instanceof Error ? err.message : String(err)
|
||||
members.value = []
|
||||
} finally {
|
||||
loadingMembers.value = false
|
||||
setGroup(projectId, { members: [], error: errMsg(err), loading: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile the open Project Groups with the current Selection.
|
||||
watch(selectedIds, (ids) => {
|
||||
groups.value = groups.value.filter((g) => ids.includes(g.project.id))
|
||||
for (const id of ids) {
|
||||
if (groups.value.some((g) => g.project.id === id)) continue
|
||||
const project = projectById.value.get(id)
|
||||
if (!project) continue
|
||||
groups.value.push({ project, members: [], loading: true, error: null })
|
||||
loadGroup(id)
|
||||
}
|
||||
groups.value.sort((a, b) => ids.indexOf(a.project.id) - ids.indexOf(b.project.id))
|
||||
})
|
||||
|
||||
async function refreshAvatars() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await refreshSlackDirectory()
|
||||
await Promise.all(groups.value.map((g) => loadGroup(g.project.id)))
|
||||
} catch (err) {
|
||||
slackStatus.value = "degraded"
|
||||
console.error(err)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadExport() {
|
||||
exporting.value = true
|
||||
exportError.value = null
|
||||
try {
|
||||
const blob = await exportPhotos(selectedIds.value)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `photofetch-${selectedIds.value.length}-projects.zip`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
exportError.value = errMsg(err)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,33 +112,79 @@ watch(selectedProjectId, async (id) => {
|
||||
<img src="/favicon.svg" alt="" class="size-7" />
|
||||
<span class="text-xl font-semibold">photofetch</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="flex flex-none items-center gap-2">
|
||||
<button
|
||||
v-if="groups.length > 0"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="refreshing"
|
||||
title="Re-sweep the Slack directory"
|
||||
@click="refreshAvatars"
|
||||
>
|
||||
<span v-if="refreshing" class="loading loading-spinner loading-xs"></span>
|
||||
Refresh avatars
|
||||
</button>
|
||||
<button
|
||||
v-if="groups.length > 0"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="exporting"
|
||||
title="Download a zip of photos, one folder per project"
|
||||
@click="downloadExport"
|
||||
>
|
||||
<span v-if="exporting" class="loading loading-spinner loading-xs"></span>
|
||||
Export photos
|
||||
</button>
|
||||
<ProjectPicker
|
||||
v-model="selectedProjectId"
|
||||
v-model="selectedIds"
|
||||
:projects="projects"
|
||||
:disabled="projects.length === 0"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<div v-if="source === 'fixture'" role="alert" class="alert alert-warning mb-4">
|
||||
<main class="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
|
||||
<div v-if="source === 'fixture'" role="alert" class="alert alert-warning">
|
||||
<span>
|
||||
Showing demo data. Set <code>NAPTA_API_TOKEN</code> and <code>SLACK_BOT_TOKEN</code> on
|
||||
the server to load your real projects.
|
||||
Showing demo data. Set <code>NAPTA_CLIENT_ID</code> /
|
||||
<code>NAPTA_CLIENT_SECRET</code> (and <code>SLACK_BOT_TOKEN</code>) on the server to load
|
||||
real projects.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="slackStatus === 'degraded'" role="alert" class="alert alert-warning">
|
||||
<span>Couldn't reach Slack — showing names without up-to-date photos. Try Refresh.</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="slackStatus === 'unconfigured' && source === 'napta'"
|
||||
role="alert"
|
||||
class="alert alert-info"
|
||||
>
|
||||
<span>
|
||||
Slack isn't configured, so Members show initials. Set
|
||||
<code>SLACK_BOT_TOKEN</code> to load photos.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="exportError" role="alert" class="alert alert-error">
|
||||
<span>Export failed: {{ exportError }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="projectsError" role="alert" class="alert alert-error">
|
||||
<span>Could not load projects: {{ projectsError }}</span>
|
||||
</div>
|
||||
|
||||
<MemberGrid
|
||||
v-else
|
||||
:members="members"
|
||||
:loading="loadingMembers"
|
||||
:error="membersError"
|
||||
:project-selected="!!selectedProjectId"
|
||||
<div v-else-if="groups.length === 0" class="py-20 text-center opacity-60">
|
||||
<p>Pick one or more projects to see their teams.</p>
|
||||
</div>
|
||||
|
||||
<ProjectGroup
|
||||
v-for="g in groups"
|
||||
:key="g.project.id"
|
||||
:project="g.project"
|
||||
:members="g.members"
|
||||
:loading="g.loading"
|
||||
:error="g.error"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
22
src/api.ts
22
src/api.ts
@@ -16,3 +16,25 @@ export function fetchProjects(): Promise<ProjectsResponse> {
|
||||
export function fetchMembers(projectId: string): Promise<MembersResponse> {
|
||||
return getJson<MembersResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`)
|
||||
}
|
||||
|
||||
export async function refreshSlackDirectory(): Promise<{ refreshed: number }> {
|
||||
const res = await fetch("/api/slack/refresh", { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`${res.status} ${res.statusText}${body ? `: ${body}` : ""}`)
|
||||
}
|
||||
return (await res.json()) as { refreshed: number }
|
||||
}
|
||||
|
||||
export async function exportPhotos(projectIds: string[]): Promise<Blob> {
|
||||
const res = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectIds }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`${res.status} ${res.statusText}${body ? `: ${body}` : ""}`)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
32
src/components/ProjectGroup.vue
Normal file
32
src/components/ProjectGroup.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import type { Member, Project } from "@/types"
|
||||
import MemberGrid from "@/components/MemberGrid.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
project: Project
|
||||
members: Member[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}>()
|
||||
|
||||
const matchedCount = computed(() => props.members.filter((m) => m.slackMatched).length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ project.name }}
|
||||
<span v-if="project.clientName" class="font-normal opacity-60">
|
||||
· {{ project.clientName }}
|
||||
</span>
|
||||
</h2>
|
||||
<span v-if="!loading && !error" class="shrink-0 text-sm opacity-60">
|
||||
{{ members.length }} {{ members.length === 1 ? "person" : "people" }}
|
||||
<template v-if="members.length"> · {{ matchedCount }} with photos</template>
|
||||
</span>
|
||||
</div>
|
||||
<MemberGrid :members="members" :loading="loading" :error="error" :project-selected="true" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,28 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import type { Project } from "@/types"
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
projects: Project[]
|
||||
modelValue: string
|
||||
modelValue: string[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: string]
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string[]]
|
||||
}>()
|
||||
|
||||
const search = ref("")
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return props.projects
|
||||
return props.projects.filter(
|
||||
(p) => p.name.toLowerCase().includes(q) || (p.clientName ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedProjects = computed(() =>
|
||||
props.modelValue
|
||||
.map((id) => props.projects.find((p) => p.id === id))
|
||||
.filter((p): p is Project => Boolean(p)),
|
||||
)
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return props.modelValue.includes(id)
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
const next = isSelected(id) ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id]
|
||||
emit("update:modelValue", next)
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
emit(
|
||||
"update:modelValue",
|
||||
props.modelValue.filter((x) => x !== id),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="select select-bordered w-72 max-w-full"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
aria-label="Select a project"
|
||||
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-if="projects.length === 0" value="">No projects available</option>
|
||||
<option v-for="p in projects" :key="p.id" :value="p.id">
|
||||
{{ p.name }}<template v-if="p.clientName"> — {{ p.clientName }}</template>
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selectedProjects.length" class="hidden max-w-md flex-wrap gap-1 lg:flex">
|
||||
<span v-for="p in selectedProjects" :key="p.id" class="badge badge-primary gap-1">
|
||||
{{ p.name }}
|
||||
<button
|
||||
class="leading-none"
|
||||
type="button"
|
||||
:aria-label="`Remove ${p.name}`"
|
||||
@click="remove(p.id)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-sm" :class="{ 'btn-disabled': disabled }">
|
||||
Projects
|
||||
<span v-if="modelValue.length" class="badge badge-sm badge-primary">
|
||||
{{ modelValue.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content z-10 mt-2 w-80 rounded-box bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search projects…"
|
||||
class="input input-sm input-bordered mb-2 w-full"
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
<ul class="menu max-h-72 flex-nowrap gap-0 overflow-y-auto p-0">
|
||||
<li v-for="p in filtered" :key="p.id">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="isSelected(p.id)"
|
||||
@change="toggle(p.id)"
|
||||
/>
|
||||
<span class="flex-1 truncate">
|
||||
{{ p.name }}
|
||||
<span v-if="p.clientName" class="opacity-60">· {{ p.clientName }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li v-if="filtered.length === 0" class="px-2 py-1 text-sm opacity-60">
|
||||
No matching projects
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Member {
|
||||
}
|
||||
|
||||
export type DataSource = "napta" | "fixture"
|
||||
export type SlackStatus = "ok" | "degraded" | "unconfigured"
|
||||
|
||||
export interface ProjectsResponse {
|
||||
source: DataSource
|
||||
@@ -23,5 +24,6 @@ export interface ProjectsResponse {
|
||||
|
||||
export interface MembersResponse {
|
||||
source: DataSource
|
||||
slack: SlackStatus
|
||||
members: Member[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user