Compare commits
31 Commits
c829e27bf6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 288767f6c0 | |||
| 9286f3e323 | |||
| 4e514c2fbb | |||
| bfc627f451 | |||
| 60afc2349e | |||
| 9557fa7d07 | |||
| 200784fa49 | |||
| 0785facd9e | |||
| 6c8d70d41c | |||
| 1e66e918ac | |||
| dc0c5fd289 | |||
| 0ca4f961f6 | |||
| 361fd67e49 | |||
| 5b30d0ca24 | |||
| d730961c96 | |||
| 09bbe0a441 | |||
| ee68dc2897 | |||
| d0be23551a | |||
| 223b6e4670 | |||
| b71f66a177 | |||
| f7bb5ed752 | |||
| d9049d2294 | |||
| ae6ea0ea46 | |||
| 72b507e434 | |||
| a8df420f36 | |||
| 4fb97e1f10 | |||
| c98824c8d7 | |||
| 81c12ace7b | |||
| 73be5d2f22 | |||
| 2353198924 | |||
| 7eae73eefb |
@@ -1,3 +1,4 @@
|
||||
# WARNING: Replace CHANGE_ME_* values before applying
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
|
||||
Generated
+548
-45
@@ -11,7 +11,7 @@
|
||||
"@chenglou/pretext": "^0.0.8",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jose": "^5.6.3",
|
||||
"next": "14.2.28",
|
||||
"next": "^14.2.35",
|
||||
"pg": "^8.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"jsdom": "^29.1.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
@@ -318,6 +319,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
@@ -911,15 +1354,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz",
|
||||
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==",
|
||||
"version": "14.2.35",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
|
||||
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
|
||||
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
|
||||
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -933,9 +1376,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
|
||||
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
|
||||
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -949,9 +1392,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
|
||||
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -968,9 +1411,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
|
||||
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -987,9 +1430,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
|
||||
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1006,9 +1449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
|
||||
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1025,9 +1468,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
|
||||
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1041,9 +1484,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
|
||||
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1057,9 +1500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
|
||||
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1914,6 +2357,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
@@ -2411,13 +2896,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz",
|
||||
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==",
|
||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
|
||||
"version": "14.2.35",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
|
||||
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.28",
|
||||
"@next/env": "14.2.35",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -2432,15 +2916,15 @@
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.28",
|
||||
"@next/swc-darwin-x64": "14.2.28",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.28",
|
||||
"@next/swc-linux-arm64-musl": "14.2.28",
|
||||
"@next/swc-linux-x64-gnu": "14.2.28",
|
||||
"@next/swc-linux-x64-musl": "14.2.28",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.28",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.28",
|
||||
"@next/swc-win32-x64-msvc": "14.2.28"
|
||||
"@next/swc-darwin-arm64": "14.2.33",
|
||||
"@next/swc-darwin-x64": "14.2.33",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.33",
|
||||
"@next/swc-linux-arm64-musl": "14.2.33",
|
||||
"@next/swc-linux-x64-gnu": "14.2.33",
|
||||
"@next/swc-linux-x64-musl": "14.2.33",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.33",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.33",
|
||||
"@next/swc-win32-x64-msvc": "14.2.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -3051,6 +3535,25 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@
|
||||
"@chenglou/pretext": "^0.0.8",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jose": "^5.6.3",
|
||||
"next": "14.2.28",
|
||||
"next": "^14.2.35",
|
||||
"pg": "^8.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"jsdom": "^29.1.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Migration script to convert existing JPEG/PNG images to WebP
|
||||
* Run with: npx ts-node scripts/migrate-images-to-webp.ts
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
async function convertToWebP(base64Data: string): Promise<string> {
|
||||
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
console.log(' - Already WebP or invalid format, skipping');
|
||||
return base64Data;
|
||||
}
|
||||
|
||||
const ext = matches[2] ? matches[1] : 'unknown';
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
|
||||
// Convert to WebP
|
||||
const webpBuffer = await sharp(buffer)
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting image migration to WebP...\n');
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Get all images
|
||||
const { rows: images } = await client.query(
|
||||
'SELECT id, filename, data, thumbnail, medium FROM images'
|
||||
);
|
||||
|
||||
console.log(`Found ${images.length} images to process\n`);
|
||||
|
||||
let converted = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const img of images) {
|
||||
console.log(`Processing image ${img.id}: ${img.filename}`);
|
||||
|
||||
try {
|
||||
let needsUpdate = false;
|
||||
const updates: { data?: string; thumbnail?: string; medium?: string } = {};
|
||||
|
||||
// Check if already WebP
|
||||
if (img.data && !img.data.includes('image/webp')) {
|
||||
console.log(' - Converting full image...');
|
||||
updates.data = await convertToWebP(img.data);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
|
||||
console.log(' - Converting thumbnail...');
|
||||
updates.thumbnail = await convertToWebP(img.thumbnail);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.medium && !img.medium.includes('image/webp')) {
|
||||
console.log(' - Converting medium...');
|
||||
updates.medium = await convertToWebP(img.medium);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
// Calculate size savings
|
||||
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
|
||||
const newSize = (updates.data?.length || img.data?.length || 0) +
|
||||
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
|
||||
(updates.medium?.length || img.medium?.length || 0);
|
||||
const savings = ((oldSize - newSize) / oldSize * 100).toFixed(1);
|
||||
const savedKB = ((oldSize - newSize) / 1024).toFixed(1);
|
||||
|
||||
console.log(` - Size reduction: ${savings}% (${savedKB} KB saved)`);
|
||||
|
||||
await client.query(
|
||||
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
|
||||
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
|
||||
);
|
||||
converted++;
|
||||
} else {
|
||||
console.log(' - Already WebP, skipping');
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` - Error: ${(err as Error).message}`);
|
||||
errors++;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('Migration complete!\n');
|
||||
console.log(`Converted: ${converted}`);
|
||||
console.log(`Skipped: ${skipped}`);
|
||||
console.log(`Errors: ${errors}`);
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Migration script to convert existing JPEG/PNG images to WebP
|
||||
* Run from the k8s cluster directly
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const pool = new Pool({
|
||||
host: 'postgres.lahuasca',
|
||||
port: 5432,
|
||||
database: 'lahuasca',
|
||||
user: 'lahuasca',
|
||||
password: (() => {
|
||||
const p = process.env.DB_PASSWORD;
|
||||
if (!p) throw new Error('DB_PASSWORD env var is required');
|
||||
return p;
|
||||
})(),
|
||||
});
|
||||
|
||||
async function convertToWebP(base64Data) {
|
||||
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return base64Data;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
const webpBuffer = await sharp(buffer)
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting image migration to WebP...\n');
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const { rows: images } = await client.query(
|
||||
'SELECT id, filename, data, thumbnail, medium FROM images'
|
||||
);
|
||||
|
||||
console.log(`Found ${images.length} images to process\n`);
|
||||
|
||||
let converted = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
let totalSaved = 0;
|
||||
|
||||
for (const img of images) {
|
||||
console.log(`Processing image ${img.id}: ${img.filename}`);
|
||||
|
||||
try {
|
||||
let needsUpdate = false;
|
||||
const updates = {};
|
||||
|
||||
if (img.data && !img.data.includes('image/webp')) {
|
||||
console.log(' Converting full image...');
|
||||
updates.data = await convertToWebP(img.data);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
|
||||
console.log(' Converting thumbnail...');
|
||||
updates.thumbnail = await convertToWebP(img.thumbnail);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.medium && !img.medium.includes('image/webp')) {
|
||||
console.log(' Converting medium...');
|
||||
updates.medium = await convertToWebP(img.medium);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
|
||||
const newSize = (updates.data?.length || img.data?.length || 0) +
|
||||
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
|
||||
(updates.medium?.length || img.medium?.length || 0);
|
||||
const savedKB = Math.round((oldSize - newSize) / 1024);
|
||||
totalSaved += savedKB;
|
||||
|
||||
console.log(` Saved: ${savedKB} KB`);
|
||||
|
||||
await client.query(
|
||||
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
|
||||
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
|
||||
);
|
||||
converted++;
|
||||
} else {
|
||||
console.log(' Already WebP, skipping');
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` Error: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log(`Converted: ${converted}`);
|
||||
console.log(`Skipped: ${skipped}`);
|
||||
console.log(`Errors: ${errors}`);
|
||||
console.log(`Total saved: ${Math.round(totalSaved / 1024)} MB`);
|
||||
console.log('========================================');
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Database Index Optimization (fixed)
|
||||
-- Run with: psql -U lahuasca -d lahuasca -f add-indexes.sql
|
||||
|
||||
-- Images table indexes (most queried)
|
||||
CREATE INDEX IF NOT EXISTS idx_images_room_id ON images(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_category ON images(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_location_target ON images(location_target);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
|
||||
|
||||
-- Room amenity links (join table)
|
||||
CREATE INDEX IF NOT EXISTS idx_room_amenity_links_amenity_id ON room_amenity_links(amenity_id);
|
||||
|
||||
-- Orders table
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at DESC);
|
||||
|
||||
-- Room reservations
|
||||
CREATE INDEX IF NOT EXISTS idx_room_reservations_room_id ON room_reservations(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_reservations_dates ON room_reservations(check_in, check_out);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_reservations_user_id ON room_reservations(user_id);
|
||||
|
||||
-- Restaurant reservations
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_user_id ON reservations(user_id);
|
||||
|
||||
-- Social event reservations
|
||||
CREATE INDEX IF NOT EXISTS idx_social_event_reservations_event_id ON social_event_reservations(social_event_id);
|
||||
|
||||
-- Contact messages
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_messages_created_at ON contact_messages(created_at DESC);
|
||||
|
||||
-- Room comments
|
||||
CREATE INDEX IF NOT EXISTS idx_room_comments_room_id ON room_comments(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_comments_created_at ON room_comments(created_at DESC);
|
||||
|
||||
-- Messages (chat)
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
||||
|
||||
-- Enable TOAST compression for large TEXT columns in images
|
||||
ALTER TABLE images ALTER COLUMN data SET STORAGE EXTERNAL;
|
||||
ALTER TABLE images ALTER COLUMN thumbnail SET STORAGE EXTERNAL;
|
||||
ALTER TABLE images ALTER COLUMN medium SET STORAGE EXTERNAL;
|
||||
|
||||
-- Vacuum analyze to update statistics
|
||||
ANALYZE images;
|
||||
ANALYZE rooms;
|
||||
ANALYZE orders;
|
||||
ANALYZE room_reservations;
|
||||
ANALYZE reservations;
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Migration: Move room photos from rooms.photos to images table
|
||||
-- Run with: psql -U lahuasca -d lahuasca -f migrate-room-photos.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- First, check which rooms have photos in rooms.photos but not in images
|
||||
-- For rooms 7 and 8, we need to copy their photos to images table
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
photo_data TEXT;
|
||||
room_id_val INTEGER;
|
||||
photo_index INTEGER;
|
||||
BEGIN
|
||||
-- Loop through rooms that have photos in the array but not in images table
|
||||
FOR rec IN
|
||||
SELECT r.id, r.name, r.photos
|
||||
FROM rooms r
|
||||
WHERE array_length(r.photos, 1) > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM images i WHERE i.room_id = r.id
|
||||
)
|
||||
LOOP
|
||||
room_id_val := rec.id;
|
||||
|
||||
-- Insert each photo from the array into images
|
||||
FOR photo_index IN 1..array_length(rec.photos, 1) LOOP
|
||||
photo_data := rec.photos[photo_index];
|
||||
|
||||
INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target)
|
||||
VALUES (
|
||||
CONCAT('room-', room_id_val, '-photo-', photo_index, '.webp'),
|
||||
photo_data,
|
||||
photo_data, -- thumbnail (will be regenerated by WebP migration if different)
|
||||
NULL,
|
||||
'rooms',
|
||||
room_id_val,
|
||||
CONCAT('room-', room_id_val)
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Migrated % photos for room % (%)', array_length(rec.photos, 1), rec.name, room_id_val;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Now clear the photos arrays in rooms table (images table is the source of truth)
|
||||
UPDATE rooms SET photos = '{}' WHERE array_length(photos, 1) > 0;
|
||||
|
||||
-- For rooms that already have images, sync the photo count
|
||||
-- This is informational only
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT r.id, r.name,
|
||||
array_length(r.photos, 1) as old_count,
|
||||
(SELECT COUNT(*) FROM images i WHERE i.room_id = r.id) as new_count
|
||||
FROM rooms r
|
||||
LOOP
|
||||
RAISE NOTICE 'Room % (%): photos array=%, images table=%',
|
||||
rec.name, rec.id, rec.old_count, rec.new_count;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Verify the migration
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
array_length(r.photos, 1) as room_photos_count,
|
||||
(SELECT COUNT(*) FROM images i WHERE i.room_id = r.id) as images_count
|
||||
FROM rooms r
|
||||
ORDER BY r.id;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Remove duplicate images, keeping only the most recent one per (filename, location_target, data length)
|
||||
-- This handles upload accidents where same image was uploaded multiple times
|
||||
|
||||
-- First, identify duplicates
|
||||
-- SELECT filename, location_target, COUNT(*) as dupes, array_agg(id) as ids
|
||||
-- FROM images GROUP BY filename, location_target, LENGTH(data) HAVING COUNT(*) > 1;
|
||||
|
||||
-- Delete duplicates, keeping the latest (highest id) per group
|
||||
DELETE FROM images a
|
||||
USING images b
|
||||
WHERE a.id < b.id
|
||||
AND a.filename = b.filename
|
||||
AND a.location_target = b.location_target
|
||||
AND LENGTH(a.data) = LENGTH(b.data);
|
||||
|
||||
-- Verify cleanup
|
||||
SELECT filename, location_target, COUNT(*) as count
|
||||
FROM images
|
||||
GROUP BY filename, location_target, LENGTH(data)
|
||||
HAVING COUNT(*) > 1;
|
||||
@@ -0,0 +1,383 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { formatDisplayDateTime } from '@/lib/date';
|
||||
|
||||
type User = { id: number; username: string; first_name: string; last_name: string; role: string; email: string };
|
||||
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Conversation = {
|
||||
id: number;
|
||||
subject: string | null;
|
||||
last_message: string | null;
|
||||
last_message_at: string | null;
|
||||
message_count: number;
|
||||
unread_count: number;
|
||||
participants: Participant[];
|
||||
};
|
||||
|
||||
export default function AdminMessagesPage() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedConv, setSelectedConv] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [showBulk, setShowBulk] = useState(false);
|
||||
const [bulkSubject, setBulkSubject] = useState('');
|
||||
const [bulkContent, setBulkContent] = useState('');
|
||||
const [bulkType, setBulkType] = useState<'all_customers' | 'specific_users' | 'admins'>('all_customers');
|
||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConv) {
|
||||
fetchMessages(selectedConv);
|
||||
}
|
||||
}, [selectedConv]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [convRes, usersRes] = await Promise.all([
|
||||
fetch('/api/admin/conversations'),
|
||||
fetch('/api/admin/users'),
|
||||
]);
|
||||
|
||||
if (convRes.ok) {
|
||||
const data = await convRes.json();
|
||||
setConversations(data.conversations || []);
|
||||
}
|
||||
if (usersRes.ok) {
|
||||
const data = await usersRes.json();
|
||||
setUsers(data.users || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async (convId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${convId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(data.messages || []);
|
||||
setParticipants(data.participants || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.trim() || !selectedConv) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newMessage.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setNewMessage('');
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const sendBulkMessage = async () => {
|
||||
if (!bulkContent.trim()) return;
|
||||
|
||||
if (bulkType === 'specific_users' && selectedUsers.length === 0) {
|
||||
alert('Please select at least one user');
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/bulk-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: bulkSubject || null,
|
||||
content: bulkContent.trim(),
|
||||
recipient_type: bulkType,
|
||||
recipient_ids: bulkType === 'specific_users' ? selectedUsers : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
alert(`Message sent to ${data.sent_count} users`);
|
||||
setShowBulk(false);
|
||||
setBulkSubject('');
|
||||
setBulkContent('');
|
||||
setSelectedUsers([]);
|
||||
fetchData();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to send');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send bulk message:', err);
|
||||
alert('Failed to send message');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayName = (p: Participant | User) => {
|
||||
return p.first_name || p.username;
|
||||
};
|
||||
|
||||
const customerConvs = conversations.filter(c => c.participants.some(p => p.role === 'customer'));
|
||||
const adminConvs = conversations.filter(c => c.participants.every(p => p.role === 'admin'));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Messages</h1>
|
||||
<button
|
||||
onClick={() => setShowBulk(true)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
Send Bulk Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Conversations Panel */}
|
||||
<div className="lg:col-span-1 bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b bg-gray-50 font-medium">Customer Conversations</div>
|
||||
<div className="divide-y max-h-[300px] overflow-y-auto">
|
||||
{customerConvs.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No customer conversations</div>
|
||||
) : (
|
||||
customerConvs.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="font-medium text-sm">
|
||||
{conv.participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ')}
|
||||
</div>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b bg-gray-50 font-medium mt-4">Admin Conversations</div>
|
||||
<div className="divide-y max-h-[300px] overflow-y-auto">
|
||||
{adminConvs.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No admin conversations</div>
|
||||
) : (
|
||||
adminConvs.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<div className="font-medium text-sm">
|
||||
{conv.subject || conv.participants.map(getDisplayName).join(', ')}
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Panel */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow flex flex-col h-[700px]">
|
||||
{selectedConv ? (
|
||||
<>
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<div className="font-medium">
|
||||
Conversation with: {participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ') || 'Admins'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'admin' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[70%] ${msg.role === 'admin' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium">
|
||||
{msg.first_name || msg.username}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${msg.role === 'admin' ? 'bg-blue-500' : 'bg-gray-200 text-gray-600'}`}>
|
||||
{msg.role}
|
||||
</span>
|
||||
</div>
|
||||
<div>{msg.content}</div>
|
||||
<div className={`text-xs mt-1 ${msg.role === 'admin' ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
{formatDisplayDateTime(msg.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t bg-gray-50">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Type a reply..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!newMessage.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
Select a conversation to view messages
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Message Modal */}
|
||||
{showBulk && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold mb-4">Send Bulk Message</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Recipients</label>
|
||||
<select
|
||||
value={bulkType}
|
||||
onChange={e => setBulkType(e.target.value as typeof bulkType)}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="all_customers">All Customers</option>
|
||||
<option value="specific_users">Specific Users</option>
|
||||
<option value="admins">Admins Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{bulkType === 'specific_users' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Select Users</label>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg p-2 space-y-1">
|
||||
{users.filter(u => u.role === 'customer').map(user => (
|
||||
<label key={user.id} className="flex items-center gap-2 p-1 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setSelectedUsers(prev => [...prev, user.id]);
|
||||
} else {
|
||||
setSelectedUsers(prev => prev.filter(id => id !== user.id));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">{user.first_name || user.username} ({user.email})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{selectedUsers.length} selected</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bulkSubject}
|
||||
onChange={e => setBulkSubject(e.target.value)}
|
||||
placeholder="Message subject"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
|
||||
<textarea
|
||||
value={bulkContent}
|
||||
onChange={e => setBulkContent(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
rows={5}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowBulk(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={sendBulkMessage}
|
||||
disabled={!bulkContent.trim() || sending}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{sending ? 'Sending...' : 'Send Message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1148
-36
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Admin: Send bulk message to customers
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const admin = await requireRole('admin');
|
||||
|
||||
const { subject, content, recipient_type, recipient_ids } = await request.json();
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Message content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['all_customers', 'specific_users', 'admins'].includes(recipient_type)) {
|
||||
return NextResponse.json({ error: 'Invalid recipient type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get recipients based on type
|
||||
let recipientList: number[] = [];
|
||||
|
||||
if (recipient_type === 'all_customers') {
|
||||
const { rows } = await db.query(
|
||||
"SELECT id FROM users WHERE role = 'customer'"
|
||||
);
|
||||
recipientList = rows.map((r: any) => r.id);
|
||||
} else if (recipient_type === 'admins') {
|
||||
const { rows } = await db.query(
|
||||
"SELECT id FROM users WHERE role = 'admin'"
|
||||
);
|
||||
recipientList = rows.map((r: any) => r.id);
|
||||
} else if (recipient_type === 'specific_users' && recipient_ids) {
|
||||
recipientList = recipient_ids;
|
||||
}
|
||||
|
||||
if (recipientList.length === 0) {
|
||||
return NextResponse.json({ error: 'No recipients found' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create bulk message record
|
||||
const { rows: bulkRows } = await db.query(
|
||||
'INSERT INTO bulk_messages (sender_id, subject, content, recipient_type) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[admin.id, subject || null, content, recipient_type]
|
||||
);
|
||||
const bulkMessage = bulkRows[0];
|
||||
|
||||
// Create recipient records
|
||||
for (const userId of recipientList) {
|
||||
await db.query(
|
||||
'INSERT INTO bulk_message_recipients (bulk_message_id, user_id) VALUES ($1, $2)',
|
||||
[bulkMessage.id, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Create individual conversations for each recipient if they don't exist
|
||||
for (const userId of recipientList) {
|
||||
// Check if there's an existing conversation between this admin and user
|
||||
const { rows: existingConv } = await db.query(`
|
||||
SELECT c.id FROM conversations c
|
||||
JOIN conversation_participants cp1 ON c.id = cp1.conversation_id
|
||||
JOIN conversation_participants cp2 ON c.id = cp2.conversation_id
|
||||
WHERE cp1.user_id = $1 AND cp2.user_id = $2
|
||||
GROUP BY c.id
|
||||
HAVING COUNT(DISTINCT cp1.user_id) = 1 AND COUNT(DISTINCT cp2.user_id) = 1
|
||||
`, [admin.id, userId]);
|
||||
|
||||
let conversationId: number;
|
||||
|
||||
if (existingConv.length > 0) {
|
||||
conversationId = existingConv[0].id;
|
||||
} else {
|
||||
// Create new conversation
|
||||
const { rows: convRows } = await db.query(
|
||||
'INSERT INTO conversations (subject, created_by) VALUES ($1, $2) RETURNING id',
|
||||
[subject || 'Admin Message', admin.id]
|
||||
);
|
||||
conversationId = convRows[0].id;
|
||||
|
||||
// Add participants
|
||||
await db.query(
|
||||
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2), ($1, $3)',
|
||||
[conversationId, admin.id, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Add the message to the conversation
|
||||
await db.query(
|
||||
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3)',
|
||||
[conversationId, admin.id, content]
|
||||
);
|
||||
|
||||
// Update conversation timestamp
|
||||
await db.query(
|
||||
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
|
||||
[conversationId]
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sent_count: recipientList.length,
|
||||
bulk_message: bulkMessage
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Send bulk message error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to send bulk message') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
|
||||
// GET - list pending comments for admin review
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'pending';
|
||||
|
||||
const { rows } = await db.query(`
|
||||
SELECT rc.*, r.name as room_name, u.username
|
||||
FROM room_comments rc
|
||||
LEFT JOIN rooms r ON rc.room_id = r.id
|
||||
LEFT JOIN users u ON rc.user_id = u.id
|
||||
WHERE rc.status = $1
|
||||
ORDER BY rc.created_at DESC
|
||||
`, [status]);
|
||||
|
||||
return NextResponse.json({ comments: rows });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to fetch comments' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH - approve, reject, or edit a comment
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { comment_id, action, text } = await request.json();
|
||||
|
||||
if (!comment_id || !action) {
|
||||
return NextResponse.json({ error: 'comment_id and action required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
if (action === 'approve') {
|
||||
result = await db.query(`
|
||||
UPDATE room_comments SET status = 'approved' WHERE id = $1 RETURNING *
|
||||
`, [comment_id]);
|
||||
} else if (action === 'reject') {
|
||||
result = await db.query(`
|
||||
UPDATE room_comments SET status = 'rejected' WHERE id = $1 RETURNING *
|
||||
`, [comment_id]);
|
||||
} else if (action === 'edit') {
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'text required for edit action' }, { status: 400 });
|
||||
}
|
||||
result = await db.query(`
|
||||
UPDATE room_comments SET text = $1, status = 'approved' WHERE id = $2 RETURNING *
|
||||
`, [text, comment_id]);
|
||||
} else if (action === 'delete') {
|
||||
result = await db.query(`
|
||||
UPDATE room_comments SET deleted_by_admin = TRUE WHERE id = $1 RETURNING *
|
||||
`, [comment_id]);
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, comment: result?.rows?.[0] });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to update comment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Admin: Get all conversations
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
// Get all conversations with participant info and last message
|
||||
const { rows } = await db.query(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.subject,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT content FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message,
|
||||
(SELECT created_at FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_at,
|
||||
(SELECT COUNT(*) FROM direct_messages WHERE conversation_id = c.id) as message_count
|
||||
FROM conversations c
|
||||
ORDER BY c.updated_at DESC
|
||||
`);
|
||||
|
||||
// Get participants for each conversation
|
||||
const conversations = await Promise.all(rows.map(async (conv: any) => {
|
||||
const { rows: participants } = await db.query(`
|
||||
SELECT u.id, u.username, u.first_name, u.last_name, u.role
|
||||
FROM conversation_participants cp
|
||||
JOIN users u ON cp.user_id = u.id
|
||||
WHERE cp.conversation_id = $1
|
||||
ORDER BY u.role DESC, u.first_name
|
||||
`, [conv.id]);
|
||||
|
||||
// Count unread for admins (messages from customers)
|
||||
const { rows: unreadRows } = await db.query(`
|
||||
SELECT COUNT(*) as unread
|
||||
FROM direct_messages dm
|
||||
JOIN users u ON dm.sender_id = u.id
|
||||
WHERE dm.conversation_id = $1
|
||||
AND u.role != 'admin'
|
||||
AND dm.created_at > COALESCE(
|
||||
(SELECT joined_at FROM conversation_participants WHERE conversation_id = $1 AND user_id = (SELECT id FROM users WHERE role = 'admin' LIMIT 1)),
|
||||
'1970-01-01'::timestamp
|
||||
)
|
||||
`, [conv.id]);
|
||||
|
||||
return {
|
||||
...conv,
|
||||
participants,
|
||||
unread_count: parseInt(unreadRows[0]?.unread || '0')
|
||||
};
|
||||
}));
|
||||
|
||||
return NextResponse.json({ conversations });
|
||||
} catch (error) {
|
||||
console.error('Admin get conversations error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to get conversations') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,46 @@ import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import sharp from 'sharp';
|
||||
|
||||
function generateThumbnail(base64Data: string, width = 300, height = 200): Promise<string> {
|
||||
interface ImageSizes {
|
||||
thumbnail: string;
|
||||
medium: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
async function generateImageSizes(base64Data: string): Promise<ImageSizes> {
|
||||
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) return Promise.resolve(base64Data);
|
||||
if (!matches) {
|
||||
return { thumbnail: base64Data, medium: base64Data, data: base64Data };
|
||||
}
|
||||
|
||||
const ext = matches[1];
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
|
||||
return sharp(buffer)
|
||||
.resize(width, height, { fit: 'cover' })
|
||||
.toBuffer()
|
||||
.then(resized => `data:image/${ext};base64,${resized.toString('base64')}`);
|
||||
// Convert to WebP for better compression
|
||||
const toBase64 = (buf: Buffer) => `data:image/webp;base64,${buf.toString('base64')}`;
|
||||
|
||||
// Generate multiple sizes in parallel
|
||||
const [thumbnail, medium] = await Promise.all([
|
||||
sharp(buffer)
|
||||
.resize(400, 300, { fit: 'cover' })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(800, 600, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
// Also convert full image to WebP
|
||||
const fullImage = await sharp(buffer)
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
thumbnail: toBase64(thumbnail),
|
||||
medium: toBase64(medium),
|
||||
data: toBase64(fullImage),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -42,7 +71,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ images: rows, count: rows.length });
|
||||
return NextResponse.json({ data: rows, count: rows.length });
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -62,12 +91,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate thumbnail for faster loading
|
||||
const thumbnail = await generateThumbnail(data, 400, 300);
|
||||
// Generate multiple sizes for responsive images (thumbnail, medium, full)
|
||||
const sizes = await generateImageSizes(data);
|
||||
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO images (filename, data, thumbnail, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, filename, data, thumbnail, category, room_id, location_target, uploaded_at',
|
||||
[filename, data, thumbnail, category || 'other', room_id || null, location_target || 'gallery']
|
||||
'INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, filename, data, thumbnail, medium, category, room_id, location_target, uploaded_at',
|
||||
[filename, sizes.data, sizes.thumbnail, sizes.medium, category || 'other', room_id || null, location_target || 'gallery']
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const maxDuration = 300; // 5 minutes for large migrations
|
||||
|
||||
async function convertToWebP(base64Data: string): Promise<string> {
|
||||
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return base64Data;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
const webpBuffer = await sharp(buffer)
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
console.log('Starting WebP migration...');
|
||||
|
||||
const { rows: images } = await db.query(
|
||||
'SELECT id, filename, data, thumbnail, medium FROM images'
|
||||
);
|
||||
|
||||
console.log(`Found ${images.length} images to process`);
|
||||
|
||||
let converted = 0;
|
||||
let skipped = 0;
|
||||
let errors: number[] = [];
|
||||
let totalSavedKB = 0;
|
||||
|
||||
for (const img of images) {
|
||||
try {
|
||||
let needsUpdate = false;
|
||||
const updates: { data?: string; thumbnail?: string; medium?: string } = {};
|
||||
|
||||
if (img.data && !img.data.includes('image/webp')) {
|
||||
console.log(`Converting image ${img.id}: ${img.filename} (full)`);
|
||||
updates.data = await convertToWebP(img.data);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
|
||||
console.log(`Converting image ${img.id}: ${img.filename} (thumbnail)`);
|
||||
updates.thumbnail = await convertToWebP(img.thumbnail);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.medium && !img.medium.includes('image/webp')) {
|
||||
console.log(`Converting image ${img.id}: ${img.filename} (medium)`);
|
||||
updates.medium = await convertToWebP(img.medium);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
|
||||
const newSize = (updates.data?.length || img.data?.length || 0) +
|
||||
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
|
||||
(updates.medium?.length || img.medium?.length || 0);
|
||||
const savedKB = Math.round((oldSize - newSize) / 1024);
|
||||
totalSavedKB += savedKB;
|
||||
|
||||
await db.query(
|
||||
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
|
||||
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
|
||||
);
|
||||
converted++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing image ${img.id}:`, err);
|
||||
errors.push(img.id);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: images.length,
|
||||
converted,
|
||||
skipped,
|
||||
errors,
|
||||
totalSavedMB: Math.round(totalSavedKB / 1024 * 10) / 10,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Migration failed', details: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
const { rows: images } = await db.query(
|
||||
'SELECT id, filename, LENGTH(data) as data_size, LENGTH(thumbnail) as thumb_size, LENGTH(medium) as medium_size FROM images'
|
||||
);
|
||||
|
||||
let totalSize = 0;
|
||||
let webpCount = 0;
|
||||
let jpegCount = 0;
|
||||
|
||||
for (const img of images) {
|
||||
totalSize += (img.data_size || 0) + (img.thumb_size || 0) + (img.medium_size || 0);
|
||||
if (img.data_size) {
|
||||
// We'd need to fetch the actual data to check format, so estimate based on size
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalImages: images.length,
|
||||
totalSizeKB: Math.round(totalSize / 1024),
|
||||
totalSizeMB: Math.round(totalSize / 1024 / 1024 * 10) / 10,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to get stats' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(`
|
||||
SELECT r.*, u.username, u.first_name, u.last_name
|
||||
FROM reservations r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
ORDER BY r.date DESC, r.time DESC
|
||||
`);
|
||||
|
||||
return NextResponse.json({ reservations: rows });
|
||||
} catch (error) {
|
||||
console.error('Get restaurant reservations error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Reservation ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM reservations WHERE id = $1', [id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete restaurant reservation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'all';
|
||||
|
||||
let query = `
|
||||
SELECT r.*, rm.name as room_name,
|
||||
u.username, u.first_name, u.last_name, u.email, u.phone
|
||||
FROM room_reservations r
|
||||
JOIN rooms rm ON r.room_id = rm.id
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
if (status !== 'all') {
|
||||
query += ' WHERE r.status = $1';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY r.created_at DESC';
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ reservations: rows });
|
||||
} catch (error) {
|
||||
console.error('Get room reservations error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, status } = body;
|
||||
|
||||
if (!id || !status) {
|
||||
return NextResponse.json({ error: 'ID and status required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.query(
|
||||
'UPDATE room_reservations SET status = $1, updated_at = NOW() WHERE id = $2',
|
||||
[status, id]
|
||||
);
|
||||
|
||||
// If cancelling, free up the blocked dates
|
||||
if (status === 'cancelled') {
|
||||
const { rows: reservation } = await db.query(
|
||||
'SELECT room_id, check_in, check_out FROM room_reservations WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (reservation.length > 0) {
|
||||
const { room_id, check_in, check_out } = reservation[0];
|
||||
await db.query(`
|
||||
DELETE FROM room_availability
|
||||
WHERE room_id = $1 AND date >= $2 AND date < $3 AND reason = $4
|
||||
`, [room_id, check_in, check_out, `Reservation #${id}`]);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Update room reservation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Reservation ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get reservation details before deleting
|
||||
const { rows: reservation } = await db.query(
|
||||
'SELECT room_id, check_in, check_out FROM room_reservations WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Delete the reservation
|
||||
await db.query('DELETE FROM room_reservations WHERE id = $1', [id]);
|
||||
|
||||
// Free up blocked dates
|
||||
if (reservation.length > 0) {
|
||||
const { room_id, check_in, check_out } = reservation[0];
|
||||
await db.query(`
|
||||
DELETE FROM room_availability
|
||||
WHERE room_id = $1 AND date >= $2 AND date < $3 AND reason = $4
|
||||
`, [room_id, check_in, check_out, `Reservation #${id}`]);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete room reservation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Get all rooms with their current status and reservation info
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
// Get all rooms with current reservation info and staff assignment
|
||||
const { rows } = await db.query(`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
r.price,
|
||||
r.clean_status,
|
||||
r.notes,
|
||||
r.active,
|
||||
r.last_cleaned,
|
||||
r.assigned_staff_id,
|
||||
r.priority,
|
||||
r.issues_count,
|
||||
r.is_vip,
|
||||
r.checkout_time,
|
||||
r.checkout_date,
|
||||
rr.id as reservation_id,
|
||||
rr.guest_name,
|
||||
rr.check_in,
|
||||
rr.check_out,
|
||||
rr.status as reservation_status,
|
||||
rr.payment_status,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.username,
|
||||
s.first_name as staff_first_name,
|
||||
s.last_name as staff_last_name,
|
||||
CASE
|
||||
WHEN rr.id IS NOT NULL AND rr.status = 'confirmed'
|
||||
AND CURRENT_DATE >= rr.check_in
|
||||
AND CURRENT_DATE <= rr.check_out
|
||||
THEN 'occupied'
|
||||
ELSE 'available'
|
||||
END as occupancy_status
|
||||
FROM rooms r
|
||||
LEFT JOIN room_reservations rr ON r.id = rr.room_id
|
||||
AND rr.status = 'confirmed'
|
||||
AND CURRENT_DATE >= rr.check_in
|
||||
AND CURRENT_DATE <= rr.check_out
|
||||
LEFT JOIN users u ON rr.user_id = u.id
|
||||
LEFT JOIN users s ON r.assigned_staff_id = s.id
|
||||
ORDER BY
|
||||
CASE r.priority
|
||||
WHEN 'urgent' THEN 1
|
||||
WHEN 'normal' THEN 2
|
||||
WHEN 'low' THEN 3
|
||||
END,
|
||||
r.name
|
||||
`);
|
||||
|
||||
// Get upcoming reservations for each room
|
||||
const rooms = await Promise.all(rows.map(async (room: any) => {
|
||||
const { rows: upcoming } = await db.query(`
|
||||
SELECT
|
||||
rr.id,
|
||||
rr.guest_name,
|
||||
rr.check_in,
|
||||
rr.check_out,
|
||||
rr.status,
|
||||
rr.payment_status,
|
||||
u.first_name,
|
||||
u.last_name
|
||||
FROM room_reservations rr
|
||||
LEFT JOIN users u ON rr.user_id = u.id
|
||||
WHERE rr.room_id = $1
|
||||
AND rr.status = 'confirmed'
|
||||
AND rr.check_in > CURRENT_DATE
|
||||
ORDER BY rr.check_in
|
||||
LIMIT 3
|
||||
`, [room.id]);
|
||||
|
||||
return {
|
||||
...room,
|
||||
upcoming_reservations: upcoming,
|
||||
current_guest: room.guest_name || (room.first_name ? `${room.first_name} ${room.last_name || ''}`.trim() : room.username) || null,
|
||||
assigned_staff: room.assigned_staff_id ? {
|
||||
id: room.assigned_staff_id,
|
||||
first_name: room.staff_first_name,
|
||||
last_name: room.staff_last_name,
|
||||
} : null,
|
||||
};
|
||||
}));
|
||||
|
||||
// Get all staff members (users who can be assigned to rooms)
|
||||
const { rows: staff } = await db.query(`
|
||||
SELECT id, first_name, last_name, username
|
||||
FROM users
|
||||
WHERE role IN ('admin', 'staff') OR role = 'user'
|
||||
ORDER BY first_name, last_name
|
||||
`);
|
||||
|
||||
return NextResponse.json({ rooms, staff });
|
||||
} catch (error) {
|
||||
console.error('Get room status error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to get room status') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update room status
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
id,
|
||||
clean_status,
|
||||
notes,
|
||||
last_cleaned,
|
||||
assigned_staff_id,
|
||||
priority,
|
||||
issues_count,
|
||||
is_vip,
|
||||
checkout_time,
|
||||
checkout_date,
|
||||
} = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Room ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (clean_status !== undefined) {
|
||||
updates.push(`clean_status = $${paramIndex}`);
|
||||
values.push(clean_status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (notes !== undefined) {
|
||||
updates.push(`notes = $${paramIndex}`);
|
||||
values.push(notes);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (last_cleaned !== undefined) {
|
||||
updates.push(`last_cleaned = $${paramIndex}`);
|
||||
values.push(last_cleaned);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (assigned_staff_id !== undefined) {
|
||||
updates.push(`assigned_staff_id = $${paramIndex}`);
|
||||
values.push(assigned_staff_id || null);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (priority !== undefined) {
|
||||
updates.push(`priority = $${paramIndex}`);
|
||||
values.push(priority);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (issues_count !== undefined) {
|
||||
updates.push(`issues_count = $${paramIndex}`);
|
||||
values.push(issues_count);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (is_vip !== undefined) {
|
||||
updates.push(`is_vip = $${paramIndex}`);
|
||||
values.push(is_vip);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (checkout_time !== undefined) {
|
||||
updates.push(`checkout_time = $${paramIndex}`);
|
||||
values.push(checkout_time || null);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (checkout_date !== undefined) {
|
||||
updates.push(`checkout_date = $${paramIndex}`);
|
||||
values.push(checkout_date || null);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Auto-set last_cleaned when status changes to 'clean'
|
||||
if (clean_status === 'clean') {
|
||||
updates.push(`last_cleaned = NOW()`);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return NextResponse.json({ room: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Update room status error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update room status') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole, createUser } from '@/lib/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Admin: Get all users for recipient selection
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at FROM users ORDER BY created_at DESC');
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { username, password, role = 'user', first_name, last_name, email, phone, country, preferred_language = 'es' } = body;
|
||||
const { rows } = await db.query(`
|
||||
SELECT id, username, first_name, last_name, role, email, created_at
|
||||
FROM users
|
||||
ORDER BY role, first_name, last_name
|
||||
`);
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (role !== 'admin' && role !== 'user') {
|
||||
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await createUser(username, password, role as 'admin' | 'user', { first_name, last_name, email, phone, country, preferred_language });
|
||||
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role, first_name: user.first_name, last_name: user.last_name } });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { id, first_name, last_name, comments_disabled, preferred_language, email, phone, country, password } = body;
|
||||
|
||||
// Build dynamic update query
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (first_name !== undefined) {
|
||||
updates.push(`first_name = $${paramIndex++}`);
|
||||
values.push(first_name || null);
|
||||
}
|
||||
if (last_name !== undefined) {
|
||||
updates.push(`last_name = $${paramIndex++}`);
|
||||
values.push(last_name || null);
|
||||
}
|
||||
if (comments_disabled !== undefined) {
|
||||
updates.push(`comments_disabled = $${paramIndex++}`);
|
||||
values.push(comments_disabled === true);
|
||||
}
|
||||
if (preferred_language !== undefined) {
|
||||
updates.push(`preferred_language = $${paramIndex++}`);
|
||||
values.push(preferred_language || 'es');
|
||||
}
|
||||
if (email !== undefined) {
|
||||
updates.push(`email = $${paramIndex++}`);
|
||||
values.push(email || null);
|
||||
}
|
||||
if (phone !== undefined) {
|
||||
updates.push(`phone = $${paramIndex++}`);
|
||||
values.push(phone || null);
|
||||
}
|
||||
if (country !== undefined) {
|
||||
updates.push(`country = $${paramIndex++}`);
|
||||
values.push(country || null);
|
||||
}
|
||||
if (password !== undefined && password) {
|
||||
const { hashPassword } = await import('@/lib/auth');
|
||||
updates.push(`password_hash = $${paramIndex++}`);
|
||||
values.push(await hashPassword(password));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const { rows } = await db.query(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, username, role, first_name, last_name, comments_disabled, preferred_language, email, phone, country, created_at`,
|
||||
values
|
||||
return NextResponse.json({ users: rows });
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to get users') },
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to update user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
|
||||
}
|
||||
await db.query('DELETE FROM users WHERE id = $1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to delete user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export async function GET() {
|
||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, username, role, first_name, last_name, comments_disabled FROM users WHERE id = $1',
|
||||
'SELECT id, username, role, first_name, last_name, email, comments_disabled FROM users WHERE id = $1',
|
||||
[session.id]
|
||||
);
|
||||
const user = rows[0] || {};
|
||||
@@ -19,6 +19,7 @@ export async function GET() {
|
||||
id: session.id,
|
||||
first_name: user.first_name || null,
|
||||
last_name: user.last_name || null,
|
||||
email: user.email || null,
|
||||
comments_disabled: user.comments_disabled === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createUser } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
@@ -12,7 +13,10 @@ export async function POST() {
|
||||
|
||||
const user = await createUser(username, password, 'admin');
|
||||
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err, 'Failed to create bootstrap user') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Send a message to a conversation
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { content } = await request.json();
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Message content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user is participant
|
||||
const { rows: participants } = await db.query(
|
||||
'SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
|
||||
[id, user.id]
|
||||
);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return NextResponse.json({ error: 'Not authorized for this conversation' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Create message
|
||||
const { rows: msgRows } = await db.query(
|
||||
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3) RETURNING *',
|
||||
[id, user.id, content]
|
||||
);
|
||||
|
||||
// Update conversation updated_at
|
||||
await db.query(
|
||||
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Get sender info
|
||||
const { rows: userRows } = await db.query(
|
||||
'SELECT id, username, first_name, last_name, role FROM users WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
message: {
|
||||
...msgRows[0],
|
||||
sender_id: user.id,
|
||||
username: userRows[0].username,
|
||||
first_name: userRows[0].first_name,
|
||||
last_name: userRows[0].last_name,
|
||||
role: userRows[0].role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Send message error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to send message') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Get messages for a conversation
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Verify user is participant
|
||||
const { rows: participants } = await db.query(
|
||||
'SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
|
||||
[id, user.id]
|
||||
);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return NextResponse.json({ error: 'Not authorized for this conversation' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get all messages with sender info
|
||||
const { rows: messages } = await db.query(`
|
||||
SELECT
|
||||
dm.id,
|
||||
dm.content,
|
||||
dm.created_at,
|
||||
u.id as sender_id,
|
||||
u.username,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.role
|
||||
FROM direct_messages dm
|
||||
JOIN users u ON dm.sender_id = u.id
|
||||
WHERE dm.conversation_id = $1
|
||||
ORDER BY dm.created_at ASC
|
||||
`, [id]);
|
||||
|
||||
// Get conversation details with participants
|
||||
const { rows: convRows } = await db.query(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.subject,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM conversations c
|
||||
WHERE c.id = $1
|
||||
`, [id]);
|
||||
|
||||
const { rows: participantRows } = await db.query(`
|
||||
SELECT u.id, u.username, u.first_name, u.last_name, u.role
|
||||
FROM conversation_participants cp
|
||||
JOIN users u ON cp.user_id = u.id
|
||||
WHERE cp.conversation_id = $1
|
||||
`, [id]);
|
||||
|
||||
return NextResponse.json({
|
||||
conversation: convRows[0],
|
||||
participants: participantRows,
|
||||
messages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get messages error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to get messages') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Get all conversations for the current user
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all conversations where user is a participant
|
||||
const { rows } = await db.query(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.subject,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT content FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message,
|
||||
(SELECT created_at FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_at,
|
||||
(SELECT COUNT(*) FROM direct_messages WHERE conversation_id = c.id AND created_at > COALESCE(
|
||||
(SELECT joined_at FROM conversation_participants WHERE conversation_id = c.id AND user_id = $1), c.created_at
|
||||
)) as unread_count
|
||||
FROM conversations c
|
||||
JOIN conversation_participants cp ON c.id = cp.conversation_id
|
||||
WHERE cp.user_id = $1
|
||||
ORDER BY c.updated_at DESC
|
||||
`, [user.id]);
|
||||
|
||||
// Get other participants for each conversation
|
||||
const conversations = await Promise.all(rows.map(async (conv: any) => {
|
||||
const { rows: participants } = await db.query(`
|
||||
SELECT u.id, u.username, u.first_name, u.last_name, u.role
|
||||
FROM conversation_participants cp
|
||||
JOIN users u ON cp.user_id = u.id
|
||||
WHERE cp.conversation_id = $1
|
||||
`, [conv.id]);
|
||||
|
||||
return {
|
||||
...conv,
|
||||
participants
|
||||
};
|
||||
}));
|
||||
|
||||
return NextResponse.json({ conversations });
|
||||
} catch (error) {
|
||||
console.error('Get conversations error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to get conversations') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new conversation
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { subject, initial_message, recipient_ids } = await request.json();
|
||||
|
||||
if (!initial_message || initial_message.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create conversation
|
||||
const { rows: convRows } = await db.query(
|
||||
'INSERT INTO conversations (subject, created_by) VALUES ($1, $2) RETURNING *',
|
||||
[subject || null, user.id]
|
||||
);
|
||||
const conversation = convRows[0];
|
||||
|
||||
// Add creator as participant
|
||||
await db.query(
|
||||
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)',
|
||||
[conversation.id, user.id]
|
||||
);
|
||||
|
||||
// Add other participants (admins for customer messages, specific user for direct)
|
||||
if (recipient_ids && Array.isArray(recipient_ids)) {
|
||||
for (const recipientId of recipient_ids) {
|
||||
await db.query(
|
||||
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[conversation.id, recipientId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If user is not admin, add all admins as participants
|
||||
if (user.role !== 'admin') {
|
||||
const { rows: admins } = await db.query(
|
||||
"SELECT id FROM users WHERE role = 'admin'"
|
||||
);
|
||||
for (const admin of admins) {
|
||||
await db.query(
|
||||
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[conversation.id, admin.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add initial message
|
||||
const { rows: msgRows } = await db.query(
|
||||
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3) RETURNING *',
|
||||
[conversation.id, user.id, initial_message]
|
||||
);
|
||||
|
||||
// Update conversation updated_at
|
||||
await db.query(
|
||||
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
|
||||
[conversation.id]
|
||||
);
|
||||
|
||||
return NextResponse.json({ conversation, message: msgRows[0] });
|
||||
} catch (error) {
|
||||
console.error('Create conversation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to create conversation') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,19 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail
|
||||
const medium = searchParams.get('medium'); // ?medium=1 for medium size
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
|
||||
// Select appropriate column based on size
|
||||
const column = thumb ? 'thumbnail' : medium ? 'medium' : 'data';
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT ${column} as data FROM images WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
@@ -19,10 +26,12 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const imageData = rows[0].data;
|
||||
|
||||
// If thumbnail requested and we have thumbnail data stored
|
||||
if (thumb) {
|
||||
// For now, return the full image (thumbnail generation happens on upload)
|
||||
// Future: store thumbnail separately in thumbnail column
|
||||
if (!imageData) {
|
||||
// Fallback to full image if variant doesn't exist
|
||||
const { rows: fallback } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
|
||||
if (!fallback.length || !fallback[0].data) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse base64 data
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
const { room_id, message, guest_name, guest_contact_method, guest_contact } = await request.json();
|
||||
|
||||
if (!message || !message.trim()) {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// For guests, require name and contact info
|
||||
if (!user && (!guest_name || !guest_contact)) {
|
||||
return NextResponse.json({ error: 'Name and contact info are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert message - for guests, user_id is NULL
|
||||
const { rows } = await db.query(`
|
||||
INSERT INTO messages (room_id, user_id, message, guest_name, guest_contact_method, guest_contact)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`, [room_id || null, user?.id || null, message.trim(), guest_name || null, guest_contact_method || null, guest_contact || null]);
|
||||
|
||||
// Get admin emails for notification
|
||||
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND (email IS NOT NULL OR phone IS NOT NULL)`);
|
||||
|
||||
// TODO: Send email/SMS notifications to admins
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Message sent successfully' });
|
||||
} catch (error) {
|
||||
console.error('Message error:', error);
|
||||
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const unreadOnly = searchParams.get('unread') === 'true';
|
||||
|
||||
let query = `
|
||||
SELECT m.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email, u.phone
|
||||
FROM messages m
|
||||
LEFT JOIN rooms r ON m.room_id = r.id
|
||||
LEFT JOIN users u ON m.user_id = u.id
|
||||
`;
|
||||
|
||||
if (unreadOnly) {
|
||||
query += ' WHERE m.read_by_admins = FALSE';
|
||||
}
|
||||
|
||||
query += ' ORDER BY m.created_at DESC LIMIT 100';
|
||||
|
||||
const { rows } = await db.query(query);
|
||||
return NextResponse.json({ messages: rows });
|
||||
} catch (error) {
|
||||
console.error('Get messages error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { message_ids } = await request.json();
|
||||
|
||||
if (!message_ids || !Array.isArray(message_ids)) {
|
||||
return NextResponse.json({ error: 'message_ids array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.query(`
|
||||
UPDATE messages SET read_by_admins = TRUE WHERE id = ANY($1)
|
||||
`, [message_ids]);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Mark messages read error:', error);
|
||||
return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
const { items, order_type, room_id, notes } = await request.json();
|
||||
|
||||
// items is array of { menu_item_id, quantity }
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Order items are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!order_type || !['pickup', 'dine_in', 'room_delivery'].includes(order_type)) {
|
||||
return NextResponse.json({ error: 'Valid order type is required (pickup, dine_in, room_delivery)' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Room delivery requires login
|
||||
if (order_type === 'room_delivery' && !user) {
|
||||
return NextResponse.json({ error: 'Room delivery requires login. Please log in to continue.' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Room delivery requires room_id
|
||||
if (order_type === 'room_delivery' && !room_id) {
|
||||
return NextResponse.json({ error: 'Room selection is required for room delivery' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get menu item prices
|
||||
const menuItemIds = items.map((i: { menu_item_id: number }) => i.menu_item_id);
|
||||
const { rows: menuItems } = await db.query(
|
||||
'SELECT id, name, price FROM menu_items WHERE id = ANY($1)',
|
||||
[menuItemIds]
|
||||
);
|
||||
|
||||
if (menuItems.length !== menuItemIds.length) {
|
||||
return NextResponse.json({ error: 'One or more menu items not found' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Calculate total
|
||||
const priceMap = new Map(menuItems.map((m: { id: number; price: string }) => [m.id, parseFloat(m.price)]));
|
||||
let subtotal = 0;
|
||||
const orderItems = items.map((i: { menu_item_id: number; quantity: number }) => {
|
||||
const price = priceMap.get(i.menu_item_id) || 0;
|
||||
const itemTotal = price * i.quantity;
|
||||
subtotal += itemTotal;
|
||||
return {
|
||||
menu_item_id: i.menu_item_id,
|
||||
quantity: i.quantity,
|
||||
unit_price: price,
|
||||
total_price: itemTotal,
|
||||
};
|
||||
});
|
||||
|
||||
// Room delivery adds 10% service fee
|
||||
const serviceFee = order_type === 'room_delivery' ? subtotal * 0.1 : 0;
|
||||
const total = subtotal + serviceFee;
|
||||
|
||||
// Create order
|
||||
const { rows: orderRows } = await db.query(`
|
||||
INSERT INTO orders (user_id, order_type, room_id, subtotal, service_fee, total, status, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)
|
||||
RETURNING *
|
||||
`, [user?.id || null, order_type, order_type === 'room_delivery' ? room_id : null, subtotal, serviceFee, total, notes || null]);
|
||||
|
||||
const orderId = orderRows[0].id;
|
||||
|
||||
// Insert order items
|
||||
for (const item of orderItems) {
|
||||
await db.query(`
|
||||
INSERT INTO order_items (order_id, menu_item_id, quantity, unit_price, total_price)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [orderId, item.menu_item_id, item.quantity, item.unit_price, item.total_price]);
|
||||
}
|
||||
|
||||
// Get full order with items
|
||||
const { rows: fullOrder } = await db.query(`
|
||||
SELECT o.*,
|
||||
json_agg(json_build_object(
|
||||
'menu_item_id', oi.menu_item_id,
|
||||
'quantity', oi.quantity,
|
||||
'unit_price', oi.unit_price,
|
||||
'total_price', oi.total_price,
|
||||
'name', m.name
|
||||
)) as items
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON o.id = oi.order_id
|
||||
LEFT JOIN menu_items m ON oi.menu_item_id = m.id
|
||||
WHERE o.id = $1
|
||||
GROUP BY o.id
|
||||
`, [orderId]);
|
||||
|
||||
// Get admin notification targets
|
||||
const { rows: admins } = await db.query(
|
||||
'SELECT email, phone FROM users WHERE role = \'admin\' AND (email IS NOT NULL OR phone IS NOT NULL)'
|
||||
);
|
||||
|
||||
// TODO: Send notification to admins (email/SMS)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
order: fullOrder[0],
|
||||
message: order_type === 'room_delivery'
|
||||
? 'Order placed! We\'ll deliver it to your room shortly.'
|
||||
: order_type === 'dine_in'
|
||||
? 'Order placed! We\'ll prepare it for dine-in.'
|
||||
: 'Order placed! We\'ll have it ready for pickup.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Order error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create order' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'all';
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
const kitchen = searchParams.get('kitchen') === 'true';
|
||||
|
||||
let query = `
|
||||
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
|
||||
FROM orders o
|
||||
LEFT JOIN rooms r ON o.room_id = r.id
|
||||
LEFT JOIN users u ON o.user_id = u.id
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
if (status !== 'all') {
|
||||
query += ' WHERE o.status = $1';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.created_at DESC LIMIT $' + (params.length + 1);
|
||||
params.push(limit);
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
|
||||
// Get items for each order
|
||||
for (const order of rows) {
|
||||
const { rows: items } = await db.query(`
|
||||
SELECT oi.*, m.name, m.description
|
||||
FROM order_items oi
|
||||
JOIN menu_items m ON oi.menu_item_id = m.id
|
||||
WHERE oi.order_id = $1
|
||||
`, [order.id]);
|
||||
order.items = items;
|
||||
}
|
||||
|
||||
return NextResponse.json({ orders: rows });
|
||||
} catch (error) {
|
||||
console.error('Get orders error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get orders' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { order_id, status } = await request.json();
|
||||
|
||||
if (!order_id || !status) {
|
||||
return NextResponse.json({ error: 'order_id and status are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const validStatuses = ['pending', 'preparing', 'ready', 'delivered', 'cancelled'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
||||
[status, order_id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, order: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Update order error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update order' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit';
|
||||
import { withRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
// GET - Get a single private event reservation (admin only)
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-private-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT * FROM private_event_reservations WHERE id = $1',
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error fetching reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update reservation status (admin only)
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-private-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
|
||||
if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status. Must be pending, confirmed, or cancelled' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current status for audit log
|
||||
const { rows: beforeRows } = await db.query(
|
||||
'SELECT * FROM private_event_reservations WHERE id = $1',
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (beforeRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const oldStatus = beforeRows[0].status;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE private_event_reservations SET status = $1 WHERE id = $2 RETURNING *`,
|
||||
[status, params.id]
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog({
|
||||
userId: session.id,
|
||||
action: 'status_change',
|
||||
entityType: 'private_event_reservation',
|
||||
entityId: parseInt(params.id, 10),
|
||||
oldValues: { status: oldStatus },
|
||||
newValues: { status },
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete a reservation (admin only)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-private-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
// Get the reservation before deleting for audit log
|
||||
const { rows: beforeRows } = await db.query(
|
||||
'SELECT * FROM private_event_reservations WHERE id = $1',
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (beforeRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM private_event_reservations WHERE id = $1', [params.id]);
|
||||
|
||||
// Audit log
|
||||
await auditLog({
|
||||
userId: session.id,
|
||||
action: 'delete',
|
||||
entityType: 'private_event_reservation',
|
||||
entityId: parseInt(params.id, 10),
|
||||
oldValues: beforeRows[0],
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { withRateLimit, getClientIp } from '@/lib/rate-limit';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
// Honeypot field name - bots often autofill all fields
|
||||
// This field should remain empty for legitimate submissions
|
||||
const HONEYPOT_FIELD = 'website_url';
|
||||
|
||||
// GET - List all private event reservations (admin only)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting for admin endpoint
|
||||
const rateCheck = withRateLimit(request, 'admin-private-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status');
|
||||
|
||||
let query = 'SELECT * FROM private_event_reservations';
|
||||
const params: any[] = [];
|
||||
|
||||
if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) {
|
||||
query += ' WHERE status = $1 ORDER BY created_at DESC';
|
||||
params.push(status);
|
||||
} else {
|
||||
query += ' ORDER BY status = \'pending\' DESC, created_at DESC';
|
||||
}
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching private event reservations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to fetch reservations') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create a new private event reservation (public endpoint)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'private-event-reservation');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
event_name,
|
||||
contact_name,
|
||||
contact_email,
|
||||
contact_phone,
|
||||
contact_method,
|
||||
event_date,
|
||||
event_time,
|
||||
guests,
|
||||
notes,
|
||||
// Honeypot field - should be empty
|
||||
[HONEYPOT_FIELD]: honeypot
|
||||
} = body;
|
||||
|
||||
// Honeypot check - reject if filled (bot detection)
|
||||
if (honeypot && honeypot.trim() !== '') {
|
||||
// Silently reject but return success to confuse bots
|
||||
console.log('Honeypot triggered, rejecting submission from:', getClientIp(request));
|
||||
return NextResponse.json({
|
||||
data: { id: Date.now(), status: 'pending' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!event_name || !contact_name || !contact_method || !guests) {
|
||||
return NextResponse.json({ error: 'Missing required fields: event_name, contact_name, contact_method, guests' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['email', 'phone', 'whatsapp'].includes(contact_method)) {
|
||||
return NextResponse.json({ error: 'Invalid contact method. Must be email, phone, or whatsapp' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate email format if provided
|
||||
if (contact_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact_email)) {
|
||||
return NextResponse.json({ error: 'Invalid email format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate phone format if provided (basic international format)
|
||||
if (contact_phone && !/^[\d\s\-+()]{7,20}$/.test(contact_phone)) {
|
||||
return NextResponse.json({ error: 'Invalid phone format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate guests is positive
|
||||
const guestsNum = parseInt(guests, 10);
|
||||
if (isNaN(guestsNum) || guestsNum < 1 || guestsNum > 1000) {
|
||||
return NextResponse.json({ error: 'Guests must be between 1 and 1000' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Length limits (prevent abuse)
|
||||
if (event_name.length > 255 || contact_name.length > 255) {
|
||||
return NextResponse.json({ error: 'Event name and contact name must be under 255 characters' }, { status: 400 });
|
||||
}
|
||||
if (contact_email && contact_email.length > 255) {
|
||||
return NextResponse.json({ error: 'Email must be under 255 characters' }, { status: 400 });
|
||||
}
|
||||
if (contact_phone && contact_phone.length > 50) {
|
||||
return NextResponse.json({ error: 'Phone must be under 50 characters' }, { status: 400 });
|
||||
}
|
||||
if (notes && notes.length > 2000) {
|
||||
return NextResponse.json({ error: 'Notes must be under 2000 characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate event_date is in the future if provided
|
||||
if (event_date) {
|
||||
const date = new Date(event_date);
|
||||
if (isNaN(date.getTime())) {
|
||||
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
|
||||
}
|
||||
if (date < new Date()) {
|
||||
return NextResponse.json({ error: 'Event date must be in the future' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO private_event_reservations
|
||||
(event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guests, notes, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')
|
||||
RETURNING id, event_name, guests, event_date, event_time, status, created_at`,
|
||||
[event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guestsNum, notes]
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error creating private event reservation:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to create reservation') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
const body = await request.json();
|
||||
const { room_id, check_in, check_out, guest_name, guest_contact_method, guest_contact } = body;
|
||||
|
||||
// Require either logged-in user or guest info
|
||||
if (!user && (!guest_name || !guest_contact)) {
|
||||
return NextResponse.json({ error: 'Please provide your name and contact information' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!room_id || !check_in || !check_out) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if room exists
|
||||
const { rows: roomRows } = await db.query('SELECT * FROM rooms WHERE id = $1', [room_id]);
|
||||
if (roomRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const room = roomRows[0];
|
||||
|
||||
// Check for conflicting reservations
|
||||
const { rows: conflicts } = await db.query(`
|
||||
SELECT * FROM room_reservations
|
||||
WHERE room_id = $1
|
||||
AND status != 'cancelled'
|
||||
AND (
|
||||
(check_in <= $2 AND check_out > $2) OR
|
||||
(check_in < $3 AND check_out >= $3) OR
|
||||
(check_in >= $2 AND check_out <= $3)
|
||||
)
|
||||
`, [room_id, check_in, check_out]);
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return NextResponse.json({ error: 'Room is not available for selected dates' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Check for blocked dates
|
||||
const { rows: blocked } = await db.query(`
|
||||
SELECT * FROM room_availability
|
||||
WHERE room_id = $1
|
||||
AND date >= $2
|
||||
AND date < $3
|
||||
AND status != 'available'
|
||||
`, [room_id, check_in, check_out]);
|
||||
|
||||
if (blocked.length > 0) {
|
||||
return NextResponse.json({ error: 'Some dates are not available' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Calculate total price
|
||||
const nights = Math.ceil((new Date(check_out).getTime() - new Date(check_in).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const pricePerNight = parseFloat(room.price) || 0;
|
||||
const total_price = nights * pricePerNight;
|
||||
|
||||
// Create reservation - for guests, user_id is NULL
|
||||
const { rows } = await db.query(`
|
||||
INSERT INTO room_reservations (room_id, user_id, check_in, check_out, status, total_price, guest_name, guest_contact_method, guest_contact)
|
||||
VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
`, [
|
||||
room_id,
|
||||
user?.id || null,
|
||||
check_in,
|
||||
check_out,
|
||||
total_price,
|
||||
guest_name || null,
|
||||
guest_contact_method || null,
|
||||
guest_contact || null,
|
||||
]);
|
||||
|
||||
// Block the dates
|
||||
const insertPromises = [];
|
||||
const checkInDate = new Date(check_in);
|
||||
const checkOutDate = new Date(check_out);
|
||||
for (let d = new Date(checkInDate); d < checkOutDate; d.setDate(d.getDate() + 1)) {
|
||||
insertPromises.push(
|
||||
db.query(`
|
||||
INSERT INTO room_availability (room_id, date, status, reason)
|
||||
VALUES ($1, $2, 'booked', $3)
|
||||
ON CONFLICT (room_id, date) DO UPDATE SET status = 'booked', reason = $3
|
||||
`, [room_id, d.toISOString().split('T')[0], `Reservation #${rows[0].id}`])
|
||||
);
|
||||
}
|
||||
await Promise.all(insertPromises);
|
||||
|
||||
// Get admin emails for notification
|
||||
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND email IS NOT NULL`);
|
||||
|
||||
// TODO: Send email notifications to admins
|
||||
// TODO: Send welcome email to user with WiFi password and instructions
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
reservation: rows[0],
|
||||
message: 'Reservation request submitted. You will receive confirmation shortly.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Reservation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to create reservation') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'all';
|
||||
|
||||
let query = `
|
||||
SELECT r.*, rm.name as room_name,
|
||||
u.username, u.first_name, u.last_name, u.email, u.phone
|
||||
FROM room_reservations r
|
||||
JOIN rooms rm ON r.room_id = rm.id
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
if (status !== 'all') {
|
||||
query += ' WHERE r.status = $1';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY r.created_at DESC';
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ reservations: rows });
|
||||
} catch (error) {
|
||||
console.error('Get reservations error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to get reservations') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// GET - list all room assignments (admin) or current user's assignment
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const onlyActive = searchParams.get('active') === 'true';
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
// Regular user - return their current active assignment
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const { rows } = await db.query(`
|
||||
SELECT ra.*, r.name as room_name, r.price
|
||||
FROM room_assignments ra
|
||||
JOIN rooms r ON ra.room_id = r.id
|
||||
WHERE ra.user_id = $1 AND ra.check_in <= $2 AND ra.check_out >= $2
|
||||
`, [user.id, today]);
|
||||
return NextResponse.json({ assignments: rows });
|
||||
}
|
||||
|
||||
// Admin - return all assignments
|
||||
let query = `
|
||||
SELECT ra.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email
|
||||
FROM room_assignments ra
|
||||
JOIN rooms r ON ra.room_id = r.id
|
||||
JOIN users u ON ra.user_id = u.id
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (onlyActive) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
query += ' WHERE ra.check_in <= $1 AND ra.check_out >= $1';
|
||||
params.push(today);
|
||||
}
|
||||
|
||||
query += ' ORDER BY ra.check_in DESC';
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ assignments: rows });
|
||||
} catch (error) {
|
||||
console.error('Get room assignments error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get room assignments' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - create new room assignment (admin only)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { room_id, user_id, check_in, check_out, notes } = await request.json();
|
||||
|
||||
if (!room_id || !user_id || !check_in || !check_out) {
|
||||
return NextResponse.json({ error: 'Room, user, check-in, and check-out are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check for conflicting assignments
|
||||
const { rows: conflicts } = await db.query(`
|
||||
SELECT id FROM room_assignments
|
||||
WHERE room_id = $1 AND check_in < $3 AND check_out > $2
|
||||
`, [room_id, check_in, check_out]);
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return NextResponse.json({ error: 'Room has conflicting assignment for these dates' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(`
|
||||
INSERT INTO room_assignments (room_id, user_id, check_in, check_out, notes, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`, [room_id, user_id, check_in, check_out, notes || null, user.id]);
|
||||
|
||||
return NextResponse.json({ success: true, assignment: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Create room assignment error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create room assignment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - remove room assignment (admin only)
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Assignment ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM room_assignments WHERE id = $1', [id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete room assignment error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete room assignment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const roomId = parseInt(params.id);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const start = searchParams.get('start');
|
||||
const end = searchParams.get('end');
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json({ error: 'start and end dates required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get availability status for the date range
|
||||
const { rows: availability } = await db.query(`
|
||||
SELECT date, status, reason
|
||||
FROM room_availability
|
||||
WHERE room_id = $1 AND date >= $2 AND date <= $3
|
||||
`, [roomId, start, end]);
|
||||
|
||||
// Get reservations in the date range
|
||||
const { rows: reservations } = await db.query(`
|
||||
SELECT check_in, check_out, status
|
||||
FROM reservations
|
||||
WHERE room_id = $1
|
||||
AND status != 'cancelled'
|
||||
AND check_in <= $3
|
||||
AND check_out >= $2
|
||||
`, [roomId, start, end]);
|
||||
|
||||
// Build day-by-day status
|
||||
const days: Array<{ date: string; status: 'available' | 'booked' | 'maintenance' }> = [];
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
const availabilityMap = new Map(availability.map(a => [a.date, a]));
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const avail = availabilityMap.get(dateStr);
|
||||
|
||||
if (avail) {
|
||||
days.push({ date: dateStr, status: avail.status as 'available' | 'booked' | 'maintenance' });
|
||||
} else {
|
||||
// Check if date falls within any reservation
|
||||
const isBooked = reservations.some(r => {
|
||||
const checkIn = new Date(r.check_in);
|
||||
const checkOut = new Date(r.check_out);
|
||||
return d >= checkIn && d < checkOut;
|
||||
});
|
||||
|
||||
days.push({ date: dateStr, status: isBooked ? 'booked' : 'available' });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ days });
|
||||
} catch (error) {
|
||||
console.error('Availability error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get availability' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const roomId = parseInt(params.id);
|
||||
const { date, status, reason } = await request.json();
|
||||
|
||||
if (!date || !status) {
|
||||
return NextResponse.json({ error: 'date and status required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(`
|
||||
INSERT INTO room_availability (room_id, date, status, reason)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (room_id, date) DO UPDATE SET status = $3, reason = $4
|
||||
RETURNING *
|
||||
`, [roomId, date, status, reason || null]);
|
||||
|
||||
return NextResponse.json({ success: true, availability: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Set availability error:', error);
|
||||
return NextResponse.json({ error: 'Failed to set availability' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
|
||||
const photoUrl = searchParams.get('photo_url') || null;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin
|
||||
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin, status
|
||||
FROM room_comments
|
||||
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE
|
||||
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE AND status = 'approved'
|
||||
ORDER BY created_at DESC`,
|
||||
[roomId, photoUrl]
|
||||
);
|
||||
@@ -47,12 +47,12 @@ export async function POST(request: NextRequest, { params }: { params: { id: str
|
||||
const lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null;
|
||||
|
||||
const { rows: inserted } = await db.query(
|
||||
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING *`,
|
||||
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()]
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: inserted[0] });
|
||||
return NextResponse.json({ data: inserted[0], message: 'Comment submitted for review' });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Failed to post comment' },
|
||||
|
||||
@@ -1,53 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
// GET - check if user liked a room and get total likes
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const roomId = parseInt(params.id, 10);
|
||||
if (isNaN(roomId)) {
|
||||
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
|
||||
const session = await getSession();
|
||||
|
||||
// Get total likes
|
||||
const { rows: countRows } = await db.query(
|
||||
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
|
||||
[roomId]
|
||||
);
|
||||
const totalLikes = parseInt(countRows[0].count) || 0;
|
||||
|
||||
// Check if current user liked this room
|
||||
let likedByUser = false;
|
||||
if (session) {
|
||||
const { rows: likeRows } = await db.query(
|
||||
'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, session.id]
|
||||
);
|
||||
likedByUser = likeRows.length > 0;
|
||||
}
|
||||
|
||||
const [countResult, userLikeResult] = await Promise.all([
|
||||
db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]),
|
||||
db.query('SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2', [roomId, 1]),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
count: parseInt(countResult.rows[0].count, 10),
|
||||
user_liked: userLikeResult.rows.length > 0,
|
||||
});
|
||||
return NextResponse.json({ totalLikes, likedByUser });
|
||||
} catch (err: any) {
|
||||
// Table may not exist yet — return safe defaults
|
||||
return NextResponse.json({ count: 0, user_liked: false });
|
||||
return NextResponse.json({ error: err.message || 'Failed to get likes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
// POST - toggle like (add if not liked, remove if already liked)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const roomId = parseInt(params.id, 10);
|
||||
if (isNaN(roomId)) {
|
||||
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// TODO: get user_id from session/auth instead of hardcoded
|
||||
const userId = 1;
|
||||
const roomId = parseInt(params.id, 10);
|
||||
|
||||
// Toggle: if already liked, unlike; otherwise like
|
||||
const existing = await db.query(
|
||||
'SELECT id FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, userId]
|
||||
// Check if already liked
|
||||
const { rows: existing } = await db.query(
|
||||
'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, session.id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await db.query('DELETE FROM room_likes WHERE id = $1', [existing.rows[0].id]);
|
||||
let liked: boolean;
|
||||
if (existing.length > 0) {
|
||||
// Unlike - remove the like
|
||||
await db.query(
|
||||
'DELETE FROM room_likes WHERE room_id = $1 AND user_id = $2',
|
||||
[roomId, session.id]
|
||||
);
|
||||
liked = false;
|
||||
} else {
|
||||
await db.query('INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)', [roomId, userId]);
|
||||
// Like - add the like
|
||||
await db.query(
|
||||
'INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)',
|
||||
[roomId, session.id]
|
||||
);
|
||||
liked = true;
|
||||
}
|
||||
|
||||
const countResult = await db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]);
|
||||
// Get updated count
|
||||
const { rows: countRows } = await db.query(
|
||||
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
|
||||
[roomId]
|
||||
);
|
||||
const totalLikes = parseInt(countRows[0].count) || 0;
|
||||
|
||||
return NextResponse.json({ count: parseInt(countResult.rows[0].count, 10) });
|
||||
// Update the rooms table for quick access
|
||||
await db.query(
|
||||
'UPDATE rooms SET likes_count = $1 WHERE id = $2',
|
||||
[totalLikes, roomId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ liked, totalLikes });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to toggle like' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -29,15 +29,16 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const { name, description, price, photos, sort_order, featured_photo, active, amenity_ids } = body;
|
||||
const { name, description, price, sort_order, featured_photo, active, amenity_ids } = body;
|
||||
|
||||
if (!name || !description || price === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Photos are stored in images table, not rooms.photos
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`,
|
||||
[name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id]
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, sort_order = $4, featured_photo = $5, active = $6 WHERE id = $7 RETURNING *`,
|
||||
[name, description, price, sort_order || 0, featured_photo || null, active !== false, id]
|
||||
);
|
||||
|
||||
// Update amenities
|
||||
@@ -47,7 +48,14 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
||||
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
// Get photos from images table
|
||||
const { rows: imageRows } = await db.query(
|
||||
'SELECT id, data, thumbnail FROM images WHERE room_id = $1 ORDER BY id',
|
||||
[id]
|
||||
);
|
||||
const photos = imageRows.map((r: any) => r.thumbnail || r.data);
|
||||
|
||||
return NextResponse.json({ data: { ...rows[0], photos, amenity_ids: amenity_ids || [] } });
|
||||
} catch (error: any) {
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('PUT /api/rooms/[id]:', error);
|
||||
|
||||
+64
-10
@@ -6,17 +6,39 @@ export async function GET() {
|
||||
try {
|
||||
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id');
|
||||
|
||||
// Get amenity links for all rooms
|
||||
const { rows: amenityLinks } = await db.query('SELECT room_id, amenity_id FROM room_amenity_links');
|
||||
const amenityMap = new Map<number, number[]>();
|
||||
for (const link of amenityLinks) {
|
||||
if (!amenityMap.has(link.room_id)) amenityMap.set(link.room_id, []);
|
||||
amenityMap.get(link.room_id)!.push(link.amenity_id);
|
||||
// Get amenity details with icons for all rooms
|
||||
const { rows: amenityRows } = await db.query(`
|
||||
SELECT ral.room_id, ra.id, ra.name, ra.icon_svg
|
||||
FROM room_amenity_links ral
|
||||
JOIN room_amenities ra ON ral.amenity_id = ra.id
|
||||
ORDER BY ra.sort_order
|
||||
`);
|
||||
|
||||
const amenityMap = new Map<number, Array<{id: number; name: string; icon_svg: string}>>();
|
||||
for (const row of amenityRows) {
|
||||
if (!amenityMap.has(row.room_id)) amenityMap.set(row.room_id, []);
|
||||
amenityMap.get(row.room_id)!.push({ id: row.id, name: row.name, icon_svg: row.icon_svg });
|
||||
}
|
||||
|
||||
// Get photos from images table for all rooms
|
||||
const { rows: imageRows } = await db.query(`
|
||||
SELECT room_id, data, thumbnail, medium
|
||||
FROM images
|
||||
WHERE room_id IS NOT NULL
|
||||
ORDER BY room_id, id
|
||||
`);
|
||||
|
||||
const imageMap = new Map<number, string[]>();
|
||||
for (const row of imageRows) {
|
||||
if (!imageMap.has(row.room_id)) imageMap.set(row.room_id, []);
|
||||
// Prefer thumbnail for display, fallback to data
|
||||
imageMap.get(row.room_id)!.push(row.thumbnail || row.data);
|
||||
}
|
||||
|
||||
const roomsWithAmenities = rows.map(r => ({
|
||||
...r,
|
||||
amenity_ids: amenityMap.get(r.id) || []
|
||||
photos: imageMap.get(r.id) || [],
|
||||
amenities: amenityMap.get(r.id) || []
|
||||
}));
|
||||
|
||||
return NextResponse.json({ data: roomsWithAmenities });
|
||||
@@ -30,17 +52,18 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { name, description, price, photos, sort_order, featured_photo, amenity_ids } = body;
|
||||
const { name, description, price, sort_order, featured_photo, amenity_ids } = body;
|
||||
|
||||
if (!name || !description || price === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Photos are now stored in images table, not rooms.photos
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, description, price, photos || [], sort_order || 0, featured_photo || null]
|
||||
[name, description, price, [], sort_order || 0, featured_photo || null]
|
||||
);
|
||||
|
||||
const roomId = rows[0].id;
|
||||
@@ -51,9 +74,40 @@ export async function POST(request: NextRequest) {
|
||||
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ...rows[0], amenity_ids: amenity_ids || [] } });
|
||||
return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } });
|
||||
} catch (error: any) {
|
||||
console.error('POST /api/rooms error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { id, name, description, price, active, sort_order, featured_photo, amenity_ids } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Room ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Photos are stored in images table, not rooms.photos
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, active = $4, sort_order = $5, featured_photo = $6
|
||||
WHERE id = $7 RETURNING *`,
|
||||
[name, description, price, active ?? true, sort_order ?? 0, featured_photo || null, id]
|
||||
);
|
||||
|
||||
// Update amenity links
|
||||
await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [id]);
|
||||
if (amenity_ids && amenity_ids.length > 0) {
|
||||
const values = amenity_ids.map((aid: number) => `(${id}, ${aid})`).join(',');
|
||||
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } });
|
||||
} catch (error: any) {
|
||||
console.error('PUT /api/rooms error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update room' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit';
|
||||
import { withRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
// GET - Get a single social event reservation
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
await requireAuth();
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-social-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT ser.*, se.name as event_name
|
||||
FROM social_event_reservations ser
|
||||
JOIN social_events se ON se.id = ser.social_event_id
|
||||
WHERE ser.id = $1`,
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error fetching reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update reservation status (admin only)
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-social-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
|
||||
if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current status for audit log
|
||||
const { rows: beforeRows } = await db.query(
|
||||
'SELECT * FROM social_event_reservations WHERE id = $1',
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (beforeRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const oldStatus = beforeRows[0].status;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE social_event_reservations SET status = $1 WHERE id = $2 RETURNING *`,
|
||||
[status, params.id]
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog({
|
||||
userId: session.id,
|
||||
action: 'status_change',
|
||||
entityType: 'social_event_reservation',
|
||||
entityId: parseInt(params.id, 10),
|
||||
oldValues: { status: oldStatus },
|
||||
newValues: { status },
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete a reservation (admin only)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = withRateLimit(request, 'admin-social-events');
|
||||
if (!rateCheck.allowed) {
|
||||
return rateCheck.response;
|
||||
}
|
||||
|
||||
// Get the reservation before deleting for audit log
|
||||
const { rows: beforeRows } = await db.query(
|
||||
'SELECT * FROM social_event_reservations WHERE id = $1',
|
||||
[params.id]
|
||||
);
|
||||
|
||||
if (beforeRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM social_event_reservations WHERE id = $1', [params.id]);
|
||||
|
||||
// Audit log
|
||||
await auditLog({
|
||||
userId: session.id,
|
||||
action: 'delete',
|
||||
entityType: 'social_event_reservation',
|
||||
entityId: parseInt(params.id, 10),
|
||||
oldValues: beforeRows[0],
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting reservation:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,31 @@ export async function GET(request: Request) {
|
||||
const session = await requireAuth();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const socialEventId = searchParams.get('social_event_id');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
let query = `
|
||||
SELECT ser.*, u.username, u.first_name, u.last_name, se.name as event_name
|
||||
SELECT ser.*, se.name as event_name
|
||||
FROM social_event_reservations ser
|
||||
JOIN users u ON u.id = ser.user_id
|
||||
JOIN social_events se ON se.id = ser.social_event_id
|
||||
`;
|
||||
const params: (number | string)[] = [];
|
||||
|
||||
if (session.role === 'admin') {
|
||||
const conditions: string[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (socialEventId) {
|
||||
query += ' WHERE ser.social_event_id = $1';
|
||||
conditions.push(`ser.social_event_id = $${paramCount++}`);
|
||||
params.push(parseInt(socialEventId, 10));
|
||||
}
|
||||
if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) {
|
||||
conditions.push(`ser.status = $${paramCount++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
} else {
|
||||
query += ' WHERE ser.user_id = $1';
|
||||
params.push(session.id);
|
||||
@@ -41,23 +52,38 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const body = await request.json();
|
||||
const { social_event_id, guests, notes } = body;
|
||||
const { social_event_id, guests, notes, contact_name, contact_email, contact_phone, contact_method } = body;
|
||||
|
||||
if (!social_event_id || !guests) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Try to authenticate, but allow guest submissions
|
||||
let userId: number | null = null;
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
userId = session.id;
|
||||
} catch {
|
||||
// Guest submission - require contact info
|
||||
if (!contact_name || !contact_method) {
|
||||
return NextResponse.json({ error: 'Contact name and method required for guest reservations' }, { status: 400 });
|
||||
}
|
||||
if (!['email', 'phone', 'whatsapp'].includes(contact_method)) {
|
||||
return NextResponse.json({ error: 'Invalid contact method' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, social_event_id)
|
||||
DO UPDATE SET guests = EXCLUDED.guests, notes = EXCLUDED.notes, status = 'pending'
|
||||
`INSERT INTO social_event_reservations
|
||||
(user_id, social_event_id, guests, notes, status, contact_name, contact_email, contact_phone, contact_method)
|
||||
VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[session.id, parseInt(social_event_id, 10), parseInt(guests, 10), notes || '']
|
||||
[userId, parseInt(social_event_id, 10), parseInt(guests, 10), notes || null, contact_name || null, contact_email || null, contact_phone || null, contact_method || null]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('POST /api/social_event_reservations error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: error.message || 'Failed to create reservation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,13 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const body = await request.json();
|
||||
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
|
||||
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
|
||||
const { rows } = await db.query(
|
||||
`UPDATE social_events
|
||||
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8
|
||||
WHERE id=$9
|
||||
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, is_private=$8, max_guests=$9, sort_order=$10
|
||||
WHERE id=$11
|
||||
RETURNING *`,
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0, id]
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0, id]
|
||||
);
|
||||
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
|
||||
@@ -2,15 +2,26 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { rows } = await db.query(`
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includePrivate = searchParams.get('includePrivate') === 'true';
|
||||
|
||||
// Public requests only see active, non-private events
|
||||
// Admin requests with includePrivate=true see all events
|
||||
let query = `
|
||||
SELECT se.*,
|
||||
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
|
||||
FROM social_events se
|
||||
WHERE se.active = TRUE
|
||||
ORDER BY se.sort_order, se.date, se.id
|
||||
`);
|
||||
`;
|
||||
|
||||
if (includePrivate) {
|
||||
query += ' ORDER BY se.is_private, se.sort_order, se.date, se.id';
|
||||
} else {
|
||||
query += ' WHERE se.active = TRUE AND (se.is_private = FALSE OR se.is_private IS NULL) ORDER BY se.sort_order, se.date, se.id';
|
||||
}
|
||||
|
||||
const { rows } = await db.query(query);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/social_events error:', error);
|
||||
@@ -22,12 +33,12 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
|
||||
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0]
|
||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, hashPassword } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { getErrorMessage } from '@/lib/errors';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -12,8 +13,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (new_password.length < 4) {
|
||||
return NextResponse.json({ error: 'Password must be at least 4 characters' }, { status: 400 });
|
||||
if (new_password.length < 12) {
|
||||
return NextResponse.json({ error: 'Password must be at least 12 characters' }, { status: 400 });
|
||||
}
|
||||
// Password complexity requirements
|
||||
if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) {
|
||||
return NextResponse.json({ error: 'Password must contain uppercase, lowercase, and numbers' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
@@ -33,7 +38,11 @@ export async function POST(request: NextRequest) {
|
||||
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, session.id]);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to change password' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error && err.message === 'Unauthorized' ? 'Unauthorized' : getErrorMessage(err, 'Failed to change password');
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{ status: err instanceof Error && err.message === 'Unauthorized' ? 401 : 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+458
-8
@@ -13,6 +13,19 @@ interface MenuItem {
|
||||
featured_photo?: string | null;
|
||||
}
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
@@ -20,15 +33,60 @@ const warm = '#a6683c';
|
||||
|
||||
export default function CoffeePage() {
|
||||
const [items, setItems] = useState<MenuItem[]>([]);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [assignedRoom, setAssignedRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Order state
|
||||
const [showOrderModal, setShowOrderModal] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Map<number, number>>(new Map());
|
||||
const [orderType, setOrderType] = useState<'pickup' | 'dine_in' | 'room_delivery'>('pickup');
|
||||
const [selectedRoom, setSelectedRoom] = useState<number | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [orderResult, setOrderResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/menu')
|
||||
.then(async (res) => {
|
||||
Promise.all([
|
||||
fetch('/api/menu').then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to load menu');
|
||||
const json = await res.json();
|
||||
setItems(json.data || []);
|
||||
return res.json();
|
||||
}),
|
||||
fetch('/api/auth/me').then(async (res) => {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.authenticated && data.id) {
|
||||
return { user: { id: data.id, username: data.username, role: data.role, first_name: data.first_name, last_name: data.last_name } };
|
||||
}
|
||||
}
|
||||
return { user: null };
|
||||
}),
|
||||
fetch('/api/rooms').then(async (res) => {
|
||||
if (!res.ok) return { rooms: [] };
|
||||
return res.json();
|
||||
}),
|
||||
])
|
||||
.then(async ([menuData, sessionData, roomsData]) => {
|
||||
setItems(menuData.data || []);
|
||||
setUser(sessionData.user || null);
|
||||
setRooms(roomsData.rooms || []);
|
||||
|
||||
// Fetch user's active room assignment if logged in
|
||||
if (sessionData.user) {
|
||||
try {
|
||||
const assignRes = await fetch('/api/room-assignments');
|
||||
if (assignRes.ok) {
|
||||
const assignData = await assignRes.json();
|
||||
if (assignData.assignments?.length > 0) {
|
||||
const assignment = assignData.assignments[0];
|
||||
setAssignedRoom({ id: assignment.room_id, name: assignment.room_name });
|
||||
setSelectedRoom(assignment.room_id);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -37,12 +95,111 @@ export default function CoffeePage() {
|
||||
const categories = Array.from(new Set(items.map((i) => i.category)));
|
||||
const daily = items.find((i) => i.is_daily_special);
|
||||
|
||||
// Calculate order totals
|
||||
const orderItems = items.filter((item) => selectedItems.has(item.id) && (selectedItems.get(item.id) || 0) > 0);
|
||||
const subtotal = orderItems.reduce((sum, item) => {
|
||||
const qty = selectedItems.get(item.id) || 0;
|
||||
return sum + parseFloat(item.price) * qty;
|
||||
}, 0);
|
||||
const serviceFee = orderType === 'room_delivery' ? subtotal * 0.1 : 0;
|
||||
const total = subtotal + serviceFee;
|
||||
|
||||
const updateQuantity = (itemId: number, delta: number) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const current = newMap.get(itemId) || 0;
|
||||
const newQty = Math.max(0, current + delta);
|
||||
if (newQty === 0) {
|
||||
newMap.delete(itemId);
|
||||
} else {
|
||||
newMap.set(itemId, newQty);
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitOrder = async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
alert('Please select at least one item');
|
||||
return;
|
||||
}
|
||||
|
||||
if (orderType === 'room_delivery' && !selectedRoom) {
|
||||
alert('Please select a room for delivery');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: Array.from(selectedItems.entries()).map(([menu_item_id, quantity]) => ({
|
||||
menu_item_id,
|
||||
quantity,
|
||||
})),
|
||||
order_type: orderType,
|
||||
room_id: orderType === 'room_delivery' ? selectedRoom : null,
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to place order');
|
||||
}
|
||||
|
||||
setOrderResult({ success: true, message: data.message });
|
||||
setSelectedItems(new Map());
|
||||
setNotes('');
|
||||
} catch (err) {
|
||||
setOrderResult({ success: false, message: err instanceof Error ? err.message : 'Failed to place order' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowOrderModal(false);
|
||||
setOrderResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ padding: 'clamp(1rem, 5vw, 2rem)', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 6vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.25rem' }}>Coffee & Kitchen</h1>
|
||||
<p style={{ color: '#555', marginBottom: '1.5rem', maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
|
||||
Locally sourced coffee, fresh pastries, and seasonal plates.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 6vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.25rem' }}>Coffee & Kitchen</h1>
|
||||
<p style={{ color: '#555', marginBottom: 0, maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
|
||||
Locally sourced coffee, fresh pastries, and seasonal plates.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowOrderModal(true)}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'clamp(14px, 3vw, 16px)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(232,168,73,0.3)',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(232,168,73,0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(232,168,73,0.3)';
|
||||
}}
|
||||
>
|
||||
Place Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <p style={{ color: warm }}>Loading menu...</p>}
|
||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||
@@ -130,6 +287,299 @@ export default function CoffeePage() {
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Order Modal */}
|
||||
{showOrderModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={closeModal}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
maxWidth: 'min(95vw, 600px)',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
padding: 'clamp(1rem, 4vw, 1.5rem)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{orderResult ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '1rem' }}>
|
||||
{orderResult.success ? '✓' : '✕'}
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy }}>{orderResult.success ? 'Order Placed!' : 'Error'}</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1.5rem' }}>{orderResult.message}</p>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
background: navy,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.75rem 2rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Place Your Order</h3>
|
||||
|
||||
{/* Order Type Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Order Type</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'pickup', label: 'Pickup' },
|
||||
{ value: 'dine_in', label: 'Dine In' },
|
||||
{ value: 'room_delivery', label: 'Room Delivery (+10%)' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setOrderType(opt.value as typeof orderType)}
|
||||
disabled={opt.value === 'room_delivery' && !user}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: '120px',
|
||||
padding: '0.75rem 1rem',
|
||||
border: `2px solid ${orderType === opt.value ? gold : '#ddd'}`,
|
||||
borderRadius: '8px',
|
||||
background: orderType === opt.value ? gold : 'white',
|
||||
color: orderType === opt.value ? 'white' : opt.value === 'room_delivery' && !user ? '#aaa' : navy,
|
||||
cursor: opt.value === 'room_delivery' && !user ? 'not-allowed' : 'pointer',
|
||||
opacity: opt.value === 'room_delivery' && !user ? 0.5 : 1,
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === 'room_delivery' && !user && (
|
||||
<div style={{ fontSize: '11px', marginTop: '2px' }}>Login required</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Selection for Delivery */}
|
||||
{orderType === 'room_delivery' && user && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>
|
||||
{assignedRoom ? 'Your Room' : 'Select Room'}
|
||||
</label>
|
||||
{assignedRoom ? (
|
||||
<div style={{
|
||||
padding: '0.75rem',
|
||||
border: '2px solid gold',
|
||||
borderRadius: '8px',
|
||||
background: '#fff9e6',
|
||||
color: navy,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
🏨 {assignedRoom.name}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedRoom || ''}
|
||||
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<option value="">Select a room...</option>
|
||||
{rooms.map((room) => (
|
||||
<option key={room.id} value={room.id}>{room.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Items Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Items</label>
|
||||
<div style={{ maxHeight: '300px', overflow: 'auto', border: '2px solid #ddd', borderRadius: '8px' }}>
|
||||
{categories.map((category) => (
|
||||
<div key={category}>
|
||||
<div style={{ background: '#f5f5f5', padding: '0.5rem 1rem', fontWeight: 600, color: navy, borderBottom: '1px solid #ddd' }}>
|
||||
{category}
|
||||
</div>
|
||||
{items
|
||||
.filter((i) => i.category === category)
|
||||
.map((item) => {
|
||||
const qty = selectedItems.get(item.id) || 0;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid #eee',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, color: navy }}>{item.name}</div>
|
||||
<div style={{ color: warm, fontWeight: 600 }}>${item.price}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, -1)}
|
||||
disabled={qty === 0}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
border: `2px solid ${qty > 0 ? navy : '#ddd'}`,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
color: qty > 0 ? navy : '#aaa',
|
||||
cursor: qty > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span style={{ minWidth: '24px', textAlign: 'center', fontWeight: 600 }}>{qty}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, 1)}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
border: `2px solid ${navy}`,
|
||||
borderRadius: '50%',
|
||||
background: navy,
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Special Instructions (optional)</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any allergies, preferences, or special requests..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
minHeight: '80px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
{selectedItems.size > 0 && (
|
||||
<div style={{ background: cream, borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem' }}>
|
||||
<h4 style={{ margin: '0 0 0.5rem', color: navy }}>Order Summary</h4>
|
||||
{orderItems.map((item) => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px', marginBottom: '0.25rem' }}>
|
||||
<span>{item.name} × {selectedItems.get(item.id)}</span>
|
||||
<span>${(parseFloat(item.price) * (selectedItems.get(item.id) || 0)).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ borderTop: '1px solid #ddd', marginTop: '0.5rem', paddingTop: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{orderType === 'room_delivery' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: warm }}>
|
||||
<span>Service Fee (10%)</span>
|
||||
<span>${serviceFee.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, marginTop: '0.25rem', fontSize: '16px' }}>
|
||||
<span>Total</span>
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
background: 'white',
|
||||
color: navy,
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitOrder}
|
||||
disabled={submitting || selectedItems.size === 0}
|
||||
style={{
|
||||
flex: 2,
|
||||
padding: '0.75rem',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: submitting || selectedItems.size === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: submitting || selectedItems.size === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Placing Order...' : `Place Order • $${total.toFixed(2)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,24 @@ a {
|
||||
|
||||
.navbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-icon-link {
|
||||
color: var(--gold);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navbar-icon-link:hover {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.navbar-btn-ghost {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { formatDisplayDateTime } from '@/lib/date';
|
||||
|
||||
type User = { id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
|
||||
type Conversation = {
|
||||
id: number;
|
||||
subject: string | null;
|
||||
last_message: string | null;
|
||||
last_message_at: string | null;
|
||||
unread_count: number;
|
||||
participants: Participant[];
|
||||
};
|
||||
|
||||
export default function MessagesPage() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConv, setSelectedConv] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [showNewConv, setShowNewConv] = useState(false);
|
||||
const [convSubject, setConvSubject] = useState('');
|
||||
const [convMessage, setConvMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchConversations();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConv) {
|
||||
fetchMessages(selectedConv);
|
||||
}
|
||||
}, [selectedConv]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.authenticated) {
|
||||
setUser({
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
role: data.role,
|
||||
});
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/conversations');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConversations(data.conversations || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch conversations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async (convId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${convId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(data.messages || []);
|
||||
setParticipants(data.participants || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.trim() || !selectedConv) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newMessage.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setNewMessage('');
|
||||
fetchConversations(); // Update last message in list
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const createConversation = async () => {
|
||||
if (!convMessage.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/conversations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: convSubject || null,
|
||||
initial_message: convMessage.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConversations(prev => [data.conversation, ...prev]);
|
||||
setSelectedConv(data.conversation.id);
|
||||
setShowNewConv(false);
|
||||
setConvSubject('');
|
||||
setConvMessage('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getOtherParticipants = (conv: Conversation) => {
|
||||
return conv.participants.filter(p => p.id !== user?.id);
|
||||
};
|
||||
|
||||
const getDisplayName = (p: Participant) => {
|
||||
if (p.role === 'admin') {
|
||||
return p.first_name || p.username;
|
||||
}
|
||||
return p.first_name || p.username;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-6xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">Messages</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="flex h-[600px]">
|
||||
{/* Conversations List */}
|
||||
<div className={`w-full md:w-1/3 border-r ${selectedConv ? 'hidden md:block' : ''}`}>
|
||||
<div className="p-3 border-b bg-gray-50 flex justify-between items-center">
|
||||
<span className="font-medium text-gray-700">Conversations</span>
|
||||
<button
|
||||
onClick={() => setShowNewConv(true)}
|
||||
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto h-[calc(100%-50px)]">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No conversations yet. Start a new one!
|
||||
</div>
|
||||
) : (
|
||||
conversations.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
|
||||
selectedConv === conv.id ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="font-medium text-gray-800">
|
||||
{conv.subject || getOtherParticipants(conv).map(getDisplayName).join(', ') || 'Conversation'}
|
||||
</div>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<div className="text-sm text-gray-500 truncate mt-1">
|
||||
{conv.last_message}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages View */}
|
||||
<div className={`flex-1 flex flex-col ${selectedConv ? '' : 'hidden md:flex'}`}>
|
||||
{selectedConv ? (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b bg-gray-50">
|
||||
<button
|
||||
onClick={() => setSelectedConv(null)}
|
||||
className="md:hidden text-blue-600 mr-3"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="font-medium text-gray-700">
|
||||
{participants.filter(p => p.id !== user?.id).map(getDisplayName).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.sender_id === user?.id ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[70%] ${msg.sender_id === user?.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
|
||||
{msg.sender_id !== user?.id && (
|
||||
<div className="text-xs font-medium mb-1 text-gray-600">
|
||||
{msg.first_name || msg.username}
|
||||
</div>
|
||||
)}
|
||||
<div>{msg.content}</div>
|
||||
<div className={`text-xs mt-1 ${msg.sender_id === user?.id ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
{formatDisplayDateTime(msg.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t bg-gray-50">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!newMessage.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
Select a conversation or start a new one
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Conversation Modal */}
|
||||
{showNewConv && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-bold mb-4">New Conversation</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={convSubject}
|
||||
onChange={e => setConvSubject(e.target.value)}
|
||||
placeholder="What is this about?"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
|
||||
<textarea
|
||||
value={convMessage}
|
||||
onChange={e => setConvMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
rows={4}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowNewConv(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createConversation}
|
||||
disabled={!convMessage.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+543
-43
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { formatDisplayDate } from '@/lib/date';
|
||||
import RoomCalendar from '@/components/RoomCalendar';
|
||||
import OptimizedImage from '@/components/OptimizedImage';
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
@@ -11,6 +13,7 @@ interface Room {
|
||||
photos: string[];
|
||||
featured_photo: string | null;
|
||||
active?: boolean;
|
||||
amenities: Array<{ id: number; name: string; icon_svg: string }>;
|
||||
}
|
||||
|
||||
interface RoomComment {
|
||||
@@ -30,6 +33,7 @@ interface User {
|
||||
role: 'admin' | 'user';
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email?: string;
|
||||
comments_disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -58,6 +62,15 @@ export default function RoomsPage() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({});
|
||||
|
||||
// Action modal states
|
||||
const [activeModal, setActiveModal] = useState<'reserve' | 'message' | 'calendar' | 'review' | null>(null);
|
||||
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
|
||||
const [reservationDates, setReservationDates] = useState({ checkIn: '', checkOut: '' });
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [reviewText, setReviewText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [guestForm, setGuestForm] = useState({ name: '', contactMethod: 'email', contact: '' });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/rooms')
|
||||
.then(async (res) => {
|
||||
@@ -74,7 +87,21 @@ export default function RoomsPage() {
|
||||
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then((j) => setUser(j.user || null))
|
||||
.then((j) => {
|
||||
if (j.authenticated) {
|
||||
setUser({
|
||||
id: j.id,
|
||||
username: j.username,
|
||||
role: j.role,
|
||||
first_name: j.first_name,
|
||||
last_name: j.last_name,
|
||||
email: j.email,
|
||||
comments_disabled: j.comments_disabled,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -139,13 +166,155 @@ export default function RoomsPage() {
|
||||
|
||||
const getPhoto = (room: Room, index: number): string | null => {
|
||||
if (!room.photos || room.photos.length === 0) return null;
|
||||
return room.photos[Math.min(index, room.photos.length - 1)] || null;
|
||||
const photo = room.photos[Math.min(index, room.photos.length - 1)];
|
||||
if (!photo) return null;
|
||||
// If it's a URL with id= param, extract id; if it's base64, return as-is
|
||||
if (photo.includes('id=')) {
|
||||
return photo;
|
||||
}
|
||||
return photo;
|
||||
};
|
||||
|
||||
const getPhotoId = (photo: string): number | null => {
|
||||
if (!photo) return null;
|
||||
if (photo.includes('id=')) {
|
||||
const match = photo.match(/id=(\d+)/);
|
||||
return match ? parseInt(match[1]) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getActiveIndex = (room: Room): number => {
|
||||
return activePhotoByRoom[room.id] ?? 0;
|
||||
};
|
||||
|
||||
const openModal = (type: 'reserve' | 'message' | 'calendar' | 'review', room: Room) => {
|
||||
setSelectedRoom(room);
|
||||
setActiveModal(type);
|
||||
setReservationDates({ checkIn: '', checkOut: '' });
|
||||
setMessageText('');
|
||||
setReviewText('');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setActiveModal(null);
|
||||
setSelectedRoom(null);
|
||||
};
|
||||
|
||||
const handleReserve = async () => {
|
||||
if (!selectedRoom || !reservationDates.checkIn || !reservationDates.checkOut) {
|
||||
alert('Please select dates');
|
||||
return;
|
||||
}
|
||||
|
||||
// For guests, require name and contact
|
||||
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
|
||||
alert('Please provide your name and contact info');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/reservations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
room_id: selectedRoom.id,
|
||||
check_in: reservationDates.checkIn,
|
||||
check_out: reservationDates.checkOut,
|
||||
...(user ? {} : {
|
||||
guest_name: guestForm.name.trim(),
|
||||
guest_contact_method: guestForm.contactMethod,
|
||||
guest_contact: guestForm.contact.trim(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Reservation request sent! You will receive a confirmation shortly.');
|
||||
closeModal();
|
||||
setReservationDates({ checkIn: '', checkOut: '' });
|
||||
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to create reservation');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to create reservation');
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!selectedRoom || !messageText.trim()) {
|
||||
alert('Please enter a message');
|
||||
return;
|
||||
}
|
||||
|
||||
// For guests, require name and contact
|
||||
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
|
||||
alert('Please provide your name and contact info');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
room_id: selectedRoom.id,
|
||||
message: messageText.trim(),
|
||||
...(user ? {} : {
|
||||
guest_name: guestForm.name.trim(),
|
||||
guest_contact_method: guestForm.contactMethod,
|
||||
guest_contact: guestForm.contact.trim(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Message sent to our team!');
|
||||
closeModal();
|
||||
setMessageText('');
|
||||
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to send message');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to send message');
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!selectedRoom || !reviewText.trim() || !user) {
|
||||
alert('Please log in and enter a review');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${selectedRoom.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ text: reviewText.trim(), status: 'pending' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Review submitted for approval');
|
||||
closeModal();
|
||||
loadComments(selectedRoom.id);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to submit review');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to submit review');
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const displayedRooms = rooms.filter((r) => r.active !== false);
|
||||
|
||||
return (
|
||||
@@ -205,65 +374,56 @@ export default function RoomsPage() {
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
||||
>
|
||||
{/* ─── Main Image ─── */}
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<div style={{ position: 'relative', width: '100%', height: '300px' }}>
|
||||
{activeSrc ? (
|
||||
<img
|
||||
src={activeSrc}
|
||||
alt={room.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
activeSrc.startsWith('data:') ? (
|
||||
<img
|
||||
src={activeSrc}
|
||||
alt={room.name}
|
||||
style={{ width: '100%', height: '300px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<OptimizedImage
|
||||
id={parseInt(activeSrc.split('id=')[1])}
|
||||
alt={room.name}
|
||||
size="medium"
|
||||
lazy={true}
|
||||
style={{ width: '100%', height: '300px' }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
|
||||
No photo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Like button overlay */}
|
||||
{/* Like button with count */}
|
||||
<button
|
||||
onClick={() => toggleLike(room.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.4)',
|
||||
color: '#fff',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.15)'; }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
aria-label="Like this room"
|
||||
>
|
||||
{hasLiked ? '\u2764\uFE0F' : '\u2661'}
|
||||
<span style={{ fontSize: '16px' }}>{hasLiked ? '❤️' : '♡'}</span>
|
||||
<span>{totalLikes}</span>
|
||||
</button>
|
||||
|
||||
{/* Like count */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 12,
|
||||
background: hasLiked ? 'rgba(231,76,60,0.85)' : 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '999px',
|
||||
}}>
|
||||
{'\u2764\uFE0F'} {totalLikes}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Thumbnail Strip ─── */}
|
||||
@@ -299,7 +459,21 @@ export default function RoomsPage() {
|
||||
}}
|
||||
>
|
||||
{p ? (
|
||||
<img src={p} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
p.startsWith('data:') ? (
|
||||
<img
|
||||
src={p}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<OptimizedImage
|
||||
id={parseInt(p.split('id=')[1])}
|
||||
alt=""
|
||||
size="thumbnail"
|
||||
lazy={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', background: '#ddd' }} />
|
||||
)}
|
||||
@@ -311,13 +485,105 @@ export default function RoomsPage() {
|
||||
|
||||
{/* ─── Room Info ─── */}
|
||||
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{room.name}</h2>
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
|
||||
<h2 style={{ margin: 0, color: navy, fontSize: '24px' }}>{room.name}</h2>
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1, marginTop: '0.5rem' }}>{room.description}</p>
|
||||
|
||||
{/* ─── Amenity Icons ─── */}
|
||||
{room.amenities && room.amenities.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.75rem' }}>
|
||||
{room.amenities.slice(0, 6).map((amenity) => (
|
||||
<div
|
||||
key={amenity.id}
|
||||
title={amenity.name}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.35rem 0.6rem',
|
||||
background: 'rgba(1,13,30,0.06)',
|
||||
borderRadius: '999px',
|
||||
fontSize: '12px',
|
||||
color: navy,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{amenity.icon_svg ? (
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '16px', height: '16px' }}
|
||||
dangerouslySetInnerHTML={{ __html: amenity.icon_svg
|
||||
.replace(/width="[^"]*"/g, '')
|
||||
.replace(/height="[^"]*"/g, '')
|
||||
.replace(/stroke="currentColor"/g, `stroke="${navy}"`)
|
||||
.replace(/<svg/, '<svg style="width:100%;height:100%"')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span>{amenity.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{room.amenities.length > 6 && (
|
||||
<span style={{ fontSize: '10px', color: '#777', alignSelf: 'center', padding: '0 0.5rem' }}>+{room.amenities.length - 6}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
|
||||
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
|
||||
</div>
|
||||
|
||||
{/* ─── Action Buttons ─── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
onClick={() => openModal('message', room)}
|
||||
style={{
|
||||
padding: '0.6rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
border: `2px solid ${navy}`,
|
||||
background: 'transparent',
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Message
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('calendar', room)}
|
||||
style={{
|
||||
padding: '0.6rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
border: `2px solid ${navy}`,
|
||||
background: 'transparent',
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Reserve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('review', room)}
|
||||
style={{
|
||||
padding: '0.6rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
border: `2px solid ${navy}`,
|
||||
background: 'transparent',
|
||||
color: navy,
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comments button — cream */}
|
||||
<button
|
||||
onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
|
||||
@@ -422,6 +688,240 @@ export default function RoomsPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ─── Modals ─── */}
|
||||
{activeModal && selectedRoom && (
|
||||
<div
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '20px',
|
||||
padding: '2rem',
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Reserve Modal */}
|
||||
{activeModal === 'reserve' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
|
||||
{!user ? (
|
||||
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to make a reservation.</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkIn}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkOut}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
|
||||
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Message Modal */}
|
||||
{activeModal === 'message' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Send a Message</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>Send a message about {selectedRoom.name} to our team.</p>
|
||||
{!user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={guestForm.name}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
|
||||
placeholder="Enter your name"
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
|
||||
<select
|
||||
value={guestForm.contactMethod}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="phone">Phone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
|
||||
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
|
||||
</label>
|
||||
<input
|
||||
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
|
||||
value={guestForm.contact}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
|
||||
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
|
||||
<strong>From:</strong> {user.first_name} {user.last_name} ({user.email})
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
placeholder="Your message..."
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleSendMessage} disabled={submitting || !messageText.trim() || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Sending...' : 'Send Message'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reserve/Calendar Modal */}
|
||||
{activeModal === 'calendar' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>Select your dates and we'll contact you to confirm.</p>
|
||||
{!user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={guestForm.name}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
|
||||
placeholder="Enter your name"
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
|
||||
<select
|
||||
value={guestForm.contactMethod}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="phone">Phone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
|
||||
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
|
||||
</label>
|
||||
<input
|
||||
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
|
||||
value={guestForm.contact}
|
||||
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
|
||||
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
|
||||
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
|
||||
<strong>Reserving as:</strong> {user.first_name} {user.last_name} ({user.email})
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkIn}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reservationDates.checkOut}
|
||||
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
|
||||
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Availability Calendar</label>
|
||||
<RoomCalendar roomId={selectedRoom.id} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Review Modal */}
|
||||
{activeModal === 'review' && (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Leave a Review</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>Share your experience with {selectedRoom.name}.</p>
|
||||
{!user ? (
|
||||
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to leave a review.</p>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={reviewText}
|
||||
onChange={(e) => setReviewText(e.target.value)}
|
||||
placeholder="Your review or suggestion..."
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: '#777', marginTop: '0.5rem' }}>Reviews are submitted for admin approval before being published.</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button onClick={handleReview} disabled={submitting || !reviewText.trim()} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Submitting...' : 'Submit Review'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,22 @@ import { Language, setLanguage, useLanguage } from '@/lib/i18n';
|
||||
|
||||
function Flag({ language }: { language: Language }) {
|
||||
if (language === 'en') {
|
||||
// Union Jack (UK flag) - simplified but recognizable
|
||||
return (
|
||||
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" aria-hidden="true">
|
||||
<rect width="24" height="16" fill="white" />
|
||||
<rect x="0" y="6" width="24" height="4" fill="#CE1124" />
|
||||
<rect x="10" y="0" width="4" height="16" fill="#CE1124" />
|
||||
<svg width="24" height="16" viewBox="0 0 60 30" fill="none" aria-hidden="true">
|
||||
{/* Blue background */}
|
||||
<rect width="60" height="30" fill="#012169"/>
|
||||
{/* White diagonal saltire (St Andrew) */}
|
||||
<path d="M0 0L60 30M60 0L0 30" stroke="white" strokeWidth="6"/>
|
||||
{/* Red diagonal saltire (St Patrick) - offset */}
|
||||
<path d="M0 0L30 15M60 0L30 15" stroke="#C8102E" strokeWidth="2"/>
|
||||
<path d="M60 30L30 15M0 30L30 15" stroke="#C8102E" strokeWidth="2"/>
|
||||
{/* White cross (St George + St Andrew) */}
|
||||
<rect x="25" y="0" width="10" height="30" fill="white"/>
|
||||
<rect x="0" y="10" width="60" height="10" fill="white"/>
|
||||
{/* Red cross (St George) */}
|
||||
<rect x="27" y="0" width="6" height="30" fill="#C8102E"/>
|
||||
<rect x="0" y="12" width="60" height="6" fill="#C8102E"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface LikeButtonProps {
|
||||
roomId: number;
|
||||
}
|
||||
|
||||
export default function LikeButton({ roomId }: LikeButtonProps) {
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [totalLikes, setTotalLikes] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/rooms/${roomId}/likes`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setLiked(data.likedByUser);
|
||||
setTotalLikes(data.totalLikes);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [roomId]);
|
||||
|
||||
const toggleLike = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${roomId}/likes`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setLiked(data.liked);
|
||||
setTotalLikes(data.totalLikes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle like:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
borderRadius: '999px',
|
||||
fontSize: '13px',
|
||||
color: '#666'
|
||||
}}>
|
||||
...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleLike}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
background: liked ? '#fee2e2' : 'rgba(0,0,0,0.04)',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: liked ? '#dc2626' : '#666',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
title={liked ? 'Unlike this room' : 'Like this room'}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill={liked ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
{totalLikes > 0 && <span>{totalLikes}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default function Navbar() {
|
||||
link.href = data.data;
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
@@ -85,6 +85,17 @@ export default function Navbar() {
|
||||
})}
|
||||
</ul>
|
||||
<div className="navbar-actions">
|
||||
{auth?.authenticated && (
|
||||
<Link
|
||||
href={auth?.role === 'admin' ? '/admin/messages' : '/messages'}
|
||||
className="navbar-icon-link"
|
||||
title="Messages"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
{auth?.authenticated ? (
|
||||
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface OptimizedImageProps {
|
||||
id: number;
|
||||
alt: string;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
size?: 'thumbnail' | 'medium' | 'full';
|
||||
lazy?: boolean;
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
export default function OptimizedImage({
|
||||
id,
|
||||
alt,
|
||||
style,
|
||||
className,
|
||||
size = 'medium',
|
||||
lazy = true,
|
||||
placeholder = true,
|
||||
}: OptimizedImageProps) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [inView, setInView] = useState(!lazy);
|
||||
const [error, setError] = useState(false);
|
||||
const imgRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (!lazy || !imgRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '50px' }
|
||||
);
|
||||
|
||||
observer.observe(imgRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [lazy]);
|
||||
|
||||
const getUrl = () => {
|
||||
const base = `/api/image?id=${id}`;
|
||||
if (size === 'thumbnail') return `${base}&thumb=1`;
|
||||
if (size === 'medium') return `${base}&medium=1`;
|
||||
return base;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={imgRef}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: placeholder ? '#f0f0f0' : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Placeholder shimmer */}
|
||||
{placeholder && !loaded && !error && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(90deg, #f5f0e8 25%, #e8e4dc 50%, #f5f0e8 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s ease-in-out infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
⚠️ Failed to load
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actual image - only loads when in view */}
|
||||
{inView && !error && (
|
||||
<img
|
||||
src={getUrl()}
|
||||
alt={alt}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 0.4s ease-out',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface RoomCalendarProps {
|
||||
roomId: number;
|
||||
}
|
||||
|
||||
interface AvailabilityDay {
|
||||
date: string;
|
||||
status: 'available' | 'booked' | 'maintenance';
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
|
||||
export default function RoomCalendar({ roomId }: RoomCalendarProps) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [availability, setAvailability] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
|
||||
const endDate = new Date(year, month + 1, 0).toISOString().split('T')[0];
|
||||
|
||||
fetch(`/api/rooms/${roomId}/availability?start=${startDate}&end=${endDate}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const map: Record<string, string> = {};
|
||||
(data.days || []).forEach((d: AvailabilityDay) => {
|
||||
map[d.date] = d.status;
|
||||
});
|
||||
setAvailability(map);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [roomId, currentMonth]);
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||
return { daysInMonth, firstDayOfMonth };
|
||||
};
|
||||
|
||||
const { daysInMonth, firstDayOfMonth } = getDaysInMonth(currentMonth);
|
||||
const days = [];
|
||||
|
||||
// Empty cells for days before the first day of the month
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
days.push(<div key={`empty-${i}`} />);
|
||||
}
|
||||
|
||||
// Days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const status = availability[dateStr] || 'available';
|
||||
const isToday = dateStr === new Date().toISOString().split('T')[0];
|
||||
|
||||
let bgColor = '#e8f5e9'; // green for available
|
||||
let textColor = navy;
|
||||
if (status === 'booked') {
|
||||
bgColor = '#ffebee'; // red for booked
|
||||
textColor = '#c23b22';
|
||||
} else if (status === 'maintenance') {
|
||||
bgColor = '#fff3e0'; // orange for maintenance
|
||||
textColor = '#e65100';
|
||||
}
|
||||
|
||||
days.push(
|
||||
<div
|
||||
key={day}
|
||||
title={`${dateStr}: ${status}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontWeight: isToday ? 700 : 400,
|
||||
border: isToday ? `2px solid ${gold}` : 'none',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const monthName = currentMonth.toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<button onClick={prevMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>←</button>
|
||||
<span style={{ fontWeight: 600, color: navy }}>{monthName}</span>
|
||||
<button onClick={nextMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>→</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '4px', textAlign: 'center' }}>
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
|
||||
<div key={d} style={{ fontSize: '11px', fontWeight: 600, color: '#777', padding: '4px' }}>{d}</div>
|
||||
))}
|
||||
{days}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', fontSize: '12px', justifyContent: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<div style={{ width: '12px', height: '12px', background: '#e8f5e9', borderRadius: '2px' }} /> Available
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<div style={{ width: '12px', height: '12px', background: '#ffebee', borderRadius: '2px' }} /> Booked
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<div style={{ width: '12px', height: '12px', background: '#fff3e0', borderRadius: '2px' }} /> Maintenance
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-10
@@ -18,17 +18,8 @@ export function useAuth() {
|
||||
}, []);
|
||||
|
||||
const login = useCallback((username: string, password: string): boolean => {
|
||||
// TODO: Implement server-side authentication via API call
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (username === 'admin' && password === 'admin') {
|
||||
localStorage.setItem(ROLE_KEY, 'admin');
|
||||
setRole('admin');
|
||||
return true;
|
||||
}
|
||||
if (username === 'user' && password === 'user') {
|
||||
localStorage.setItem(ROLE_KEY, 'user');
|
||||
setRole('user');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Audit logging for admin actions
|
||||
// Records who did what to which entity, with before/after values
|
||||
|
||||
import { db } from './db';
|
||||
|
||||
export type AuditAction =
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'status_change'
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'password_change';
|
||||
|
||||
export type EntityType =
|
||||
| 'reservation'
|
||||
| 'private_event_reservation'
|
||||
| 'social_event_reservation'
|
||||
| 'room_reservation'
|
||||
| 'user'
|
||||
| 'room'
|
||||
| 'menu_item'
|
||||
| 'social_event'
|
||||
| 'offer'
|
||||
| 'config';
|
||||
|
||||
interface AuditLogEntry {
|
||||
userId?: number;
|
||||
action: AuditAction;
|
||||
entityType: EntityType;
|
||||
entityId?: number;
|
||||
oldValues?: Record<string, unknown>;
|
||||
newValues?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an admin action to the audit log
|
||||
*/
|
||||
export async function auditLog(entry: AuditLogEntry): Promise<void> {
|
||||
try {
|
||||
await db.query(
|
||||
`INSERT INTO audit_log
|
||||
(user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
entry.userId || null,
|
||||
entry.action,
|
||||
entry.entityType,
|
||||
entry.entityId || null,
|
||||
entry.oldValues ? JSON.stringify(entry.oldValues) : null,
|
||||
entry.newValues ? JSON.stringify(entry.newValues) : null,
|
||||
entry.ipAddress || null,
|
||||
entry.userAgent || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
// Log but don't throw - audit failures shouldn't break the main operation
|
||||
console.error('Failed to write audit log:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP from request headers
|
||||
*/
|
||||
export function getAuditClientIp(request: Request): string | undefined {
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
return request.headers.get('x-real-ip') || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user agent from request
|
||||
*/
|
||||
export function getAuditUserAgent(request: Request): string | undefined {
|
||||
return request.headers.get('user-agent') || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to log a status change on a reservation
|
||||
*/
|
||||
export async function logStatusChange(
|
||||
request: Request,
|
||||
userId: number,
|
||||
entityType: EntityType,
|
||||
entityId: number,
|
||||
oldStatus: string,
|
||||
newStatus: string
|
||||
): Promise<void> {
|
||||
await auditLog({
|
||||
userId,
|
||||
action: 'status_change',
|
||||
entityType,
|
||||
entityId,
|
||||
oldValues: { status: oldStatus },
|
||||
newValues: { status: newStatus },
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to log a deletion
|
||||
*/
|
||||
export async function logDeletion(
|
||||
request: Request,
|
||||
userId: number,
|
||||
entityType: EntityType,
|
||||
entityId: number,
|
||||
deletedValues: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await auditLog({
|
||||
userId,
|
||||
action: 'delete',
|
||||
entityType,
|
||||
entityId,
|
||||
oldValues: deletedValues,
|
||||
ipAddress: getAuditClientIp(request),
|
||||
userAgent: getAuditUserAgent(request),
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -54,9 +54,9 @@ export async function authenticateUser(username: string, password: string) {
|
||||
export async function createSession(user: { id: number; username: string; role: string }) {
|
||||
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setExpirationTime('7d')
|
||||
.setExpirationTime('4h') // 4 hours for security (reduced from 7 days)
|
||||
.sign(getSecret());
|
||||
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 7 });
|
||||
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 4 }); // 4 hours
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Production-safe error handling utility
|
||||
* Returns detailed errors in development, generic errors in production
|
||||
*/
|
||||
|
||||
export function getErrorMessage(error: unknown, fallback = 'An error occurred'): string {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized error response
|
||||
*/
|
||||
export function errorResponse(message: string, status = 500, details?: Record<string, unknown>) {
|
||||
const body: Record<string, unknown> = { error: message };
|
||||
|
||||
// Only include details in development
|
||||
if (process.env.NODE_ENV !== 'production' && details) {
|
||||
body.details = details;
|
||||
}
|
||||
|
||||
return Response.json(body, { status });
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Rate limiting for public API endpoints
|
||||
// Uses in-memory store with sliding window - suitable for single-instance deployments
|
||||
// For multi-instance deployments, consider using Redis or database-backed rate limiting
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
interface RateLimitConfig {
|
||||
windowMs: number; // Time window in milliseconds
|
||||
maxRequests: number; // Max requests per window
|
||||
}
|
||||
|
||||
// Default configs for different endpoints
|
||||
export const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||
'private-event-reservation': {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
maxRequests: 5, // 5 submissions per hour per IP
|
||||
},
|
||||
'contact-form': {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
maxRequests: 10,
|
||||
},
|
||||
'login': {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
maxRequests: 10,
|
||||
},
|
||||
// Admin endpoints - higher limits for legitimate admin use
|
||||
'admin-private-events': {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
maxRequests: 100, // 100 requests per minute
|
||||
},
|
||||
'admin-social-events': {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
maxRequests: 100, // 100 requests per minute
|
||||
},
|
||||
};
|
||||
|
||||
// Clean up expired entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
rateLimitStore.forEach((entry, key) => {
|
||||
if (entry.resetAt < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000); // Clean every minute
|
||||
|
||||
/**
|
||||
* Check if a request is rate limited
|
||||
* @param endpointKey - The endpoint identifier (e.g., 'private-event-reservation')
|
||||
* @param identifier - The client identifier (e.g., IP address)
|
||||
* @returns { limited: boolean, remaining: number, resetAt: number }
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
endpointKey: string,
|
||||
identifier: string
|
||||
): { limited: boolean; remaining: number; resetAt: number; retryAfter?: number } {
|
||||
const config = RATE_LIMITS[endpointKey];
|
||||
if (!config) {
|
||||
// No rate limit configured for this endpoint
|
||||
return { limited: false, remaining: Infinity, resetAt: Date.now() + 60000 };
|
||||
}
|
||||
|
||||
const key = `${endpointKey}:${identifier}`;
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(key);
|
||||
|
||||
// If no entry or window expired, create new entry
|
||||
if (!entry || entry.resetAt < now) {
|
||||
const resetAt = now + config.windowMs;
|
||||
rateLimitStore.set(key, { count: 1, resetAt });
|
||||
return { limited: false, remaining: config.maxRequests - 1, resetAt };
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (entry.count >= config.maxRequests) {
|
||||
return {
|
||||
limited: true,
|
||||
remaining: 0,
|
||||
resetAt: entry.resetAt,
|
||||
retryAfter: Math.ceil((entry.resetAt - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// Increment count
|
||||
entry.count++;
|
||||
return { limited: false, remaining: config.maxRequests - entry.count, resetAt: entry.resetAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP from request headers
|
||||
* Handles X-Forwarded-For and X-Real-IP headers for reverse proxy setups
|
||||
*/
|
||||
export function getClientIp(request: Request): string {
|
||||
// Check X-Forwarded-For header (common for reverse proxies)
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
// Take the first IP (original client) from the chain
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Check X-Real-IP header (used by some proxies)
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
// Fallback - this may be undefined in some environments
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware-style rate limit checker for API routes
|
||||
* Returns null if allowed, or a Response object if rate limited
|
||||
*/
|
||||
export function withRateLimit(
|
||||
request: Request,
|
||||
endpointKey: string
|
||||
): { allowed: true } | { allowed: false; response: Response } {
|
||||
const clientIp = getClientIp(request);
|
||||
const result = checkRateLimit(endpointKey, clientIp);
|
||||
|
||||
if (result.limited) {
|
||||
return {
|
||||
allowed: false,
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: result.retryAfter,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(result.retryAfter || 60),
|
||||
'X-RateLimit-Limit': String(RATE_LIMITS[endpointKey]?.maxRequests || 0),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export async function initSchema() {
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
thumbnail TEXT,
|
||||
medium TEXT,
|
||||
category VARCHAR(100) DEFAULT 'other',
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
location_target VARCHAR(100) DEFAULT 'gallery',
|
||||
@@ -27,6 +29,10 @@ export async function initSchema() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Add columns if they don't exist (for existing databases)
|
||||
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS medium TEXT`);
|
||||
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS thumbnail TEXT`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -106,6 +112,10 @@ export async function initSchema() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Add columns if they don't exist (for existing databases)
|
||||
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
|
||||
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -122,6 +132,10 @@ export async function initSchema() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Add columns if they don't exist (for existing databases)
|
||||
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
|
||||
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -170,11 +184,17 @@ export async function initSchema() {
|
||||
photos TEXT[] DEFAULT '{}',
|
||||
featured_photo VARCHAR(500),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
is_private BOOLEAN DEFAULT FALSE,
|
||||
max_guests INTEGER,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Add is_private and max_guests columns if they don't exist
|
||||
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE`);
|
||||
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS max_guests INTEGER`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS social_event_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -308,6 +328,219 @@ export async function initSchema() {
|
||||
PRIMARY KEY (room_id, amenity_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
check_in DATE NOT NULL,
|
||||
check_out DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
total_price DECIMAL(10,2),
|
||||
guest_name VARCHAR(100),
|
||||
guest_contact_method VARCHAR(20),
|
||||
guest_contact VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_availability (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
||||
date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'available',
|
||||
reason TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(room_id, date)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER REFERENCES rooms(id),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
message TEXT NOT NULL,
|
||||
guest_name VARCHAR(100),
|
||||
guest_contact_method VARCHAR(20),
|
||||
guest_contact VARCHAR(255),
|
||||
read_by_admins BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`ALTER TABLE room_comments ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'approved'`);
|
||||
|
||||
// Add guest columns to room_reservations
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE room_reservations ALTER COLUMN user_id DROP NOT NULL`);
|
||||
|
||||
// Add guest columns to messages
|
||||
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
|
||||
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
|
||||
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE messages ALTER COLUMN user_id DROP NOT NULL`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_reservations_room_dates ON reservations(room_id, check_in, check_out)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_availability_room_date ON room_availability(room_id, date)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC)`);
|
||||
|
||||
// ─── Conversations & Direct Messages ───
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subject VARCHAR(255),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
joined_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(conversation_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_conversation_participants_user ON conversation_participants(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation ON direct_messages(conversation_id, created_at DESC)`);
|
||||
|
||||
// ─── Bulk Messages ───
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS bulk_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id),
|
||||
subject VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
recipient_type VARCHAR(20) NOT NULL DEFAULT 'all_customers',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS bulk_message_recipients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
bulk_message_id INTEGER NOT NULL REFERENCES bulk_messages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
read_at TIMESTAMP,
|
||||
UNIQUE(bulk_message_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Orders tables for coffee/kitchen
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
order_type VARCHAR(20) NOT NULL,
|
||||
room_id INTEGER REFERENCES rooms(id),
|
||||
subtotal DECIMAL(10,2) NOT NULL,
|
||||
service_fee DECIMAL(10,2) DEFAULT 0,
|
||||
total DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
menu_item_id INTEGER NOT NULL REFERENCES menu_items(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price DECIMAL(10,2) NOT NULL,
|
||||
total_price DECIMAL(10,2) NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS private_event_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_name VARCHAR(255) NOT NULL,
|
||||
contact_name VARCHAR(255) NOT NULL,
|
||||
contact_email VARCHAR(255),
|
||||
contact_phone VARCHAR(50),
|
||||
contact_method VARCHAR(20) NOT NULL CHECK (contact_method IN ('email', 'phone', 'whatsapp')),
|
||||
event_date DATE,
|
||||
event_time TIME,
|
||||
guests INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Add status column to social_event_reservations if it doesn't exist
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled'))`);
|
||||
|
||||
// Add contact columns to social_event_reservations
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255)`);
|
||||
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50)`);
|
||||
await db.query(`ALTER TABLE social_event_reservations ALTER COLUMN user_id DROP NOT NULL`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
|
||||
|
||||
// Room assignments - admins assign registered users to rooms
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_assignments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
check_in DATE NOT NULL,
|
||||
check_out DATE NOT NULL,
|
||||
notes TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(room_id, check_in, check_out)
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_room ON room_assignments(room_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_user ON room_assignments(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_dates ON room_assignments(check_in, check_out)`);
|
||||
|
||||
// Audit log for admin actions
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER,
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`);
|
||||
}
|
||||
|
||||
export async function migrateFromLegacyPlaces() {
|
||||
@@ -325,6 +558,20 @@ export async function migrateFromLegacyPlaces() {
|
||||
await db.query(`
|
||||
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||
`);
|
||||
|
||||
// Add room status columns for housekeeping management
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS clean_status VARCHAR(20) DEFAULT 'clean'`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS notes TEXT`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS last_cleaned TIMESTAMP`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assigned_staff_id INTEGER REFERENCES users(id)`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS priority VARCHAR(10) DEFAULT 'normal'`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS issues_count INTEGER DEFAULT 0`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS is_vip BOOLEAN DEFAULT FALSE`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_time TIME`);
|
||||
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_date DATE`);
|
||||
|
||||
// Add payment_status to room_reservations
|
||||
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) DEFAULT 'pending'`);
|
||||
}
|
||||
|
||||
export async function seed() {
|
||||
|
||||
+54
-6
@@ -1,14 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
// Middleware runs on edge runtime - we can't verify JWT here easily
|
||||
// Let the pages handle auth checks via /api/auth/me
|
||||
export function middleware(request: NextRequest) {
|
||||
// No redirects - login and admin pages handle their own auth UI
|
||||
return NextResponse.next();
|
||||
// Paths that require authentication
|
||||
const PROTECTED_PATHS = ['/admin'];
|
||||
// Paths that require no authentication (login page should not redirect logged-in users)
|
||||
const AUTH_PATHS = ['/login'];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Check if this is a protected path
|
||||
const isProtectedPath = PROTECTED_PATHS.some(path => pathname.startsWith(path));
|
||||
const isAuthPath = AUTH_PATHS.some(path => pathname.startsWith(path));
|
||||
|
||||
if (isProtectedPath) {
|
||||
// Get session token from cookie
|
||||
const sessionToken = request.cookies.get('session')?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
// No session, redirect to login
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Session exists - actual auth validation happens in API routes
|
||||
// We can't verify JWT in Edge runtime without external libraries
|
||||
// The login page and API routes handle full auth checks
|
||||
}
|
||||
|
||||
// Add security headers to all responses
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-XSS-Protection', '1; mode=block');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Content Security Policy - allow inline styles for Next.js
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob: https:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-ancestors 'none';"
|
||||
);
|
||||
|
||||
// HSTS for production (assuming TLS termination at proxy)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*', '/login'],
|
||||
matcher: ['/admin/:path*', '/login', '/api/:path*'],
|
||||
};
|
||||
+5
-1
@@ -31,13 +31,17 @@ describe('auth helpers', () => {
|
||||
it('authenticates a user with the stored password hash', async () => {
|
||||
const passwordHash = await hashPassword('secret-password');
|
||||
query.mockResolvedValueOnce({
|
||||
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin' }],
|
||||
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin', first_name: null, last_name: null, preferred_language: 'es', terms_accepted_at: null }],
|
||||
});
|
||||
|
||||
await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({
|
||||
id: 2,
|
||||
username: 'bob',
|
||||
role: 'admin',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
preferred_language: 'es',
|
||||
terms_accepted_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,13 +20,18 @@ describe('/api/rooms', () => {
|
||||
});
|
||||
|
||||
it('returns active rooms', async () => {
|
||||
// Mock rooms query
|
||||
query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] });
|
||||
// Mock amenities query
|
||||
query.mockResolvedValueOnce({ rows: [] });
|
||||
// Mock images query
|
||||
query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true }] });
|
||||
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true, photos: [], amenities: [] }] });
|
||||
});
|
||||
|
||||
it('creates a room for admins', async () => {
|
||||
@@ -52,6 +57,6 @@ describe('/api/rooms', () => {
|
||||
|
||||
expect(requireRole).toHaveBeenCalledWith('admin');
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00' } });
|
||||
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00', photos: [], amenity_ids: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user