Skip to content

Commit aa38ad1

Browse files
committed
feat: initialize server with Express, add middleware and routes, and set up Swagger documentation
1 parent f49c6d9 commit aa38ad1

File tree

15 files changed

+2885
-33
lines changed

15 files changed

+2885
-33
lines changed

.env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
GOOGLE_CLIENT_ID="your-google-client-id-here"
2+
GOOGLE_CLIENT_SECRET="your-google-client-secret-here"
3+
GOOGLE_LOGIN_DOMAIN="http://localhost:5173"
4+
DATABASE_URL="postgresql://username:password@localhost:5432/bottlecrm?schema=public"
5+
6+
# API Configuration
7+
API_PORT=3001
8+
JWT_SECRET=your-super-secure-jwt-secret-key-change-this-in-production
9+
JWT_EXPIRES_IN=24h
10+
FRONTEND_URL=http://localhost:5173
11+
12+
# Logging Configuration
13+
ENABLE_REQUEST_LOGGING=true
14+
LOG_REQUEST_BODY=false
15+
LOG_RESPONSE_BODY=false
16+
17+
# Environment
18+
NODE_ENV=development

ENV.md

Lines changed: 0 additions & 4 deletions
This file was deleted.

api/README.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# BottleCRM API
2+
3+
Express.js API for BottleCRM with JWT authentication, Swagger documentation, and configurable request logging.
4+
5+
## Features
6+
7+
- **Google OAuth Authentication**: Secure Google Sign-In for mobile apps
8+
- **Multi-tenant**: Organization-based data isolation using existing Prisma schema
9+
- **Swagger Documentation**: Interactive API documentation at `/api-docs`
10+
- **Request Logging**: Configurable input/output HTTP request logging
11+
- **Security**: Helmet, CORS, rate limiting
12+
- **Organization Access Control**: Ensures users can only access their organization's data
13+
14+
## Quick Start
15+
16+
1. The required environment variables are already added to your existing `.env` file.
17+
18+
2. **Generate a secure JWT secret** (required for production):
19+
```bash
20+
# Using Node.js
21+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
22+
23+
# Using OpenSSL (if available)
24+
openssl rand -hex 32
25+
26+
# Using online generator (for development only)
27+
# Visit: https://generate-secret.vercel.app/32
28+
```
29+
30+
3. Update your `.env` file with the generated secret:
31+
```env
32+
JWT_SECRET=your-generated-secret-key-here
33+
```
34+
35+
4. Start the API server:
36+
```bash
37+
# Development with auto-reload
38+
pnpm run api:dev
39+
40+
# Production
41+
pnpm run api:start
42+
```
43+
44+
5. Visit Swagger documentation:
45+
```
46+
http://localhost:3001/api-docs
47+
```
48+
49+
## Authentication
50+
51+
1. **Google Login**: POST `/api/auth/google`
52+
- Request: `{ "idToken": "google-id-token-from-mobile-app" }`
53+
- Response: `{ "token": "jwt-token", "user": {...} }`
54+
55+
2. **Use Token**: Include in Authorization header:
56+
```
57+
Authorization: Bearer <jwt-token>
58+
```
59+
60+
3. **Select Organization**: Include organization ID in header:
61+
```
62+
X-Organization-ID: <organization-id>
63+
```
64+
65+
## API Endpoints
66+
67+
### Authentication
68+
- `POST /api/auth/google` - Google OAuth mobile login
69+
- `GET /api/auth/me` - Get current user profile
70+
71+
### Leads
72+
- `GET /api/leads` - Get organization leads (paginated)
73+
- `GET /api/leads/:id` - Get lead by ID
74+
- `POST /api/leads` - Create new lead
75+
76+
### Accounts
77+
- `GET /api/accounts` - Get organization accounts
78+
- `POST /api/accounts` - Create new account
79+
80+
### Contacts
81+
- `GET /api/contacts` - Get organization contacts
82+
- `POST /api/contacts` - Create new contact
83+
84+
### Opportunities
85+
- `GET /api/opportunities` - Get organization opportunities
86+
- `POST /api/opportunities` - Create new opportunity
87+
88+
## Configuration
89+
90+
### Environment Variables
91+
92+
- `API_PORT`: Server port (default: 3001)
93+
- `JWT_SECRET`: Secret key for JWT tokens (required) - **Generate using the commands above**
94+
- `JWT_EXPIRES_IN`: Token expiration time (default: 24h)
95+
- `FRONTEND_URL`: Frontend URL for CORS (default: http://localhost:5173)
96+
97+
### Logging Configuration
98+
99+
- `LOG_LEVEL`: Logging level (info, debug, error)
100+
- `ENABLE_REQUEST_LOGGING`: Enable/disable request logging (true/false)
101+
- `LOG_REQUEST_BODY`: Log request bodies (true/false)
102+
- `LOG_RESPONSE_BODY`: Log response bodies (true/false)
103+
104+
### Security Features
105+
106+
- **Rate Limiting**: 100 requests per 15 minutes per IP
107+
- **Helmet**: Security headers
108+
- **CORS**: Cross-origin request handling
109+
- **JWT Validation**: Token verification on protected routes
110+
- **Organization Isolation**: Users can only access their organization's data
111+
112+
## Data Access Control
113+
114+
All API endpoints enforce organization-based access control:
115+
116+
1. **Authentication Required**: All endpoints (except login) require valid JWT token
117+
2. **Organization Header**: Protected endpoints require `X-Organization-ID` header
118+
3. **Membership Validation**: User must be a member of the specified organization
119+
4. **Data Filtering**: All database queries are filtered by organization ID
120+
121+
## Development
122+
123+
The API uses the same Prisma schema as the main SvelteKit application, ensuring data consistency and leveraging existing:
124+
125+
- Database models and relationships
126+
- Organization-based multi-tenancy
127+
- User role management (ADMIN/USER)
128+
- Super admin access (@micropyramid.com domain)
129+
130+
## Testing with Swagger
131+
132+
Access the interactive API documentation at `http://localhost:3001/api-docs` to:
133+
134+
1. Test authentication endpoints
135+
2. Explore available endpoints
136+
3. Test API calls with different parameters
137+
4. View request/response schemas

api/config/logger.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const createLogger = () => {
2+
return {
3+
info: (message, meta) => {
4+
console.log(`[INFO] ${message}`);
5+
if (meta) {
6+
console.log(JSON.stringify(meta, null, 2));
7+
}
8+
},
9+
error: (message, meta) => {
10+
console.error(`[ERROR] ${message}`);
11+
if (meta) {
12+
console.error(JSON.stringify(meta, null, 2));
13+
}
14+
},
15+
warn: (message, meta) => {
16+
console.warn(`[WARN] ${message}`);
17+
if (meta) {
18+
console.warn(JSON.stringify(meta, null, 2));
19+
}
20+
}
21+
};
22+
};

api/middleware/auth.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import jwt from 'jsonwebtoken';
2+
import { PrismaClient } from '@prisma/client';
3+
4+
const prisma = new PrismaClient();
5+
6+
export const verifyToken = async (req, res, next) => {
7+
try {
8+
const token = req.header('Authorization')?.replace('Bearer ', '');
9+
10+
if (!token) {
11+
return res.status(401).json({ error: 'Access denied. No token provided.' });
12+
}
13+
14+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
15+
16+
const user = await prisma.user.findUnique({
17+
where: { id: decoded.userId },
18+
include: {
19+
userOrganizations: {
20+
include: {
21+
organization: true
22+
}
23+
}
24+
}
25+
});
26+
27+
if (!user) {
28+
return res.status(401).json({ error: 'Invalid token. User not found.' });
29+
}
30+
31+
req.user = user;
32+
req.userId = user.id;
33+
next();
34+
} catch (error) {
35+
return res.status(401).json({ error: 'Invalid token.' });
36+
}
37+
};
38+
39+
export const requireOrganization = async (req, res, next) => {
40+
try {
41+
const organizationId = req.header('X-Organization-ID');
42+
43+
if (!organizationId) {
44+
return res.status(400).json({ error: 'Organization ID is required in X-Organization-ID header.' });
45+
}
46+
47+
const userOrg = req.user.userOrganizations.find(
48+
uo => uo.organizationId === organizationId
49+
);
50+
51+
if (!userOrg) {
52+
return res.status(403).json({ error: 'Access denied to this organization.' });
53+
}
54+
55+
req.organizationId = organizationId;
56+
req.userRole = userOrg.role;
57+
req.organization = userOrg.organization;
58+
next();
59+
} catch (error) {
60+
return res.status(500).json({ error: 'Internal server error.' });
61+
}
62+
};
63+
64+
export const requireRole = (roles) => {
65+
return (req, res, next) => {
66+
if (!roles.includes(req.userRole)) {
67+
return res.status(403).json({ error: 'Insufficient permissions.' });
68+
}
69+
next();
70+
};
71+
};
72+
73+
export const requireSuperAdmin = (req, res, next) => {
74+
if (!req.user.email.endsWith('@micropyramid.com')) {
75+
return res.status(403).json({ error: 'Super admin access required.' });
76+
}
77+
next();
78+
};

api/middleware/errorHandler.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createLogger } from '../config/logger.js';
2+
3+
const logger = createLogger();
4+
5+
export const errorHandler = (err, req, res, next) => {
6+
logger.error('Unhandled Error', {
7+
error: err.message,
8+
stack: err.stack,
9+
method: req.method,
10+
url: req.url,
11+
userId: req.user?.id,
12+
organizationId: req.organizationId,
13+
timestamp: new Date().toISOString(),
14+
});
15+
16+
if (process.env.NODE_ENV === 'production') {
17+
res.status(500).json({ error: 'Internal server error' });
18+
} else {
19+
res.status(500).json({
20+
error: err.message,
21+
stack: err.stack
22+
});
23+
}
24+
};

api/middleware/requestLogger.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
export const requestLogger = (req, res, next) => {
2+
const start = Date.now();
3+
4+
const originalSend = res.send;
5+
const originalJson = res.json;
6+
7+
let responseBody = null;
8+
let requestBody = null;
9+
10+
if (req.body && Object.keys(req.body).length > 0) {
11+
requestBody = { ...req.body };
12+
if (requestBody.password) requestBody.password = '[REDACTED]';
13+
if (requestBody.token) requestBody.token = '[REDACTED]';
14+
}
15+
16+
res.send = function(body) {
17+
responseBody = body;
18+
return originalSend.call(this, body);
19+
};
20+
21+
res.json = function(body) {
22+
responseBody = body;
23+
return originalJson.call(this, body);
24+
};
25+
26+
res.on('finish', () => {
27+
const duration = Date.now() - start;
28+
29+
const logData = {
30+
method: req.method,
31+
url: req.url,
32+
statusCode: res.statusCode,
33+
duration: `${duration}ms`,
34+
userAgent: req.get('User-Agent'),
35+
ip: req.ip,
36+
timestamp: new Date().toISOString(),
37+
};
38+
39+
if (process.env.LOG_REQUEST_BODY === 'true' && requestBody) {
40+
logData.requestBody = requestBody;
41+
}
42+
43+
if (process.env.LOG_RESPONSE_BODY === 'true' && responseBody) {
44+
try {
45+
logData.responseBody = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
46+
} catch (e) {
47+
logData.responseBody = responseBody;
48+
}
49+
}
50+
51+
if (req.user) {
52+
logData.userId = req.user.id;
53+
logData.userEmail = req.user.email;
54+
}
55+
56+
if (req.organizationId) {
57+
logData.organizationId = req.organizationId;
58+
}
59+
60+
console.log(`\n=== HTTP REQUEST LOG ===`);
61+
console.log(`${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`);
62+
63+
if (requestBody) {
64+
console.log('REQUEST BODY:', JSON.stringify(requestBody, null, 2));
65+
}
66+
67+
if (responseBody) {
68+
console.log('RESPONSE BODY:', JSON.stringify(responseBody, null, 2));
69+
}
70+
71+
console.log('FULL LOG DATA:', JSON.stringify(logData, null, 2));
72+
console.log(`=== END LOG ===\n`);
73+
});
74+
75+
next();
76+
};

0 commit comments

Comments
 (0)