Prod hardening and K8s drafts: auth fail-close, DB pool, init/schema split, prod init guard
This commit is contained in:
+39
-55
@@ -1,105 +1,89 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: ${NAMESPACE}
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: la-huasca-nextjs
|
name: la-huasca-ts
|
||||||
namespace: ${NAMESPACE}
|
namespace: la-huasca-ts
|
||||||
spec:
|
spec:
|
||||||
replicas: 2
|
replicas: 1
|
||||||
strategy:
|
|
||||||
type: RollingUpdate
|
|
||||||
rollingUpdate:
|
|
||||||
maxSurge: 1
|
|
||||||
maxUnavailable: 0
|
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: la-huasca-nextjs
|
app: la-huasca-ts
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: la-huasca-nextjs
|
app: la-huasca-ts
|
||||||
spec:
|
spec:
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: la-huasca-registry
|
- name: la-huasca-registry
|
||||||
containers:
|
containers:
|
||||||
- name: nextjs
|
- name: la-huasca-ts
|
||||||
image: gitea.ajavasoft.com/muken/la-huasca-ts:${TAG}
|
image: gitea.ajavasoft.com/muken/la-huasca-ts:latest
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
|
name: http
|
||||||
env:
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: production
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: la-huasca-db-secrets
|
name: calendar-db-secrets
|
||||||
key: DATABASE_URL
|
key: url
|
||||||
- name: JWT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: la-huasca-app-secrets
|
|
||||||
key: JWT_SECRET
|
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /api/health
|
||||||
port: 3000
|
port: http
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 20
|
||||||
periodSeconds: 5
|
periodSeconds: 15
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /api/health
|
||||||
port: 3000
|
port: http
|
||||||
initialDelaySeconds: 15
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 10
|
periodSeconds: 30
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "256Mi"
|
cpu: 200m
|
||||||
cpu: "100m"
|
memory: 256Mi
|
||||||
limits:
|
limits:
|
||||||
memory: "512Mi"
|
cpu: 500m
|
||||||
cpu: "500m"
|
memory: 512Mi
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: la-huasca-nextjs
|
name: la-huasca-ts
|
||||||
namespace: ${NAMESPACE}
|
namespace: la-huasca-ts
|
||||||
spec:
|
spec:
|
||||||
selector:
|
|
||||||
app: la-huasca-nextjs
|
|
||||||
ports:
|
ports:
|
||||||
- port: 80
|
- port: 80
|
||||||
targetPort: 3000
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: la-huasca-ts
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: la-huasca-nextjs
|
name: la-huasca-ts
|
||||||
namespace: ${NAMESPACE}
|
namespace: la-huasca-ts
|
||||||
annotations:
|
annotations:
|
||||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- la-huasca.ajavasoft.com
|
||||||
|
secretName: la-huasca-ts-tls
|
||||||
rules:
|
rules:
|
||||||
- host: lahuasca.com
|
- host: la-huasca.ajavasoft.com
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: la-huasca-nextjs
|
name: la-huasca-ts
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
- host: www.lahuasca.com
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: la-huasca-nextjs
|
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "apps/v1",
|
||||||
|
"kind": "Deployment",
|
||||||
|
"metadata": {
|
||||||
|
"name": "la-huasca-ts",
|
||||||
|
"namespace": "la-huasca-ts"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"replicas": 1,
|
||||||
|
"selector": {
|
||||||
|
"matchLabels": {
|
||||||
|
"app": "la-huasca-ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"metadata": {
|
||||||
|
"labels": {
|
||||||
|
"app": "la-huasca-ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"imagePullSecrets": [
|
||||||
|
{
|
||||||
|
"name": "la-huasca-registry"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"name": "la-huasca-ts",
|
||||||
|
"image": "gitea.ajavasoft.com/muken/la-huasca-ts:latest",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"containerPort": 3000,
|
||||||
|
"name": "http"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "NODE_ENV",
|
||||||
|
"value": "production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATABASE_URL",
|
||||||
|
"valueFrom": {
|
||||||
|
"secretKeyRef": {
|
||||||
|
"name": "calendar-db-secrets",
|
||||||
|
"key": "url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"readinessProbe": {
|
||||||
|
"httpGet": {
|
||||||
|
"path": "/api/health",
|
||||||
|
"port": "http"
|
||||||
|
},
|
||||||
|
"initialDelaySeconds": 20,
|
||||||
|
"periodSeconds": 15
|
||||||
|
},
|
||||||
|
"livenessProbe": {
|
||||||
|
"httpGet": {
|
||||||
|
"path": "/api/health",
|
||||||
|
"port": "http"
|
||||||
|
},
|
||||||
|
"initialDelaySeconds": 60,
|
||||||
|
"periodSeconds": 30
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"requests": {
|
||||||
|
"cpu": "200m",
|
||||||
|
"memory": "256Mi"
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"cpu": "500m",
|
||||||
|
"memory": "512Mi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "networking.k8s.io/v1",
|
||||||
|
"kind": "Ingress",
|
||||||
|
"metadata": {
|
||||||
|
"name": "la-huasca-ts",
|
||||||
|
"namespace": "la-huasca-ts",
|
||||||
|
"annotations": {
|
||||||
|
"traefik.ingress.kubernetes.io/router.entrypoints": "web,websecure"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"ingressClassName": "traefik",
|
||||||
|
"tls": [
|
||||||
|
{
|
||||||
|
"hosts": [
|
||||||
|
"lahuasca.com"
|
||||||
|
],
|
||||||
|
"secretName": "la-huasca-ts-tls"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"host": "lahuasca.com",
|
||||||
|
"http": {
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"path": "/",
|
||||||
|
"pathType": "Prefix",
|
||||||
|
"backend": {
|
||||||
|
"service": {
|
||||||
|
"name": "la-huasca-ts",
|
||||||
|
"port": {
|
||||||
|
"number": 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Service",
|
||||||
|
"metadata": {
|
||||||
|
"name": "la-huasca-ts",
|
||||||
|
"namespace": "la-huasca-ts"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"port": 80,
|
||||||
|
"targetPort": "http",
|
||||||
|
"protocol": "TCP",
|
||||||
|
"name": "http"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selector": {
|
||||||
|
"app": "la-huasca-ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-107
@@ -329,14 +329,20 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const savePlace = async (e: React.FormEvent) => {
|
const savePlace = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = { ...placeForm, photos: placeForm.photos || [], featured_photo: placeForm.featured_photo || null, active: placeForm.active !== false, sort_order: placeForm.sort_order || 0 };
|
const payload = {
|
||||||
|
...placeForm,
|
||||||
|
photos: Array.isArray(placeForm.photos) ? placeForm.photos : [],
|
||||||
|
featured_photo: placeForm.featured_photo || null,
|
||||||
|
active: placeForm.active === true,
|
||||||
|
sort_order: placeForm.sort_order || 0,
|
||||||
|
};
|
||||||
if (editingPlace) {
|
if (editingPlace) {
|
||||||
await api(`/api/places/${editingPlace}`, 'PUT', payload);
|
const r = await api(`/api/places/${editingPlace}`, 'PUT', payload);
|
||||||
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...payload } as Place : p)));
|
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? ({ ...p, ...payload } as Place) : p)));
|
||||||
setEditingPlace(null);
|
setEditingPlace(null);
|
||||||
} else {
|
} else {
|
||||||
const json = await api('/api/places', 'POST', payload);
|
const r = await api('/api/places', 'POST', payload);
|
||||||
setPlaces((prev) => [...prev, json.data]);
|
setPlaces((prev) => [...prev, r.data]);
|
||||||
}
|
}
|
||||||
setPlaceForm({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
|
setPlaceForm({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
|
||||||
notify('Place saved.');
|
notify('Place saved.');
|
||||||
@@ -490,7 +496,7 @@ export default function AdminPage() {
|
|||||||
{error && <p style={{ color: '#c23b22', marginBottom: '1rem' }}>{error}</p>}
|
{error && <p style={{ color: '#c23b22', marginBottom: '1rem' }}>{error}</p>}
|
||||||
{message && <p style={{ color: '#3b7a22', marginBottom: '1rem' }}>{message}</p>}
|
{message && <p style={{ color: '#3b7a22', marginBottom: '1rem' }}>{message}</p>}
|
||||||
|
|
||||||
<Section title="Brand Assets">
|
<AccordionSection title="Brand Assets">
|
||||||
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr auto', alignItems: 'end' }}>
|
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr auto', alignItems: 'end' }}>
|
||||||
<Text label="Navbar logo" value={logoUrl} onChange={setLogoUrl} />
|
<Text label="Navbar logo" value={logoUrl} onChange={setLogoUrl} />
|
||||||
<Button onClick={() => updateAsset('logo', logoUrl)} disabled={!logoUrl}>Save logo</Button>
|
<Button onClick={() => updateAsset('logo', logoUrl)} disabled={!logoUrl}>Save logo</Button>
|
||||||
@@ -504,7 +510,7 @@ export default function AdminPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Footer / Contact">
|
<AccordionSection title="Footer / Contact">
|
||||||
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||||
<Text label="Facebook URL" value={footer.facebook_url} onChange={(v) => setFooter({ ...footer, facebook_url: v })} />
|
<Text label="Facebook URL" value={footer.facebook_url} onChange={(v) => setFooter({ ...footer, facebook_url: v })} />
|
||||||
<Text label="Instagram URL" value={footer.instagram_url} onChange={(v) => setFooter({ ...footer, instagram_url: v })} />
|
<Text label="Instagram URL" value={footer.instagram_url} onChange={(v) => setFooter({ ...footer, instagram_url: v })} />
|
||||||
@@ -518,7 +524,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Image Gallery">
|
<AccordionSection title="Image Gallery">
|
||||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem 1.25rem', background: gold, color: navy, borderRadius: '999px', cursor: 'pointer', fontWeight: 600, marginBottom: '1.5rem' }}>
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem 1.25rem', background: gold, color: navy, borderRadius: '999px', cursor: 'pointer', fontWeight: 600, marginBottom: '1.5rem' }}>
|
||||||
+ Upload images
|
+ Upload images
|
||||||
<input type="file" accept="image/*" multiple onChange={handleUpload} style={{ display: 'none' }} />
|
<input type="file" accept="image/*" multiple onChange={handleUpload} style={{ display: 'none' }} />
|
||||||
@@ -556,7 +562,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Homepage Slideshow">
|
<AccordionSection title="Homepage Slideshow">
|
||||||
<form onSubmit={saveSlide} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
<form onSubmit={saveSlide} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
<Text label="Title" value={slideForm.title || ''} onChange={(v) => setSlideForm({ ...slideForm, title: v })} />
|
<Text label="Title" value={slideForm.title || ''} onChange={(v) => setSlideForm({ ...slideForm, title: v })} />
|
||||||
<Text label="Subtitle" value={slideForm.subtitle || ''} onChange={(v) => setSlideForm({ ...slideForm, subtitle: v })} />
|
<Text label="Subtitle" value={slideForm.subtitle || ''} onChange={(v) => setSlideForm({ ...slideForm, subtitle: v })} />
|
||||||
@@ -583,7 +589,7 @@ export default function AdminPage() {
|
|||||||
<ListTable rows={slides.map((s) => [s.title || '(untitled)', s.image_url ? '🖼️ image' : 'no image', s.active ? 'On' : 'Off', `#${s.sort_order}`])} onEdit={(i) => editSlide(slides[i])} onDelete={(i) => deleteSlide(slides[i].id)} />
|
<ListTable rows={slides.map((s) => [s.title || '(untitled)', s.image_url ? '🖼️ image' : 'no image', s.active ? 'On' : 'Off', `#${s.sort_order}`])} onEdit={(i) => editSlide(slides[i])} onDelete={(i) => deleteSlide(slides[i].id)} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Calendar Events">
|
<AccordionSection title="Calendar Events">
|
||||||
<form onSubmit={saveEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
<form onSubmit={saveEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
<Text label="Date" type="date" value={eventForm.date || ''} onChange={(v) => setEventForm({ ...eventForm, date: v })} />
|
<Text label="Date" type="date" value={eventForm.date || ''} onChange={(v) => setEventForm({ ...eventForm, date: v })} />
|
||||||
<Text label="Title" value={eventForm.title || ''} onChange={(v) => setEventForm({ ...eventForm, title: v })} />
|
<Text label="Title" value={eventForm.title || ''} onChange={(v) => setEventForm({ ...eventForm, title: v })} />
|
||||||
@@ -621,7 +627,7 @@ export default function AdminPage() {
|
|||||||
<ListTable rows={events.map((ev) => [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
|
<ListTable rows={events.map((ev) => [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Social Events">
|
<AccordionSection title="Social Events">
|
||||||
<form onSubmit={saveSocialEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
<form onSubmit={saveSocialEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
<Text label="Name" value={socialEventForm.name || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, name: v })} />
|
<Text label="Name" value={socialEventForm.name || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, name: v })} />
|
||||||
<Text label="Price" value={socialEventForm.price || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, price: v })} />
|
<Text label="Price" value={socialEventForm.price || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, price: v })} />
|
||||||
@@ -686,7 +692,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Rooms">
|
<AccordionSection title="Rooms">
|
||||||
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
<Text label="Name" value={roomForm.name || ''} onChange={(v) => setRoomForm({ ...roomForm, name: v })} />
|
<Text label="Name" value={roomForm.name || ''} onChange={(v) => setRoomForm({ ...roomForm, name: v })} />
|
||||||
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
|
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
|
||||||
@@ -741,7 +747,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Menu">
|
<AccordionSection title="Menu">
|
||||||
<form onSubmit={saveMenu} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
<form onSubmit={saveMenu} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
<Text label="Category" value={menuForm.category || ''} onChange={(v) => setMenuForm({ ...menuForm, category: v })} />
|
<Text label="Category" value={menuForm.category || ''} onChange={(v) => setMenuForm({ ...menuForm, category: v })} />
|
||||||
<Text label="Name" value={menuForm.name || ''} onChange={(v) => setMenuForm({ ...menuForm, name: v })} />
|
<Text label="Name" value={menuForm.name || ''} onChange={(v) => setMenuForm({ ...menuForm, name: v })} />
|
||||||
@@ -764,7 +770,7 @@ export default function AdminPage() {
|
|||||||
<ListTable rows={menu.map((m) => [m.category, m.name, `$${m.price}`, m.is_daily_special ? '★ Daily' : '', m.active ? 'On' : 'Off', `#${m.sort_order}`])} onEdit={(i) => editMenu(menu[i])} onDelete={(i) => deleteMenu(menu[i].id)} />
|
<ListTable rows={menu.map((m) => [m.category, m.name, `$${m.price}`, m.is_daily_special ? '★ Daily' : '', m.active ? 'On' : 'Off', `#${m.sort_order}`])} onEdit={(i) => editMenu(menu[i])} onDelete={(i) => deleteMenu(menu[i].id)} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Landscape Places">
|
<AccordionSection title="Landscape Places">
|
||||||
<form onSubmit={savePlace} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
<form onSubmit={savePlace} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
||||||
<Text label="Name" value={placeForm.name || ''} onChange={(v) => setPlaceForm({ ...placeForm, name: v })} />
|
<Text label="Name" value={placeForm.name || ''} onChange={(v) => setPlaceForm({ ...placeForm, name: v })} />
|
||||||
<Text label="Distance (km)" value={placeForm.distance_km || ''} onChange={(v) => setPlaceForm({ ...placeForm, distance_km: v })} />
|
<Text label="Distance (km)" value={placeForm.distance_km || ''} onChange={(v) => setPlaceForm({ ...placeForm, distance_km: v })} />
|
||||||
@@ -791,7 +797,7 @@ export default function AdminPage() {
|
|||||||
<ListTable rows={places.map((p) => [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.photos.length > 0 ? `🖼️ ${p.photos.length}` : 'no image', p.active ? 'On' : 'Off', `#${p.sort_order}`])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} />
|
<ListTable rows={places.map((p) => [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.photos.length > 0 ? `🖼️ ${p.photos.length}` : 'no image', p.active ? 'On' : 'Off', `#${p.sort_order}`])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Pending Reviews">
|
<AccordionSection title="Pending Reviews">
|
||||||
{reviews.length === 0 ? <p style={{ color: '#777' }}>No pending reviews.</p> : (
|
{reviews.length === 0 ? <p style={{ color: '#777' }}>No pending reviews.</p> : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
{reviews.map((r) => (
|
{reviews.map((r) => (
|
||||||
@@ -807,101 +813,65 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
function AccordionSection({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
||||||
)}
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
</Section>
|
return (
|
||||||
|
<section style={{ marginBottom: '2.5rem', background: '#fff', borderRadius: '12px', boxShadow: '0 1px 3px rgba(1,13,30,0.06)', padding: '1rem 1.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', cursor: 'pointer' }} onClick={() => setOpen((v) => !v)}>
|
||||||
|
<h2 style={{ fontSize: 'clamp(20px, 3vw, 28px)', fontWeight: 400, color: navy, margin: 0 }}>{title}</h2>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: '#555' }}>{open ? 'Minimize' : 'Maximize'}</span>
|
||||||
|
</div>
|
||||||
|
{open && <div style={{ marginTop: '0.25rem' }}>{children}</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Section title="Users">
|
function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) {
|
||||||
<form onSubmit={saveUser} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
|
return (
|
||||||
<Text label="Username" value={userForm.username} onChange={(v) => setUserForm({ ...userForm, username: v })} />
|
<button
|
||||||
<Text label="Password" value={userForm.password} type="password" onChange={(v) => setUserForm({ ...userForm, password: v })} />
|
type={type}
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
|
disabled={disabled}
|
||||||
Role
|
onClick={onClick}
|
||||||
<select value={userForm.role} onChange={(e) => setUserForm({ ...userForm, role: e.target.value as 'admin' | 'user' })} style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}>
|
style={{
|
||||||
<option value="user">User</option>
|
padding: '0.65rem 1.25rem',
|
||||||
<option value="admin">Admin</option>
|
background: disabled ? '#ccc' : gold,
|
||||||
</select>
|
color: navy,
|
||||||
</label>
|
border: 'none',
|
||||||
<div style={{ display: 'flex', alignItems: 'end' }}>
|
borderRadius: '999px',
|
||||||
<Button type="submit">Create user</Button>
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
</div>
|
fontWeight: 600,
|
||||||
</form>
|
transition: 'filter 150ms',
|
||||||
<ListTable rows={users.map((u) => [u.username, u.role, u.first_name || '', u.last_name || '', u.comments_disabled ? 'comments disabled' : ''])} onDelete={(i) => deleteUser(users[i].id)} onEdit={(i) => { setEditingUser(users[i].id); setUserEditForm({ first_name: users[i].first_name || '', last_name: users[i].last_name || '', comments_disabled: users[i].comments_disabled }); }} />
|
}}
|
||||||
{editingUser !== null && (
|
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.filter = 'brightness(1.08)'; }}
|
||||||
<div style={{ marginTop: '1rem', padding: '1rem', background: '#fff', borderRadius: '12px', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
onMouseLeave={(e) => { e.currentTarget.style.filter = 'brightness(1)'; }}
|
||||||
<strong>Edit user {users.find((u) => u.id === editingUser)?.username}</strong>
|
>
|
||||||
<Text label="First name" value={userEditForm.first_name} onChange={(v) => setUserEditForm({ ...userEditForm, first_name: v })} />
|
{children}
|
||||||
<Text label="Last name" value={userEditForm.last_name} onChange={(v) => setUserEditForm({ ...userEditForm, last_name: v })} />
|
</button>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
);
|
||||||
<input type="checkbox" checked={userEditForm.comments_disabled} onChange={(e) => setUserEditForm({ ...userEditForm, comments_disabled: e.target.checked })} />
|
}
|
||||||
Disable comments
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<Button onClick={() => { const u = users.find((u) => u.id === editingUser)!; saveUserNames(u); toggleUserComments(u); }}>Save</Button>
|
|
||||||
<SecondaryButton onClick={() => setEditingUser(null)}>Cancel</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) {
|
||||||
return (
|
return (
|
||||||
<section style={{ marginBottom: '2.5rem' }}>
|
<button
|
||||||
<h2 style={{ fontSize: 'clamp(20px, 3vw, 28px)', fontWeight: 400, color: navy, margin: '0 0 1rem' }}>{title}</h2>
|
type={type}
|
||||||
{children}
|
onClick={onClick}
|
||||||
</section>
|
style={{
|
||||||
);
|
padding: '0.65rem 1.25rem',
|
||||||
}
|
background: 'transparent',
|
||||||
|
color: navy,
|
||||||
|
border: `1px solid ${navy}`,
|
||||||
|
borderRadius: '999px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) {
|
function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
|
||||||
type={type}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
padding: '0.65rem 1.25rem',
|
|
||||||
background: disabled ? '#ccc' : gold,
|
|
||||||
color: navy,
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
||||||
fontWeight: 600,
|
|
||||||
transition: 'filter 150ms',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.filter = 'brightness(1.08)'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.filter = 'brightness(1)'; }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type={type}
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
padding: '0.65rem 1.25rem',
|
|
||||||
background: 'transparent',
|
|
||||||
color: navy,
|
|
||||||
border: `1px solid ${navy}`,
|
|
||||||
borderRadius: '999px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{ padding: '0.5rem 1rem', background: '#c23b22', color: '#fff', border: 'none', borderRadius: '999px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }}
|
style={{ padding: '0.5rem 1rem', background: '#c23b22', color: '#fff', border: 'none', borderRadius: '999px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { initSchema, seed, resetDatabase } from '@/lib/schema';
|
import { initSchema, seed, resetDatabase, migrateFromLegacyPlaces } from '@/lib/schema';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return NextResponse.json({ error: 'Database initialization is disabled in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const reset = searchParams.get('reset') === 'true';
|
const reset = searchParams.get('reset') === 'true';
|
||||||
@@ -12,6 +16,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await initSchema();
|
await initSchema();
|
||||||
|
await migrateFromLegacyPlaces();
|
||||||
await seed();
|
await seed();
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+4
-1
@@ -3,7 +3,10 @@ import { SignJWT, jwtVerify } from 'jose';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
if (!JWT_SECRET) {
|
||||||
|
throw new Error('JWT_SECRET is not configured');
|
||||||
|
}
|
||||||
const secret = new TextEncoder().encode(JWT_SECRET);
|
const secret = new TextEncoder().encode(JWT_SECRET);
|
||||||
|
|
||||||
export async function hashPassword(password: string) {
|
export async function hashPassword(password: string) {
|
||||||
|
|||||||
+7
-3
@@ -5,7 +5,6 @@ function createPool() {
|
|||||||
|
|
||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
if (typeof process.env.NEXT_PHASE === 'string' && process.env.NEXT_PHASE.includes('phase-production-build')) {
|
if (typeof process.env.NEXT_PHASE === 'string' && process.env.NEXT_PHASE.includes('phase-production-build')) {
|
||||||
// Next.js collects page data during the build. We don't need a real DB for that.
|
|
||||||
return {
|
return {
|
||||||
query: async () => {
|
query: async () => {
|
||||||
throw new Error('DATABASE_URL environment variable is not set');
|
throw new Error('DATABASE_URL environment variable is not set');
|
||||||
@@ -21,10 +20,15 @@ function createPool() {
|
|||||||
throw new Error('DATABASE_URL environment variable is not set');
|
throw new Error('DATABASE_URL environment variable is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Pool({ connectionString });
|
return new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
|
||||||
|
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
|
||||||
|
connectionTimeoutMillis: parseInt(process.env.DB_CONNECT_TIMEOUT || '10000', 10),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = createPool();
|
const db = createPool();
|
||||||
|
|
||||||
export async function query(text: string, params?: unknown[]) {
|
export async function query(text: string, params?: unknown[]) {
|
||||||
const result = await db.query(text, params);
|
const result = await db.query(text, params);
|
||||||
|
|||||||
+65
-89
@@ -8,6 +8,9 @@ export async function initSchema() {
|
|||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
||||||
|
comments_disabled BOOLEAN DEFAULT FALSE,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
@@ -49,6 +52,7 @@ export async function initSchema() {
|
|||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
active BOOLEAN DEFAULT TRUE,
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
@@ -75,6 +79,7 @@ export async function initSchema() {
|
|||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
price NUMERIC(10,2) NOT NULL,
|
price NUMERIC(10,2) NOT NULL,
|
||||||
photos TEXT[] DEFAULT '{}',
|
photos TEXT[] DEFAULT '{}',
|
||||||
|
featured_photo VARCHAR(500),
|
||||||
active BOOLEAN DEFAULT TRUE,
|
active BOOLEAN DEFAULT TRUE,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
@@ -150,28 +155,6 @@ export async function initSchema() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await db.query(`
|
|
||||||
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.query(`
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.query(`
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.query(`
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.query(`
|
|
||||||
ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
|
||||||
UPDATE places SET photos = ARRAY[photo] WHERE photo IS NOT NULL AND (photos IS NULL OR photos = '{}');
|
|
||||||
ALTER TABLE places DROP COLUMN IF EXISTS photo;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS social_events (
|
CREATE TABLE IF NOT EXISTS social_events (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -200,6 +183,17 @@ export async function initSchema() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
date DATE NOT NULL UNIQUE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color VARCHAR(20) NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -208,9 +202,46 @@ export async function initSchema() {
|
|||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateFromLegacyPlaces() {
|
||||||
|
await db.query(`
|
||||||
|
UPDATE places SET photos = ARRAY[photo] WHERE photo IS NOT NULL AND (photos IS NULL OR photos = '{}');
|
||||||
|
`);
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE places DROP COLUMN IF EXISTS photo;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seed() {
|
export async function seed() {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error('Seeding is disabled in production');
|
||||||
|
}
|
||||||
|
|
||||||
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
|
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
|
||||||
if (adminRows.length === 0) {
|
if (adminRows.length === 0) {
|
||||||
const adminHash = bcrypt.hashSync('admin', 10);
|
const adminHash = bcrypt.hashSync('admin', 10);
|
||||||
@@ -225,11 +256,12 @@ export async function seed() {
|
|||||||
const { rows: offerRows } = await db.query(`SELECT id FROM offers`);
|
const { rows: offerRows } = await db.query(`SELECT id FROM offers`);
|
||||||
if (offerRows.length === 0) {
|
if (offerRows.length === 0) {
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO offers (title, description) VALUES
|
`INSERT INTO offers (title, description) VALUES ($1, $2), ($3, $4)`,
|
||||||
($1, $2), ($3, $4)`,
|
|
||||||
[
|
[
|
||||||
'Summer Wine Weekend', 'Complimentary wine tasting with every two-night stay.',
|
'Summer Wine Weekend',
|
||||||
'Sunset Dinner', 'Three-course menu on the terrace every Friday.',
|
'Complimentary wine tasting with every two-night stay.',
|
||||||
|
'Sunset Dinner',
|
||||||
|
'Three-course menu on the terrace every Friday.',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,8 +269,7 @@ export async function seed() {
|
|||||||
const { rows: benefitRows } = await db.query(`SELECT id FROM benefits`);
|
const { rows: benefitRows } = await db.query(`SELECT id FROM benefits`);
|
||||||
if (benefitRows.length === 0) {
|
if (benefitRows.length === 0) {
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO benefits (text) VALUES
|
`INSERT INTO benefits (text) VALUES ($1), ($2), ($3), ($4)`,
|
||||||
($1), ($2), ($3), ($4)`,
|
|
||||||
[
|
[
|
||||||
'Priority booking for peak weekends',
|
'Priority booking for peak weekends',
|
||||||
'Late checkout on availability',
|
'Late checkout on availability',
|
||||||
@@ -248,69 +279,13 @@ export async function seed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows: configRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
|
const { rows: configWifiRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
|
||||||
if (configRows.length === 0) {
|
if (configWifiRows.length === 0) {
|
||||||
await db.query(
|
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['wifi_password', 'LaHuascaGuest2026']);
|
||||||
`INSERT INTO config (key, value) VALUES ($1, $2)`,
|
|
||||||
['wifi_password', 'LaHuascaGuest2026']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows: userResRows } = await db.query(`SELECT id FROM reservations WHERE user_id = (SELECT id FROM users WHERE username = 'user')`);
|
const { rows: configTaglineRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
|
||||||
if (userResRows.length === 0) {
|
if (configTaglineRows.length === 0) {
|
||||||
await db.query(
|
|
||||||
`INSERT INTO reservations (user_id, date, time, guests, table_name)
|
|
||||||
SELECT id, $2, $3, $4, $5 FROM users WHERE username = $1`,
|
|
||||||
['user', '2026-07-12', '19:00', 4, 'Terrace 3']
|
|
||||||
);
|
|
||||||
await db.query(
|
|
||||||
`INSERT INTO reservations (user_id, date, time, guests, table_name)
|
|
||||||
SELECT id, $2, $3, $4, $5 FROM users WHERE username = $1`,
|
|
||||||
['user', '2026-07-25', '20:30', 2, 'Barrel 7']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows: userCouponRows } = await db.query(`SELECT id FROM coupons WHERE user_id = (SELECT id FROM users WHERE username = 'user')`);
|
|
||||||
if (userCouponRows.length === 0) {
|
|
||||||
await db.query(
|
|
||||||
`INSERT INTO coupons (user_id, code, discount)
|
|
||||||
SELECT id, $2, $3 FROM users WHERE username = $1`,
|
|
||||||
['user', 'WELCOME20', '20% off your next stay']
|
|
||||||
);
|
|
||||||
await db.query(
|
|
||||||
`INSERT INTO coupons (user_id, code, discount)
|
|
||||||
SELECT id, $2, $3 FROM users WHERE username = $1`,
|
|
||||||
['user', 'COFFEE5', '$5 off any coffee tasting']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows: calendarRows } = await db.query(`SELECT id FROM calendar_events`);
|
|
||||||
if (calendarRows.length === 0) {
|
|
||||||
const thisYear = new Date().getFullYear();
|
|
||||||
await db.query(
|
|
||||||
`INSERT INTO calendar_events (date, title, type, description, color) VALUES
|
|
||||||
($1, $2, $3, $4, $5), ($6, $7, $8, $9, $10), ($11, $12, $13, $14, $15),
|
|
||||||
($16, $17, $18, $19, $20), ($21, $22, $23, $24, $25), ($26, $27, $28, $29, $30),
|
|
||||||
($31, $32, $33, $34, $35), ($36, $37, $38, $39, $40), ($41, $42, $43, $44, $45),
|
|
||||||
($46, $47, $48, $49, $50), ($51, $52, $53, $54, $55)`,
|
|
||||||
[
|
|
||||||
`${thisYear}-01-01`, 'New Year', 'holiday', 'Start the year in the Andes.', '#E8A849',
|
|
||||||
`${thisYear}-02-14`, 'Valentine', 'holiday', 'Special dinner menu.', '#D94B73',
|
|
||||||
`${thisYear}-03-20`, 'Spring Equinox', 'season', 'Blooming highlands.', '#7CB342',
|
|
||||||
`${thisYear}-06-21`, 'Summer Solstice', 'season', 'Longest days of the year.', '#E8A849',
|
|
||||||
`${thisYear}-09-22`, 'Fall Equinox', 'season', 'Harvest colors.', '#A6683C',
|
|
||||||
`${thisYear}-10-31`, 'Halloween', 'holiday', 'Costume dinner party.', '#9C27B0',
|
|
||||||
`${thisYear}-11-02`, 'Day of the Dead', 'holiday', 'Traditional altar and meal.', '#FF9800',
|
|
||||||
`${thisYear}-12-21`, 'Winter Solstice', 'season', 'Cozy fires and clear skies.', '#4FC3F7',
|
|
||||||
`${thisYear}-12-25`, 'Christmas', 'holiday', 'Holiday feast.', '#C62828',
|
|
||||||
`${thisYear}-12-31`, 'New Year Eve', 'holiday', 'Countdown dinner.', '#E8A849',
|
|
||||||
`${thisYear}-06-28`, 'Sunny 22°C', 'weather', 'Typical Otavalo dry season day.', '#FFD54F',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows: siteRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
|
|
||||||
if (siteRows.length === 0) {
|
|
||||||
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', 'Hotel · Restaurant · Andean Highlands']);
|
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', 'Hotel · Restaurant · Andean Highlands']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,5 +293,6 @@ export async function seed() {
|
|||||||
export async function resetDatabase() {
|
export async function resetDatabase() {
|
||||||
await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events, social_events, social_event_reservations CASCADE');
|
await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events, social_events, social_event_reservations CASCADE');
|
||||||
await initSchema();
|
await initSchema();
|
||||||
|
await migrateFromLegacyPlaces();
|
||||||
await seed();
|
await seed();
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user