diff --git a/backend/migrations/034_users_extend.sql b/backend/migrations/034_users_extend.sql new file mode 100644 index 0000000..06d9849 --- /dev/null +++ b/backend/migrations/034_users_extend.sql @@ -0,0 +1,9 @@ +-- Extend role constraint to support more roles +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +ALTER TABLE users ADD CONSTRAINT users_role_check + CHECK (role IN ('manager', 'housekeeper', 'super_admin', 'receptionist', 'accountant', 'security')); + +-- Add new columns +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50); +ALTER TABLE users ADD COLUMN IF NOT EXISTS position VARCHAR(100); +ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 394eea9..d3bb213 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -14,7 +14,7 @@ const users: FastifyPluginAsync = async (fastify) => { const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug - const USER_FIELDS = 'id, hotel_id, email, name, role, created_at, updated_at' + const USER_FIELDS = 'id, hotel_id, email, name, role, phone, position, active, created_at, updated_at' // ── GET /api/hotels/:slug/users ──────────────────────────────────────────── fastify.get( @@ -41,7 +41,7 @@ const users: FastifyPluginAsync = async (fastify) => { // ── POST /api/hotels/:slug/users ─────────────────────────────────────────── fastify.post( '/api/hotels/:slug/users', { onRequest: [fastify.authenticate] }, @@ -56,20 +56,21 @@ const users: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { email, password, name, role = 'housekeeper' } = request.body + const { email, password, name, role = 'housekeeper', phone, position } = request.body // Managers cannot create other managers or super_admins - if (request.user.role === 'manager' && role !== 'housekeeper') { - return reply.code(403).send({ error: 'Managers can only create housekeepers' }) + const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security'] + if (request.user.role === 'manager' && !managerAllowedRoles.includes(role)) { + return reply.code(403).send({ error: 'Managers cannot create managers or super_admins' }) } const passwordHash = await bcrypt.hash(password, 12) try { const { rows } = await db.query( - `INSERT INTO users (hotel_id, email, password_hash, name, role) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO users (hotel_id, email, password_hash, name, role, phone, position) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING ${USER_FIELDS}`, - [hotelId, email.toLowerCase(), passwordHash, name, role], + [hotelId, email.toLowerCase(), passwordHash, name, role, phone ?? null, position ?? null], ) return reply.code(201).send(rows[0]) } catch (err: unknown) { @@ -84,6 +85,7 @@ const users: FastifyPluginAsync = async (fastify) => { // ── PATCH /api/hotels/:slug/users/:id ───────────────────────────────────── fastify.patch( '/api/hotels/:slug/users/:id', { onRequest: [fastify.authenticate] }, @@ -108,13 +110,27 @@ const users: FastifyPluginAsync = async (fastify) => { if (request.body.email) { updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++ } - if (request.body.role && request.user.role === 'super_admin') { - updates.push(`role = $${idx}`); values.push(request.body.role); idx++ + if (request.body.role) { + // Managers can change role but not to manager/super_admin + const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security'] + if (request.user.role === 'super_admin' || + (request.user.role === 'manager' && managerAllowedRoles.includes(request.body.role))) { + updates.push(`role = $${idx}`); values.push(request.body.role); idx++ + } } if (request.body.password) { const hash = await bcrypt.hash(request.body.password, 12) updates.push(`password_hash = $${idx}`); values.push(hash); idx++ } + if (request.body.phone !== undefined) { + updates.push(`phone = $${idx}`); values.push(request.body.phone || null); idx++ + } + if (request.body.position !== undefined) { + updates.push(`position = $${idx}`); values.push(request.body.position || null); idx++ + } + if (request.body.active !== undefined) { + updates.push(`active = $${idx}`); values.push(request.body.active); idx++ + } if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) updates.push(`updated_at = NOW()`) diff --git a/src/lib/api.ts b/src/lib/api.ts index ae15cab..a63e3b9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -264,10 +264,10 @@ export const api = { list: (slug: string) => req('GET', `/api/hotels/${slug}/users`), - create: (slug: string, data: { email: string; password: string; name: string; role: string }) => + create: (slug: string, data: { email: string; password: string; name: string; role: string; phone?: string; position?: string }) => req('POST', `/api/hotels/${slug}/users`, data), - update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string }>) => + update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string; role: string; phone: string; position: string; active: boolean }>) => req('PATCH', `/api/hotels/${slug}/users/${id}`, data), delete: (slug: string, id: string) => diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6291ee9..3d61baf 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -97,6 +97,14 @@ export function SettingsPage() { if (s.auto_cancel_noshow_hours) setAutoCancelNoShowHours(Number(s.auto_cancel_noshow_hours)) setAutoCheckout(Boolean(s.auto_checkout_enabled)) if (s.auto_checkout_hours) setAutoCheckoutHours(Number(s.auto_checkout_hours)) + setSmtp({ + host: String(s.smtp_host ?? ''), + port: String(s.smtp_port ?? '587'), + user: String(s.smtp_user ?? ''), + password: String(s.smtp_password ?? ''), + fromEmail: String(s.smtp_from_email ?? ''), + fromName: String(s.smtp_from_name ?? ''), + }) setHotelSettingsLoaded(true) }) .catch(console.error) @@ -249,6 +257,20 @@ export function SettingsPage() { console.error('Failed to save hotel settings', err) } } + if (section === 'notify') { + try { + await api.hotelSettings.update(slug, { + smtp_host: smtp.host, + smtp_port: smtp.port, + smtp_user: smtp.user, + smtp_password: smtp.password, + smtp_from_email: smtp.fromEmail, + smtp_from_name: smtp.fromName, + }) + } catch (err) { + console.error('Failed to save SMTP settings', err) + } + } setSaved(true) setTimeout(() => setSaved(false), 2000) } diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 0c44715..f9af9f4 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -174,7 +174,11 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ // ── Helpers ──────────────────────────────────────────────────────────────────── function mapRole(r: string): StaffRole { - return r === 'housekeeper' ? 'housekeeper' : 'hotel_manager' + if (r === 'housekeeper') return 'housekeeper' + if (r === 'receptionist') return 'receptionist' + if (r === 'accountant') return 'accountant' + if (r === 'security') return 'security' + return 'hotel_manager' } function toStaffUser(u: User): StaffUser { @@ -188,9 +192,10 @@ function toStaffUser(u: User): StaffUser { firstName, lastName, email: u.email, + phone: u.phone ?? '', role, - position: DEFAULT_POSITIONS[role]?.[0] ?? '', - isActive: true, + position: u.position ?? DEFAULT_POSITIONS[role]?.[0] ?? '', + isActive: u.active ?? true, createdAt: u.createdAt?.slice(0, 10) ?? '', avatarColor: AVATAR_COLORS[colorIndex], } @@ -701,15 +706,23 @@ export function UsersPage() { const handleSave = async (u: StaffUser, password?: string) => { const fullName = `${u.firstName} ${u.lastName}`.trim() - const backendRole = u.role === 'housekeeper' ? 'housekeeper' : 'manager' + const backendRole = u.role === 'housekeeper' ? 'housekeeper' + : u.role === 'receptionist' ? 'receptionist' + : u.role === 'accountant' ? 'accountant' + : u.role === 'security' ? 'security' + : 'manager' try { if (!u.id) { const created = await api.users.create(slug, { email: u.email, name: fullName, password: password ?? '', role: backendRole, + phone: u.phone || undefined, position: u.position || undefined, }) setUsers(prev => [...prev, toStaffUser(created)]) } else { - const upd: Partial<{ name: string; email: string; password: string }> = { name: fullName, email: u.email } + const upd: Partial<{ name: string; email: string; password: string; role: string; phone: string; position: string; active: boolean }> = { + name: fullName, email: u.email, role: backendRole, + phone: u.phone || undefined, position: u.position || undefined, active: u.isActive, + } if (password) upd.password = password const updated = await api.users.update(slug, u.id, upd) setUsers(prev => prev.map(x => x.id === u.id ? { ...toStaffUser(updated), lastLogin: x.lastLogin } : x)) diff --git a/src/types/index.ts b/src/types/index.ts index d008b01..578b9ad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,9 @@ export interface User { hotelName?: string hotelSlug?: string avatarUrl?: string + phone?: string | null + position?: string | null + active?: boolean createdAt?: string updatedAt?: string }