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

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"