Compare commits

..

11 Commits

Author SHA1 Message Date
mikicv f45a6f46a5 Merge pull request 'feat: implement admin blocks management feature' (#2) from feat/booking-blocks/court-blocking into main
Reviewed-on: #2
2025-12-29 17:09:00 +00:00
mikicvi 40c56770a2 feat: implement admin blocks management feature
- Added AdminBlocksManagement component for managing court blocks.
- Implemented functionality to create, edit, and delete blocks.
- Integrated fetching of courts and blocks from the API.
- Added validation for block creation and editing forms.
- Enhanced UI with responsive design for mobile and desktop views.
- Created database migration for court_blocks table and updated users table with theme_preference.
2025-12-29 17:04:16 +00:00
mikicv 54240a2cfd Merge pull request 'Deps: update dependencies and script' (#1) from deps/security/upgrade-modules into main
Reviewed-on: #1
2025-12-29 17:02:32 +00:00
mikicvi ab1ac4427a chore: update database command and next.js version
- Changed the database migration command from "db:migrate" to "db:generate" for SQLite.
- Updated the Next.js version from 15.5.3 to 15.5.7 in package.json.
2025-12-29 16:59:57 +00:00
mikicvi 69b456f3f8 Update license year, enhance user management with last booking date, and improve admin dashboard navigation 2025-10-06 11:18:55 +01:00
mikicvi 7fdd7285a4 restructure docs and config, license, readme redo 2025-09-28 21:49:56 +01:00
mikicvi 1911aa9211 Enhance admin dashboard and header with responsive layouts and additional navigation buttons, icon 2025-09-28 21:31:24 +01:00
mikicv 43c0cf1359 docker image to alpine, reduce size, compile scripts, entrypoint for alpine 2025-09-28 20:47:23 +01:00
mikicv d4aa460f91 fully working when deployed 2025-09-28 18:47:31 +01:00
mikicv 38ee5a8886 Merge branch 'main' of https://gitea.mikicvi.com/mikicv/tt-booking 2025-09-28 16:34:20 +01:00
mikicv 2d31c49235 Refactor production setup and database management
- Updated Dockerfile for production to ensure proper database permissions and improved startup script.
- Removed outdated PRODUCTION_README.md and consolidated relevant information into other documentation.
- Enhanced deploy.sh script to fix database permissions and streamline deployment process.
- Modified docker-compose configuration to use a dedicated production file and adjusted port mappings.
- Removed legacy docker-compose.yml file to avoid confusion.
- Improved session management by refining secure cookie settings based on environment variables.
- Deleted obsolete Nginx configuration and old seed scripts to clean up the project structure.
- Updated database setup scripts to reflect new structure and removed old seed data scripts.
- Adjusted reset-db script to use environment variable for database path.
- Enhanced setup-database script to provide dynamic admin credentials in the summary.
- Removed unnecessary backup file for SQLite database.
2025-09-28 16:32:31 +01:00
50 changed files with 3161 additions and 1816 deletions
-14
View File
@@ -1,14 +0,0 @@
# Database
DATABASE_URL="./sqlite.db"
# NextAuth.js
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here-make-this-very-long-and-random"
# Email Configuration (Gmail)
EMAIL_USER="your-email@gmail.com"
EMAIL_PASSWORD="your-app-password-here"
# Admin Configuration
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="admin123"
+20 -2
View File
@@ -17,6 +17,8 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
logs/
*.log
# Runtime data
pids
@@ -28,10 +30,14 @@ pids
*.db
*.sqlite
*.sqlite3
data/
backups/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
@@ -48,6 +54,18 @@ build/
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Temporary files
*.tmp
*.temp
tmp/
temp/
# Backups
*.bak
*.backup
# Runtime directories (created during execution)
/logs
/backups
/data
-38
View File
@@ -1,38 +0,0 @@
# Use the official Node.js runtime as the base image
FROM node:18-alpine
# Set the working directory in the container
WORKDIR /app
# Copy package.json and package-lock.json (if available)
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy the rest of the application code
COPY . .
# Create the SQLite database directory
RUN mkdir -p /app/data
# Build the Next.js application
RUN npm run build
# Expose the port the app runs on
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV DATABASE_URL=/app/data/sqlite.db
# Create a non-root user to run the application
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# Change ownership of the app directory to the nextjs user
RUN chown -R nextjs:nodejs /app
USER nextjs
# Command to run the application
CMD ["npm", "start"]
+32 -25
View File
@@ -14,8 +14,8 @@ RUN \
fi
# Rebuild the source code only when needed
FROM base AS builder
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
FROM node:22-alpine AS builder
RUN apk add --no-cache python3 make g++ curl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -23,44 +23,50 @@ COPY . .
# Rebuild better-sqlite3 for Alpine Linux
RUN npm rebuild better-sqlite3
# Build TypeScript database scripts to JavaScript
RUN node build-scripts.js
# Build the application
RUN npm run build
# Remove development-only dependencies and dedupe after build
RUN npm prune --omit=dev && npm dedupe --prod \
&& find node_modules -type f \( -name "README" -o -name "README.*" -o -name "CHANGELOG*" -o -name "*.md" -o -name "*.map" \) -delete \
&& find node_modules -type d \( -name "__tests__" -o -name "test" -o -name "tests" -o -name "docs" -o -name "examples" \) -prune -exec rm -rf {} + \
&& npm cache clean --force
# Production image, copy all the files and run next
FROM base AS runner
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
FROM node:22-alpine AS runner
RUN apk add --no-cache curl
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create system user and group
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder stage
COPY --from=builder /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=root:root /app/.next/static ./.next/static
# Copy compiled database scripts instead of TypeScript sources
COPY --from=builder /app/dist ./dist
# Copy minimal runtime dependencies for database operations
COPY --from=builder /app/node_modules ./node_modules
# Copy database config and package.json
COPY --from=builder /app/lib ./lib
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/package.json ./package.json
# Copy entrypoint script
COPY docker-entrypoint-alpine.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Create public directory if it doesn't exist
RUN mkdir -p public
# Create directories for data and backups
RUN mkdir -p /app/data /app/backups /app/logs && \
chown -R nextjs:nodejs /app/data /app/backups /app/logs
# Create startup script
COPY --chown=nextjs:nodejs <<EOF /app/start.sh
#!/bin/sh
set -e
echo "🚀 Starting TT Booking Production..."
echo "🌟 Starting server..."
exec node server.js
EOF
RUN chmod +x /app/start.sh
USER nextjs
RUN mkdir -p /app/data /app/backups /app/logs
EXPOSE 3000
@@ -71,4 +77,5 @@ ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["/app/start.sh"]
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]
+27
View File
@@ -0,0 +1,27 @@
## License
This project is licensed under the MIT License:
```
MIT License
Copyright (c) 2025 Vilim Mikic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
-149
View File
@@ -1,149 +0,0 @@
# LCC Table Tennis Booking - Production Setup
## Quick Start
Your production environment is configured for domain: **lcc-tt-booking.mikicvi.com**
### 1. Deploy the Application
```bash
# Make deployment script executable (if not already done)
chmod +x deploy.sh
# Deploy to production
./deploy.sh
```
### 2. Set Up Cloudflare Tunnel
```bash
# Run the tunnel setup script
./setup-tunnel.sh
# Follow the instructions provided by the script
```
## Configuration Files Created
### Environment Configuration
- **`.env.production`** - Production environment variables
- **`docker-compose.production.yml`** - Production Docker Compose configuration
- **`Dockerfile.production`** - Optimized production Docker build
### Security & Secrets
- **NEXTAUTH_SECRET**: `qHYNaq516ByAY6H4HdxacMICd05I1DqvrTitIuVtT20=` (pre-generated)
- **Domain**: `lcc-tt-booking.mikicvi.com`
- **Admin Email**: `admin@lcc-tt-booking.mikicvi.com`
### Scripts & Automation
- **`deploy.sh`** - One-command production deployment
- **`setup-tunnel.sh`** - Cloudflare Tunnel setup assistant
- **`cloudflare-tunnel-config.yml`** - Tunnel configuration template
### Health & Monitoring
- **`/api/health`** - Health check endpoint with database connectivity and memory usage
- **Automated backups** - Daily SQLite backups (30-day retention)
- **Log rotation** - Automatic log management
## Production Checklist
### Before Going Live:
- [ ] Update email credentials in `.env.production`
- [ ] Change admin password from default
- [ ] Set up Cloudflare Tunnel
- [ ] Test health check endpoint
- [ ] Verify SSL certificate is working
### After Deployment:
- [ ] Test all booking functionality
- [ ] Verify admin panel access
- [ ] Check automated backups are working
- [ ] Set up monitoring alerts (optional)
## Management Commands
```bash
# View application logs
docker-compose -f docker-compose.production.yml logs -f tt-booking
# Restart application
docker-compose -f docker-compose.production.yml restart tt-booking
# Stop all services
docker-compose -f docker-compose.production.yml down
# Update and redeploy
./deploy.sh
# Access database backup
ls -la backups/
# Check application health
curl http://localhost:3000/api/health
```
## Directory Structure
```
/Users/mikicv/Documents/tt-booking/
├── .env.production # Production environment variables
├── docker-compose.production.yml # Production Docker Compose
├── Dockerfile.production # Production Docker build
├── deploy.sh # Deployment script
├── setup-tunnel.sh # Cloudflare Tunnel setup
├── cloudflare-tunnel-config.yml # Tunnel configuration
├── data/ # SQLite database storage
├── backups/ # Automated backups
└── logs/ # Application logs
```
## Default Admin Access
- **URL**: https://lcc-tt-booking.mikicvi.com/admin
- **Email**: admin@lcc-tt-booking.mikicvi.com
- **Password**: ChangeMeInProduction123! (⚠️ CHANGE THIS!)
## Support & Troubleshooting
### Common Issues:
1. **Container won't start**: Check `docker-compose -f docker-compose.production.yml logs`
2. **Database issues**: Ensure `data/` directory permissions are correct
3. **Tunnel not working**: Verify Cloudflare DNS settings and tunnel configuration
### Health Check:
The health endpoint (`/api/health`) provides:
- Application status
- Database connectivity
- Memory usage
- Uptime information
### Backup Verification:
```bash
# List all backups
ls -la backups/
# Check latest backup size
du -h backups/sqlite-$(date +%Y%m%d)*.db | tail -1
```
## Production Features Included:
- ✅ Automated daily backups (30-day retention)
- ✅ Health monitoring endpoint
- ✅ Log rotation and management
- ✅ Multi-stage Docker optimization
- ✅ Security hardening
- ✅ Rate limiting configured
- ✅ SSL-ready with Cloudflare integration
Your LCC Table Tennis Booking System is ready for production! 🏓
+92 -167
View File
@@ -2,133 +2,93 @@
A modern, full-stack table tennis court booking system built with Next.js, shadcn/ui, and SQLite.
## Features
## Features
### User Features
- **🔐 Secure Authentication**: JWT-based registration and login
- **📅 Interactive Calendar**: Real-time court availability and booking
- **📧 Email Notifications**: Automatic booking confirmations
- **📱 Mobile-First Design**: Responsive UI for all devices
- ** Admin Panel**: Court management, user administration, analytics
- **🔒 Security**: Rate limiting, input validation, password hashing
- **Secure Authentication**: User registration and login with JWT tokens
- **Court Booking**: Interactive booking calendar with real-time availability
- **Email Notifications**: Automatic confirmation and cancellation emails
- **Mobile-First Design**: Responsive UI that works on all devices
- **Booking Management**: View and manage your bookings
### Admin Features
- **Court Management**: Add/remove courts and configure availability
- **Time Slot Configuration**: Set operating hours for different days
- **User Management**: View and manage user accounts
- **Booking Override**: Admin can edit or cancel any booking
- **Announcements**: Create and manage system announcements
- **Activity Logs**: Comprehensive logging of all system activities
- **Analytics Dashboard**: Booking statistics and usage metrics
### System Features
- **7-Day Booking Window**: Users can only book up to 1 week in advance
- **Real-time Validation**: Both client and server-side booking validation
- **Secure Backend**: SQLite database with Drizzle ORM
- **Docker Support**: Easy deployment with Docker and reverse proxy
- **Email Integration**: Gmail SMTP integration for notifications
## Technology Stack
- **Frontend**: Next.js 14, React, TypeScript
- **UI Components**: shadcn/ui, Tailwind CSS, Radix UI
- **Backend**: Next.js API routes, Drizzle ORM
- **Database**: SQLite
- **Authentication**: JWT tokens with httpOnly cookies
- **Email**: Nodemailer with Gmail
- **Deployment**: Docker, Nginx reverse proxy
## Quick Start
## 🚀 Quick Start
### Prerequisites
- Node.js 18+
- npm or yarn
- Gmail account for email notifications
- npm/yarn
- Gmail account (for notifications)
### Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd tt-booking
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment variables**
```bash
cp .env.example .env.local
```
Edit `.env.local` with your configuration:
```env
NEXTAUTH_SECRET="your-secret-key-here-make-this-very-long-and-random"
EMAIL_USER="your-email@gmail.com"
EMAIL_PASSWORD="your-gmail-app-password"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="admin123"
```
4. **Set up the database**
```bash
npm run db:push
```
5. **Run the development server**
```bash
npm run dev
```
6. **Access the application**
- User interface: http://localhost:3000
- Admin panel: http://localhost:3000/admin
## Configuration
### Gmail Setup
1. Enable 2-factor authentication on your Gmail account
2. Generate an App Password: Google Account > Security > App passwords
3. Use the App Password as `EMAIL_PASSWORD` in your environment variables
### Default Settings
- **Courts**: 2 courts (configurable via admin panel)
- **Monday/Tuesday**: 19:00-23:00 (configurable)
- **Sunday**: 12:00-17:00 (configurable)
- **Booking window**: 7 days from current date
## Docker Deployment
### Development
```bash
docker-compose up -d
# Clone and install
git clone <repository-url>
cd tt-booking
npm install
# Configure environment
cp .env.sample .env.local
# Edit .env.local with your settings
# Setup database
npm run db:setup
# Start development
npm run dev
```
### Production
**Access**: http://localhost:3000 (Admin: `/admin`)
1. **Update environment variables** in `docker-compose.yml`
2. **Configure SSL certificates** in the `ssl` directory
3. **Update domain** in `nginx.conf`
4. **Deploy**:
```bash
docker-compose -f docker-compose.yml up -d
```
## 🔧 Configuration
## Project Structure
Create `.env.local` with:
```env
NEXTAUTH_SECRET=your-very-long-random-secret-key
NEXTAUTH_URL=http://localhost:3000
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=your-secure-password
# Optional - Email notifications
EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-gmail-app-password
```
## 🗄️ Database Commands
```bash
npm run db:setup # Full database setup
npm run db:seed # Essential data only
npm run db:reset-confirm # Reset database
npm run db:studio # Open database GUI
```
## 🐳 Deployment
### Production (Docker)
```bash
cp .env.sample .env.production
docker compose -f docker-compose.production.yml up -d
```
### Home Server
```bash
./setup-tunnel.sh # Setup Cloudflare tunnel
./deploy.sh # Deploy
```
## 🛠️ Technology Stack
- **Frontend**: Next.js 14, React, TypeScript, shadcn/ui, Tailwind CSS
- **Backend**: Next.js API routes, SQLite, Drizzle ORM
- **Auth**: JWT with httpOnly cookies
- **Email**: Nodemailer + Gmail
- **Deployment**: Docker, Nginx
## 🗂️ Project Structure
```
tt-booking/
@@ -139,63 +99,28 @@ tt-booking/
│ └── layout.tsx # Root layout
├── components/ # React components
│ ├── ui/ # shadcn/ui components
│ ├── auth/ # Authentication forms
│ ├── booking/ # Booking components
│ └── admin/ # Admin components
├── lib/ # Utility libraries
│ ├── db/ # Database schema and connection
│ ├── auth.ts # Authentication utilities
── email.ts # Email functionality
│ └── utils.ts # General utilities
├── docker-compose.yml # Docker configuration
├── Dockerfile # Container definition
└── nginx.conf # Reverse proxy configuration
│ ├── auth/ # Authentication
│ ├── booking/ # Booking system
│ └── admin/ # Admin interface
├── lib/ # Utilities
│ ├── db/ # Database schema
│ ├── auth.ts # Authentication
── email.ts # Email service
├── scripts/ # Database scripts
│ ├── setup-database.ts # Database setup
│ └── reset-db.ts # Database reset
└── docker-compose.*.yml # Deployment configs
```
## API Endpoints
### Authentication
- `POST /api/auth/login` - User login
- `POST /api/auth/register` - User registration
- `POST /api/auth/logout` - User logout
### Bookings
- `GET /api/bookings` - Get user bookings
- `POST /api/bookings` - Create booking
- `PUT /api/bookings/[id]` - Update booking
- `DELETE /api/bookings/[id]` - Cancel booking
### Admin
- `GET /api/admin/stats` - Dashboard statistics
- `GET /api/admin/courts` - Manage courts
- `GET /api/admin/settings` - System settings
- `GET /api/admin/logs` - Activity logs
## Security Features
- **Rate Limiting**: API endpoints are rate-limited via Nginx
- **CSRF Protection**: Built-in Next.js CSRF protection
- **SQL Injection Prevention**: Drizzle ORM parameterized queries
- **XSS Protection**: Content Security Policy headers
- **Secure Cookies**: httpOnly, secure, sameSite cookies
- **Input Validation**: Zod schema validation
- **Password Hashing**: bcrypt with salt rounds
## Contributing
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
2. Create feature branch
3. Make changes and test
4. Submit pull request
## License
## 🆘 Support
This project is licensed under the MIT License.
## Support
For issues and questions, please create an issue in the repository.
- 📚 [Database Setup Guide](docs/DATABASE_SETUP.md)
- 🚀 [Deployment Guide](docs/DEPLOYMENT_GUIDE.md)
- 🐛 Create an issue for bugs or questions
+128
View File
@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
const { courtId, date, startTime, endTime, reason } = await request.json();
if (!date || !startTime || !endTime || !reason) {
return NextResponse.json(
{ error: 'Missing required fields: date, startTime, endTime, reason' },
{ status: 400 }
);
}
// Check if block exists
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
if (existingBlock.length === 0) {
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
}
// Validate date is not in the past
const blockDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (blockDate < today) {
return NextResponse.json({ error: 'Cannot set blocks for past dates' }, { status: 400 });
}
// If courtId is provided, verify it exists
if (courtId) {
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
if (court.length === 0) {
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
}
}
// Update the block
const [updatedBlock] = await db
.update(courtBlocks)
.set({
courtId: courtId || null,
date,
startTime,
endTime,
reason,
})
.where(eq(courtBlocks.id, id))
.returning();
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_UPDATE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: id,
details: {
courtId: courtId || 'all',
date,
startTime,
endTime,
reason,
},
});
return NextResponse.json({
block: updatedBlock,
message: 'Block updated successfully',
});
} catch (error) {
console.error('Error updating block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
// Check if block exists
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
if (existingBlock.length === 0) {
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
}
const block = existingBlock[0];
// Delete the block
await db.delete(courtBlocks).where(eq(courtBlocks.id, id));
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_DELETE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: id,
details: {
courtId: block.courtId || 'all',
date: block.date,
startTime: block.startTime,
endTime: block.endTime,
reason: block.reason,
},
});
return NextResponse.json({ message: 'Block deleted successfully' });
} catch (error) {
console.error('Error deleting block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts, users, announcements } from '@/lib/db/schema';
import { eq, gte, asc } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const includeExpired = searchParams.get('includeExpired') === 'true';
// Get today's date
const today = new Date().toISOString().split('T')[0];
// Fetch blocks with court and creator info
let query = db
.select({
id: courtBlocks.id,
courtId: courtBlocks.courtId,
date: courtBlocks.date,
startTime: courtBlocks.startTime,
endTime: courtBlocks.endTime,
reason: courtBlocks.reason,
createdBy: courtBlocks.createdBy,
createdAt: courtBlocks.createdAt,
court: {
id: courts.id,
name: courts.name,
},
creator: {
id: users.id,
name: users.name,
surname: users.surname,
},
})
.from(courtBlocks)
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
.innerJoin(users, eq(courtBlocks.createdBy, users.id));
const rawBlocks = includeExpired
? await query.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime))
: await query
.where(gte(courtBlocks.date, today))
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
// Transform to flat structure for frontend
const blocks = rawBlocks.map((block) => ({
id: block.id,
courtId: block.courtId,
courtName: block.court?.name || null,
date: block.date,
startTime: block.startTime,
endTime: block.endTime,
reason: block.reason,
createdBy: block.createdBy,
creatorName: block.creator ? `${block.creator.name} ${block.creator.surname}` : 'Unknown',
createdAt: block.createdAt,
}));
return NextResponse.json({ blocks });
} catch (error) {
console.error('Error fetching blocks:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { courtId, date, startTime, endTime, reason, createAnnouncement } = await request.json();
if (!date || !startTime || !endTime || !reason) {
return NextResponse.json(
{ error: 'Missing required fields: date, startTime, endTime, reason' },
{ status: 400 }
);
}
// Validate date is not in the past
const blockDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (blockDate < today) {
return NextResponse.json({ error: 'Cannot create blocks for past dates' }, { status: 400 });
}
// Get court name if courtId is provided
let courtName = 'All Courts';
if (courtId) {
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
if (court.length === 0) {
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
}
courtName = court[0].name;
}
// Create the block
const blockId = crypto.randomUUID();
const [newBlock] = await db
.insert(courtBlocks)
.values({
id: blockId,
courtId: courtId || null, // null means all courts
date,
startTime,
endTime,
reason,
createdBy: session.userId,
createdAt: new Date(),
})
.returning();
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_CREATE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: blockId,
details: {
courtId: courtId || 'all',
date,
startTime,
endTime,
reason,
},
});
// Optionally create an announcement that expires when the block ends
let announcementCreated = false;
if (createAnnouncement) {
const formattedDate = new Date(date).toLocaleDateString('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
// Set expiry to end of block day at the end time
const expiryDate = new Date(date);
const [endHour] = endTime.split(':').map(Number);
expiryDate.setHours(endHour, 0, 0, 0);
await db.insert(announcements).values({
id: crypto.randomUUID(),
title: `${reason} - ${formattedDate}`,
content: `${courtName} will be unavailable on ${formattedDate} from ${startTime} to ${endTime} due to: ${reason}`,
priority: 'high',
expiresAt: expiryDate,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
announcementCreated = true;
}
return NextResponse.json({
block: newBlock,
announcementCreated,
message: 'Block created successfully',
});
} catch (error) {
console.error('Error creating block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+7 -3
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { users, bookings } from '@/lib/db/schema';
import { eq, desc, max, sql, and } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import bcrypt from 'bcryptjs';
@@ -12,6 +12,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get all users with their last booking date using LEFT JOIN and GROUP BY
const allUsers = await db
.select({
id: users.id,
@@ -20,8 +21,11 @@ export async function GET(request: NextRequest) {
email: users.email,
role: users.role,
createdAt: users.createdAt,
lastBookingDate: max(bookings.date),
})
.from(users);
.from(users)
.leftJoin(bookings, and(eq(bookings.userId, users.id), eq(bookings.status, 'active')))
.groupBy(users.id, users.name, users.surname, users.email, users.role, users.createdAt);
return NextResponse.json({ users: allUsers });
} catch (error) {
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts } from '@/lib/db/schema';
import { eq, gte, and, lte, asc } from 'drizzle-orm';
import { getSession } from '@/lib/session';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
// Default to today if no start date provided
const today = new Date().toISOString().split('T')[0];
const queryStartDate = startDate || today;
// Default to 60 days ahead if no end date provided (8 weeks + buffer)
const defaultEndDate = new Date();
defaultEndDate.setDate(defaultEndDate.getDate() + 60);
const queryEndDate = endDate || defaultEndDate.toISOString().split('T')[0];
// Fetch blocks with court info for the date range
const blocks = await db
.select({
id: courtBlocks.id,
courtId: courtBlocks.courtId,
date: courtBlocks.date,
startTime: courtBlocks.startTime,
endTime: courtBlocks.endTime,
reason: courtBlocks.reason,
court: {
id: courts.id,
name: courts.name,
},
})
.from(courtBlocks)
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
.where(and(gte(courtBlocks.date, queryStartDate), lte(courtBlocks.date, queryEndDate)))
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
return NextResponse.json({ blocks });
} catch (error) {
console.error('Error fetching blocks:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+53 -6
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { bookings, courts, timeSlots, settings, metrics } from '@/lib/db/schema';
import { eq, and, gte, asc } from 'drizzle-orm';
import { bookings, courts, timeSlots, settings, metrics, courtBlocks } from '@/lib/db/schema';
import { eq, and, gte, asc, or, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
@@ -65,6 +65,9 @@ export async function POST(request: NextRequest) {
const [hours, minutes] = timeSlot.split(':').map(Number);
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
// Check if user is admin (for bypassing certain restrictions)
const isAdmin = session.role === 'admin';
// Validate booking date is not in the past
const bookingDate = new Date(date);
const today = new Date();
@@ -90,6 +93,45 @@ export async function POST(request: NextRequest) {
);
}
// CHECK FOR BLOCKS - applies to everyone including admins
// A block prevents any booking on that court/time (admins should remove the block first)
const requestedHour = parseInt(startTime.split(':')[0]);
const activeBlocks = await db
.select()
.from(courtBlocks)
.where(
and(
eq(courtBlocks.date, date),
or(
eq(courtBlocks.courtId, courtId), // Block for this specific court
isNull(courtBlocks.courtId) // Block for all courts
)
)
);
// Check if any block covers this time slot
const isBlockedSlot = activeBlocks.some((block) => {
const blockStartHour = parseInt(block.startTime.split(':')[0]);
const blockEndHour = parseInt(block.endTime.split(':')[0]);
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
});
if (isBlockedSlot) {
const blockingBlock = activeBlocks.find((block) => {
const blockStartHour = parseInt(block.startTime.split(':')[0]);
const blockEndHour = parseInt(block.endTime.split(':')[0]);
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
});
return NextResponse.json(
{
error: `This slot is blocked: ${
blockingBlock?.reason || 'Court unavailable'
}. Please choose a different time.`,
},
{ status: 400 }
);
}
// CRITICAL: Validate that booking is allowed for this day and time
const dayOfWeek = bookingDate.getDay();
const availableTimeSlots = await db
@@ -97,8 +139,8 @@ export async function POST(request: NextRequest) {
.from(timeSlots)
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
// Check if any time slots are configured for this day
if (availableTimeSlots.length === 0) {
// Check if any time slots are configured for this day (admins can bypass if needed)
if (availableTimeSlots.length === 0 && !isAdmin) {
return NextResponse.json(
{
error: `No bookings are allowed on ${
@@ -110,7 +152,8 @@ export async function POST(request: NextRequest) {
}
// Check if the requested time slot is within any of the allowed time ranges
const requestedHour = parseInt(startTime.split(':')[0]);
// Admins can bypass time slot restrictions
if (!isAdmin) {
const isTimeSlotValid = availableTimeSlots.some((slot) => {
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
@@ -128,15 +171,18 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
}
// Check booking restrictions per user per hour per day
// Admins bypass this restriction
if (!isAdmin) {
const maxBookingsSetting = await db
.select()
.from(settings)
.where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
.limit(1);
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1; // Default to 1 if setting not found
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1;
// Count user's existing bookings for this hour on this day
const userBookingsThisHour = await db
@@ -159,6 +205,7 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
}
// Check if slot is already booked
const existingBooking = await db
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
console.log('📦 Building TypeScript database scripts...');
// Create dist directory
const distDir = path.join(__dirname, 'dist');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Bundle scripts with esbuild (available via Next.js)
const scripts = [
'scripts/check-database.ts',
'scripts/setup-database.ts'
];
scripts.forEach(script => {
const scriptName = path.basename(script, '.ts');
const outFile = path.join(distDir, `${scriptName}.js`);
console.log(` Building ${script} -> ${outFile}`);
try {
execSync(`npx esbuild ${script} --bundle --platform=node --target=node22 --outfile=${outFile} --external:better-sqlite3`, {
stdio: 'pipe'
});
console.log(`${scriptName}.js built successfully`);
} catch (error) {
console.error(` ❌ Failed to build ${scriptName}:`, error.message);
process.exit(1);
}
});
console.log('🎉 All database scripts built successfully!');
+753
View File
@@ -0,0 +1,753 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Calendar, Trash2, Plus, Ban, CalendarX, Edit, Bell } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface Court {
id: string;
name: string;
}
interface CourtBlock {
id: string;
courtId: string | null;
courtName: string | null;
date: string;
startTime: string;
endTime: string;
reason: string;
createdBy: string;
creatorName: string;
createdAt: string;
}
export function AdminBlocksManagement() {
const { toast } = useToast();
const [blocks, setBlocks] = useState<CourtBlock[]>([]);
const [courts, setCourts] = useState<Court[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
// Form state for creating
const [selectedCourt, setSelectedCourt] = useState<string>('all');
const [blockDate, setBlockDate] = useState<string>('');
const [startTime, setStartTime] = useState<string>('18:00');
const [endTime, setEndTime] = useState<string>('23:00');
const [reason, setReason] = useState<string>('');
const [createAnnouncement, setCreateAnnouncement] = useState<boolean>(true);
// Edit modal state
const [editingBlock, setEditingBlock] = useState<CourtBlock | null>(null);
const [editCourt, setEditCourt] = useState<string>('all');
const [editDate, setEditDate] = useState<string>('');
const [editStartTime, setEditStartTime] = useState<string>('');
const [editEndTime, setEditEndTime] = useState<string>('');
const [editReason, setEditReason] = useState<string>('');
// Generate time options (e.g., 06:00 to 23:00)
const timeOptions = [];
for (let h = 6; h <= 23; h++) {
timeOptions.push(`${String(h).padStart(2, '0')}:00`);
}
const fetchBlocks = useCallback(async () => {
try {
const response = await fetch('/api/admin/blocks');
if (response.ok) {
const data = await response.json();
setBlocks(data.blocks || []);
} else {
toast({
title: 'Error',
description: 'Failed to fetch blocks',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error fetching blocks:', error);
toast({
title: 'Error',
description: 'Failed to fetch blocks',
variant: 'destructive',
});
} finally {
setLoading(false);
}
}, [toast]);
const fetchCourts = useCallback(async () => {
try {
const response = await fetch('/api/courts');
if (response.ok) {
const data = await response.json();
setCourts(data.courts || []);
}
} catch (error) {
console.error('Error fetching courts:', error);
}
}, []);
useEffect(() => {
fetchBlocks();
fetchCourts();
}, [fetchBlocks, fetchCourts]);
const handleCreateBlock = async (e: React.FormEvent) => {
e.preventDefault();
if (!blockDate) {
toast({
title: 'Validation Error',
description: 'Please select a date',
variant: 'destructive',
});
return;
}
if (!reason.trim()) {
toast({
title: 'Validation Error',
description: 'Please provide a reason for the block',
variant: 'destructive',
});
return;
}
if (startTime >= endTime) {
toast({
title: 'Validation Error',
description: 'End time must be after start time',
variant: 'destructive',
});
return;
}
setSubmitting(true);
try {
const response = await fetch('/api/admin/blocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
courtId: selectedCourt === 'all' ? null : selectedCourt,
date: blockDate,
startTime,
endTime,
reason: reason.trim(),
createAnnouncement,
}),
});
if (response.ok) {
const data = await response.json();
toast({
title: 'Success',
description: data.announcementCreated
? 'Block created and announcement posted'
: 'Block created successfully',
});
// Reset form
setBlockDate('');
setReason('');
setSelectedCourt('all');
setStartTime('18:00');
setEndTime('22:00');
setCreateAnnouncement(true);
// Refresh blocks list
fetchBlocks();
} else {
const data = await response.json();
toast({
title: 'Error',
description: data.error || 'Failed to create block',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error creating block:', error);
toast({
title: 'Error',
description: 'Failed to create block',
variant: 'destructive',
});
} finally {
setSubmitting(false);
}
};
const handleEditClick = (block: CourtBlock) => {
setEditingBlock(block);
setEditCourt(block.courtId || 'all');
setEditDate(block.date);
setEditStartTime(block.startTime);
setEditEndTime(block.endTime);
setEditReason(block.reason);
};
const handleEditSave = async () => {
if (!editingBlock) return;
if (!editDate || !editReason.trim()) {
toast({
title: 'Validation Error',
description: 'Please fill in all required fields',
variant: 'destructive',
});
return;
}
if (editStartTime >= editEndTime) {
toast({
title: 'Validation Error',
description: 'End time must be after start time',
variant: 'destructive',
});
return;
}
setSubmitting(true);
try {
const response = await fetch(`/api/admin/blocks/${editingBlock.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
courtId: editCourt === 'all' ? null : editCourt,
date: editDate,
startTime: editStartTime,
endTime: editEndTime,
reason: editReason.trim(),
}),
});
if (response.ok) {
toast({
title: 'Success',
description: 'Block updated successfully',
});
setEditingBlock(null);
fetchBlocks();
} else {
const data = await response.json();
toast({
title: 'Error',
description: data.error || 'Failed to update block',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error updating block:', error);
toast({
title: 'Error',
description: 'Failed to update block',
variant: 'destructive',
});
} finally {
setSubmitting(false);
}
};
const handleDeleteBlock = async (blockId: string) => {
try {
const response = await fetch(`/api/admin/blocks/${blockId}`, {
method: 'DELETE',
});
if (response.ok) {
toast({
title: 'Success',
description: 'Block removed successfully',
});
fetchBlocks();
} else {
const data = await response.json();
toast({
title: 'Error',
description: data.error || 'Failed to delete block',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error deleting block:', error);
toast({
title: 'Error',
description: 'Failed to delete block',
variant: 'destructive',
});
}
};
// Get min date (today) and max date (e.g., 12 weeks from now)
const today = new Date();
const minDate = today.toISOString().split('T')[0];
const maxDate = new Date(today.getTime() + 84 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // 12 weeks
// Format date for display
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-IE', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
// Check if a block is in the past
const isBlockPast = (dateStr: string) => {
const blockDate = new Date(dateStr);
blockDate.setHours(23, 59, 59, 999);
return blockDate < new Date();
};
// Sort blocks by date
const sortedBlocks = [...blocks].sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateA.getTime() - dateB.getTime();
});
return (
<div className='space-y-6'>
{/* Create Block Form */}
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<CalendarX className='h-5 w-5' />
Block Courts/Hall
</CardTitle>
<CardDescription>
Block court availability for tournaments, AGM, maintenance, or other events. Blocked slots will
be visible to members but cannot be booked.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateBlock} className='space-y-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{/* Court Selection */}
<div className='space-y-2'>
<Label htmlFor='court'>Court</Label>
<Select value={selectedCourt} onValueChange={setSelectedCourt}>
<SelectTrigger>
<SelectValue placeholder='Select court' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Courts (Entire Hall)</SelectItem>
{courts.map((court) => (
<SelectItem key={court.id} value={court.id}>
{court.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Date Selection */}
<div className='space-y-2'>
<Label htmlFor='date'>Date</Label>
<Input
id='date'
type='date'
value={blockDate}
onChange={(e) => setBlockDate(e.target.value)}
min={minDate}
max={maxDate}
required
/>
</div>
{/* Reason */}
<div className='space-y-2 md:col-span-2 lg:col-span-1'>
<Label htmlFor='reason'>Reason</Label>
<Input
id='reason'
type='text'
placeholder='e.g., Tournament, AGM, Maintenance'
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={200}
required
/>
</div>
</div>
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
{/* Start Time */}
<div className='space-y-2'>
<Label htmlFor='startTime'>Start Time</Label>
<Select value={startTime} onValueChange={setStartTime}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* End Time */}
<div className='space-y-2'>
<Label htmlFor='endTime'>End Time</Label>
<Select value={endTime} onValueChange={setEndTime}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Create Announcement Toggle */}
<div className='col-span-2 flex items-center justify-between p-3 bg-muted/50 rounded-lg'>
<div className='flex items-center gap-2'>
<Bell className='h-4 w-4 text-muted-foreground' />
<Label htmlFor='createAnnouncement' className='text-sm cursor-pointer'>
Create announcement
</Label>
</div>
<Switch
id='createAnnouncement'
checked={createAnnouncement}
onCheckedChange={setCreateAnnouncement}
/>
</div>
</div>
{createAnnouncement && (
<p className='text-xs text-muted-foreground'>
An announcement will be created and automatically expire when the block ends.
</p>
)}
{/* Submit Button */}
<Button type='submit' disabled={submitting} className='w-full md:w-auto'>
<Plus className='h-4 w-4 mr-2' />
{submitting ? 'Creating...' : 'Create Block'}
</Button>
</form>
</CardContent>
</Card>
{/* Existing Blocks List */}
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Ban className='h-5 w-5' />
Scheduled Blocks
</CardTitle>
<CardDescription>
Upcoming and current court blocks. Past blocks are shown for reference.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className='text-center py-8 text-muted-foreground'>Loading blocks...</div>
) : sortedBlocks.length === 0 ? (
<div className='text-center py-8 text-muted-foreground'>
No blocks scheduled. Create a block above to reserve courts for events.
</div>
) : (
<>
{/* Desktop Table */}
<div className='hidden md:block overflow-x-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Court</TableHead>
<TableHead>Time</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Created By</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedBlocks.map((block) => (
<TableRow
key={block.id}
className={isBlockPast(block.date) ? 'opacity-50' : ''}
>
<TableCell>
<div className='flex items-center gap-2'>
<Calendar className='h-4 w-4 text-muted-foreground' />
{formatDate(block.date)}
{isBlockPast(block.date) && (
<Badge variant='outline' className='text-xs'>
Past
</Badge>
)}
</div>
</TableCell>
<TableCell>
{block.courtName ? (
<Badge variant='secondary'>{block.courtName}</Badge>
) : (
<Badge variant='destructive'>All Courts</Badge>
)}
</TableCell>
<TableCell>
{block.startTime} - {block.endTime}
</TableCell>
<TableCell className='max-w-[200px] truncate' title={block.reason}>
{block.reason}
</TableCell>
<TableCell>{block.creatorName}</TableCell>
<TableCell className='text-right'>
<div className='flex items-center justify-end gap-1'>
{!isBlockPast(block.date) && (
<Button
variant='ghost'
size='sm'
onClick={() => handleEditClick(block)}
>
<Edit className='h-4 w-4' />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
>
<Trash2 className='h-4 w-4' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Block?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the block for{' '}
{formatDate(block.date)} ({block.reason}).
Members will be able to book this time slot
again.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteBlock(block.id)}
>
Remove Block
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Mobile Cards */}
<div className='md:hidden space-y-4'>
{sortedBlocks.map((block) => (
<Card key={block.id} className={isBlockPast(block.date) ? 'opacity-50' : ''}>
<CardContent className='pt-4'>
<div className='flex justify-between items-start mb-2'>
<div className='flex items-center gap-2'>
<Calendar className='h-4 w-4 text-muted-foreground' />
<span className='font-medium'>{formatDate(block.date)}</span>
{isBlockPast(block.date) && (
<Badge variant='outline' className='text-xs'>
Past
</Badge>
)}
</div>
<div className='flex items-center gap-1'>
{!isBlockPast(block.date) && (
<Button
variant='ghost'
size='sm'
onClick={() => handleEditClick(block)}
>
<Edit className='h-4 w-4' />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
>
<Trash2 className='h-4 w-4' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Block?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the block for{' '}
{formatDate(block.date)} ({block.reason}).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteBlock(block.id)}
>
Remove Block
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className='space-y-1 text-sm'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground'>Court:</span>
{block.courtName ? (
<Badge variant='secondary'>{block.courtName}</Badge>
) : (
<Badge variant='destructive'>All Courts</Badge>
)}
</div>
<div>
<span className='text-muted-foreground'>Time: </span>
{block.startTime} - {block.endTime}
</div>
<div>
<span className='text-muted-foreground'>Reason: </span>
{block.reason}
</div>
<div className='text-xs text-muted-foreground'>
Created by {block.creatorName}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</>
)}
</CardContent>
</Card>
{/* Edit Block Dialog */}
<Dialog open={!!editingBlock} onOpenChange={(open) => !open && setEditingBlock(null)}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Edit Block</DialogTitle>
<DialogDescription>Modify the court closure details.</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='edit-court'>Court</Label>
<Select value={editCourt || 'all'} onValueChange={setEditCourt}>
<SelectTrigger>
<SelectValue placeholder='Select a court' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Courts</SelectItem>
{courts.map((court) => (
<SelectItem key={court.id} value={court.id.toString()}>
{court.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit-date'>Date</Label>
<Input
id='edit-date'
type='date'
value={editDate}
onChange={(e) => setEditDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-2'>
<Label htmlFor='edit-start-time'>Start Time</Label>
<Select value={editStartTime} onValueChange={setEditStartTime}>
<SelectTrigger>
<SelectValue placeholder='Start time' />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit-end-time'>End Time</Label>
<Select value={editEndTime} onValueChange={setEditEndTime}>
<SelectTrigger>
<SelectValue placeholder='End time' />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit-reason'>Reason</Label>
<Input
id='edit-reason'
type='text'
value={editReason}
onChange={(e) => setEditReason(e.target.value)}
placeholder='e.g., Tournament, Maintenance'
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setEditingBlock(null)}>
Cancel
</Button>
<Button onClick={handleEditSave} disabled={submitting}>
{submitting ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+33 -7
View File
@@ -19,9 +19,10 @@ import {
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast';
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw } from 'lucide-react';
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw, Ban } from 'lucide-react';
import { AdminBlocksManagement } from './AdminBlocksManagement';
interface Court {
id: string;
@@ -219,7 +220,7 @@ export function AdminCourtManagement() {
return (
<Card>
<CardHeader>
<CardTitle>Court Management</CardTitle>
<CardTitle>Courts & Closures</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-4'>
@@ -236,6 +237,20 @@ export function AdminCourtManagement() {
}
return (
<div className='space-y-6'>
<Tabs defaultValue='courts' className='w-full'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='courts' className='flex items-center gap-2'>
<MapPin className='h-4 w-4' />
Courts
</TabsTrigger>
<TabsTrigger value='closures' className='flex items-center gap-2'>
<Ban className='h-4 w-4' />
Closures
</TabsTrigger>
</TabsList>
<TabsContent value='courts'>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2'>
@@ -256,7 +271,9 @@ export function AdminCourtManagement() {
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingCourt ? 'Edit Court' : 'Create New Court'}</DialogTitle>
<DialogTitle>
{editingCourt ? 'Edit Court' : 'Create New Court'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className='space-y-4'>
<div>
@@ -319,7 +336,8 @@ export function AdminCourtManagement() {
<div>
<h3 className='font-medium'>{court.name}</h3>
<p className='text-sm text-gray-500'>
Created {new Date(court.createdAt).toLocaleDateString('en-IE')}
Created{' '}
{new Date(court.createdAt).toLocaleDateString('en-IE')}
</p>
</div>
</div>
@@ -366,8 +384,9 @@ export function AdminCourtManagement() {
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}?
This action cannot be undone.
Are you sure you want to delete{' '}
{courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -382,5 +401,12 @@ export function AdminCourtManagement() {
</AlertDialogContent>
</AlertDialog>
</Card>
</TabsContent>
<TabsContent value='closures'>
<AdminBlocksManagement />
</TabsContent>
</Tabs>
</div>
);
}
+95 -1
View File
@@ -30,6 +30,7 @@ interface User {
email: string;
role: 'user' | 'admin';
createdAt: string;
lastBookingDate: string | null;
}
interface UserFormData {
@@ -387,6 +388,8 @@ export function AdminUserManagement() {
<CardTitle>All Users ({filteredUsers.length})</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop Table */}
<div className='hidden md:block'>
<Table>
<TableHeader>
<TableRow>
@@ -394,6 +397,7 @@ export function AdminUserManagement() {
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Played</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
@@ -420,9 +424,26 @@ export function AdminUserManagement() {
{new Date(user.createdAt).toLocaleDateString('en-IE')}
</div>
</TableCell>
<TableCell>
{user.lastBookingDate ? (
<div className='flex items-center gap-2'>
<Calendar className='h-4 w-4 text-green-600' />
{new Date(user.lastBookingDate).toLocaleDateString('en-IE')}
</div>
) : (
<div className='flex items-center gap-2 text-gray-500'>
<Calendar className='h-4 w-4' />
Never played
</div>
)}
</TableCell>
<TableCell>
<div className='flex gap-2'>
<Button variant='outline' size='sm' onClick={() => openEditDialog(user)}>
<Button
variant='outline'
size='sm'
onClick={() => openEditDialog(user)}
>
<Edit className='h-4 w-4' />
</Button>
<Button
@@ -439,6 +460,79 @@ export function AdminUserManagement() {
))}
</TableBody>
</Table>
</div>
{/* Mobile Card Layout */}
<div className='md:hidden space-y-4'>
{filteredUsers.map((user) => (
<Card key={user.id} className='p-4'>
<div className='space-y-3'>
<div className='flex items-start justify-between'>
<div>
<h3 className='font-medium text-sm'>
{user.name} {user.surname}
</h3>
<p className='text-xs text-muted-foreground flex items-center gap-1 mt-1'>
<Mail className='h-3 w-3' />
{user.email}
</p>
</div>
<Badge
variant={user.role === 'admin' ? 'default' : 'secondary'}
className='text-xs'
>
{user.role}
</Badge>
</div>
<div className='grid grid-cols-2 gap-3 text-xs'>
<div>
<span className='text-muted-foreground block'>Created</span>
<span className='flex items-center gap-1 mt-1'>
<Calendar className='h-3 w-3 text-gray-500' />
{new Date(user.createdAt).toLocaleDateString('en-IE')}
</span>
</div>
<div>
<span className='text-muted-foreground block'>Last Played</span>
{user.lastBookingDate ? (
<span className='flex items-center gap-1 mt-1'>
<Calendar className='h-3 w-3 text-green-600' />
{new Date(user.lastBookingDate).toLocaleDateString('en-IE')}
</span>
) : (
<span className='flex items-center gap-1 mt-1 text-gray-500'>
<Calendar className='h-3 w-3' />
Never played
</span>
)}
</div>
</div>
<div className='flex gap-2 pt-2'>
<Button
variant='outline'
size='sm'
onClick={() => openEditDialog(user)}
className='flex-1'
>
<Edit className='h-3 w-3 mr-1' />
Edit
</Button>
<Button
variant='outline'
size='sm'
onClick={() => openDeleteDialog(user)}
className='flex-1 text-red-600 hover:text-red-700'
>
<Trash2 className='h-3 w-3 mr-1' />
Delete
</Button>
</div>
</div>
</Card>
))}
</div>
{filteredUsers.length === 0 && (
<div className='text-center py-8 text-gray-500'>
No users found matching your search criteria
+116 -5
View File
@@ -5,7 +5,26 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Users, Calendar, Settings, BarChart3, Bell, Shield, Clock, MapPin, Activity, LogOut } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Users,
Calendar,
Settings,
BarChart3,
Bell,
Shield,
Clock,
MapPin,
Activity,
LogOut,
ArrowLeft,
ChevronDown,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { AdminUserManagement } from './AdminUserManagement';
import { AdminAnnouncementManagement } from './AdminAnnouncementManagement';
@@ -35,6 +54,7 @@ interface RecentBooking {
export function AdminDashboard() {
const router = useRouter();
const [activeTab, setActiveTab] = useState('bookings');
const [stats, setStats] = useState<AdminStats>({
totalUsers: 0,
activeCourts: 0,
@@ -79,13 +99,23 @@ export function AdminDashboard() {
{/* Header */}
<header className='bg-card border-b border-border'>
<div className='container mx-auto px-4'>
<div className='flex items-center justify-between h-16'>
{/* Desktop Layout */}
<div className='hidden md:flex items-center justify-between h-16'>
<div className='flex items-center space-x-4'>
<Shield className='h-6 w-6 text-blue-600 dark:text-blue-400' />
<h1 className='text-xl font-semibold text-foreground'>Admin Dashboard</h1>
</div>
<div className='flex items-center space-x-4'>
<Button
variant='ghost'
size='sm'
onClick={() => router.push('/dashboard')}
className='flex items-center gap-2'
>
<ArrowLeft className='h-4 w-4' />
Back to Booking
</Button>
<Badge variant='secondary'>Administrator</Badge>
<ModeToggle />
<Button variant='ghost' size='sm' onClick={handleLogout}>
@@ -94,6 +124,44 @@ export function AdminDashboard() {
</Button>
</div>
</div>
{/* Mobile Layout */}
<div className='md:hidden py-3'>
{/* Top row */}
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center space-x-2 min-w-0 flex-1'>
<Shield className='h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0' />
<h1 className='text-lg font-semibold text-foreground truncate'>Admin Dashboard</h1>
<Badge variant='secondary' className='flex-shrink-0'>
Admin
</Badge>
</div>
<ModeToggle />
</div>
{/* Bottom row */}
<div className='flex items-center justify-between gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => router.push('/dashboard')}
className='flex items-center gap-1 px-3 py-2 min-h-[36px] touch-manipulation'
>
<ArrowLeft className='h-4 w-4' />
<span className='text-xs font-medium'>Booking</span>
</Button>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='flex items-center gap-1 px-3 py-2 min-h-[36px] touch-manipulation'
>
<LogOut className='h-4 w-4' />
<span className='text-xs font-medium'>Logout</span>
</Button>
</div>
</div>
</div>
</header>
@@ -146,8 +214,9 @@ export function AdminDashboard() {
</div>
{/* Admin Tabs */}
<Tabs defaultValue='bookings' className='space-y-6'>
<TabsList className='grid w-full grid-cols-6'>
<Tabs value={activeTab} onValueChange={setActiveTab} className='space-y-6'>
{/* Desktop Tabs */}
<TabsList className='hidden md:grid w-full grid-cols-6'>
<TabsTrigger value='bookings'>Bookings</TabsTrigger>
<TabsTrigger value='users'>Users</TabsTrigger>
<TabsTrigger value='courts'>Courts</TabsTrigger>
@@ -155,6 +224,48 @@ export function AdminDashboard() {
<TabsTrigger value='announcements'>Announcements</TabsTrigger>
<TabsTrigger value='logs'>Logs</TabsTrigger>
</TabsList>
{/* Mobile Dropdown */}
<div className='md:hidden'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' className='w-full justify-between'>
{activeTab === 'bookings' && 'Bookings'}
{activeTab === 'users' && 'Users'}
{activeTab === 'courts' && 'Courts'}
{activeTab === 'settings' && 'Settings'}
{activeTab === 'announcements' && 'Announcements'}
{activeTab === 'logs' && 'Logs'}
<ChevronDown className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-full'>
<DropdownMenuItem onClick={() => setActiveTab('bookings')}>
<Calendar className='h-4 w-4 mr-2' />
Bookings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActiveTab('users')}>
<Users className='h-4 w-4 mr-2' />
Users
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActiveTab('courts')}>
<MapPin className='h-4 w-4 mr-2' />
Courts
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActiveTab('settings')}>
<Settings className='h-4 w-4 mr-2' />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActiveTab('announcements')}>
<Bell className='h-4 w-4 mr-2' />
Announcements
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActiveTab('logs')}>
<Activity className='h-4 w-4 mr-2' />
Logs
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<TabsContent value='bookings'>
<AdminRecentBookings />
</TabsContent>
@@ -163,7 +274,7 @@ export function AdminDashboard() {
</TabsContent>
<TabsContent value='courts'>
<AdminCourtManagement />
</TabsContent>{' '}
</TabsContent>
<TabsContent value='settings'>
<div className='space-y-6'>
<AdminSettingsManagement />
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User } from 'lucide-react';
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User, Ban } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface Court {
@@ -40,6 +40,8 @@ interface BookingSlot {
bookingId?: string;
bookedBy?: string;
partner?: string;
blocked?: boolean;
blockReason?: string;
}
interface TimeSlot {
@@ -50,6 +52,15 @@ interface TimeSlot {
isActive: boolean;
}
interface CourtBlock {
id: string;
courtId: string | null;
date: string;
startTime: string;
endTime: string;
reason: string;
}
interface Settings {
booking_window_days: string;
booking_start_time: string;
@@ -63,6 +74,7 @@ export function EnhancedBookingCalendar() {
const [bookings, setBookings] = useState<Booking[]>([]);
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const [blocks, setBlocks] = useState<CourtBlock[]>([]);
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(false);
const [partnerName, setPartnerName] = useState('');
@@ -75,6 +87,7 @@ export function EnhancedBookingCalendar() {
fetchSettings();
fetchCourts();
fetchTimeSlots();
fetchBlocks();
}, []);
useEffect(() => {
@@ -156,6 +169,20 @@ export function EnhancedBookingCalendar() {
}
};
// Fetch court blocks (closures)
const fetchBlocks = async () => {
try {
const response = await fetch('/api/blocks');
if (response.ok) {
const data = await response.json();
setBlocks(data.blocks || []);
}
} catch (error) {
console.error('Error fetching blocks:', error);
// If blocks fetch fails, just proceed without block data
}
};
const fetchBookings = async () => {
try {
const dateStr = selectedDate.toISOString().split('T')[0];
@@ -236,6 +263,9 @@ export function EnhancedBookingCalendar() {
const timeSlots = generateTimeSlots();
const slots: BookingSlot[] = [];
// Get blocks for the selected date
const dateBlocks = blocks.filter((block) => block.date === dateStr);
courts.forEach((court) => {
timeSlots.forEach((time) => {
const existingBooking = existingBookings.find(
@@ -246,6 +276,17 @@ export function EnhancedBookingCalendar() {
booking.status === 'active'
);
// Check if this time slot is blocked
const slotHour = parseInt(time.split(':')[0]);
const blockingBlock = dateBlocks.find((block) => {
const blockStartHour = parseInt(block.startTime.split(':')[0]);
const blockEndHour = parseInt(block.endTime.split(':')[0]);
const isTimeInBlock = slotHour >= blockStartHour && slotHour < blockEndHour;
// Block applies if it's for this specific court or for all courts (courtId null/undefined/empty)
const appliesToCourt = !block.courtId || block.courtId === court.id;
return isTimeInBlock && appliesToCourt;
});
const bookedBy = existingBooking?.user
? `${existingBooking.user.name} ${existingBooking.user.surname}`
: undefined;
@@ -256,10 +297,12 @@ export function EnhancedBookingCalendar() {
time,
courtId: court.id,
courtName: court.name,
available: !existingBooking,
available: !existingBooking && !blockingBlock,
bookingId: existingBooking?.id,
bookedBy,
partner,
blocked: !!blockingBlock,
blockReason: blockingBlock?.reason,
});
});
});
@@ -302,6 +345,15 @@ export function EnhancedBookingCalendar() {
};
const handleSlotClick = (slot: BookingSlot) => {
if (slot.blocked) {
toast({
title: 'Slot Blocked',
description: slot.blockReason || 'This slot is blocked for an event',
variant: 'destructive',
});
return;
}
if (!slot.available) return;
// Double-check that this day is actually bookable
@@ -570,7 +622,9 @@ export function EnhancedBookingCalendar() {
<div
key={`${slot.courtId}-${slot.time}`}
className={`p-3 border rounded-lg transition-all duration-200 ${
slot.available
slot.blocked
? 'border-orange-300 bg-orange-50 cursor-not-allowed dark:border-orange-700 dark:bg-orange-950/50'
: slot.available
? 'border-green-200 bg-green-50 hover:bg-green-100 hover:shadow-sm cursor-pointer dark:border-green-700 dark:bg-green-950 dark:hover:bg-green-900'
: 'border-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100'
}`}
@@ -582,7 +636,15 @@ export function EnhancedBookingCalendar() {
<MapPin className='h-4 w-4' />
{slot.courtName}
</div>
{!slot.available && slot.bookedBy && (
{slot.blocked && (
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
<Ban className='h-3 w-3' />
{slot.blockReason || 'Blocked'}
</div>
)}
{!slot.blocked &&
!slot.available &&
slot.bookedBy && (
<div className='space-y-1'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Users className='h-3 w-3' />
@@ -596,7 +658,9 @@ export function EnhancedBookingCalendar() {
)}
</div>
)}
{!slot.available && !slot.bookedBy && (
{!slot.blocked &&
!slot.available &&
!slot.bookedBy && (
<div className='text-xs text-muted-foreground'>
Already booked
</div>
@@ -604,17 +668,27 @@ export function EnhancedBookingCalendar() {
</div>
<Button
size='sm'
disabled={!slot.available}
disabled={!slot.available || slot.blocked}
variant={
slot.available ? 'default' : 'secondary'
slot.blocked
? 'outline'
: slot.available
? 'default'
: 'secondary'
}
className={
slot.available
slot.blocked
? 'border-orange-400 text-orange-600 dark:border-orange-600 dark:text-orange-400 cursor-not-allowed'
: slot.available
? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 border-0'
: 'opacity-50 cursor-not-allowed'
}
>
{slot.available ? 'Book' : 'Booked'}
{slot.blocked
? 'Blocked'
: slot.available
? 'Book'
: 'Booked'}
</Button>
</div>
</div>
+69 -1
View File
@@ -92,7 +92,8 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
return (
<header className='bg-background/80 backdrop-blur-md border-b border-border sticky top-0 z-50'>
<div className='container mx-auto px-4'>
<div className='flex items-center justify-between h-16'>
{/* Desktop Layout */}
<div className='hidden md:flex items-center justify-between h-16'>
<div className='flex items-center space-x-4'>
<div className='flex items-center space-x-2'>
<Calendar className='h-6 w-6 text-primary' />
@@ -131,6 +132,73 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
</Button>
</div>
</div>
{/* Mobile Layout */}
<div className='md:hidden py-3'>
{/* Top row */}
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center space-x-2 min-w-0 flex-1'>
<Calendar className='h-5 w-5 text-primary flex-shrink-0' />
<h1 className='text-lg font-bold text-foreground truncate'>
{appConfig?.clubName || 'TT Booking'}
</h1>
{user.role === 'admin' && (
<Badge variant='secondary' className='flex-shrink-0'>
Admin
</Badge>
)}
</div>
<div className='flex items-center space-x-2 flex-shrink-0'>
<NotificationBell unreadCount={unreadCount} onClick={() => setShowAnnouncements(true)} />
<ModeToggle />
</div>
</div>
{/* Bottom row */}
<div className='flex items-center justify-between gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => setShowUserProfile(true)}
className='flex items-center space-x-2 min-w-0 flex-1 justify-start'
>
<User className='h-4 w-4 text-muted-foreground flex-shrink-0' />
<span className='text-sm text-foreground truncate'>
{user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}
</span>
</Button>
<div className='flex items-center gap-2 flex-shrink-0'>
{user.role === 'admin' && (
<Button
variant='ghost'
size='sm'
onClick={() => {
console.log('Admin button clicked');
router.push('/admin');
}}
className='flex items-center gap-1 px-3 py-2 min-h-[36px] touch-manipulation'
title='Admin Panel'
>
<Settings className='h-4 w-4' />
<span className='text-xs font-medium'>Admin</span>
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={handleLogout}
disabled={isLoggingOut}
className='flex items-center gap-1 px-2'
title={isLoggingOut ? 'Logging out...' : 'Logout'}
>
<LogOut className='h-4 w-4' />
<span className='text-xs'>{isLoggingOut ? 'Out' : 'Logout'}</span>
</Button>
</div>
</div>
</div>
</div>
{/* Announcements Modal */}
+22
View File
@@ -0,0 +1,22 @@
# Required Environment Variables for TT Booking
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=your-secret-key-here
# Your application URL
NEXTAUTH_URL=http://localhost:3000
# Admin user credentials (change these!)
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=your-secure-password
# Optional: Email configuration for notifications
EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-gmail-app-password
# Optional: Rate limiting (defaults shown)
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW=900000
# Optional: Logging
LOG_LEVEL=info
+54
View File
@@ -0,0 +1,54 @@
# Sample Docker Compose for TT Booking
# Copy this file and create a .env file with your settings
services:
tt-booking:
image: your-registry/tt-booking:latest
container_name: tt-booking
ports:
- "3000:3000"
environment:
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@tabletennis.com}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
# Optional email settings
- EMAIL_USER=${EMAIL_USER}
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
# Optional rate limiting
- RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-100}
- RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-900000}
volumes:
- ./data:/app/data
- ./backups:/app/backups
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
# Optional: Automated backup service
backup:
image: alpine:latest
container_name: tt-booking-backup
volumes:
- ./data:/data:ro
- ./backups:/backups
environment:
- TZ=UTC
command: >
sh -c "
apk add --no-cache tzdata &&
while true; do
timestamp=$$(date +%Y%m%d-%H%M%S)
echo \"Creating backup at $$timestamp\"
cp /data/sqlite.db \"/backups/sqlite-$$timestamp.db\" 2>/dev/null || echo 'No database file found yet'
# Keep backups for 7 days
find /backups -name 'sqlite-*.db' -mtime +7 -delete 2>/dev/null || true
echo \"Backup completed, sleeping for 24 hours\"
sleep 86400
done"
restart: unless-stopped
depends_on:
- tt-booking
+33 -46
View File
@@ -14,77 +14,64 @@ if [ ! -f .env.production ]; then
exit 1
fi
# Create necessary directories
echo "📁 Creating necessary directories..."
mkdir -p data backups logs
# Set proper permissions
echo "🔒 Setting directory permissions..."
chmod 755 data backups logs
# Pull latest changes (if using git)
if [ -d ".git" ]; then
echo "📦 Pulling latest changes..."
git pull origin main || echo "⚠️ Git pull failed or not needed"
fi
# Setup database
echo "🛠️ Setting up the database..."
npx tsx scripts/setup-database.ts
# Build and deploy with Docker Compose
echo "🐳 Building and starting Docker containers..."
echo "🐳 Building and deploying containers..."
# Stop existing containers
docker-compose -f docker-compose.production.yml down || echo "No existing containers to stop"
docker compose -f docker-compose.production.yml down || echo "No existing containers to stop"
# Build and start containers
docker-compose -f docker-compose.production.yml up -d --build
# Build and start containers (database initialization is automated)
docker compose -f docker-compose.production.yml up -d --build
# Wait for containers to be healthy
echo "⏳ Waiting for containers to be healthy..."
sleep 30
# Check health
echo "🔍 Checking application health..."
for i in {1..10}; do
if curl -f http://localhost:3000/api/health >/dev/null 2>&1; then
# Wait for health check to pass (container has built-in health checks)
echo "⏳ Waiting for application to be ready..."
timeout=60
counter=0
while [ $counter -lt $timeout ]; do
if docker compose -f docker-compose.production.yml ps tt-booking | grep -q "healthy"; then
echo "✅ Application is healthy!"
break
elif [ $i -eq 10 ]; then
echo "❌ Application health check failed after 10 attempts"
elif [ $counter -eq $((timeout-10)) ]; then
echo "❌ Application failed to become healthy within ${timeout}s"
echo "📋 Container logs:"
docker-compose -f docker-compose.production.yml logs tt-booking
docker compose -f docker-compose.production.yml logs --tail=30 tt-booking
exit 1
else
echo "Attempt $i/10: Application not ready yet, waiting..."
sleep 10
echo "Waiting for health check... (${counter}s/${timeout}s)"
sleep 5
counter=$((counter+5))
fi
done
# Show running containers
echo "📊 Running containers:"
docker-compose -f docker-compose.production.yml ps
# Show deployment status
echo "📊 Deployment Status:"
docker compose -f docker-compose.production.yml ps tt-booking
# Show logs
echo "📋 Recent application logs:"
docker-compose -f docker-compose.production.yml logs --tail=20 tt-booking
docker compose -f docker-compose.production.yml logs --tail=10 tt-booking
echo ""
echo "🎉 Deployment completed successfully!"
echo ""
echo "📊 Application Status:"
echo " URL: https://lcc-tt-booking.mikicvi.com"
echo " • Health Check: http://localhost:3000/api/health"
echo " • Container Status: $(docker-compose -f docker-compose.production.yml ps -q tt-booking | xargs docker inspect -f '{{.State.Status}}')"
echo "📊 Application Details:"
echo " - URL: https://lcc-tt-booking.mikicvi.com"
echo " - Local: http://localhost:3036"
echo " - Health: http://localhost:3036/api/health"
echo ""
echo "🔧 Useful commands:"
echo " • View logs: docker-compose -f docker-compose.production.yml logs -f tt-booking"
echo " Restart: docker-compose -f docker-compose.production.yml restart tt-booking"
echo " Stop: docker-compose -f docker-compose.production.yml down"
echo "🔧 Management commands:"
echo " - Logs: docker compose -f docker-compose.production.yml logs -f tt-booking"
echo " - Restart: docker compose -f docker-compose.production.yml restart tt-booking"
echo " - Stop: docker compose -f docker-compose.production.yml down"
echo " - Shell: docker compose -f docker-compose.production.yml exec tt-booking sh"
echo ""
echo "⚠️ Don't forget to:"
echo " 1. Set up Cloudflare Tunnel to expose your application"
echo " 2. Update your .env.production with real email credentials"
echo " 3. Change the default admin password"
echo "⚠️ Post-deployment checklist:"
echo " - Cloudflare Tunnel is configured and running"
echo " - Admin password changed from default"
echo " - Email settings configured in .env.production"
echo ""
+1 -8
View File
@@ -1,20 +1,13 @@
version: '3.8'
services:
tt-booking:
build:
context: .
dockerfile: Dockerfile
dockerfile: Dockerfile.production
container_name: lcc-tt-booking
ports:
- '3036:3000'
env_file:
- .env.production
environment:
- NODE_ENV=production
- DATABASE_URL=/app/data/sqlite.db
- NEXTAUTH_URL=https://lcc-tt-booking.mikicvi.com
- PORT=3000
volumes:
- ./data:/app/data
- ./backups:/app/backups
-32
View File
@@ -1,32 +0,0 @@
version: '3.8'
services:
tt-booking:
build: .
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=/app/data/sqlite.db
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=your-secret-key-here-make-this-very-long-and-random
- EMAIL_USER=your-email@gmail.com
- EMAIL_PASSWORD=your-app-password-here
- ADMIN_EMAIL=admin@example.com
- ADMIN_PASSWORD=admin123
volumes:
- ./data:/app/data
restart: unless-stopped
# Nginx reverse proxy (optional, for production deployment)
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- tt-booking
restart: unless-stopped
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
set -e
echo "🚀 Starting TT Booking Production..."
# Create required directories if they don't exist
echo "📁 Ensuring directories exist..."
mkdir -p /app/data /app/backups /app/logs
chmod 755 /app/data /app/backups /app/logs
# Set defaults for environment variables
export DATABASE_URL="${DATABASE_URL:-/app/data/sqlite.db}"
export NODE_ENV="${NODE_ENV:-production}"
export ADMIN_EMAIL="${ADMIN_EMAIL:-admin@tabletennis.com}"
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
# Validate required environment variables
if [ -z "$NEXTAUTH_SECRET" ]; then
echo "❌ NEXTAUTH_SECRET is required but not set"
echo "💡 Generate one with: openssl rand -base64 32"
exit 1
fi
if [ -z "$NEXTAUTH_URL" ]; then
echo "❌ NEXTAUTH_URL is required but not set"
echo "💡 Set to your application URL, e.g., https://your-domain.com"
exit 1
fi
# Check database state and determine what actions are needed
echo "📊 Analyzing database state..."
DB_CHECK_EXIT=0
node dist/check-database.js || DB_CHECK_EXIT=$?
case $DB_CHECK_EXIT in
0)
echo "✅ Database is ready - no action needed"
;;
1)
echo "🔧 Database needs migration..."
npm run db:push
echo "✅ Migration completed"
;;
2)
echo "🌱 Database needs seeding..."
echo " Admin Email: $ADMIN_EMAIL"
echo " Admin Password: [HIDDEN]"
node dist/setup-database.js --essential-only
echo "✅ Seeding completed"
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
;;
3)
echo "🔄 Database needs migration and seeding..."
npm run db:push
echo "✅ Migration completed"
echo "🌱 Seeding database..."
echo " Admin Email: $ADMIN_EMAIL"
echo " Admin Password: [HIDDEN]"
node dist/setup-database.js --essential-only
echo "✅ Database initialization completed"
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
;;
4)
echo "❌ Database state check failed - see logs above"
exit 1
;;
*)
echo "❌ Unexpected database check result: $DB_CHECK_EXIT"
exit 1
;;
esac
echo "🌟 Starting server..."
# Execute the main command
exec "$@"
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
set -e
echo "🚀 Starting TT Booking Production..."
# Create required directories if they don't exist
echo "📁 Ensuring directories exist..."
mkdir -p /app/data /app/backups /app/logs
chmod 755 /app/data /app/backups /app/logs
# Set defaults for environment variables
export DATABASE_URL="${DATABASE_URL:-/app/data/sqlite.db}"
export NODE_ENV="${NODE_ENV:-production}"
export ADMIN_EMAIL="${ADMIN_EMAIL:-admin@tabletennis.com}"
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
# Validate required environment variables
if [ -z "$NEXTAUTH_SECRET" ]; then
echo "❌ NEXTAUTH_SECRET is required but not set"
echo "💡 Generate one with: openssl rand -base64 32"
exit 1
fi
if [ -z "$NEXTAUTH_URL" ]; then
echo "❌ NEXTAUTH_URL is required but not set"
echo "💡 Set to your application URL, e.g., https://your-domain.com"
exit 1
fi
# Check database state and determine what actions are needed
echo " Analyzing database state..."
DB_CHECK_EXIT=0
npx tsx scripts/check-database.ts || DB_CHECK_EXIT=$?
case $DB_CHECK_EXIT in
0)
echo "✅ Database is ready - no action needed"
;;
1)
echo " Database needs migration..."
npm run db:push
echo "✅ Migration completed"
;;
2)
echo "🌱 Database needs seeding..."
echo " Admin Email: $ADMIN_EMAIL"
echo " Admin Password: [HIDDEN]"
npm run db:seed
echo "✅ Seeding completed"
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
;;
3)
echo "🔄 Database needs migration and seeding..."
npm run db:push
echo "✅ Migration completed"
echo "🌱 Seeding database..."
echo " Admin Email: $ADMIN_EMAIL"
echo " Admin Password: [HIDDEN]"
npm run db:seed
echo "✅ Database initialization completed"
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
;;
4)
echo "❌ Database state check failed - see logs above"
exit 1
;;
*)
echo "❌ Unexpected database check result: $DB_CHECK_EXIT"
exit 1
;;
esac
echo "🌟 Starting server..."
# Execute the main command
exec "$@"
+6
View File
@@ -85,6 +85,11 @@ export const ACTIONS = {
TIME_SLOT_UPDATE: 'update_time_slot',
TIME_SLOT_DELETE: 'delete_time_slot',
// Court block actions
BLOCK_CREATE: 'create_block',
BLOCK_UPDATE: 'update_block',
BLOCK_DELETE: 'delete_block',
// System actions
SYSTEM_START: 'system_start',
SYSTEM_ERROR: 'system_error',
@@ -97,5 +102,6 @@ export const ENTITY_TYPES = {
ANNOUNCEMENT: 'announcement',
SETTINGS: 'settings',
TIME_SLOT: 'time_slot',
COURT_BLOCK: 'court_block',
SYSTEM: 'system',
} as const;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `court_blocks` (
`id` text PRIMARY KEY NOT NULL,
`court_id` text,
`date` text NOT NULL,
`start_time` text NOT NULL,
`end_time` text NOT NULL,
`reason` text NOT NULL,
`created_by` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`court_id`) REFERENCES `courts`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE users ADD `theme_preference` text DEFAULT 'system' NOT NULL;
+649
View File
@@ -0,0 +1,649 @@
{
"version": "5",
"dialect": "sqlite",
"id": "cb371f1e-1bfd-4fa2-be7c-a0375bbc11bc",
"prevId": "b6ff7034-4299-4b61-8a16-3b46eae7b4ef",
"tables": {
"activity_logs": {
"name": "activity_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"activity_logs_user_id_users_id_fk": {
"name": "activity_logs_user_id_users_id_fk",
"tableFrom": "activity_logs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"announcements": {
"name": "announcements",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"bookings": {
"name": "bookings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"court_id": {
"name": "court_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"bookings_user_id_users_id_fk": {
"name": "bookings_user_id_users_id_fk",
"tableFrom": "bookings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"bookings_court_id_courts_id_fk": {
"name": "bookings_court_id_courts_id_fk",
"tableFrom": "bookings",
"tableTo": "courts",
"columnsFrom": [
"court_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"court_blocks": {
"name": "court_blocks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"court_id": {
"name": "court_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"court_blocks_court_id_courts_id_fk": {
"name": "court_blocks_court_id_courts_id_fk",
"tableFrom": "court_blocks",
"tableTo": "courts",
"columnsFrom": [
"court_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"court_blocks_created_by_users_id_fk": {
"name": "court_blocks_created_by_users_id_fk",
"tableFrom": "court_blocks",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"courts": {
"name": "courts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"metrics": {
"name": "metrics",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"metric_type": {
"name": "metric_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"period": {
"name": "period",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"settings_key_unique": {
"name": "settings_key_unique",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"time_slots": {
"name": "time_slots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_of_week": {
"name": "day_of_week",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"surname": {
"name": "surname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"theme_preference": {
"name": "theme_preference",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'system'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1758824962110,
"tag": "0001_slimy_starjammers",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1766916904651,
"tag": "0002_thick_makkari",
"breakpoints": true
}
]
}
+18
View File
@@ -104,6 +104,20 @@ export const metrics = sqliteTable('metrics', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Court blocks table for admin-managed closures (tournaments, maintenance, etc.)
export const courtBlocks = sqliteTable('court_blocks', {
id: text('id').primaryKey(),
courtId: text('court_id').references(() => courts.id, { onDelete: 'cascade' }), // NULL means all courts
date: text('date').notNull(), // Format: "YYYY-MM-DD"
startTime: text('start_time').notNull(), // Format: "HH:MM"
endTime: text('end_time').notNull(), // Format: "HH:MM"
reason: text('reason').notNull(), // e.g., "Tournament", "AGM", "Maintenance"
createdBy: text('created_by')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
// Zod schemas for validation
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
@@ -121,6 +135,8 @@ export const insertActivityLogSchema = createInsertSchema(activityLogs);
export const selectActivityLogSchema = createSelectSchema(activityLogs);
export const insertMetricSchema = createInsertSchema(metrics);
export const selectMetricSchema = createSelectSchema(metrics);
export const insertCourtBlockSchema = createInsertSchema(courtBlocks);
export const selectCourtBlockSchema = createSelectSchema(courtBlocks);
// Types
export type User = typeof users.$inferSelect;
@@ -139,3 +155,5 @@ export type ActivityLog = typeof activityLogs.$inferSelect;
export type NewActivityLog = typeof activityLogs.$inferInsert;
export type Metric = typeof metrics.$inferSelect;
export type NewMetric = typeof metrics.$inferInsert;
export type CourtBlock = typeof courtBlocks.$inferSelect;
export type NewCourtBlock = typeof courtBlocks.$inferInsert;
+2 -2
View File
@@ -49,7 +49,7 @@ export async function createSession(payload: Omit<SessionPayload, 'expiresAt'>)
const cookieStore = await cookies();
cookieStore.set('session', session, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: process.env.NODE_ENV === 'production' && process.env.NEXTAUTH_URL?.startsWith('https'),
expires: expiresAt,
sameSite: 'lax',
path: '/',
@@ -70,7 +70,7 @@ export async function updateSession() {
cookieStore.set('session', newSession, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: process.env.NODE_ENV === 'production' && process.env.NEXTAUTH_URL?.startsWith('https'),
expires: expires,
sameSite: 'lax',
path: '/',
-78
View File
@@ -1,78 +0,0 @@
events {
worker_connections 1024;
}
http {
upstream app {
server tt-booking:3000;
}
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" 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:;" always;
server {
listen 80;
server_name your-domain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL configuration
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# API rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app;
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;
}
# Login rate limiting
location /api/auth/login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://app;
proxy_http_version 1.1;
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;
}
# Static files and general requests
location / {
proxy_pass http://app;
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;
}
}
}
+54 -177
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -8,13 +8,14 @@
"start": "next start",
"lint": "next lint",
"db:push": "drizzle-kit push:sqlite",
"db:migrate": "drizzle-kit migrate",
"db:generate": "drizzle-kit generate:sqlite",
"db:studio": "drizzle-kit studio",
"db:init": "mkdir -p data && npm run db:push",
"db:setup": "tsx scripts/setup-database.ts",
"db:reset": "tsx scripts/reset-db.ts",
"db:reset-confirm": "tsx scripts/reset-db.ts --confirm",
"db:seed": "tsx scripts/setup-database.ts --essential-only",
"db:check": "tsx scripts/check-database.ts",
"postinstall": "npm run db:init"
},
"dependencies": {
@@ -41,12 +42,13 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"drizzle-kit": "^0.20.6",
"drizzle-orm": "^0.29.1",
"drizzle-zod": "^0.5.1",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.544.0",
"next": "^15.5.3",
"next": "^15.5.7",
"next-themes": "^0.2.1",
"nodemailer": "^6.9.7",
"react": "^19.1.1",
@@ -55,6 +57,7 @@
"react-hook-form": "^7.62.0",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.20.5",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -66,12 +69,10 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"drizzle-kit": "^0.20.6",
"eslint": "^8",
"eslint-config-next": "^15.5.3",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"tsx": "^4.20.5",
"typescript": "^5"
}
}
+130
View File
@@ -0,0 +1,130 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from '../lib/db/schema';
import { eq } from 'drizzle-orm';
import { existsSync } from 'fs';
interface InitResult {
needsMigration: boolean;
needsSeeding: boolean;
hasData: boolean;
adminExists: boolean;
summary: string;
}
async function checkDatabaseState(): Promise<InitResult> {
const dbPath = process.env.DATABASE_URL || './data/sqlite.db';
const result: InitResult = {
needsMigration: false,
needsSeeding: false,
hasData: false,
adminExists: false,
summary: ''
};
// Check if database file exists
if (!existsSync(dbPath)) {
result.needsMigration = true;
result.needsSeeding = true;
result.summary = 'Database file does not exist - full initialization needed';
return result;
}
let sqlite: Database.Database | null = null;
try {
sqlite = new Database(dbPath);
const db = drizzle(sqlite, { schema });
// Check if core tables exist by trying to query them
try {
const userCount = db.select().from(schema.users).limit(1).all();
const courtCount = db.select().from(schema.courts).limit(1).all();
const settingsCount = db.select().from(schema.settings).limit(1).all();
// Database has tables and some basic structure
result.hasData = userCount.length > 0 || courtCount.length > 0 || settingsCount.length > 0;
// Check for admin users specifically
const adminUsers = db
.select()
.from(schema.users)
.where(eq(schema.users.role, 'admin'))
.limit(1)
.all();
result.adminExists = adminUsers.length > 0;
// Determine what's needed
if (!result.hasData) {
result.needsSeeding = true;
result.summary = 'Database exists with empty tables - seeding needed';
} else if (!result.adminExists) {
result.needsSeeding = true;
result.summary = 'Database has data but no admin users found - admin creation needed';
} else {
result.summary = `Database ready - found ${userCount.length ? 'users' : 'no users'}, ${courtCount.length ? 'courts' : 'no courts'}, admin users present`;
}
} catch (tableError) {
// Tables don't exist or schema is outdated
result.needsMigration = true;
result.needsSeeding = true;
result.summary = 'Database exists but tables missing/outdated - migration and seeding needed';
}
} catch (dbError) {
// Database file exists but is corrupted or inaccessible
result.needsMigration = true;
result.needsSeeding = true;
result.summary = `Database file exists but inaccessible: ${(dbError as Error).message}`;
} finally {
if (sqlite) {
try {
sqlite.close();
} catch (e) {
// Ignore close errors
}
}
}
return result;
}
async function main() {
try {
console.log('🔍 Checking database state...');
const state = await checkDatabaseState();
console.log(`📊 ${state.summary}`);
console.log(` Migration needed: ${state.needsMigration ? '✅' : '❌'}`);
console.log(` Seeding needed: ${state.needsSeeding ? '✅' : '❌'}`);
console.log(` Has existing data: ${state.hasData ? '✅' : '❌'}`);
console.log(` Admin user exists: ${state.adminExists ? '✅' : '❌'}`);
// Output structured result for shell consumption
process.env.DB_NEEDS_MIGRATION = state.needsMigration.toString();
process.env.DB_NEEDS_SEEDING = state.needsSeeding.toString();
process.env.DB_HAS_DATA = state.hasData.toString();
process.env.DB_ADMIN_EXISTS = state.adminExists.toString();
// Exit codes for shell scripting
// 0 = ready, 1 = needs migration, 2 = needs seeding, 3 = needs both
if (state.needsMigration && state.needsSeeding) {
process.exit(3);
} else if (state.needsMigration) {
process.exit(1);
} else if (state.needsSeeding) {
process.exit(2);
} else {
process.exit(0);
}
} catch (error) {
console.error('❌ Database state check failed:', error);
process.exit(4); // Error state
}
}
if (require.main === module) {
main();
}
export { checkDatabaseState, type InitResult };
-101
View File
@@ -1,101 +0,0 @@
#!/bin/bash
# Cleanup old seed scripts and organize database utilities
# This script consolidates old individual seed scripts into the new unified system
echo "🧹 Cleaning up old database scripts..."
# Create a backup directory for old scripts
mkdir -p scripts/old-seeds
# Move old individual seed scripts to backup
echo "📦 Moving old seed scripts to scripts/old-seeds/..."
if [ -f "scripts/seed-data.ts" ]; then
mv scripts/seed-data.ts scripts/old-seeds/
echo " ✓ Moved seed-data.ts"
fi
if [ -f "scripts/seed-announcements.ts" ]; then
mv scripts/seed-announcements.ts scripts/old-seeds/
echo " ✓ Moved seed-announcements.ts"
fi
if [ -f "scripts/seed-time-slots.ts" ]; then
mv scripts/seed-time-slots.ts scripts/old-seeds/
echo " ✓ Moved seed-time-slots.ts"
fi
if [ -f "scripts/init-admin-data.ts" ]; then
mv scripts/init-admin-data.ts scripts/old-seeds/
echo " ✓ Moved init-admin-data.ts"
fi
# Remove old reset script if it exists
if [ -f "scripts/reset-database.ts" ]; then
mv scripts/reset-database.ts scripts/old-seeds/
echo " ✓ Moved old reset-database.ts"
fi
# Create a README in the old-seeds directory
cat > scripts/old-seeds/README.md << 'EOF'
# Old Seed Scripts (Archived)
These are the original individual seed scripts that have been consolidated into the new unified database setup system.
## Consolidated Into:
- **setup-database.ts** - Unified setup script with all functionality
- **reset-db.ts** - Improved reset script with safety features
## Original Scripts:
- `seed-data.ts` - Sample bookings and activity logs
- `seed-announcements.ts` - Test announcements
- `seed-time-slots.ts` - Time slot configuration
- `init-admin-data.ts` - Admin dashboard initialization
- `reset-database.ts` - Original reset script
## Migration Notes:
All functionality from these scripts has been intelligently integrated into the new system:
### New Command Equivalents:
| Old Script | New Command |
|------------|-------------|
| `tsx scripts/seed-data.ts` | `npm run db:setup` |
| `tsx scripts/seed-announcements.ts` | Integrated into setup |
| `tsx scripts/seed-time-slots.ts` | Integrated into setup |
| `tsx scripts/init-admin-data.ts` | Integrated into setup |
| `tsx scripts/reset-database.ts` | `npm run db:reset-confirm` |
### Advantages of New System:
1. **Intelligent Setup** - Automatically handles dependencies and order
2. **Flexible Options** - Essential-only or full sample data modes
3. **Safety Features** - Reset confirmation and detailed logging
4. **Better Documentation** - Comprehensive help and summaries
5. **Single Source** - All database setup in one place
These old scripts are kept for reference but should not be used in new development.
EOF
echo " ✓ Created README in old-seeds directory"
echo ""
echo "✅ Cleanup complete!"
echo ""
echo "📋 Summary of changes:"
echo " • Old seed scripts moved to scripts/old-seeds/"
echo " • New unified system active:"
echo " - setup-database.ts (comprehensive setup)"
echo " - reset-db.ts (safe reset with confirmation)"
echo ""
echo "🚀 New database commands:"
echo " npm run db:setup # Full setup with sample data"
echo " npm run db:seed # Essential data only"
echo " npm run db:reset # Safe reset (shows warning)"
echo " npm run db:reset-confirm # Immediate reset"
echo ""
echo "📖 See DATABASE_SETUP.md for detailed documentation"
-40
View File
@@ -1,40 +0,0 @@
# Old Seed Scripts (Archived)
These are the original individual seed scripts that have been consolidated into the new unified database setup system.
## Consolidated Into:
- **setup-database.ts** - Unified setup script with all functionality
- **reset-db.ts** - Improved reset script with safety features
## Original Scripts:
- `seed-data.ts` - Sample bookings and activity logs
- `seed-announcements.ts` - Test announcements
- `seed-time-slots.ts` - Time slot configuration
- `init-admin-data.ts` - Admin dashboard initialization
- `reset-database.ts` - Original reset script
## Migration Notes:
All functionality from these scripts has been intelligently integrated into the new system:
### New Command Equivalents:
| Old Script | New Command |
|------------|-------------|
| `tsx scripts/seed-data.ts` | `npm run db:setup` |
| `tsx scripts/seed-announcements.ts` | Integrated into setup |
| `tsx scripts/seed-time-slots.ts` | Integrated into setup |
| `tsx scripts/init-admin-data.ts` | Integrated into setup |
| `tsx scripts/reset-database.ts` | `npm run db:reset-confirm` |
### Advantages of New System:
1. **Intelligent Setup** - Automatically handles dependencies and order
2. **Flexible Options** - Essential-only or full sample data modes
3. **Safety Features** - Reset confirmation and detailed logging
4. **Better Documentation** - Comprehensive help and summaries
5. **Single Source** - All database setup in one place
These old scripts are kept for reference but should not be used in new development.
-90
View File
@@ -1,90 +0,0 @@
import { db } from '../lib/db';
import { settings, metrics } from '../lib/db/schema';
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
async function initializeAdminData() {
try {
console.log('Initializing admin dashboard data...');
const now = new Date();
// Initialize default settings
console.log('Setting up default settings...');
const defaultSettings = [
{
id: randomUUID(),
key: 'booking_window_days',
value: '14',
updatedAt: now,
},
{
id: randomUUID(),
key: 'max_booking_duration_hours',
value: '2',
updatedAt: now,
},
{
id: randomUUID(),
key: 'max_bookings_per_user_per_hour_per_day',
value: '1',
updatedAt: now,
},
{
id: randomUUID(),
key: 'allow_booking_modifications',
value: 'true',
updatedAt: now,
},
{
id: randomUUID(),
key: 'booking_modification_hours_before',
value: '2',
updatedAt: now,
},
];
// Insert settings if they don't exist
for (const setting of defaultSettings) {
const existingSetting = await db.select().from(settings).where(eq(settings.key, setting.key)).limit(1);
if (existingSetting.length === 0) {
await db.insert(settings).values(setting);
console.log(`✓ Setting '${setting.key}' initialized with value '${setting.value}'`);
} else {
console.log(`- Setting '${setting.key}' already exists`);
}
}
// Initialize current month's metrics
console.log('Initializing monthly metrics...');
const currentMonth = now.toISOString().substring(0, 7); // "2025-09"
const existingMetric = await db.select().from(metrics).where(eq(metrics.period, currentMonth)).limit(1);
if (existingMetric.length === 0) {
const monthlyMetric = {
id: randomUUID(),
metricType: 'monthly_bookings',
period: currentMonth,
value: 0,
createdAt: now,
updatedAt: now,
};
await db.insert(metrics).values(monthlyMetric);
console.log(`✓ Monthly metrics initialized for ${currentMonth}`);
} else {
console.log(`- Monthly metrics for ${currentMonth} already exist`);
}
console.log('Admin dashboard data initialization complete!');
} catch (error) {
console.error('Error initializing admin data:', error);
throw error;
}
}
initializeAdminData();
-263
View File
@@ -1,263 +0,0 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from '../lib/db/schema';
import { sql } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import bcrypt from 'bcryptjs';
const sqlite = new Database('./sqlite.db');
const db = drizzle(sqlite, { schema });
async function resetDatabase() {
console.log('Resetting database...');
// Drop all tables
const tables = [
'activity_logs',
'bookings',
'announcements',
'time_slots',
'settings',
'courts',
'users',
'__drizzle_migrations',
'__old_push_courts',
'__old_push_users',
];
for (const table of tables) {
try {
await db.run(sql.raw(`DROP TABLE IF EXISTS ${table}`));
console.log(`Dropped table: ${table}`);
} catch (error) {
console.log(`Table ${table} doesn't exist or error dropping:`, error);
}
}
// Create all tables with current schema
console.log('Creating tables...');
// Users table
await db.run(sql`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
surname TEXT NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Courts table
await db.run(sql`
CREATE TABLE courts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Settings table
await db.run(sql`
CREATE TABLE settings (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Time slots table
await db.run(sql`
CREATE TABLE time_slots (
id TEXT PRIMARY KEY,
day_of_week INTEGER NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Bookings table
await db.run(sql`
CREATE TABLE bookings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
court_id TEXT NOT NULL REFERENCES courts(id) ON DELETE CASCADE,
date TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled')),
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Announcements table with all required columns
await db.run(sql`
CREATE TABLE announcements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Activity logs table
await db.run(sql`
CREATE TABLE activity_logs (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
details TEXT,
ip_address TEXT,
user_agent TEXT,
created_at INTEGER NOT NULL
)
`);
console.log('All tables created successfully!');
// Insert seed data
console.log('Inserting seed data...');
const now = Date.now();
// Create admin user
const hashedPassword = await bcrypt.hash('admin123', 12);
await db.insert(schema.users).values({
id: randomUUID(),
email: 'admin@ttbooking.com',
name: 'Admin',
surname: 'User',
password: hashedPassword,
role: 'admin',
createdAt: new Date(now),
updatedAt: new Date(now),
});
// Create test user
const testPassword = await bcrypt.hash('password123', 12);
await db.insert(schema.users).values({
id: randomUUID(),
email: 'user@test.com',
name: 'Test',
surname: 'User',
password: testPassword,
role: 'user',
createdAt: new Date(now),
updatedAt: new Date(now),
});
// Create courts
const court1Id = randomUUID();
const court2Id = randomUUID();
await db.insert(schema.courts).values([
{
id: court1Id,
name: 'Court 1',
isActive: true,
createdAt: new Date(now),
updatedAt: new Date(now),
},
{
id: court2Id,
name: 'Court 2',
isActive: true,
createdAt: new Date(now),
updatedAt: new Date(now),
},
]);
// Insert default settings
await db.insert(schema.settings).values([
{
id: randomUUID(),
key: 'booking_window_days',
value: '7',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'max_booking_duration_hours',
value: '2',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'min_booking_duration_minutes',
value: '30',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'booking_start_time',
value: '08:00',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'booking_end_time',
value: '22:00',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'allow_weekend_bookings',
value: 'true',
updatedAt: new Date(now),
},
]);
// Create time slots for all days (8 AM to 10 PM)
const timeSlotData = [];
for (let day = 0; day < 7; day++) {
for (let hour = 8; hour < 22; hour += 2) {
timeSlotData.push({
id: randomUUID(),
dayOfWeek: day,
startTime: `${hour.toString().padStart(2, '0')}:00`,
endTime: `${(hour + 2).toString().padStart(2, '0')}:00`,
isActive: true,
createdAt: new Date(now),
updatedAt: new Date(now),
});
}
}
await db.insert(schema.timeSlots).values(timeSlotData);
// Create sample announcement
await db.insert(schema.announcements).values({
id: randomUUID(),
title: 'Welcome to Table Tennis Booking System',
content: 'Book your court times easily and manage your games efficiently.',
isActive: true,
priority: 'high',
expiresAt: null,
createdAt: new Date(now),
updatedAt: new Date(now),
});
console.log('Seed data inserted successfully!');
console.log('Database reset complete!');
sqlite.close();
}
resetDatabase().catch(console.error);
-50
View File
@@ -1,50 +0,0 @@
import { db } from '@/lib/db';
import { announcements } from '@/lib/db/schema';
import { randomUUID } from 'crypto';
async function seedAnnouncements() {
try {
const testAnnouncements = [
{
id: randomUUID(),
title: 'Welcome to the New Booking System!',
content:
'We have upgraded our table tennis booking system with new features including mobile support, partner booking, and booking management. Enjoy your games!',
priority: 'high' as const,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: randomUUID(),
title: 'Court Maintenance Schedule',
content:
'Court 2 will be under maintenance this Friday from 2 PM to 4 PM. Please plan your bookings accordingly.',
priority: 'medium' as const,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: randomUUID(),
title: 'New Partnership Feature',
content:
'You can now specify your playing partner when making a booking. This helps other players know who will be using the court.',
priority: 'low' as const,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
for (const announcement of testAnnouncements) {
await db.insert(announcements).values(announcement);
}
console.log('Test announcements created successfully!');
} catch (error) {
console.error('Error creating test announcements:', error);
}
}
seedAnnouncements();
-203
View File
@@ -1,203 +0,0 @@
import { db } from '../lib/db';
import { users, courts as courtsTable, bookings, announcements, activityLogs } from '../lib/db/schema';
import { randomUUID } from 'crypto';
import bcrypt from 'bcryptjs';
async function seedData() {
try {
console.log('Starting data seeding...');
// Get existing users to add sample bookings and activities
const existingUsers = await db.select().from(users);
if (existingUsers.length < 2) {
console.log('Not enough users found. Please run the reset-database script first.');
return;
}
const adminUser = existingUsers.find((u) => u.role === 'admin');
const regularUser = existingUsers.find((u) => u.role === 'user');
const courts = await db.select().from(courtsTable);
if (!adminUser || !regularUser || courts.length === 0) {
console.log('Missing admin user, regular user, or courts. Please run reset-database first.');
return;
}
const now = new Date();
const today = now.toISOString().split('T')[0];
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Add some sample bookings
console.log('Creating sample bookings...');
const sampleBookings = [
{
id: randomUUID(),
userId: regularUser.id,
courtId: courts[0].id,
date: today,
startTime: '19:00',
endTime: '20:00',
status: 'active' as const,
notes: 'Regular evening practice session',
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
updatedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
},
{
id: randomUUID(),
userId: regularUser.id,
courtId: courts[1] ? courts[1].id : courts[0].id,
date: tomorrow,
startTime: '20:00',
endTime: '21:00',
status: 'active' as const,
notes: 'Tournament preparation',
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
updatedAt: new Date(now.getTime() - 1 * 60 * 60 * 1000),
},
];
await db.insert(bookings).values(sampleBookings);
// Add sample activity logs
console.log('Creating sample activity logs...');
const sampleLogs = [
{
id: randomUUID(),
userId: adminUser.id,
action: 'login',
entityType: 'user',
entityId: adminUser.id,
details: JSON.stringify({
email: adminUser.email,
role: adminUser.role,
loginMethod: 'password',
}),
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
createdAt: new Date(now.getTime() - 3 * 60 * 60 * 1000), // 3 hours ago
},
{
id: randomUUID(),
userId: regularUser.id,
action: 'create_booking',
entityType: 'booking',
entityId: sampleBookings[0].id,
details: JSON.stringify({
courtId: courts[0].id,
courtName: courts[0].name,
date: today,
startTime: '19:00',
endTime: '20:00',
}),
ipAddress: '192.168.1.101',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
},
{
id: randomUUID(),
userId: regularUser.id,
action: 'login',
entityType: 'user',
entityId: regularUser.id,
details: JSON.stringify({
email: regularUser.email,
role: regularUser.role,
loginMethod: 'password',
}),
ipAddress: '192.168.1.101',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
createdAt: new Date(now.getTime() - 2.5 * 60 * 60 * 1000), // 2.5 hours ago
},
{
id: randomUUID(),
userId: adminUser.id,
action: 'create_announcement',
entityType: 'announcement',
entityId: null,
details: JSON.stringify({
title: 'System Maintenance',
priority: 'high',
action: 'created_via_seed',
}),
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
},
{
id: randomUUID(),
userId: regularUser.id,
action: 'create_booking',
entityType: 'booking',
entityId: sampleBookings[1].id,
details: JSON.stringify({
courtId: courts[1] ? courts[1].id : courts[0].id,
courtName: courts[1] ? courts[1].name : courts[0].name,
date: tomorrow,
startTime: '20:00',
endTime: '21:00',
}),
ipAddress: '192.168.1.101',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
},
{
id: randomUUID(),
userId: adminUser.id,
action: 'update_settings',
entityType: 'settings',
entityId: null,
details: JSON.stringify({
changedSettings: ['booking_window_days', 'max_booking_duration_hours'],
previousValues: { booking_window_days: '7', max_booking_duration_hours: '2' },
newValues: { booking_window_days: '14', max_booking_duration_hours: '3' },
}),
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
createdAt: new Date(now.getTime() - 30 * 60 * 1000), // 30 minutes ago
},
];
await db.insert(activityLogs).values(sampleLogs);
// Add more announcements for testing
console.log('Creating additional announcements...');
const additionalAnnouncements = [
{
id: randomUUID(),
title: 'New Court Rules',
content: 'Please remember to clean up after your sessions and respect the time limits.',
isActive: true,
priority: 'medium' as const,
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), // 1 week from now
createdAt: new Date(now.getTime() - 4 * 60 * 60 * 1000), // 4 hours ago
updatedAt: new Date(now.getTime() - 4 * 60 * 60 * 1000),
},
{
id: randomUUID(),
title: 'Tournament Sign-ups Open',
content: 'The annual table tennis tournament sign-ups are now open! Register by the end of this month.',
isActive: true,
priority: 'high' as const,
expiresAt: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
createdAt: new Date(now.getTime() - 24 * 60 * 60 * 1000), // 1 day ago
updatedAt: new Date(now.getTime() - 24 * 60 * 60 * 1000),
},
];
await db.insert(announcements).values(additionalAnnouncements);
console.log('Sample data seeding completed successfully!');
console.log(`Created:
- ${sampleBookings.length} sample bookings
- ${sampleLogs.length} activity logs
- ${additionalAnnouncements.length} additional announcements`);
} catch (error) {
console.error('Error seeding data:', error);
}
}
seedData();
-55
View File
@@ -1,55 +0,0 @@
import { db } from '../lib/db';
import { timeSlots } from '../lib/db/schema';
async function seedTimeSlots() {
console.log('🌱 Seeding time slots...');
// Example time slots for different days
const timeSlotData = [
// Sunday: 12:00 - 17:00
{ dayOfWeek: 0, startTime: '12:00', endTime: '17:00' },
// Monday: 19:00 - 23:00
{ dayOfWeek: 1, startTime: '19:00', endTime: '23:00' },
// Tuesday: 19:00 - 23:00
{ dayOfWeek: 2, startTime: '19:00', endTime: '23:00' },
// Wednesday: NO SLOTS (facility closed)
// { dayOfWeek: 3, startTime: '18:00', endTime: '22:00' },
// Thursday: NO SLOTS (facility closed)
// { dayOfWeek: 4, startTime: '19:00', endTime: '23:00' },
// Friday: 18:00 - 22:00
{ dayOfWeek: 5, startTime: '18:00', endTime: '22:00' },
// Saturday: 10:00 - 18:00
{ dayOfWeek: 6, startTime: '10:00', endTime: '18:00' },
];
for (const slot of timeSlotData) {
await db.insert(timeSlots).values({
id: crypto.randomUUID(),
dayOfWeek: slot.dayOfWeek,
startTime: slot.startTime,
endTime: slot.endTime,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
}
console.log('✅ Time slots seeding completed');
}
// Run the seeding function
seedTimeSlots()
.then(() => {
console.log('Time slots seeding process completed');
process.exit(0);
})
.catch((error) => {
console.error('Error during time slots seeding:', error);
process.exit(1);
});
+2 -1
View File
@@ -3,7 +3,8 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from '../lib/db/schema';
import { sql } from 'drizzle-orm';
const sqlite = new Database('./sqlite.db');
const dbPath = process.env.DATABASE_URL || './data/sqlite.db';
const sqlite = new Database(dbPath);
const db = drizzle(sqlite, { schema });
interface ResetOptions {
+1 -1
View File
@@ -598,7 +598,7 @@ async function printDatabaseSummary() {
console.log(`\n⚙️ Settings: ${settings.length} configured`);
console.log('\n💡 Login Credentials:');
console.log(' Admin: admin@tabletennis.com / admin123');
console.log(` Admin: ${process.env.ADMIN_EMAIL || 'admin@tabletennis.com'} / ${process.env.ADMIN_PASSWORD || 'admin123'}`);
console.log(' User: user@tabletennis.com / user123');
console.log('\n🚀 Ready to start! Run: npm run dev');
BIN
View File
Binary file not shown.