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:
4
backend/.dockerignore
Normal file
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
.env.*
|
||||
16
backend/.env.example
Normal file
16
backend/.env.example
Normal 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
11
backend/Dockerfile
Normal 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
19
backend/package.json
Normal 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
62
backend/pnpm-lock.yaml
generated
Normal 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
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"
|
||||
19
backend/tsconfig.json
Normal file
19
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user