This commit is contained in:
2026-02-12 20:04:53 +07:00
parent a063762ddc
commit 25a9488aca
21 changed files with 1708 additions and 15 deletions

32
.env.example Normal file
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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;
}
}
}

View File

@@ -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
View 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.

View 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();

View 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
View 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
View 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 };

View 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
View 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
View 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
View 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
View 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();

View 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();

View 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);
});
});
});