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:
19
backend/src/config.ts
Normal file
19
backend/src/config.ts
Normal 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
47
backend/src/db.ts
Normal 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
47
backend/src/fixtures.ts
Normal 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
35
backend/src/index.ts
Normal 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
15
backend/src/napta.ts
Normal 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
9
backend/src/slack.ts
Normal 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
17
backend/src/types.ts
Normal 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"
|
||||
Reference in New Issue
Block a user