initial version of the app
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { activityLogs } from '@/lib/db/schema';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export interface ActivityLogData {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
details?: any;
|
||||
request?: NextRequest;
|
||||
}
|
||||
|
||||
export async function logActivity({ userId, action, entityType, entityId, details, request }: ActivityLogData) {
|
||||
try {
|
||||
// Extract IP and User Agent from request if provided
|
||||
let ipAddress: string | null = null;
|
||||
let userAgent: string | null = null;
|
||||
|
||||
if (request) {
|
||||
// Try to get real IP address
|
||||
ipAddress =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
request.headers.get('cf-connecting-ip') ||
|
||||
'127.0.0.1';
|
||||
|
||||
userAgent = request.headers.get('user-agent');
|
||||
}
|
||||
|
||||
await db.insert(activityLogs).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
details: details ? JSON.stringify(details) : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Activity logged: ${action} on ${entityType}${entityId ? ` (${entityId})` : ''} by user ${
|
||||
userId || 'anonymous'
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to log activity:', error);
|
||||
// Don't throw error to avoid breaking the main request
|
||||
}
|
||||
}
|
||||
|
||||
// Predefined action types for consistency
|
||||
export const ACTIONS = {
|
||||
// User actions
|
||||
USER_LOGIN: 'login',
|
||||
USER_LOGOUT: 'logout',
|
||||
USER_REGISTER: 'register',
|
||||
USER_CREATE: 'create_user',
|
||||
USER_UPDATE: 'update_user',
|
||||
USER_DELETE: 'delete_user',
|
||||
|
||||
// Booking actions
|
||||
BOOKING_CREATE: 'create_booking',
|
||||
BOOKING_UPDATE: 'update_booking',
|
||||
BOOKING_CANCEL: 'cancel_booking',
|
||||
BOOKING_DELETE: 'delete_booking',
|
||||
|
||||
// Court actions
|
||||
COURT_CREATE: 'create_court',
|
||||
COURT_UPDATE: 'update_court',
|
||||
COURT_DELETE: 'delete_court',
|
||||
|
||||
// Announcement actions
|
||||
ANNOUNCEMENT_CREATE: 'create_announcement',
|
||||
ANNOUNCEMENT_UPDATE: 'update_announcement',
|
||||
ANNOUNCEMENT_DELETE: 'delete_announcement',
|
||||
|
||||
// Settings actions
|
||||
SETTINGS_UPDATE: 'update_settings',
|
||||
|
||||
// System actions
|
||||
SYSTEM_START: 'system_start',
|
||||
SYSTEM_ERROR: 'system_error',
|
||||
} as const;
|
||||
|
||||
export const ENTITY_TYPES = {
|
||||
USER: 'user',
|
||||
BOOKING: 'booking',
|
||||
COURT: 'court',
|
||||
ANNOUNCEMENT: 'announcement',
|
||||
SETTINGS: 'settings',
|
||||
SYSTEM: 'system',
|
||||
} as const;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateId } from '@/lib/utils';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
export async function createUser(data: {
|
||||
email: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
password: string;
|
||||
role?: 'user' | 'admin';
|
||||
}) {
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
const now = new Date();
|
||||
|
||||
const newUser = {
|
||||
id: generateId(),
|
||||
email: data.email.toLowerCase(),
|
||||
name: data.name,
|
||||
surname: data.surname,
|
||||
password: hashedPassword,
|
||||
role: data.role || 'user',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const [user] = await db.insert(users).values(newUser).returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email.toLowerCase()));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import * as schema from './schema';
|
||||
|
||||
const sqlite = new Database('./sqlite.db');
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
// Run migrations on startup
|
||||
try {
|
||||
migrate(db, { migrationsFolder: './lib/db/migrations' });
|
||||
console.log('Database migrations completed');
|
||||
} catch (error) {
|
||||
console.error('Database migration failed:', error);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":"5","dialect":"sqlite","entries":[]}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Users table
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
surname: text('surname').notNull(),
|
||||
password: text('password').notNull(),
|
||||
role: text('role', { enum: ['user', 'admin'] })
|
||||
.notNull()
|
||||
.default('user'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Courts table
|
||||
export const courts = sqliteTable('courts', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Settings table for admin configuration
|
||||
export const settings = sqliteTable('settings', {
|
||||
id: text('id').primaryKey(),
|
||||
key: text('key').notNull().unique(),
|
||||
value: text('value').notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Time slots configuration
|
||||
export const timeSlots = sqliteTable('time_slots', {
|
||||
id: text('id').primaryKey(),
|
||||
dayOfWeek: integer('day_of_week').notNull(), // 0 = Sunday, 1 = Monday, etc.
|
||||
startTime: text('start_time').notNull(), // Format: "HH:MM"
|
||||
endTime: text('end_time').notNull(), // Format: "HH:MM"
|
||||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Bookings table
|
||||
export const bookings = sqliteTable('bookings', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
courtId: text('court_id')
|
||||
.notNull()
|
||||
.references(() => courts.id, { onDelete: 'cascade' }),
|
||||
date: text('date').notNull(), // Format: "YYYY-MM-DD"
|
||||
startTime: text('start_time').notNull(), // Format: "HH:MM"
|
||||
endTime: text('end_time').notNull(), // Format: "HH:MM"
|
||||
status: text('status', { enum: ['active', 'cancelled'] })
|
||||
.notNull()
|
||||
.default('active'),
|
||||
notes: text('notes'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Announcements table
|
||||
export const announcements = sqliteTable('announcements', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull(),
|
||||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||
priority: text('priority', { enum: ['low', 'medium', 'high'] })
|
||||
.notNull()
|
||||
.default('medium'),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Activity logs for admin transparency
|
||||
export const activityLogs = sqliteTable('activity_logs', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id'),
|
||||
details: text('details'), // JSON string
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertUserSchema = createInsertSchema(users);
|
||||
export const selectUserSchema = createSelectSchema(users);
|
||||
export const insertCourtSchema = createInsertSchema(courts);
|
||||
export const selectCourtSchema = createSelectSchema(courts);
|
||||
export const insertBookingSchema = createInsertSchema(bookings);
|
||||
export const selectBookingSchema = createSelectSchema(bookings);
|
||||
export const insertAnnouncementSchema = createInsertSchema(announcements);
|
||||
export const selectAnnouncementSchema = createSelectSchema(announcements);
|
||||
export const insertTimeSlotSchema = createInsertSchema(timeSlots);
|
||||
export const selectTimeSlotSchema = createSelectSchema(timeSlots);
|
||||
export const insertSettingSchema = createInsertSchema(settings);
|
||||
export const selectSettingSchema = createSelectSchema(settings);
|
||||
export const insertActivityLogSchema = createInsertSchema(activityLogs);
|
||||
export const selectActivityLogSchema = createSelectSchema(activityLogs);
|
||||
|
||||
// Types
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
export type Court = typeof courts.$inferSelect;
|
||||
export type NewCourt = typeof courts.$inferInsert;
|
||||
export type Booking = typeof bookings.$inferSelect;
|
||||
export type NewBooking = typeof bookings.$inferInsert;
|
||||
export type Announcement = typeof announcements.$inferSelect;
|
||||
export type NewAnnouncement = typeof announcements.$inferInsert;
|
||||
export type TimeSlot = typeof timeSlots.$inferSelect;
|
||||
export type NewTimeSlot = typeof timeSlots.$inferInsert;
|
||||
export type Setting = typeof settings.$inferSelect;
|
||||
export type NewSetting = typeof settings.$inferInsert;
|
||||
export type ActivityLog = typeof activityLogs.$inferSelect;
|
||||
export type NewActivityLog = typeof activityLogs.$inferInsert;
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
interface EmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
// Create reusable transporter object using SMTP transport
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASSWORD, // Use App Password for Gmail
|
||||
},
|
||||
});
|
||||
|
||||
export async function sendEmail({ to, subject, html }: EmailOptions) {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: `"Table Tennis Booking" <${process.env.EMAIL_USER}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
console.log('Email sent: %s', info.messageId);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function generateBookingConfirmationEmail(booking: {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
courtName: string;
|
||||
userName: string;
|
||||
}) {
|
||||
return {
|
||||
subject: 'Booking Confirmation - Table Tennis Court',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Booking Confirmed!</h2>
|
||||
<p>Hello ${booking.userName},</p>
|
||||
<p>Your table tennis court booking has been confirmed:</p>
|
||||
|
||||
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Booking Details</h3>
|
||||
<p><strong>Booking ID:</strong> ${booking.id}</p>
|
||||
<p><strong>Date:</strong> ${booking.date}</p>
|
||||
<p><strong>Time:</strong> ${booking.startTime} - ${booking.endTime}</p>
|
||||
<p><strong>Court:</strong> ${booking.courtName}</p>
|
||||
</div>
|
||||
|
||||
<p>Please arrive 5 minutes before your booking time. If you need to cancel or modify your booking, please log in to your account.</p>
|
||||
|
||||
<p>Thank you for choosing our table tennis facility!</p>
|
||||
|
||||
<hr style="margin: 30px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
This is an automated email. Please do not reply to this message.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateBookingCancellationEmail(booking: {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
courtName: string;
|
||||
userName: string;
|
||||
}) {
|
||||
return {
|
||||
subject: 'Booking Cancelled - Table Tennis Court',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #d32f2f;">Booking Cancelled</h2>
|
||||
<p>Hello ${booking.userName},</p>
|
||||
<p>Your table tennis court booking has been cancelled:</p>
|
||||
|
||||
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Cancelled Booking Details</h3>
|
||||
<p><strong>Booking ID:</strong> ${booking.id}</p>
|
||||
<p><strong>Date:</strong> ${booking.date}</p>
|
||||
<p><strong>Time:</strong> ${booking.startTime} - ${booking.endTime}</p>
|
||||
<p><strong>Court:</strong> ${booking.courtName}</p>
|
||||
</div>
|
||||
|
||||
<p>You can make a new booking anytime through our booking system.</p>
|
||||
|
||||
<p>Thank you for using our table tennis facility!</p>
|
||||
|
||||
<hr style="margin: 30px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
This is an automated email. Please do not reply to this message.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const secretKey = process.env.NEXTAUTH_SECRET;
|
||||
const encodedKey = new TextEncoder().encode(secretKey);
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: 'user' | 'admin';
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export async function encrypt(payload: SessionPayload) {
|
||||
return new SignJWT({
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
expiresAt: payload.expiresAt.getTime(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(encodedKey);
|
||||
}
|
||||
|
||||
export async function decrypt(session: string | undefined = '') {
|
||||
try {
|
||||
const { payload } = await jwtVerify(session, encodedKey, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
return {
|
||||
userId: payload.userId as string,
|
||||
email: payload.email as string,
|
||||
role: payload.role as 'user' | 'admin',
|
||||
expiresAt: new Date(payload.expiresAt as number),
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Failed to verify session');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(payload: Omit<SessionPayload, 'expiresAt'>) {
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const session = await encrypt({ ...payload, expiresAt });
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('session', session, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
expires: expiresAt,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSession() {
|
||||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get('session')?.value;
|
||||
const payload = await decrypt(session);
|
||||
|
||||
if (!session || !payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const newSession = await encrypt({ ...payload, expiresAt: expires });
|
||||
|
||||
cookieStore.set('session', newSession, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
expires: expires,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSession() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('session');
|
||||
}
|
||||
|
||||
export async function getSession() {
|
||||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get('session')?.value;
|
||||
return await decrypt(session);
|
||||
}
|
||||
|
||||
export async function verifySession() {
|
||||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get('session')?.value;
|
||||
const payload = await decrypt(session);
|
||||
|
||||
if (!payload) {
|
||||
return { isAuth: false, userId: null, role: null };
|
||||
}
|
||||
|
||||
return { isAuth: true, userId: payload.userId, role: payload.role };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour % 12 || 12;
|
||||
return `${displayHour}:${minutes} ${ampm}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function isWithinBookingWindow(date: string): boolean {
|
||||
const bookingDate = new Date(date);
|
||||
const today = new Date();
|
||||
const maxDate = new Date();
|
||||
maxDate.setDate(today.getDate() + 6); // 7 days including today
|
||||
|
||||
// Reset time to start of day for comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
maxDate.setHours(23, 59, 59, 999);
|
||||
bookingDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return bookingDate >= today && bookingDate <= maxDate;
|
||||
}
|
||||
|
||||
export function getWeekDays(): Array<{ value: number; label: string }> {
|
||||
return [
|
||||
{ value: 0, label: 'Sunday' },
|
||||
{ value: 1, label: 'Monday' },
|
||||
{ value: 2, label: 'Tuesday' },
|
||||
{ value: 3, label: 'Wednesday' },
|
||||
{ value: 4, label: 'Thursday' },
|
||||
{ value: 5, label: 'Friday' },
|
||||
{ value: 6, label: 'Saturday' },
|
||||
];
|
||||
}
|
||||
|
||||
export function generateTimeSlots(startHour: number, endHour: number): string[] {
|
||||
const slots = [];
|
||||
for (let hour = startHour; hour < endHour; hour++) {
|
||||
const hourStr = hour < 10 ? '0' + hour : hour.toString();
|
||||
slots.push(`${hourStr}:00`);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
Reference in New Issue
Block a user