Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,73 @@
# listee-api
API server for Listee

listee-api exposes Listee's HTTP interface. It packages `@listee/api` inside a Next.js App Router host so the CLI and web clients share the same business logic, validation, and database access.

## Overview
- Next.js 15 application that forwards requests to `createFetchHandler` from `@listee/api`.
- Supabase supplies authentication (JWT) and the Postgres database.
- Shared models and utilities come from the `@listee/*` packages (auth, db, types, api).

## Architecture
- `src/app/api/handler.ts` is the single hand-off into `@listee/api`.
- `@listee/api` (Hono + Drizzle ORM) defines routes, validation, and service orchestration.
- `@listee/db` provides Drizzle schema definitions and Postgres connection management.
- Authentication is header-based via `@listee/auth`.

## Environment Variables
Configure these values in `.env.local` for development and in production:
- `POSTGRES_URL` – Supabase Postgres connection string.
- `SUPABASE_URL` – Supabase project base URL (e.g. `https://your-project.supabase.co`).
- `SUPABASE_PUBLISHABLE_KEY` – Supabase publishable (anon) key used to call Auth endpoints.
- `SUPABASE_JWT_AUDIENCE` – optional; audience value to enforce.
- `SUPABASE_JWT_REQUIRED_ROLE` – optional; enforce a specific `role` claim (e.g. `authenticated`).
- `SUPABASE_JWT_ISSUER` – optional; override the expected issuer. Defaults to `${SUPABASE_URL}/auth/v1`.
- `SUPABASE_JWKS_PATH` – optional; override the JWKS endpoint path. Defaults to `/auth/v1/.well-known/jwks.json`.
- `SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS` – optional; non-negative integer clock skew tolerance.

## Response Contract
- Success responses always return JSON with a top-level `data` property. DELETE operations respond with `{ "data": null }`.
- Error responses return `{ "error": "message" }` plus the appropriate HTTP status code (`400` validation, `401/403` auth, `404` missing resources, `500` unexpected failures).

## API Surface
| Method | Path | Description |
| ------ | ---- | ----------- |
| POST | `/api/auth/signup` | Forward email/password signups to Supabase Auth |
| POST | `/api/auth/login` | Exchange email/password for Supabase access + refresh tokens |
| POST | `/api/auth/token` | Refresh Supabase access tokens using a stored refresh token |
| GET | `/api/users/:userId/categories` | List categories for the authenticated user |
| POST | `/api/users/:userId/categories` | Create a new category |
| GET | `/api/categories/:categoryId` | Fetch category details |
| PATCH | `/api/categories/:categoryId` | Update a category name |
| DELETE | `/api/categories/:categoryId` | Delete a category owned by the user |
| GET | `/api/categories/:categoryId/tasks` | List tasks in a category |
| POST | `/api/categories/:categoryId/tasks` | Create a task inside the category |
| GET | `/api/tasks/:taskId` | Fetch task details |
| PATCH | `/api/tasks/:taskId` | Update task name, description, or status |
| DELETE | `/api/tasks/:taskId` | Delete a task owned by the user |
| GET | `/api/healthz` | Database connectivity probe |

All endpoints expect `Authorization: Bearer <token>` where `<token>` is a Supabase JWT access token issued for the authenticated user.

## Local Development
1. Install dependencies: `bun install`.
2. Provide environment variables in `.env.local`.
3. Run the dev server: `bun run dev` (Next.js on port 3000).
4. Lint the project: `bun run lint`.
5. Build for production verification: `bun run build`.

### Database Migrations
- Schema definitions live in `@listee/db`. Do not hand-edit generated SQL.
- Generate migrations with `bun run db:generate` after schema changes.
- Apply migrations with `bun run db:migrate` (uses `POSTGRES_URL`).

## Testing
Automated tests are not yet in place. Use CLI smoke tests (e.g. `listee categories update`, `listee tasks delete`) to verify JSON contracts until formal integration tests land.

## Deployment Notes
- `bun run build` produces the Next.js bundle for production. Deploy on Vercel or any Node 20+ platform capable of running Next.js 15.
- Confirm environment variables for each target environment before deploy.
- Monitor `/api/healthz` after rollout to confirm database access.

## Conventions
- Keep repository documentation and comments in English.
- Follow Listee org standards: Bun 1.3.x, Biome linting, Drizzle migrations, and semantic versioning via Changesets when publishing packages.
27 changes: 19 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 20 additions & 5 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { schemaPath } from "@listee/db";
import { loadEnvConfig } from "@next/env";
import { defineConfig } from "drizzle-kit";
import { ZodError } from "zod";
import { getEnv } from "./src/app/env";

loadEnvConfig(process.cwd());

const databaseUrl = process.env.POSTGRES_URL;

if (databaseUrl === undefined || databaseUrl.length === 0) {
throw new Error("POSTGRES_URL is not set.");
}
const databaseUrl = (() => {
try {
return getEnv().POSTGRES_URL;
} catch (error) {
if (error instanceof ZodError) {
const issue = error.issues.find((entry) => {
return entry.path.join(".") === "POSTGRES_URL";
});
if (issue !== undefined) {
if (issue.code === "invalid_type") {
throw new Error("POSTGRES_URL is not set.");
}
throw new Error(issue.message);
}
}
throw error;
}
})();

export default defineConfig({
dialect: "postgresql",
Expand Down
15 changes: 14 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { createJiti } from "jiti";
import type { NextConfig } from "next";

type EnvModule = typeof import("./src/app/env");

const jiti = createJiti(import.meta.url);

const loadEnvModule = async (): Promise<void> => {
const envModule = await jiti.import<EnvModule>("./src/app/env");
envModule.getEnv();
};

const nextConfig: NextConfig = {
/* config options here */
};

export default nextConfig;
export default async (): Promise<NextConfig> => {
await loadEnvModule();
return nextConfig;
};
15 changes: 9 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,24 @@
"db:migrate": "drizzle-kit migrate --config drizzle.config.ts"
},
"dependencies": {
"@listee/api": "0.2.3",
"@listee/auth": "0.2.3",
"@listee/db": "0.2.3",
"@listee/types": "0.2.3",
"@listee/api": "0.3.2",
"@listee/auth": "0.5.0",
"@listee/db": "0.4.0",
"@listee/types": "0.5.0",
"@t3-oss/env-nextjs": "0.13.8",
"drizzle-orm": "0.44.5",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.4",
"drizzle-orm": "0.44.5"
"zod": "4.1.12"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.0",
"jiti": "^2.6.1",
"typescript": "^5"
}
}
26 changes: 26 additions & 0 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { SupabaseAuthError } from "@listee/auth";

import {
ApiError,
handleRoute,
parseJsonBody,
respondWithData,
} from "@/app/api/auth/utils";
import { loginSchema } from "@/app/api/auth/validation";
import { getSupabaseAuthClient } from "@/app/supabase-auth-client";

export async function POST(request: Request): Promise<Response> {
return handleRoute(async () => {
const input = await parseJsonBody(request, loginSchema);
try {
const authClient = getSupabaseAuthClient();
const tokenResponse = await authClient.login(input);
return respondWithData(tokenResponse, 200);
} catch (error) {
if (error instanceof SupabaseAuthError) {
throw new ApiError(error.statusCode, error.message);
}
throw error;
}
});
}
26 changes: 26 additions & 0 deletions src/app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { SupabaseAuthError } from "@listee/auth";

import {
ApiError,
handleRoute,
parseJsonBody,
respondWithData,
} from "@/app/api/auth/utils";
import { signupSchema } from "@/app/api/auth/validation";
import { getSupabaseAuthClient } from "@/app/supabase-auth-client";

export async function POST(request: Request): Promise<Response> {
return handleRoute(async () => {
const input = await parseJsonBody(request, signupSchema);
try {
const authClient = getSupabaseAuthClient();
await authClient.signup(input);
} catch (error) {
if (error instanceof SupabaseAuthError) {
throw new ApiError(error.statusCode, error.message);
}
throw error;
}
return respondWithData(null, 200);
});
}
26 changes: 26 additions & 0 deletions src/app/api/auth/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { SupabaseAuthError } from "@listee/auth";

import {
ApiError,
handleRoute,
parseJsonBody,
respondWithData,
} from "@/app/api/auth/utils";
import { tokenSchema } from "@/app/api/auth/validation";
import { getSupabaseAuthClient } from "@/app/supabase-auth-client";

export async function POST(request: Request): Promise<Response> {
return handleRoute(async () => {
const input = await parseJsonBody(request, tokenSchema);
try {
const authClient = getSupabaseAuthClient();
const tokenResponse = await authClient.refresh(input);
return respondWithData(tokenResponse, 200);
} catch (error) {
if (error instanceof SupabaseAuthError) {
throw new ApiError(error.statusCode, error.message);
}
throw error;
}
});
}
53 changes: 53 additions & 0 deletions src/app/api/auth/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ZodType } from "zod";

export class ApiError extends Error {
readonly statusCode: number;

constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}

export async function parseJsonBody<T>(
request: Request,
schema: ZodType<T>,
): Promise<T> {
let parsed: unknown;
try {
parsed = await request.json();
} catch {
throw new ApiError(400, "Request body must be valid JSON.");
}

const result = schema.safeParse(parsed);
if (!result.success) {
const issue = result.error.issues[0];
const message = issue?.message ?? "Invalid request body.";
throw new ApiError(400, message);
}

return result.data;
}

export const respondWithData = <T>(data: T, status = 200): Response => {
return Response.json({ data }, { status });
};

export const respondWithError = (message: string, status: number): Response => {
return Response.json({ error: message }, { status });
};

export const handleRoute = async (
handler: () => Promise<Response>,
): Promise<Response> => {
try {
return await handler();
} catch (error) {
if (error instanceof ApiError) {
return respondWithError(error.message, error.statusCode);
}
console.error("Unhandled auth route error:", error);
return respondWithError("Internal server error.", 500);
}
};
Loading