chore: initial scaffold

Vite + Vue 3 + DaisyUI SPA (project picker + Slack avatar member grid) with a
TypeScript/Node (Hono) backend proxying Napta + Slack, SQLite avatar cache via
node:sqlite, and docker-compose for Coolify deploy.
This commit is contained in:
Julien Calixte
2026-06-26 15:26:20 +01:00
commit 528251f176
40 changed files with 2410 additions and 0 deletions

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
data
.env
.env.*

16
backend/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Backend configuration. Copy to .env for local dev (gitignored), or set these
# as environment variables on the Coolify app for production.
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_BASE_URL=https://app.napta.io/api/v1
# Slack bot token (xoxb-...) with scopes: users:read, users:read.email
# Used to resolve each person's profile picture by email.
SLACK_BOT_TOKEN=
# SQLite location (avatar cache). Matches the Coolify persistent volume.
DB_PATH=data/app.db

11
backend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
# Node 24 runs TypeScript directly (type stripping) and ships node:sqlite
# built in — so no build step, no tsx, no native sqlite module needed.
FROM node:24-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --prod --frozen-lockfile
COPY . .
ENV PORT=8000
ENV DB_PATH=/app/data/app.db
EXPOSE 8000
CMD ["node", "src/index.ts"]

19
backend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "photofetch-backend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.ts",
"start": "node src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^2.0.6",
"hono": "^4.12.27"
},
"devDependencies": {
"@types/node": "^26.0.1",
"typescript": "^6.0.3"
}
}

62
backend/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,62 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@hono/node-server':
specifier: ^2.0.6
version: 2.0.6(hono@4.12.27)
hono:
specifier: ^4.12.27
version: 4.12.27
devDependencies:
'@types/node':
specifier: ^26.0.1
version: 26.0.1
typescript:
specifier: ^6.0.3
version: 6.0.3
packages:
'@hono/node-server@2.0.6':
resolution: {integrity: sha512-7DeRlKG57JDBNZ5Qj2jwVdgwQy4b0tLubRLl3zCf91/rCf9i7p1V5FtW/yWibm1uUHE493ts9ZXH/7g/LQWl+g==}
engines: {node: '>=20'}
peerDependencies:
hono: ^4
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
hono@4.12.27:
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
engines: {node: '>=16.9.0'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
snapshots:
'@hono/node-server@2.0.6(hono@4.12.27)':
dependencies:
hono: 4.12.27
'@types/node@26.0.1':
dependencies:
undici-types: 8.3.0
hono@4.12.27: {}
typescript@6.0.3: {}
undici-types@8.3.0: {}

19
backend/src/config.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface Config {
port: number
naptaApiToken: string | null
naptaBaseUrl: string
slackBotToken: string | null
}
export const config: Config = {
port: Number(process.env.PORT ?? 8000),
naptaApiToken: process.env.NAPTA_API_TOKEN || null,
naptaBaseUrl: process.env.NAPTA_BASE_URL || "https://app.napta.io/api/v1",
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,
)

47
backend/src/db.ts Normal file
View File

@@ -0,0 +1,47 @@
import { DatabaseSync } from "node:sqlite"
import { mkdirSync } from "node:fs"
import { dirname } from "node:path"
const dbPath = process.env.DB_PATH || "data/app.db"
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).
db.exec(`
CREATE TABLE IF NOT EXISTS slack_avatar_cache (
email TEXT PRIMARY KEY,
image_url TEXT,
cached_at INTEGER NOT NULL
)
`)
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
interface CacheRow {
image_url: string | null
cached_at: number
}
// 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 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())
}

47
backend/src/fixtures.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { Member, Project } from "./types.ts"
// Demo data served when live Napta/Slack credentials are not configured. Lets
// the UI render a realistic team grid out of the box. imageUrl is null so the
// frontend shows initials avatars (the same fallback used for real members who
// have no Slack match).
export const fixtureProjects: Project[] = [
{ id: "demo-apollo", name: "Apollo Platform", clientName: "Acme Corp" },
{ id: "demo-zephyr", name: "Zephyr Mobile App", clientName: "Globex" },
]
function member(
id: string,
firstName: string,
lastName: string,
role: string,
): Member {
return {
id,
firstName,
lastName,
role,
email: `${firstName}.${lastName}@example.com`.toLowerCase(),
imageUrl: null,
slackMatched: false,
}
}
const membersByProject: Record<string, Member[]> = {
"demo-apollo": [
member("1", "Ada", "Lovelace", "Tech Lead"),
member("2", "Alan", "Turing", "Backend Engineer"),
member("3", "Grace", "Hopper", "Engineering Manager"),
member("4", "Katherine", "Johnson", "Data Scientist"),
member("5", "Dennis", "Ritchie", "Platform Engineer"),
],
"demo-zephyr": [
member("6", "Margaret", "Hamilton", "Product Owner"),
member("7", "Linus", "Torvalds", "Mobile Lead"),
member("8", "Barbara", "Liskov", "iOS Engineer"),
],
}
export function fixtureMembers(projectId: string): Member[] {
return membersByProject[projectId] ?? []
}

35
backend/src/index.ts Normal file
View File

@@ -0,0 +1,35 @@
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 "./db.ts" // initialise the SQLite schema at boot
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 })
}
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) })
}
const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id))
return c.json({ source: "napta", members })
})
console.log(
`photofetch backend listening on :${config.port} (live credentials: ${hasLiveCredentials})`,
)
serve({ fetch: app.fetch, port: config.port })

15
backend/src/napta.ts Normal file
View File

@@ -0,0 +1,15 @@
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.
export async function fetchNaptaProjects(): Promise<Project[]> {
throw new Error("Napta integration not implemented yet (Step 11)")
}
export async function fetchNaptaProjectMembers(_projectId: string): Promise<Member[]> {
throw new Error("Napta integration not implemented yet (Step 11)")
}

9
backend/src/slack.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Member } from "./types.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
}

17
backend/src/types.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface Project {
id: string
name: string
clientName?: string
}
export interface Member {
id: string
firstName: string
lastName: string
role: string
email: string
imageUrl: string | null
slackMatched: boolean
}
export type DataSource = "napta" | "fixture"

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["esnext"],
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}