Skip to content
Open
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
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# --------------------------------------------------
# Disasters API — Environment Variables
# --------------------------------------------------
# Copy this file to .env and adjust values as needed.
# cp .env.example .env
#
# When running via Docker Compose these are set in
# docker-compose.yml and this file is not required.
# --------------------------------------------------

# Database connection (required)
POSTGRES_URI=postgresql://disasters:disasters_pass@localhost:5432/disasters_test

# Server
NODE_ENV=development
PORT=3000

# CORS (comma-separated origins, or * for all)
CORS_ORIGIN=*

# Optional
# LOG_LEVEL=info
# RATE_LIMIT_IP_WHITELIST=127.0.0.1
# RATE_LIMIT_IP_BLACKLIST=
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Install dependencies
run: npm ci
- name: Lint
Expand Down
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ yarn-error.log*
# Environment variables
.env
.env.*
!.env.example

# Coverage reports
coverage/
Expand All @@ -36,8 +37,7 @@ Thumbs.db
# Ignore compiled JS in source tree (should only be in dist/ or build/)
*.js
*.js.map
!dist/
!build/
!eslint.config.js

# Ignore test output
*.test.js
Expand All @@ -52,3 +52,6 @@ data/
# Protobuf generated files
proto/disaster_pb.js
proto/disaster_pb.d.ts

# Prisma generated client
generated/
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run lint -- --fix
npm run format
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
11 changes: 8 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Use official Node.js LTS image
FROM node:20-slim
FROM node:22-slim

# Set working directory
WORKDIR /usr/src/app

# Copy package files and install dependencies
COPY package*.json ./
RUN npm install --force
RUN npm install

# Install nodemon globally for hot-reloading
RUN npm install -g nodemon
Expand All @@ -17,8 +17,13 @@ RUN npm install --save-dev protobufjs-cli
# Copy proto files for code generation
COPY proto/ ./proto/

# Generate protobuf JS and TS files at build time
# Copy Prisma schema and config for client generation
COPY prisma/ ./prisma/
COPY prisma.config.ts ./

# Generate protobuf JS/TS and Prisma client at build time
RUN npm run proto:all
RUN npx prisma generate

# Don't copy source code here - it will be mounted as a volume
# COPY . .
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.production
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# syntax=docker/dockerfile:1

# --- Build stage ---
FROM node:20-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd Dockerfile.production

Repository: goodthingsdev/disasters-api

Length of output: 92


🌐 Web query:

npm ci --only=production deprecated npm 10.x modern alternative --omit=dev

💡 Result:

npm ci --only=production is deprecated in npm 10+ because the only=production / production=true config flags are deprecated in favor of the newer omit/include mechanism. The modern equivalent is:

npm ci --omit=dev

This omits devDependencies from what’s installed on disk (and is also what npm defaults to when NODE_ENV=production). [1] [2]

Related:

  • Old --production flag is also deprecated → use --omit=dev. [1]
  • If you need to force dev deps to be installed (even with NODE_ENV=production), use:
    npm ci --include=dev
    (Works as the replacement for older --also=dev, which is deprecated.) [1] [3]

Sources

  1. npm CLI docs (config: only/production deprecated; use --omit=dev, and only=production is an alias for --omit=dev) [1]
  2. npm CLI docs (npm ci: omit option and default behavior) [2]
  3. Notes on --also deprecation and --include=dev usage (npm 10.x) [3]

🏁 Script executed:

cat -n Dockerfile.production

Repository: goodthingsdev/disasters-api

Length of output: 732


Build stage installs only production dependencies but then runs npm run build.

npm ci --only=production skips devDependencies such as TypeScript, build tools, and type definitions. The subsequent npm run build on line 9 will fail because the compiler isn't installed. Install all dependencies in the build stage and restrict to production-only in the production stage.

Additionally, --only=production is deprecated in npm 10.x (shipped with Node 22). Update to --omit=dev.

🐛 Proposed fixes

Line 7 (build stage):

-RUN npm ci --only=production && npm cache clean --force
+RUN npm ci && npm cache clean --force

Line 18 (production stage):

-RUN npm ci --only=production && npm cache clean --force
+RUN npm ci --omit=dev && npm cache clean --force
🤖 Prompt for AI Agents
In `@Dockerfile.production` at line 7, The build stage currently runs "RUN npm ci
--only=production" which omits devDependencies (like TypeScript and build tools)
so the following "npm run build" will fail; change the build stage to install
all deps (use "npm ci" without omitting dev deps) so tools required by the build
scripts are present, then in the final production stage install only runtime
deps using the new npm 10+ flag (use "npm ci --omit=dev" or equivalent) to avoid
bringing devDependencies into the production image; update any references to
"--only=production" to "--omit=dev" to be compatible with npm 10+.

COPY . .
RUN npm run build

# --- Production stage ---
FROM node:20-alpine AS production
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package*.json ./
Expand Down
98 changes: 60 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,76 +52,98 @@ docker compose exec api npm test -- --coverage

### Prerequisites

- [Node.js](https://nodejs.org/) (for development, not needed if using Docker)
- [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/)
- [Node.js 22 LTS](https://nodejs.org/) (only for local non-Docker development)

### Development
### Quick Start (Docker -- recommended)

1. **Install dependencies:**
Build and start the API + PostgreSQL with a single command:

```sh
docker compose up --build
```

The API is available at `http://localhost:3000` once the container is healthy.
Protobuf files, Prisma client, and all dependencies are generated during the
Docker build -- no manual steps required.

Useful endpoints to verify the server is running:

```sh
curl http://localhost:3000/healthz # health check
curl http://localhost:3000/readyz # readiness (DB connected)
curl http://localhost:3000/api/v1/disasters # REST API
```

### Running Tests

Tests run inside the Docker container. The test framework sets `NODE_ENV=test`
and configures per-worker test databases automatically:

```sh
docker compose exec api npm test
```

### Local Development (without Docker)

If you prefer running Node.js directly on your machine:

1. Copy the environment template and adjust the database URL:

```sh
cp .env.example .env
# Edit .env -- point POSTGRES_URI to your local PostgreSQL + PostGIS instance
```

2. Install dependencies:

```sh
nvm use # uses the version pinned in .nvmrc (Node 22)
npm install
```

2. **Build TypeScript:**
3. Generate Prisma client and protobuf files:

```sh
npm run build
# Output will be in ./dist/
npx prisma generate
npm run proto:all
```

3. **Run locally:**
4. Start the dev server (auto-reloads on file changes):

```sh
npm start
# or, for development with auto-reload:
npm run dev
```

### Docker Compose

To run the API and PostgreSQL together:
### Environment Variables

```sh
docker compose up --build
```
All required and optional variables are documented in `.env.example`. When
running via Docker Compose, they are set in `docker-compose.yml` and no
`.env` file is needed.

- The API will be available at `http://localhost:3000` (or as configured).
- The PostgreSQL container is also started.
| Variable | Required | Default | Description |
| -------------- | -------- | ------------- | -------------------------------------------- |
| `POSTGRES_URI` | Yes | -- | PostgreSQL connection string |
| `NODE_ENV` | No | `development` | `development`, `test`, `ci`, or `production` |
| `PORT` | No | `3000` | HTTP server port |
| `CORS_ORIGIN` | No | `*` | Allowed CORS origins |
| `LOG_LEVEL` | No | `info` | Winston log level |

### Running on macOS (Apple Silicon / ARM)

When running this project with Docker on macOS (especially Apple Silicon / M-series chips), there are a few things to be aware of:

1. **Platform mismatch for PostGIS image:**
The `postgis/postgis:15-3.4` image is built for `linux/amd64`. Docker Desktop on Apple Silicon runs it under emulation automatically, but you may see a warning:

> `The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)`

This is expected and the container works correctly under emulation.

2. **Node modules volume isolation (bind mount + native binaries):**
The `docker-compose.yml` uses a bind mount (`.:/usr/src/app`) for live code reloading. An anonymous volume (`/usr/src/app/node_modules`) is used to prevent the host's macOS-native `node_modules` from overriding the container's Linux-native packages. This is critical because packages like `esbuild`, `tsx`, and Prisma's query engine include platform-specific binaries that differ between macOS ARM and Linux.

If you see errors like `@esbuild/darwin-arm64 package is present but this platform needs @esbuild/linux-arm64`, the `node_modules` volume isolation is not working correctly. Ensure the anonymous volume line is present in `docker-compose.yml`:
```yaml
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
```

3. **First-time setup after cloning:**
After `docker compose up --build`, you may need to run these commands inside the container on first setup:
```sh
# Generate Prisma client (for the container's Linux platform)
docker compose exec api npx prisma generate

# Apply database migrations
docker compose exec api npx prisma migrate deploy

# Generate Protobuf JS/TS files (if not already present)
docker compose exec api npm run proto:all
```

4. **Prisma OpenSSL warning:**
3. **Prisma OpenSSL warning:**
You may see a Prisma warning about failing to detect the libssl/openssl version. This is cosmetic and does not affect functionality. If needed, install OpenSSL in the container by adding to the Dockerfile:
```dockerfile
RUN apt-get update -y && apt-get install -y openssl
Expand Down
48 changes: 15 additions & 33 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import dotenv from 'dotenv';
import Joi from 'joi';
import express from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';

import { Pool } from 'pg';
import winston from 'winston';
import helmet from 'helmet';
Expand All @@ -14,13 +14,13 @@ import fs from 'fs';
import path from 'path';
import hpp from 'hpp';
import client from 'prom-client';
import { ApolloServer } from 'apollo-server-express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express5';
import { router } from './routes/disasters.js';
import { typeDefs } from './graphql/schema.js';
import { resolvers } from './graphql/resolvers.js';
import { errorHandler } from './middleware/error.js';
import type { GraphQLError, GraphQLFormattedError } from 'graphql';
import type { GraphQLResponse } from 'apollo-server-types';
import type { GraphQLFormattedError } from 'graphql';
import { CREATE_DISASTERS_TABLE_SQL, CREATE_LOCATION_INDEX_SQL } from './disaster.model.js';

dotenv.config();
Expand Down Expand Up @@ -53,7 +53,6 @@ const envSchema = Joi.object({
PORT: Joi.number().integer().min(1).max(65535).default(3000),
POSTGRES_URI: Joi.string().uri().required(),
CORS_ORIGIN: Joi.string().allow('*').default('*'),
API_KEY: Joi.string().optional(),
// Add more as needed
}).unknown();

Expand Down Expand Up @@ -94,41 +93,24 @@ function register404Handler(req: express.Request, res: express.Response) {
res.status(404).json({ error: 'Not found', url: req.originalUrl });
}

// --- ApolloServer initialization: always apply to app before any /graphql route ---
// --- ApolloServer initialization ---
let apolloServer: ApolloServer | undefined;
let apolloReadyResolve: (() => void) | undefined;
const apolloReady: Promise<void> = new Promise((resolve) => {
apolloReadyResolve = resolve;
});
async function initApollo(app?: express.Application): Promise<void> {
async function initApollo(app: express.Application): Promise<void> {
if (!apolloServer) {
apolloServer = new ApolloServer({
typeDefs,
resolvers,
formatError: (error: GraphQLError): GraphQLFormattedError => {
const msg =
'[GraphQL ERROR] ' + (error && error.stack ? error.stack : JSON.stringify(error));
formatError: (formattedError: GraphQLFormattedError): GraphQLFormattedError => {
const msg = '[GraphQL ERROR] ' + JSON.stringify(formattedError);
console.error(msg);
if (logger && logger.error) logger.error('[GraphQL ERROR]', { error });
if (logger && logger.error) logger.error('[GraphQL ERROR]', { error: formattedError });
if (process && process.stderr && process.stderr.write) process.stderr.write(msg + '\n');
return {
message: error.message,
path: error.path,
locations: error.locations,
extensions: error.extensions,
};
},
formatResponse: (response: GraphQLResponse): GraphQLResponse | null => {
if (response.errors && Array.isArray(response.errors)) {
console.error('[GraphQL RESPONSE ERRORS]', JSON.stringify(response.errors));
}
return response;
return formattedError;
},
});
await apolloServer.start();
// @ts-expect-error: Suppress Application type mismatch between express and apollo-server-express
apolloServer.applyMiddleware({ app, path: '/graphql' });
if (apolloReadyResolve) apolloReadyResolve();
// Mount Apollo Server v4 via expressMiddleware on /graphql
app.use('/graphql', express.json(), expressMiddleware(apolloServer));
}
}

Expand Down Expand Up @@ -226,8 +208,8 @@ async function createApp(pgPool?: Pool): Promise<express.Application> {
// ApolloServer initialization (now synchronous)
await initApollo(app);

// Only apply bodyParser.json() to REST routes
app.use('/api', bodyParser.json());
// Only apply JSON body parsing to REST routes (built into Express 5)
app.use('/api', express.json());
app.use(helmet());
app.use(hpp());

Expand Down Expand Up @@ -490,4 +472,4 @@ function isBlacklisted(ip: string): boolean {
return IP_BLACKLIST.length > 0 && IP_BLACKLIST.includes(ip);
}

export { createApp, apolloReady, gracefulShutdown, logger, isWhitelisted, isBlacklisted };
export { createApp, gracefulShutdown, logger, isWhitelisted, isBlacklisted };
7 changes: 4 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@ services:
- postgres
environment:
- POSTGRES_URI=postgresql://disasters:disasters_pass@postgres:5432/disasters_test
- NODE_ENV=test
- NODE_ENV=development
ports:
- '3000:3000'
restart: always
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api-docs']
test:
['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/healthz']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
start_period: 30s
volumes:
postgres_data:
Loading