Mid Way
This commit is contained in:
32
.env.example
Normal file
32
.env.example
Normal file
@@ -0,0 +1,32 @@
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=hashed-password-here
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Google Sheets Integration
|
||||
GOOGLE_SHEETS_WEBHOOK_URL=https://script.google.com/macros/s/xxx/exec
|
||||
|
||||
# Email SMTP Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
NOTIFICATION_EMAIL=admin@example.com
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_API_URL=http://localhost:3000/api
|
||||
|
||||
# Database
|
||||
DATABASE_PATH=./data/wedding.db
|
||||
|
||||
# Security
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
61
Dockerfile
Normal file
61
Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# Multi-stage build for production
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the client
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS production
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
dumb-init \
|
||||
tini
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Copy built client from builder stage
|
||||
COPY --from=builder /app/client/dist ./client/dist
|
||||
|
||||
# Copy server code
|
||||
COPY server ./server
|
||||
|
||||
# Copy database directory
|
||||
COPY data ./data
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nodejs -u 1001
|
||||
|
||||
# Change ownership of directories
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server/index.js"]
|
||||
25
client/package.json
Normal file
25
client/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "wedding-rsvp-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"axios": "^1.3.0",
|
||||
"tailwindcss": "^3.2.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"vite": "^4.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
35
client/src/App.jsx
Normal file
35
client/src/App.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { AuthContext } from './contexts/AuthContext';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import Landing from './pages/Landing';
|
||||
import Rsvp from './pages/Rsvp';
|
||||
import Gallery from './pages/Gallery';
|
||||
import Accommodation from './pages/Accommodation';
|
||||
import Admin from './pages/Admin';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
function App() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element=<Landing /> />
|
||||
<Route path="/rsvp" element=<ProtectedRoute ><Rsvp /></ProtectedRoute> />
|
||||
<Route path="/gallery" element=<Gallery /> />
|
||||
<Route path="/accommodation" element=<Accommodation /> />
|
||||
<Route path="/admin" element=<ProtectedRoute ><Admin /></ProtectedRoute> />
|
||||
<Route path="/login" element=<LoginForm /> />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
73
client/src/contexts/AuthContext.jsx
Normal file
73
client/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
fetchUser();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/me');
|
||||
setUser(response.data);
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', { email, password });
|
||||
const token = response.data.token;
|
||||
localStorage.setItem('token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
setUser(response.data.user);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.response?.data?.message || 'Login failed' };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
loading
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
16
client/src/index.js
Normal file
16
client/src/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: wedding-rsvp-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- wedding-rsvp
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- wedding-network
|
||||
|
||||
wedding-rsvp:
|
||||
build: .
|
||||
container_name: wedding-rsvp-app
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- GOOGLE_SHEETS_WEBHOOK_URL=${GOOGLE_SHEETS_WEBHOOK_URL}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- NOTIFICATION_EMAIL=${NOTIFICATION_EMAIL}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- wedding-network
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (res) =\u003e { process.exit(res.statusCode === 200 ? 0 : 1) })\""]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
wedding-network:
|
||||
driver: bridge
|
||||
81
nginx.conf
Normal file
81
nginx.conf
Normal file
@@ -0,0 +1,81 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream wedding-rsvp {
|
||||
server wedding-rsvp:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://wedding-rsvp:3000;" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Static file caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary Accept-Encoding;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API routes
|
||||
location /api/ {
|
||||
proxy_pass http://wedding-rsvp;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
# Frontend routes
|
||||
location / {
|
||||
proxy_pass http://wedding-rsvp;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
package.json
37
package.json
@@ -1,29 +1,36 @@
|
||||
{
|
||||
"name": "wedding-project",
|
||||
"name": "wedding-rsvp",
|
||||
"version": "1.0.0",
|
||||
"description": "A comprehensive wedding planning and RSVP management system",
|
||||
"main": "index.js",
|
||||
"description": "A modern minimalist wedding RSVP website with individual guest authentication",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"test": "jest",
|
||||
"build": "webpack --mode production"
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||
"server": "nodemon server/index.js",
|
||||
"client": "cd client && npm run dev",
|
||||
"build": "cd client && npm run build",
|
||||
"start": "node server/index.js",
|
||||
"docker:build": "docker build -t wedding-rsvp .",
|
||||
"docker:run": "docker-compose up -d"
|
||||
},
|
||||
"keywords": ["wedding", "rsvp", "planning", "management"],
|
||||
"keywords": ["wedding", "rsvp", "react", "nodejs", "express"],
|
||||
"author": "Wedding Team",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mongoose": "^7.5.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"nodemailer": "^6.9.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1"
|
||||
"helmet": "^7.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"joi": "^17.11.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"jest": "^29.6.2",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-cli": "^5.1.4"
|
||||
"nodemon": "^3.0.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
160
server/README.md
Normal file
160
server/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Wedding RSVP Server
|
||||
|
||||
A Node.js/Express backend server for managing wedding RSVPs with individual guest authentication.
|
||||
|
||||
## Features
|
||||
|
||||
- **Individual Guest Authentication**: Each guest has a unique code for secure access
|
||||
- **RSVP Management**: Guests can confirm attendance and provide dietary restrictions
|
||||
- **Admin Dashboard**: Full admin interface for managing guests and groups
|
||||
- **Email Notifications**: Automated email confirmations and notifications
|
||||
- **Google Sheets Integration**: Export data to Google Sheets for easy tracking
|
||||
- **Security**: JWT authentication, rate limiting, and input validation
|
||||
- **Database**: SQLite with proper schema and relationships
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
#### Authentication
|
||||
- `POST /api/auth/login` - Guest login with code and name
|
||||
- `POST /api/auth/admin/login` - Admin login with username and password
|
||||
|
||||
#### RSVP Management
|
||||
- `POST /api/rsvp` - Submit RSVP response (requires authentication)
|
||||
- `GET /api/rsvp/guest/:code` - Get guest details (requires authentication)
|
||||
- `PUT /api/rsvp/guest/:code` - Update guest information (requires authentication)
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
#### Guest Management
|
||||
- `GET /api/admin/guests` - Get all guests
|
||||
- `GET /api/admin/guests/:id` - Get specific guest
|
||||
- `PUT /api/admin/guests/:id` - Update guest information
|
||||
- `DELETE /api/admin/guests/:id` - Delete guest
|
||||
|
||||
#### Group Management
|
||||
- `GET /api/admin/groups` - Get all groups
|
||||
- `POST /api/admin/groups` - Create new group
|
||||
- `PUT /api/admin/groups/:id` - Update group
|
||||
- `DELETE /api/admin/groups/:id` - Delete group
|
||||
|
||||
#### Data Export
|
||||
- `GET /api/admin/export` - Export all data to Google Sheets
|
||||
|
||||
### Health Check
|
||||
- `GET /api/health` - Server health status
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file based on `.env.example`:
|
||||
|
||||
```
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=hashed-password-here
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Google Sheets Integration
|
||||
GOOGLE_SHEETS_WEBHOOK_URL=https://script.google.com/macros/s/xxx/exec
|
||||
|
||||
# Email SMTP Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
NOTIFICATION_EMAIL=admin@example.com
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_API_URL=http://localhost:3000/api
|
||||
|
||||
# Database
|
||||
DATABASE_PATH=./data/wedding.db
|
||||
|
||||
# Security
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
3. Start the server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
The server uses SQLite with the following schema:
|
||||
|
||||
- **guests**: Guest information with unique codes
|
||||
- **groups**: Guest groups with plus-one limits
|
||||
- **plus_ones**: Additional guests for plus-one arrangements
|
||||
- **admin_users**: Admin user accounts
|
||||
|
||||
## Security Features
|
||||
|
||||
- JWT-based authentication
|
||||
- Rate limiting (100 requests per 15 minutes)
|
||||
- Input validation with Joi
|
||||
- Helmet security headers
|
||||
- CORS configuration
|
||||
- SQL injection prevention
|
||||
|
||||
## Email Service
|
||||
|
||||
The server can send automated emails for:
|
||||
- RSVP confirmations
|
||||
- Admin notifications
|
||||
- Guest welcome emails
|
||||
|
||||
Configure SMTP settings in the environment variables.
|
||||
|
||||
## Google Sheets Integration
|
||||
|
||||
Export all guest and group data to Google Sheets using a webhook URL. Configure the webhook URL in the environment variables.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
- Server runs on `http://localhost:3000`
|
||||
- API base URL: `http://localhost:3000/api`
|
||||
- Health check: `GET /api/health`
|
||||
|
||||
## Production
|
||||
|
||||
For production deployment:
|
||||
1. Set `NODE_ENV=production`
|
||||
2. Use a strong JWT secret
|
||||
3. Configure proper SMTP settings
|
||||
4. Set up reverse proxy (e.g., Nginx)
|
||||
5. Use a process manager (e.g., PM2)
|
||||
|
||||
## API Documentation
|
||||
|
||||
See the [API Documentation](API.md) for detailed endpoint specifications and examples.
|
||||
79
server/database/connection.js
Normal file
79
server/database/connection.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
const dbPath = path.join(__dirname, '../data/wedding.db');
|
||||
this.db = new sqlite3.Database(dbPath, (err) => {
|
||||
if (err) {
|
||||
console.error('Error opening database:', err.message);
|
||||
} else {
|
||||
console.log('Connected to SQLite database');
|
||||
this.createTables();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createTables() {
|
||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||
const fs = require('fs');
|
||||
|
||||
fs.readFile(schemaPath, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
console.error('Error reading schema file:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.exec(data, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating tables:', err.message);
|
||||
} else {
|
||||
console.log('Database tables created successfully');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
query(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(sql, params, (err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, params, function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ id: this.lastID, changes: this.changes });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) {
|
||||
this.db.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error closing database:', err.message);
|
||||
} else {
|
||||
console.log('Database connection closed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Database();
|
||||
61
server/database/schema.sql
Normal file
61
server/database/schema.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Wedding RSVP Database Schema
|
||||
|
||||
-- Guests table
|
||||
CREATE TABLE guests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
group_id INTEGER,
|
||||
is_attending INTEGER DEFAULT 0,
|
||||
dietary_restrictions TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (group_id) REFERENCES groups (id)
|
||||
);
|
||||
|
||||
-- Groups table
|
||||
CREATE TABLE groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
plus_one_limit INTEGER DEFAULT 0,
|
||||
custom_info TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Plus-ones table
|
||||
CREATE TABLE plus_ones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guest_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dietary_restrictions TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (guest_id) REFERENCES guests (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Admin users table
|
||||
CREATE TABLE admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default admin user (password: admin123)
|
||||
INSERT INTO admin_users (username, password_hash) VALUES (
|
||||
'admin',
|
||||
'$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi' -- bcrypt hash for 'admin123'
|
||||
);
|
||||
|
||||
-- Insert default groups
|
||||
INSERT INTO groups (name, plus_one_limit, custom_info) VALUES
|
||||
('Family', 2, 'You are welcome to bring your immediate family members.'),
|
||||
('Friends', 1, 'You are welcome to bring one guest.'),
|
||||
('VIP', 2, 'You are welcome to bring two guests.'),
|
||||
('Colleagues', 0, 'Unfortunately, we cannot accommodate plus-ones for colleagues.');
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX idx_guests_code ON guests (code);
|
||||
CREATE INDEX idx_guests_group_id ON guests (group_id);
|
||||
CREATE INDEX idx_plus_ones_guest_id ON plus_ones (guest_id);
|
||||
92
server/index.js
Normal file
92
server/index.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, // limit each IP to 100 requests per windowMs
|
||||
message: {
|
||||
error: 'Too many requests from this IP'
|
||||
}
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Database connection
|
||||
require('./database/connection.js');
|
||||
|
||||
// Routes
|
||||
const authRoutes = require('./routes/auth.js');
|
||||
const rsvpRoutes = require('./routes/rsvp.js');
|
||||
const adminRoutes = require('./routes/admin.js');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/rsvp', rsvpRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage()
|
||||
});
|
||||
});
|
||||
|
||||
// Basic 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: `The requested resource ${req.originalUrl} was not found on this server.`
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
if (err.name === 'ValidationError') {
|
||||
return res.status(400).json({
|
||||
error: 'Validation Error',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid Token',
|
||||
message: 'The provided token is invalid or has expired'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Something went wrong!'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`API URL: http://localhost:${PORT}/api`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
68
server/middleware/auth.js
Normal file
68
server/middleware/auth.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const verifyToken = promisify(jwt.verify);
|
||||
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({ error: 'Authorization header missing' });
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Token missing' });
|
||||
}
|
||||
|
||||
const decoded = await verifyToken(token, process.env.JWT_SECRET);
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const adminAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({ error: 'Authorization header missing' });
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Token missing' });
|
||||
}
|
||||
|
||||
const decoded = await verifyToken(token, process.env.JWT_SECRET);
|
||||
|
||||
if (!decoded.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { authMiddleware, adminAuthMiddleware };
|
||||
69
server/middleware/validation.js
Normal file
69
server/middleware/validation.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const validateGuestLogin = (req, res, next) => {
|
||||
const schema = Joi.object({
|
||||
code: Joi.string().required(),
|
||||
name: Joi.string().required(),
|
||||
email: Joi.string().email().optional()
|
||||
});
|
||||
|
||||
const { error } = schema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const validateRSVP = (req, res, next) => {
|
||||
const schema = Joi.object({
|
||||
code: Joi.string().required(),
|
||||
isAttending: Joi.boolean().required(),
|
||||
dietaryRestrictions: Joi.string().optional(),
|
||||
plusOnes: Joi.array().items(Joi.object({
|
||||
name: Joi.string().required(),
|
||||
dietaryRestrictions: Joi.string().optional()
|
||||
})).optional()
|
||||
});
|
||||
|
||||
const { error } = schema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const validateAdminLogin = (req, res, next) => {
|
||||
const schema = Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required()
|
||||
});
|
||||
|
||||
const { error } = schema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const validateGuestUpdate = (req, res, next) => {
|
||||
const schema = Joi.object({
|
||||
name: Joi.string().optional(),
|
||||
email: Joi.string().email().optional(),
|
||||
phone: Joi.string().optional(),
|
||||
dietaryRestrictions: Joi.string().optional(),
|
||||
isAttending: Joi.boolean().optional()
|
||||
}).min(1);
|
||||
|
||||
const { error } = schema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
validateGuestLogin,
|
||||
validateRSVP,
|
||||
validateAdminLogin,
|
||||
validateGuestUpdate
|
||||
};
|
||||
208
server/routes/admin.js
Normal file
208
server/routes/admin.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../database/connection.js');
|
||||
const { adminAuthMiddleware } = require('../middleware/auth.js');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/guests', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const guests = await db.query(`
|
||||
SELECT g.*, gr.name as group_name
|
||||
FROM guests g
|
||||
LEFT JOIN groups gr ON g.group_id = gr.id
|
||||
ORDER BY g.name
|
||||
`);
|
||||
|
||||
res.json(guests);
|
||||
} catch (error) {
|
||||
console.error('Get guests error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/guests/:id', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const guest = await db.query(
|
||||
'SELECT * FROM guests WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (guest.length === 0) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
const plusOnes = await db.query(
|
||||
'SELECT * FROM plus_ones WHERE guest_id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
guest: guest[0],
|
||||
plusOnes: plusOnes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get guest by ID error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/guests/:id', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const guest = await db.query(
|
||||
'SELECT * FROM guests WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (guest.length === 0) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
const setClause = Object.keys(updates)
|
||||
.map(key => `${key} = ?`)
|
||||
.join(', ');
|
||||
|
||||
const values = [...Object.values(updates), id];
|
||||
|
||||
await db.run(
|
||||
`UPDATE guests SET ${setClause}, updated_at = datetime('now') WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
const updatedGuest = await db.query(
|
||||
'SELECT * FROM guests WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Guest updated successfully',
|
||||
guest: updatedGuest[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update guest error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/guests/:id', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await db.run(
|
||||
'DELETE FROM guests WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({ message: 'Guest deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete guest error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/groups', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const groups = await db.query('SELECT * FROM groups ORDER BY name');
|
||||
res.json(groups);
|
||||
} catch (error) {
|
||||
console.error('Get groups error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/groups', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { name, plusOneLimit, customInfo } = req.body;
|
||||
|
||||
const result = await db.run(
|
||||
'INSERT INTO groups (name, plus_one_limit, custom_info) VALUES (?, ?, ?)',
|
||||
[name, plusOneLimit, customInfo]
|
||||
);
|
||||
|
||||
const group = await db.query(
|
||||
'SELECT * FROM groups WHERE id = ?',
|
||||
[result.id]
|
||||
);
|
||||
|
||||
res.status(201).json(group[0]);
|
||||
} catch (error) {
|
||||
console.error('Create group error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/groups/:id', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, plusOneLimit, customInfo } = req.body;
|
||||
|
||||
await db.run(
|
||||
'UPDATE groups SET name = ?, plus_one_limit = ?, custom_info = ?, updated_at = datetime(\'now\') WHERE id = ?',
|
||||
[name, plusOneLimit, customInfo, id]
|
||||
);
|
||||
|
||||
const group = await db.query(
|
||||
'SELECT * FROM groups WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Group updated successfully',
|
||||
group: group[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update group error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/groups/:id', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await db.run(
|
||||
'DELETE FROM groups WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({ message: 'Group deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete group error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/export', adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const db = require('../database/connection.js');
|
||||
const googleSheetsService = require('../services/googleSheets.js');
|
||||
|
||||
const guests = await db.query(`
|
||||
SELECT g.*, gr.name as group_name
|
||||
FROM guests g
|
||||
LEFT JOIN groups gr ON g.group_id = gr.id
|
||||
ORDER BY g.name
|
||||
`);
|
||||
|
||||
const groups = await db.query('SELECT * FROM groups ORDER BY name');
|
||||
|
||||
await googleSheetsService.exportAllGuests();
|
||||
await googleSheetsService.exportAllGroups();
|
||||
|
||||
res.json({
|
||||
message: 'Data exported successfully',
|
||||
guests: guests.length,
|
||||
groups: groups.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
88
server/routes/auth.js
Normal file
88
server/routes/auth.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../database/connection.js');
|
||||
const { validateGuestLogin, validateAdminLogin } = require('../middleware/validation.js');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', validateGuestLogin, async (req, res) => {
|
||||
try {
|
||||
const { code, name, email } = req.body;
|
||||
|
||||
const guest = await db.query(
|
||||
'SELECT * FROM guests WHERE code = ? AND name = ?',
|
||||
[code, name]
|
||||
);
|
||||
|
||||
if (guest.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid guest credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
guestId: guest[0].id,
|
||||
code: guest[0].code,
|
||||
name: guest[0].name,
|
||||
email: guest[0].email,
|
||||
isAdmin: false
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
guest: {
|
||||
id: guest[0].id,
|
||||
code: guest[0].code,
|
||||
name: guest[0].name,
|
||||
email: guest[0].email,
|
||||
phone: guest[0].phone,
|
||||
isAttending: guest[0].isAttending,
|
||||
dietaryRestrictions: guest[0].dietaryRestrictions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/admin/login', validateAdminLogin, async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
const admin = await db.query(
|
||||
'SELECT * FROM admin_users WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (admin.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid admin credentials' });
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, admin[0].password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid admin credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
adminId: admin[0].id,
|
||||
username: admin[0].username,
|
||||
isAdmin: true
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
console.error('Admin login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
160
server/routes/rsvp.js
Normal file
160
server/routes/rsvp.js
Normal file
@@ -0,0 +1,160 @@
|
||||
const express = require('express');
|
||||
const db = require('../database/connection.js');
|
||||
const { authMiddleware, adminAuthMiddleware } = require('../middleware/auth.js');
|
||||
const { validateRSVP, validateGuestUpdate } = require('../middleware/validation.js');
|
||||
const emailService = require('../services/email.js');
|
||||
const googleSheetsService = require('../services/googleSheets.js');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', authMiddleware, validateRSVP, async (req, res) => {
|
||||
try {
|
||||
const { code, isAttending, dietaryRestrictions, plusOnes } = req.body;
|
||||
|
||||
const guest = await db.query(
|
||||
'SELECT * FROM guests WHERE code = ?',
|
||||
[code]
|
||||
);
|
||||
|
||||
if (guest.length === 0) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
const guestData = guest[0];
|
||||
|
||||
await db.run(
|
||||
'UPDATE guests SET is_attending = ?, dietary_restrictions = ?, updated_at = datetime(\'now\') WHERE id = ?',
|
||||
[isAttending, dietaryRestrictions, guestData.id]
|
||||
);
|
||||
|
||||
if (plusOnes && plusOnes.length > 0) {
|
||||
await db.run(
|
||||
'DELETE FROM plus_ones WHERE guest_id = ?',
|
||||
[guestData.id]
|
||||
);
|
||||
|
||||
for (const plusOne of plusOnes) {
|
||||
await db.run(
|
||||
'INSERT INTO plus_ones (guest_id, name, dietary_restrictions) VALUES (?, ?, ?)',
|
||||
[guestData.id, plusOne.name, plusOne.dietaryRestrictions]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGuest = await db.query(
|
||||
'SELECT * FROM guests WHERE id = ?',
|
||||
[guestData.id]
|
||||
);
|
||||
|
||||
await emailService.sendRSVPConfirmation(updatedGuest[0]);
|
||||
await emailService.sendAdminNotification(updatedGuest[0], 'RSVP Updated');
|
||||
await googleSheetsService.exportGuestData(updatedGuest[0]);
|
||||
|
||||
res.json({
|
||||
message: 'RSVP updated successfully',
|
||||
guest: {
|
||||
id: updatedGuest[0].id,
|
||||
code: updatedGuest[0].code,
|
||||
name: updatedGuest[0].name,
|
||||
email: updatedGuest[0].email,
|
||||
isAttending: updatedGuest[0].isAttending,
|
||||
dietaryRestrictions: updatedGuest[0].dietaryRestrictions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('RSVP error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/guest/:code', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
|
||||
const guest = await db.query(
|
||||
'SELECT * FROM guests WHERE code = ?',
|
||||
[code]
|
||||
);
|
||||
|
||||
if (guest.length === 0) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
const plusOnes = await db.query(
|
||||
'SELECT * FROM plus_ones WHERE guest_id = ?',
|
||||
[guest[0].id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
guest: {
|
||||
id: guest[0].id,
|
||||
code: guest[0].code,
|
||||
name: guest[0].name,
|
||||
email: guest[0].email,
|
||||
phone: guest[0].phone,
|
||||
isAttending: guest[0].isAttending,
|
||||
dietaryRestrictions: guest[0].dietaryRestrictions,
|
||||
group: guest[0].group_id,
|
||||
createdAt: guest[0].created_at,
|
||||
updatedAt: guest[0].updated_at
|
||||
},
|
||||
plusOnes: plusOnes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get guest error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/guest/:code', authMiddleware, validateGuestUpdate, async (req, res) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const guest = await db.query(
|
||||
'SELECT * FROM guests WHERE code = ?',
|
||||
[code]
|
||||
);
|
||||
|
||||
if (guest.length === 0) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
const setClause = Object.keys(updates)
|
||||
.map(key => `${key} = ?`)
|
||||
.join(', ');
|
||||
|
||||
const values = [...Object.values(updates), guest[0].id];
|
||||
|
||||
await db.run(
|
||||
`UPDATE guests SET ${setClause}, updated_at = datetime('now') WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
const updatedGuest = await db.query(
|
||||
'SELECT * FROM guests WHERE id = ?',
|
||||
[guest[0].id]
|
||||
);
|
||||
|
||||
await emailService.sendAdminNotification(updatedGuest[0], 'Guest Information Updated');
|
||||
await googleSheetsService.exportGuestData(updatedGuest[0]);
|
||||
|
||||
res.json({
|
||||
message: 'Guest information updated successfully',
|
||||
guest: {
|
||||
id: updatedGuest[0].id,
|
||||
code: updatedGuest[0].code,
|
||||
name: updatedGuest[0].name,
|
||||
email: updatedGuest[0].email,
|
||||
phone: updatedGuest[0].phone,
|
||||
isAttending: updatedGuest[0].isAttending,
|
||||
dietaryRestrictions: updatedGuest[0].dietaryRestrictions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update guest error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
130
server/services/email.js
Normal file
130
server/services/email.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
class EmailService {
|
||||
constructor() {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT),
|
||||
secure: parseInt(process.env.SMTP_PORT) === 465,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async sendRSVPConfirmation(guest) {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<title>RSVP Confirmation</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Thank you for your RSVP!</h1>
|
||||
<p>Dear ${guest.name},</p>
|
||||
<p>We have received your RSVP response. Here are the details:</p>
|
||||
<ul>
|
||||
${guest.isAttending ? '<li>You will be attending the wedding</li>' : '<li>You will not be attending the wedding</li>'}
|
||||
${guest.dietaryRestrictions ? `<li>Dietary restrictions: ${guest.dietaryRestrictions}</li>` : '<li>No dietary restrictions provided</li>'}
|
||||
</ul>
|
||||
<p>If you need to make any changes, please contact us at ${process.env.NOTIFICATION_EMAIL}.</p>
|
||||
<p>We look forward to celebrating with you!</p>
|
||||
<p>Best regards,</p>
|
||||
<p>The Wedding Team</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.NOTIFICATION_EMAIL,
|
||||
to: guest.email,
|
||||
subject: 'RSVP Confirmation - Wedding',
|
||||
html: html
|
||||
};
|
||||
|
||||
try {
|
||||
await this.transporter.sendMail(mailOptions);
|
||||
console.log('RSVP confirmation email sent to:', guest.email);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error sending RSVP confirmation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendAdminNotification(guest, action) {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<title>RSVP Update Notification</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RSVP Update Notification</h1>
|
||||
<p>Guest: ${guest.name}</p>
|
||||
<p>Action: ${action}</p>
|
||||
<p>Attendance: ${guest.isAttending ? 'Attending' : 'Not Attending'}</p>
|
||||
${guest.dietaryRestrictions ? `<p>Dietary Restrictions: ${guest.dietaryRestrictions}</p>` : '<p>No dietary restrictions</p>'}
|
||||
<p>Date: ${new Date().toISOString()}</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.NOTIFICATION_EMAIL,
|
||||
to: process.env.NOTIFICATION_EMAIL,
|
||||
subject: 'RSVP Update - Wedding',
|
||||
html: html
|
||||
};
|
||||
|
||||
try {
|
||||
await this.transporter.sendMail(mailOptions);
|
||||
console.log('Admin notification email sent');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error sending admin notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendGuestWelcome(guest) {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to Our Wedding</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to Our Wedding!</h1>
|
||||
<p>Dear ${guest.name},</p>
|
||||
<p>Thank you for being a part of our special day. Here are some important details:</p>
|
||||
<ul>
|
||||
<li>Date: [Insert Wedding Date]</li>
|
||||
<li>Venue: [Insert Venue Name and Address]</li>
|
||||
<li>Your RSVP code: ${guest.code}</li>
|
||||
</ul>
|
||||
<p>Please use your RSVP code to confirm your attendance and provide any dietary restrictions.</p>
|
||||
<p>We can't wait to celebrate with you!</p>
|
||||
<p>Best regards,</p>
|
||||
<p>The Wedding Couple</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.NOTIFICATION_EMAIL,
|
||||
to: guest.email,
|
||||
subject: 'Welcome to Our Wedding!',
|
||||
html: html
|
||||
};
|
||||
|
||||
try {
|
||||
await this.transporter.sendMail(mailOptions);
|
||||
console.log('Welcome email sent to:', guest.email);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error sending welcome email:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
127
server/services/googleSheets.js
Normal file
127
server/services/googleSheets.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const axios = require('axios');
|
||||
|
||||
class GoogleSheetsService {
|
||||
constructor() {
|
||||
this.webhookUrl = process.env.GOOGLE_SHEETS_WEBHOOK_URL;
|
||||
}
|
||||
|
||||
async exportGuestData(guest) {
|
||||
if (!this.webhookUrl) {
|
||||
console.log('Google Sheets webhook URL not configured, skipping export');
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
guestId: guest.id,
|
||||
guestCode: guest.code,
|
||||
name: guest.name,
|
||||
email: guest.email,
|
||||
phone: guest.phone,
|
||||
isAttending: guest.isAttending ? 'Yes' : 'No',
|
||||
dietaryRestrictions: guest.dietaryRestrictions || 'None',
|
||||
groupId: guest.group_id,
|
||||
createdAt: guest.created_at,
|
||||
updatedAt: guest.updated_at
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(this.webhookUrl, payload);
|
||||
console.log('Guest data exported to Google Sheets:', response.status);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error exporting to Google Sheets:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async exportAllGuests() {
|
||||
if (!this.webhookUrl) {
|
||||
console.log('Google Sheets webhook URL not configured, skipping export');
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = require('../database/connection.js');
|
||||
|
||||
try {
|
||||
const guests = await db.query(`
|
||||
SELECT g.*, gr.name as group_name
|
||||
FROM guests g
|
||||
LEFT JOIN groups gr ON g.group_id = gr.id
|
||||
`);
|
||||
|
||||
const payload = guests.map(guest => ({
|
||||
guestId: guest.id,
|
||||
guestCode: guest.code,
|
||||
name: guest.name,
|
||||
email: guest.email,
|
||||
phone: guest.phone,
|
||||
isAttending: guest.isAttending ? 'Yes' : 'No',
|
||||
dietaryRestrictions: guest.dietaryRestrictions || 'None',
|
||||
groupName: guest.group_name || 'Unknown',
|
||||
createdAt: guest.created_at,
|
||||
updatedAt: guest.updated_at
|
||||
}));
|
||||
|
||||
const response = await axios.post(this.webhookUrl, payload);
|
||||
console.log('All guests exported to Google Sheets:', response.status);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error exporting all guests to Google Sheets:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async exportGroupData(group) {
|
||||
if (!this.webhookUrl) {
|
||||
console.log('Google Sheets webhook URL not configured, skipping export');
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
plusOneLimit: group.plus_one_limit,
|
||||
customInfo: group.custom_info,
|
||||
createdAt: group.created_at
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(this.webhookUrl, payload);
|
||||
console.log('Group data exported to Google Sheets:', response.status);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error exporting group to Google Sheets:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async exportAllGroups() {
|
||||
if (!this.webhookUrl) {
|
||||
console.log('Google Sheets webhook URL not configured, skipping export');
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = require('../database/connection.js');
|
||||
|
||||
try {
|
||||
const groups = await db.query('SELECT * FROM groups');
|
||||
|
||||
const payload = groups.map(group => ({
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
plusOneLimit: group.plus_one_limit,
|
||||
customInfo: group.custom_info,
|
||||
createdAt: group.created_at
|
||||
}));
|
||||
|
||||
const response = await axios.post(this.webhookUrl, payload);
|
||||
console.log('All groups exported to Google Sheets:', response.status);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error exporting all groups to Google Sheets:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GoogleSheetsService();
|
||||
76
server/test/server.test.js
Normal file
76
server/test/server.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const request = require('supertest');
|
||||
const app = require('../server/index.js');
|
||||
|
||||
describe('Server Tests', () => {
|
||||
describe('Health Check', () => {
|
||||
it('should return 200 OK', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/health')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('should return 401 for invalid guest login', async () => {
|
||||
await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ code: 'invalid', name: 'John Doe' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should return 400 for missing fields', async () => {
|
||||
await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ code: 'invalid' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RSVP', () => {
|
||||
it('should return 401 for unauthenticated access', async () => {
|
||||
await request(app)
|
||||
.post('/api/rsvp')
|
||||
.send({ code: 'test', isAttending: true })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should return 400 for missing fields', async () => {
|
||||
await request(app)
|
||||
.post('/api/rsvp')
|
||||
.send({ code: 'test' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Routes', () => {
|
||||
it('should return 401 for unauthenticated admin access', async () => {
|
||||
await request(app)
|
||||
.get('/api/admin/guests')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent admin route', async () => {
|
||||
await request(app)
|
||||
.get('/api/admin/nonexistent')
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle 404 errors', async () => {
|
||||
await request(app)
|
||||
.get('/api/nonexistent')
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should handle 500 errors', async () => {
|
||||
// This test is just to verify error handling is in place
|
||||
// Actual 500 errors would depend on specific implementation
|
||||
await request(app)
|
||||
.get('/api/health')
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user