GTD MVP: Expo app (iOS/Android/web) + Bun API + Postgres schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.expo/
|
||||
server/.env
|
||||
server/.env.token.local
|
||||
*.log
|
||||
.DS_Store
|
||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# GTD
|
||||
|
||||
Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Android, and web; data lives in Postgres on the VPS.
|
||||
|
||||
- **Web**: https://gtd.rehbock.xyz (Expo web export, static)
|
||||
- **API**: https://gtd.rehbock.xyz/api → `gtd-api` container (Bun, `server/`)
|
||||
- **DB**: `gtd` database in the `personal-db` Postgres container (`server/schema.sql`)
|
||||
- **Auth**: single bearer token (`API_TOKEN` in `server/.env` on the VPS); enter it once in the app's Settings
|
||||
|
||||
## Layout
|
||||
|
||||
- `app/` — Expo app (expo-router, TypeScript). Tabs: Inbox / Next / Waiting / Someday / Projects; tap a task to clarify (list, project, context, waiting-for, due/defer dates).
|
||||
- `server/` — Bun API + Dockerfile + compose. Deployed at `~/personal/gtd` on rehbock.xyz, joined to `personal-db_default` + `caddy_net`.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
cd app && npx expo start # scan QR with Expo Go (iPhone or Pixel)
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
```sh
|
||||
# API
|
||||
rsync -a server/ --exclude .env.token.local rehbock.xyz:personal/gtd/
|
||||
ssh rehbock.xyz 'cd ~/personal/gtd; and docker compose up -d --build'
|
||||
|
||||
# Web
|
||||
cd app && npx expo export --platform web
|
||||
rsync -a --delete dist/ rehbock.xyz:personal/gtd/web/
|
||||
```
|
||||
1
app
Submodule
1
app
Submodule
Submodule app added at b35ebf9336
3
server/.env.example
Normal file
3
server/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Copy to .env on the server. Real values live only there.
|
||||
DATABASE_URL=postgres://marcus:CHANGE_ME@personal-db:5432/gtd
|
||||
API_TOKEN=CHANGE_ME
|
||||
5
server/Dockerfile
Normal file
5
server/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM oven/bun:1-alpine
|
||||
WORKDIR /app
|
||||
COPY index.ts schema.sql ./
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "run", "index.ts"]
|
||||
22
server/docker-compose.yml
Normal file
22
server/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
gtd-api:
|
||||
build: .
|
||||
container_name: gtd-api
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
networks:
|
||||
# Reach Postgres by service name `personal-db` on its network.
|
||||
- personal-db_default
|
||||
# Be reachable by paico-proxy for gtd.rehbock.xyz.
|
||||
- caddy_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
personal-db_default:
|
||||
external: true
|
||||
caddy_net:
|
||||
external: true
|
||||
160
server/index.ts
Normal file
160
server/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// gtd-api — minimal GTD backend. Bun + Postgres (personal-db), bearer-token auth.
|
||||
import { SQL } from "bun";
|
||||
|
||||
const sql = new SQL(process.env.DATABASE_URL!);
|
||||
const API_TOKEN = process.env.API_TOKEN!;
|
||||
if (!API_TOKEN) throw new Error("API_TOKEN not set");
|
||||
|
||||
const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
|
||||
due_date::text as due_date, defer_date::text as defer_date,
|
||||
completed_at, sort_order, created_at, updated_at`;
|
||||
const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`;
|
||||
|
||||
const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"];
|
||||
const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"];
|
||||
|
||||
const CORS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||
};
|
||||
|
||||
function json(data: unknown, status = 200) {
|
||||
return Response.json(data, { status, headers: CORS });
|
||||
}
|
||||
function err(message: string, status: number) {
|
||||
return json({ error: message }, status);
|
||||
}
|
||||
|
||||
// Fields a client may set. completed_at is derived from status server-side.
|
||||
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "sort_order"];
|
||||
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"];
|
||||
|
||||
function pick(body: Record<string, unknown>, fields: string[]) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) if (f in body) out[f] = body[f] === "" ? null : body[f];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handle(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
const method = req.method;
|
||||
|
||||
if (method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
|
||||
if (path === "/healthz") return json({ ok: true });
|
||||
|
||||
if (req.headers.get("authorization") !== `Bearer ${API_TOKEN}`) return err("unauthorized", 401);
|
||||
|
||||
const body = method === "POST" || method === "PATCH" ? await req.json().catch(() => null) : null;
|
||||
if ((method === "POST" || method === "PATCH") && !body) return err("invalid JSON body", 400);
|
||||
|
||||
// ---- tasks ----
|
||||
if (path === "/v1/tasks" && method === "GET") {
|
||||
const status = url.searchParams.get("status");
|
||||
const projectId = url.searchParams.get("project_id");
|
||||
if (status && !TASK_STATUSES.includes(status)) return err("bad status", 400);
|
||||
const where: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (status) { params.push(status); where.push(`status = $${params.length}`); }
|
||||
else where.push(`status != 'trashed'`);
|
||||
if (projectId) { params.push(projectId); where.push(`project_id = $${params.length}`); }
|
||||
const rows = await sql.unsafe(
|
||||
`select ${TASK_COLS} from tasks where ${where.join(" and ")}
|
||||
order by sort_order, created_at desc`, params);
|
||||
return json(rows);
|
||||
}
|
||||
|
||||
if (path === "/v1/tasks" && method === "POST") {
|
||||
const t = pick(body, TASK_FIELDS);
|
||||
if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400);
|
||||
if (t.status && !TASK_STATUSES.includes(t.status as string)) return err("bad status", 400);
|
||||
const rows = await sql.unsafe(
|
||||
`insert into tasks (title, notes, status, project_id, context, waiting_for, due_date, defer_date, sort_order)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
returning ${TASK_COLS}`,
|
||||
[t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null,
|
||||
t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.sort_order ?? 0]);
|
||||
return json(rows[0], 201);
|
||||
}
|
||||
|
||||
let m = path.match(/^\/v1\/tasks\/([0-9a-f-]{36})$/);
|
||||
if (m && method === "GET") {
|
||||
const rows = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [m[1]]);
|
||||
return rows.length ? json(rows[0]) : err("not found", 404);
|
||||
}
|
||||
if (m && (method === "PATCH" || method === "DELETE")) {
|
||||
const id = m[1];
|
||||
const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS);
|
||||
if (patch.status && !TASK_STATUSES.includes(patch.status as string)) return err("bad status", 400);
|
||||
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
|
||||
if (!existing.length) return err("not found", 404);
|
||||
const cur = { ...existing[0], ...patch };
|
||||
const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null;
|
||||
const rows = await sql.unsafe(
|
||||
`update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7,
|
||||
due_date=$8, defer_date=$9, completed_at=$10, sort_order=$11, updated_at=now()
|
||||
where id=$1 returning ${TASK_COLS}`,
|
||||
[id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for,
|
||||
cur.due_date, cur.defer_date, completedAt, cur.sort_order]);
|
||||
return json(rows[0]);
|
||||
}
|
||||
|
||||
// ---- projects ----
|
||||
if (path === "/v1/projects" && method === "GET") {
|
||||
const rows = await sql.unsafe(
|
||||
`select ${PROJECT_COLS},
|
||||
(select count(*) from tasks t where t.project_id = projects.id
|
||||
and t.status not in ('done', 'trashed'))::int as open_tasks
|
||||
from projects where status != 'dropped'
|
||||
order by sort_order, created_at desc`, []);
|
||||
return json(rows);
|
||||
}
|
||||
|
||||
if (path === "/v1/projects" && method === "POST") {
|
||||
const p = pick(body, PROJECT_FIELDS);
|
||||
if (typeof p.name !== "string" || !p.name.trim()) return err("name required", 400);
|
||||
if (p.status && !PROJECT_STATUSES.includes(p.status as string)) return err("bad status", 400);
|
||||
const rows = await sql.unsafe(
|
||||
`insert into projects (name, status, notes, sort_order) values ($1, $2, $3, $4)
|
||||
returning ${PROJECT_COLS}`,
|
||||
[p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]);
|
||||
return json(rows[0], 201);
|
||||
}
|
||||
|
||||
m = path.match(/^\/v1\/projects\/([0-9a-f-]{36})$/);
|
||||
if (m && (method === "PATCH" || method === "DELETE")) {
|
||||
const id = m[1];
|
||||
const patch = method === "DELETE" ? { status: "dropped" } : pick(body, PROJECT_FIELDS);
|
||||
if (patch.status && !PROJECT_STATUSES.includes(patch.status as string)) return err("bad status", 400);
|
||||
const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]);
|
||||
if (!existing.length) return err("not found", 404);
|
||||
const cur = { ...existing[0], ...patch };
|
||||
const rows = await sql.unsafe(
|
||||
`update projects set name=$2, status=$3, notes=$4, sort_order=$5, updated_at=now()
|
||||
where id=$1 returning ${PROJECT_COLS}`,
|
||||
[id, cur.name, cur.status, cur.notes, cur.sort_order]);
|
||||
return json(rows[0]);
|
||||
}
|
||||
|
||||
if (path === "/v1/contexts" && method === "GET") {
|
||||
const rows = await sql.unsafe(
|
||||
`select distinct context from tasks
|
||||
where context is not null and status not in ('done', 'trashed') order by context`, []);
|
||||
return json(rows.map((r: { context: string }) => r.context));
|
||||
}
|
||||
|
||||
return err("not found", 404);
|
||||
}
|
||||
|
||||
Bun.serve({
|
||||
port: 3000,
|
||||
hostname: "0.0.0.0",
|
||||
fetch: (req) =>
|
||||
handle(req).catch((e) => {
|
||||
console.error(e);
|
||||
return err("internal error", 500);
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("gtd-api listening on :3000");
|
||||
33
server/schema.sql
Normal file
33
server/schema.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- GTD schema. Applied idempotently by migrate step in entrypoint.
|
||||
|
||||
create table if not exists projects (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
status text not null default 'active'
|
||||
check (status in ('active', 'someday', 'completed', 'dropped')),
|
||||
notes text not null default '',
|
||||
sort_order double precision not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists tasks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
title text not null,
|
||||
notes text not null default '',
|
||||
-- The GTD lists. 'scheduled' = calendar/tickler items surfaced by defer_date.
|
||||
status text not null default 'inbox'
|
||||
check (status in ('inbox', 'next', 'waiting', 'scheduled', 'someday', 'done', 'trashed')),
|
||||
project_id uuid references projects(id) on delete set null,
|
||||
context text, -- '@home', '@computer', '@errands', '@calls', ...
|
||||
waiting_for text, -- who/what is being waited on when status = 'waiting'
|
||||
due_date date,
|
||||
defer_date date, -- hide from lists until this date
|
||||
completed_at timestamptz,
|
||||
sort_order double precision not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists tasks_status_idx on tasks (status);
|
||||
create index if not exists tasks_project_idx on tasks (project_id);
|
||||
Reference in New Issue
Block a user