Compare commits
13 Commits
0ebe2172f4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b28fadb684 | |||
| bf2d939efe | |||
| 352212c0e0 | |||
| 5ffbc2e3d3 | |||
| 88ff0131b7 | |||
| af8c442bd1 | |||
| b305c4563f | |||
| 57003532be | |||
| 32225127ab | |||
| 3a16705614 | |||
| 3a50bb5299 | |||
| 97d8f12fc0 | |||
| e104a9d377 |
@@ -11,6 +11,7 @@ RUN npm install
|
||||
COPY . .
|
||||
# Use a temporary DB for generation
|
||||
ENV DATABASE_URL="file:./temp.db"
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
|
||||
42
README.md
42
README.md
@@ -61,6 +61,31 @@ Am einfachsten via **Intergram**:
|
||||
2. Sende `/start`, um deine **Chat ID** zu erhalten.
|
||||
3. Webhook-URL: `https://www.intergram.xyz/msg/DEINE_CHAT_ID`
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## 📲 Push Notifications (PWA)
|
||||
|
||||
Die App funktioniert als **Progressive Web App (PWA)** und unterstützt **Push-Benachrichtigungen** direkt auf dem Smartphone oder Desktop.
|
||||
|
||||
### Setup für Selbsthoster (VAPID Keys)
|
||||
Damit Push funktioniert, müssen VAPID Keys in der Umgebung hinterlegt werden.
|
||||
1. Generiere Keys: `npx web-push generate-vapid-keys`
|
||||
2. Setze die Environment Variables (z.B. in `.env` oder Docker):
|
||||
|
||||
```env
|
||||
# Public Key (Wird vom Browser benötigt)
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY="<Dein Public Key>"
|
||||
|
||||
# Private Key (Bleibt auf dem Server!)
|
||||
VAPID_PRIVATE_KEY="<Dein Private Key>"
|
||||
|
||||
# Kontakt-Email für den Push-Service
|
||||
VAPID_SUBJECT="mailto:admin@example.com"
|
||||
```
|
||||
|
||||
Der Benutzer kann Push-Benachrichtigungen dann direkt im Dashboard über die **Einstellungen** aktivieren.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Deployment (Docker)
|
||||
@@ -76,23 +101,38 @@ Mit **Docker CLI**:
|
||||
# Image bauen
|
||||
docker build -t cat-sitting-planner .
|
||||
|
||||
# Container starten
|
||||
# Container starten
|
||||
docker run -d \
|
||||
--name cat-sitting-planner \
|
||||
-p 3000:3000 \
|
||||
-v /pfad/zum/host/data:/app/data \
|
||||
-v /pfad/zum/host/uploads:/app/public/uploads \
|
||||
--restart always \
|
||||
cat-sitting-planner
|
||||
```
|
||||
|
||||
### 2. Datenpersistenz (Wichtig)
|
||||
Die Daten liegen in `/app/data/dev.db`.
|
||||
Mappe diesen Ordner unbedingt auf ein lokales Volume:
|
||||
Bilder werden in `/app/public/uploads` gespeichert.
|
||||
Mappe diese Ordner unbedingt auf lokale Volumes:
|
||||
```yaml
|
||||
volumes:
|
||||
- /pfad/zum/host/data:/app/data
|
||||
- /pfad/zum/host/uploads:/app/public/uploads
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🏥 System Status
|
||||
|
||||
Die App stellt einen einfachen Healthcheck-Endpoint bereit, der von Docker oder externen Monitoring-Tools genutzt werden kann:
|
||||
|
||||
- **Endpoint**: `/health`
|
||||
- **Method**: `GET`
|
||||
- **Response**: `200 OK` `{"status":"ok"}`
|
||||
|
||||
---
|
||||
|
||||
*Erstellt mit ❤️ für alle Dosenöffner.*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { format, eachDayOfInterval, isSameDay } from "date-fns"
|
||||
import { de, enUS } from "date-fns/locale"
|
||||
import { CalendarIcon, User, Home, X, Info, Utensils, Trash2, Check } from "lucide-react"
|
||||
import { CalendarIcon, User, Home, X, Info, Utensils, Trash2, Check, Share2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { uploadImage } from "@/app/actions/upload-image"
|
||||
import { Camera, Upload } from "lucide-react"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
@@ -61,12 +65,56 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
||||
const [cancelReason, setCancelReason] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleComplete = async (bookingId: number) => {
|
||||
// Completion Dialog State
|
||||
const [isCompleteDialogOpen, setIsCompleteDialogOpen] = useState(false)
|
||||
const [bookingToComplete, setBookingToComplete] = useState<number | null>(null)
|
||||
const [completionMessage, setCompletionMessage] = useState("")
|
||||
const [completionImage, setCompletionImage] = useState<File | null>(null)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
|
||||
const handleCompleteClick = (bookingId: number) => {
|
||||
setBookingToComplete(bookingId)
|
||||
setCompletionMessage("")
|
||||
setCompletionImage(null)
|
||||
setImagePreview(null)
|
||||
setIsCompleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setCompletionImage(file)
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmComplete = async () => {
|
||||
if (!bookingToComplete) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await completeBooking(bookingId, plan.id, lang)
|
||||
toast.success(dict.bookedSuccess) // reuse for now or add new toast
|
||||
let imageUrl: string | undefined = undefined
|
||||
|
||||
if (completionImage) {
|
||||
const formData = new FormData()
|
||||
formData.append("file", completionImage)
|
||||
formData.append("planId", plan.id)
|
||||
imageUrl = await uploadImage(formData)
|
||||
}
|
||||
|
||||
await completeBooking(bookingToComplete, plan.id, lang, completionMessage, imageUrl)
|
||||
toast.success(dict.jobDone)
|
||||
setIsCompleteDialogOpen(false)
|
||||
setBookingToComplete(null)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(dict.bookError)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +220,25 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
||||
dict={settingsDict}
|
||||
lang={lang}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: plan.title,
|
||||
text: dict.shareTitle,
|
||||
url: window.location.href,
|
||||
}).catch(() => {
|
||||
// Fallback if share fails / is cancelled
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
toast.success(dict.copySuccess)
|
||||
})
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
toast.success(dict.copySuccess)
|
||||
}
|
||||
}}>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
{dict.share}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +305,7 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full flex gap-2 items-center border-green-200 hover:bg-green-100/50 text-green-700 dark:border-green-800 dark:hover:bg-green-900/40 font-semibold"
|
||||
onClick={() => handleComplete(booking.id)}
|
||||
onClick={() => handleCompleteClick(booking.id)}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{dict.markDone}
|
||||
@@ -352,6 +419,72 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Completion Dialog */}
|
||||
<Dialog open={isCompleteDialogOpen} onOpenChange={setIsCompleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.completeTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.completeDesc}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="message">{dict.completeMessage}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={completionMessage}
|
||||
onChange={(e) => setCompletionMessage(e.target.value)}
|
||||
placeholder={dict.completeMessagePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="photo">{dict.completePhoto}</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" className="w-12 h-12" onClick={() => document.getElementById('photo-upload')?.click()}>
|
||||
<Camera className="w-6 h-6" />
|
||||
</Button>
|
||||
<Input
|
||||
id="photo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
{imagePreview && (
|
||||
<div className="relative w-20 h-20 rounded-md overflow-hidden border">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-0 right-0 h-5 w-5 bg-background/50 hover:bg-background"
|
||||
onClick={() => {
|
||||
setCompletionImage(null)
|
||||
setImagePreview(null)
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCompleteDialogOpen(false)} disabled={isSubmitting}>
|
||||
{dict.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmComplete} disabled={isSubmitting}>
|
||||
{isSubmitting ? dict.completing : dict.completeSubmit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str
|
||||
revalidatePath(`/${lang}/dashboard/${planId}`)
|
||||
}
|
||||
|
||||
export async function completeBooking(bookingId: number, planId: string, lang: string = "en") {
|
||||
export async function completeBooking(bookingId: number, planId: string, lang: string = "en", message?: string, imageUrl?: string) {
|
||||
const dict = await getDictionary(lang as any)
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
@@ -95,7 +95,11 @@ export async function completeBooking(bookingId: number, planId: string, lang: s
|
||||
|
||||
await prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { completedAt: new Date() }
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
completionMessage: message,
|
||||
completionImage: imageUrl
|
||||
}
|
||||
})
|
||||
|
||||
if (booking.plan.notifyAll) {
|
||||
@@ -104,12 +108,19 @@ export async function completeBooking(bookingId: number, planId: string, lang: s
|
||||
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
||||
|
||||
const dateStr = booking.date.toLocaleDateString(lang)
|
||||
const message = dict.notifications.completed
|
||||
let notificationMessage = dict.notifications.completed
|
||||
.replace("{name}", booking.sitterName || "Someone")
|
||||
.replace("{date}", dateStr)
|
||||
.replace("{url}", planUrl)
|
||||
|
||||
await sendPlanNotification(planId, message, booking.plan.webhookUrl)
|
||||
if (message) {
|
||||
notificationMessage += `\n"${message}"`
|
||||
}
|
||||
|
||||
// We need to pass the full URL for the image
|
||||
const fullImageUrl = imageUrl ? `${protocol}://${host}${imageUrl}` : undefined
|
||||
|
||||
await sendPlanNotification(planId, notificationMessage, booking.plan.webhookUrl, fullImageUrl)
|
||||
}
|
||||
|
||||
revalidatePath(`/${lang}/dashboard/${planId}`)
|
||||
|
||||
@@ -43,3 +43,7 @@ export async function unsubscribeUser(endpoint: string) {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getVapidPublicKey() {
|
||||
return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
50
app/actions/upload-image.ts
Normal file
50
app/actions/upload-image.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
"use server"
|
||||
|
||||
import { writeFile, mkdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
|
||||
export async function uploadImage(data: FormData) {
|
||||
const file: File | null = data.get("file") as unknown as File
|
||||
|
||||
if (!file) {
|
||||
throw new Error("No file uploaded")
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
|
||||
// Validate mime type (basic check)
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("Only images are allowed")
|
||||
}
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
// We organize by generic uploads folder for now, or per plan?
|
||||
// Let's optimize for simplicity: /public/uploads/YYYY/MM
|
||||
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const params = data.get("planId") ? String(data.get("planId")) : "misc"
|
||||
|
||||
// Use /uploads/planId/ to keep it somewhat organized or cleanup-able
|
||||
const relativeDir = join("uploads", params)
|
||||
const uploadDir = join(process.cwd(), "public", relativeDir)
|
||||
|
||||
try {
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
} catch (e) {
|
||||
// ignore if exists
|
||||
}
|
||||
|
||||
// Generate filename
|
||||
const ext = file.name.split('.').pop() || "jpg"
|
||||
const filename = `${uuidv4()}.${ext}`
|
||||
const filepath = join(uploadDir, filename)
|
||||
|
||||
await writeFile(filepath, buffer)
|
||||
|
||||
// Return the public URL
|
||||
return `/${relativeDir}/${filename}`
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export async function GET(
|
||||
icsContent = icsContent.replace(`DTEND;VALUE=DATE:${dateStr}\n`, `DTEND;VALUE=DATE:${nextDateStr}\n`)
|
||||
|
||||
icsContent += `SUMMARY:Cat Sitting: ${booking.sitterName}\n`
|
||||
icsContent += `DESCRIPTION:Cat sitting for plan ${plan.id}\n`
|
||||
icsContent += `DESCRIPTION:Cat sitting for plan ${plan.title}\n`
|
||||
icsContent += "END:VEVENT\n"
|
||||
}
|
||||
|
||||
|
||||
5
app/health/route.ts
Normal file
5
app/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok' }, { status: 200 });
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Bell, BellOff, Loader2, AlertTriangle } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { subscribeUser, unsubscribeUser } from "@/app/actions/subscription"
|
||||
import { subscribeUser, unsubscribeUser, getVapidPublicKey } from "@/app/actions/subscription"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
@@ -31,25 +31,44 @@ export function PushSubscriptionSettings({ planId }: { planId: string }) {
|
||||
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [debugInfo, setDebugInfo] = useState<string | null>(null)
|
||||
const [vapidKey, setVapidKey] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const checks = []
|
||||
if (!('serviceWorker' in navigator)) checks.push("No Service Worker support")
|
||||
if (!('PushManager' in window)) checks.push("No PushManager support")
|
||||
if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) checks.push("Missing VAPID Key")
|
||||
async function checkSupport() {
|
||||
const checks = []
|
||||
if (!('serviceWorker' in navigator)) checks.push("No Service Worker support")
|
||||
if (!('PushManager' in window)) checks.push("No PushManager support")
|
||||
|
||||
if (checks.length === 0) {
|
||||
setIsSupported(true)
|
||||
registerServiceWorker()
|
||||
} else {
|
||||
console.warn("Push not supported:", checks.join(", "))
|
||||
setDebugInfo(checks.join(", "))
|
||||
try {
|
||||
const key = await getVapidPublicKey()
|
||||
if (!key) {
|
||||
checks.push("Missing VAPID Key (Server)")
|
||||
} else {
|
||||
setVapidKey(key)
|
||||
}
|
||||
} catch (e) {
|
||||
checks.push("Failed to fetch VAPID Key")
|
||||
}
|
||||
|
||||
if (checks.length === 0) {
|
||||
setIsSupported(true)
|
||||
registerServiceWorker()
|
||||
} else {
|
||||
console.warn("Push not supported:", checks.join(", "))
|
||||
setDebugInfo(checks.join(", "))
|
||||
}
|
||||
}
|
||||
checkSupport()
|
||||
}, [])
|
||||
|
||||
async function registerServiceWorker() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
// Explicitly register the service worker
|
||||
const registration = await navigator.serviceWorker.register('/push-sw.js')
|
||||
|
||||
// Wait for it to be ready
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
const sub = await registration.pushManager.getSubscription()
|
||||
setSubscription(sub)
|
||||
} catch (e: any) {
|
||||
@@ -59,6 +78,10 @@ export function PushSubscriptionSettings({ planId }: { planId: string }) {
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
if (!vapidKey) {
|
||||
toast.error("VAPID Key missing")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setDebugInfo(null)
|
||||
try {
|
||||
@@ -84,7 +107,7 @@ export function PushSubscriptionSettings({ planId }: { planId: string }) {
|
||||
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!)
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey)
|
||||
})
|
||||
|
||||
console.log("Subscribed locally:", sub)
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"instructionsTitle": "Katzenpflege-Anleitungen",
|
||||
"export": "Exportieren",
|
||||
"settings": "Einstellungen",
|
||||
"share": "Teilen",
|
||||
"shareTitle": "Plan teilen",
|
||||
"copySuccess": "Link kopiert!",
|
||||
"shareError": "Fehler beim Teilen.",
|
||||
"feeding": "Füttern",
|
||||
"litter": "Klo reinigen",
|
||||
"ownerHome": "Besitzer zu Hause",
|
||||
@@ -62,7 +66,14 @@
|
||||
"cancelError": "Entfernen fehlgeschlagen",
|
||||
"noInstructions": "Noch keine spezifischen Instruktionen hinterlegt.",
|
||||
"markDone": "Job erledigt?",
|
||||
"jobDone": "Job erledigt!"
|
||||
"jobDone": "Job erledigt!",
|
||||
"completeTitle": "Job abschließen",
|
||||
"completeDesc": "Füge optional eine Nachricht oder ein Foto hinzu.",
|
||||
"completeMessage": "Nachricht (optional)",
|
||||
"completeMessagePlaceholder": "Alles super!",
|
||||
"completePhoto": "Foto (optional)",
|
||||
"completeSubmit": "Job als erledigt markieren",
|
||||
"completing": "Speichere..."
|
||||
},
|
||||
"login": {
|
||||
"title": "Passwort eingeben",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"instructionsTitle": "Cat Care Instructions",
|
||||
"export": "Export",
|
||||
"settings": "Settings",
|
||||
"share": "Share",
|
||||
"shareTitle": "Share Plan",
|
||||
"copySuccess": "Link copied to clipboard!",
|
||||
"shareError": "Could not share plan.",
|
||||
"feeding": "Feeding",
|
||||
"litter": "Clean litter",
|
||||
"ownerHome": "Owner Home",
|
||||
@@ -62,7 +66,14 @@
|
||||
"cancelError": "Cancellation failed",
|
||||
"noInstructions": "No specific instructions added yet.",
|
||||
"markDone": "Job done?",
|
||||
"jobDone": "Job done!"
|
||||
"jobDone": "Job done!",
|
||||
"completeTitle": "Complete Job",
|
||||
"completeDesc": "Add a message or photo (optional) and confirm.",
|
||||
"completeMessage": "Message (optional)",
|
||||
"completeMessagePlaceholder": "All good!",
|
||||
"completePhoto": "Photo (optional)",
|
||||
"completeSubmit": "Complete Job",
|
||||
"completing": "Completing..."
|
||||
},
|
||||
"login": {
|
||||
"title": "Enter Password",
|
||||
|
||||
@@ -7,4 +7,10 @@ services:
|
||||
- DATABASE_URL=file:/app/data/dev.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./public/uploads:/app/public/uploads
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
export async function sendNotification(webhookUrl: string | null, message: string) {
|
||||
export async function sendNotification(webhookUrl: string | null, message: string, imageUrl?: string) {
|
||||
if (!webhookUrl) return;
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
content: message,
|
||||
text: message
|
||||
}
|
||||
|
||||
// For Discord: use embed if image exists
|
||||
if (imageUrl && webhookUrl.includes("discord")) {
|
||||
payload.embeds = [{
|
||||
image: {
|
||||
url: imageUrl
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
// For generic webhooks, just append URL to text if not Discord, or leave as is?
|
||||
// Let's create a simpler payload if image exists but not discord?
|
||||
// Actually, if we just send JSON, most won't render it unless specific format.
|
||||
// Let's just append the image URL to the message if it's not a Discord webhook, so it's clickable.
|
||||
if (imageUrl && !webhookUrl.includes("discord")) {
|
||||
payload.content += `\n${imageUrl}`;
|
||||
payload.text += `\n${imageUrl}`;
|
||||
}
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "CatSittingPlanner/1.0"
|
||||
},
|
||||
// Works for Discord (content) and generic Telegram Webhook bridges (text)
|
||||
body: JSON.stringify({
|
||||
content: message,
|
||||
text: message
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`[Notification] Webhook failed with status ${response.status}`);
|
||||
@@ -25,12 +44,12 @@ export async function sendNotification(webhookUrl: string | null, message: strin
|
||||
import { sendPushNotification } from "./push";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function sendPlanNotification(planId: string, message: string, webhookUrl?: string | null) {
|
||||
export async function sendPlanNotification(planId: string, message: string, webhookUrl?: string | null, imageUrl?: string) {
|
||||
// Parallelize sending
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
if (webhookUrl) {
|
||||
promises.push(sendNotification(webhookUrl, message));
|
||||
promises.push(sendNotification(webhookUrl, message, imageUrl));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -40,13 +59,11 @@ export async function sendPlanNotification(planId: string, message: string, webh
|
||||
|
||||
if (subscriptions.length > 0) {
|
||||
console.log(`[Push] Found ${subscriptions.length} subscriptions for plan ${planId}`);
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
title: "Cat Sitting Planner",
|
||||
body: message,
|
||||
url: `/`
|
||||
// We could pass specific URL if needed, but for now root is okay or dashboard?
|
||||
// The service worker opens the URL.
|
||||
// Ideally, we want to open `/dashboard/[planId]`.
|
||||
url: `/`,
|
||||
image: imageUrl
|
||||
};
|
||||
|
||||
// Refine URL in payload
|
||||
|
||||
@@ -14,6 +14,8 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
if (pathnameHasLocale) return;
|
||||
|
||||
if (pathname.startsWith("/api") || pathname.includes("sw.js") || pathname.includes("workbox")) return NextResponse.next();
|
||||
|
||||
// Redirect if there is no locale
|
||||
const locale = defaultLocale; // For simplicity, we default to 'de' as requested
|
||||
request.nextUrl.pathname = `/${locale}${pathname}`;
|
||||
@@ -25,6 +27,6 @@ export function middleware(request: NextRequest) {
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip all internal paths (_next)
|
||||
"/((?!_next|api|public|manifest|icon|file|globe|next|vercel|window).*)",
|
||||
"/((?!_next|api|public|manifest|icon|file|globe|next|vercel|window|push-sw.js|sw.js|workbox|health).*)",
|
||||
],
|
||||
};
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"react-hook-form": "^7.71.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
@@ -4332,6 +4334,12 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
@@ -11003,6 +11011,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"react-hook-form": "^7.71.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
@@ -50,4 +52,4 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "completionImage" TEXT;
|
||||
ALTER TABLE "Booking" ADD COLUMN "completionMessage" TEXT;
|
||||
@@ -34,6 +34,9 @@ model Booking {
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
completionMessage String?
|
||||
completionImage String?
|
||||
|
||||
@@unique([planId, date, sitterName])
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Reference in New Issue
Block a user