initial version of the app

This commit is contained in:
mikicvi
2025-09-21 17:11:02 +01:00
commit c8062cf96b
101 changed files with 23061 additions and 0 deletions
+95
View File
@@ -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
View File
@@ -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;
}
+15
View File
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
{"version":"5","dialect":"sqlite","entries":[]}
+124
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+62
View File
@@ -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;
}