Compare commits

...

14 Commits

Author SHA1 Message Date
elpatron a4b3515711 feat: implement voice memo transcription with local parakeet container and fallback timeouts 2026-06-06 11:01:15 +02:00
elpatron 41acbaebac chore: release v0.1.1.22 2026-06-05 19:58:21 +02:00
elpatron 6c83cd7d36 feat: differentiate weather fetch errors by cause 2026-06-05 19:52:33 +02:00
elpatron 9089e1c6f9 feat: resolve user profile photos in chronological event log 2026-06-05 19:46:18 +02:00
elpatron 1504960d85 chore: release v0.1.1.21 2026-06-05 19:13:20 +02:00
elpatron 599f090895 fix: resolve unbound variable error on remote deploy to prod 2026-06-05 19:13:03 +02:00
elpatron 4eb2b4c517 chore: release v0.1.1.20 2026-06-05 19:07:30 +02:00
elpatron be3b23ed8c docs: update marketing sticker image 2026-06-05 19:07:21 +02:00
elpatron 697c5781b7 feat(deploy): Unterstützung für Staging-Backups und -Wiederherstellungen
Erweitert die Backup- und Restore-Skripte um die Möglichkeit, Staging-Umgebungen zu unterstützen. Fügt die Option `-dest stage` hinzu, um spezifische Konfigurationen für Staging zu verwenden, einschließlich separater Docker-Compose-Dateien und Datenbankcontainer. Dokumentation aktualisiert, um manuelle Tests und Umgebungsvariablen für Staging zu reflektieren.
2026-06-05 18:45:52 +02:00
elpatron 4c36c9160a feat(deploy): Server-Backup und Restore für Produktion
Automatisiert pg_dump, .env, Compose und Git-Archiv mit Tag-Zuordnung, Retention (5) und Pre-Deploy-Hook nur für Prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:40:47 +02:00
elpatron d559a762d2 fix(deploy): Vor Deploy sauberen und synchronen Git-Stand erzwingen
update-remotes.sh bricht ab, wenn uncommitted Änderungen bestehen oder
HEAD nicht mit origin übereinstimmt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:24:21 +02:00
elpatron a2180a302c refactor(tour): interne z-index-Schichtung im Overlay vereinfachen
Ersetzt irreführende 10001/10002-Werte durch relative Layer 1–3 innerhalb
von .app-tour-root und dokumentiert den Stacking-Context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:15:17 +02:00
elpatron cd29115233 fix(tour): Tour-Tooltip über hervorgehobenen Profil-Schritten anzeigen
Erhöht den z-index des Tour-Overlays über app-tour-target-active, damit
das Modal in Schritt 8 (Stammcrew & Skipper) nicht von der Spotlight-Karte verdeckt wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:28 +02:00
elpatron e4b07ca896 refactor(deploy): update-prod.sh zu update-remotes.sh umbenennen
Ein Skript für Prod und Staging; update-staging.sh entfällt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:08 +02:00
33 changed files with 1275 additions and 83 deletions
+4
View File
@@ -6,6 +6,10 @@ OpenRouterAPIKey=
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
# OpenRouterModel=anthropic/claude-3.5-haiku
# Speech-to-Text Transcription Service (local Parakeet container endpoint)
# Defaults to: http://localhost:5092/v1/audio/transcriptions (or http://parakeet:5092/v1/audio/transcriptions in Docker)
# PARAKEET_URL=http://localhost:5092/v1/audio/transcriptions
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey=
+5 -2
View File
@@ -251,13 +251,15 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
```bash
./scripts/update-prod.sh -dest prod
./scripts/update-remotes.sh -dest prod
```
Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
### Staging
@@ -265,7 +267,7 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
```bash
./scripts/update-prod.sh -dest stage
./scripts/update-remotes.sh -dest stage
```
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
@@ -277,6 +279,7 @@ Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `R
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
+1 -1
View File
@@ -1 +1 @@
0.1.1.20
0.1.1.23
+6 -3
View File
@@ -6034,13 +6034,15 @@ html.theme-cupertino .events-scroll-container {
.app-tour-root {
position: fixed;
inset: 0;
z-index: 10000;
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
z-index: 10010;
pointer-events: none;
}
.app-tour-backdrop {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(2, 6, 23, 0.62);
pointer-events: auto;
}
@@ -6058,7 +6060,7 @@ html.theme-cupertino .events-scroll-container {
0 0 32px rgba(56, 189, 248, 0.5),
0 12px 40px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 10001;
z-index: 2;
}
body.app-tour-active .app-tour-target-active {
@@ -6069,7 +6071,8 @@ body.app-tour-active .app-tour-target-active {
.app-tour-tooltip {
position: fixed;
z-index: 10002;
/* Layer above backdrop/spotlight inside .app-tour-root (not vs. root's 10010) */
z-index: 3;
box-sizing: border-box;
width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
+32 -5
View File
@@ -45,11 +45,38 @@ export default function CreatorAvatar({
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
if (creatorId && crewSnapshotsById) {
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
// Fallback: If not found directly by key, search by role or name or active user
if (!snap) {
if (creatorId === 'skipper') {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
} else {
// Try to match name case-insensitively
snap = Object.values(crewSnapshotsById).find(
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
)
// Try to match active username/userid to the skipper snapshot
if (!snap) {
const activeUsername = localStorage.getItem('active_username')
const activeUserId = localStorage.getItem('active_userid')
if (
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
(activeUserId && creatorId === activeUserId)
) {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
}
}
}
}
if (snap) {
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
}
// Fallback to active username if owner or no crew pool matches
+90 -7
View File
@@ -1,24 +1,80 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Mic, Loader2 } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import { useDialog } from './ModalDialog.tsx'
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
interface EventRemarksCellProps {
event: LogEventPayload
logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
readOnly?: boolean
}
export default function EventRemarksCell({
event,
logbookId,
voiceMemoLookup
voiceMemoLookup,
readOnly = false
}: EventRemarksCellProps) {
const { t } = useTranslation()
const { showAlert } = useDialog()
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
const [transcribing, setTranscribing] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
const handleTranscribe = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (transcribing || !preloaded?.audio || !voiceId) return
setTranscribing(true)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) {
throw new Error(`Server returned status ${res.status}`)
}
const data = await res.json()
const text = (data.text || '').trim()
if (!text) {
throw new Error('Transcription returned empty text')
}
await updateVoiceMemoTranscript(logbookId, voiceId, text)
} catch (err) {
clearTimeout(timeoutId)
console.error('[EventRemarksCell] Transcription failed:', err)
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} finally {
setTranscribing(false)
}
}
let summary = formatEventSummary(event, t)
if (voiceId && preloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
@@ -28,12 +84,39 @@ export default function EventRemarksCell({
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
<span>{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
<button
type="button"
className="btn-icon-text link-sec"
style={{
fontSize: '0.8rem',
padding: '2px 6px',
height: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
margin: 0
}}
onClick={handleTranscribe}
disabled={transcribing}
title={t('logs.live_voice_transcribe_action')}
>
{transcribing ? (
<Loader2 size={12} className="spin" />
) : (
<Mic size={12} />
)}
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
</button>
)}
</div>
)}
</div>
)
+66 -26
View File
@@ -31,7 +31,6 @@ import {
removeLastEvent
} from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
getLastAutoPositionMs,
getLastLoggedPositionWithin,
@@ -43,7 +42,6 @@ import {
liveFuelRemark,
livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
livePrecipRemark,
liveSailsRemark,
liveSogRemark,
@@ -80,7 +78,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
import EventRemarksCell from './EventRemarksCell.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
@@ -713,13 +711,27 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
if (err instanceof WeatherApiError) {
if (err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'UNAUTHORIZED') {
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'BAD_REQUEST') {
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
return
}
}
console.error('Live log OWM weather failed:', err)
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
@@ -822,13 +834,46 @@ export default function LiveLogView({
void (async () => {
try {
const audioDataUrl = await blobToAudioDataUrl(blob)
let transcriptionText = ''
let transcribed = true
let transcriptionError = false
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 4000)
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioDataUrl }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Status ${res.status}`)
const data = await res.json()
transcriptionText = (data.text || '').trim()
} catch (err) {
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
transcriptionError = true
transcribed = false
}
let finalCaption = caption
if (transcriptionText) {
finalCaption = caption
? `${caption}\n(Transkript: ${transcriptionText})`
: transcriptionText
}
const voiceId = await saveEntryVoiceMemo({
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption,
caption: finalCaption,
transcribed,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
@@ -840,6 +885,10 @@ export default function LiveLogView({
setVoiceCaption('')
showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
if (transcriptionError) {
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
}
} catch (err: unknown) {
console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
@@ -1211,12 +1260,6 @@ export default function LiveLogView({
) : (
<ol className="live-log-stream">
{events.map((event, index) => {
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
let summary = formatEventSummary(event, t)
if (voiceId && voicePreloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
}
return (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
@@ -1226,15 +1269,12 @@ export default function LiveLogView({
size={24}
/>
<div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={voicePreloaded}
compact
/>
)}
<EventRemarksCell
event={event}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={false}
/>
</div>
</li>
)
+22 -7
View File
@@ -1178,13 +1178,27 @@ export default function LogEntryEditor({
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
if (err instanceof WeatherApiError) {
if (err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
if (err.code === 'UNAUTHORIZED') {
showAlert(t('settings.weather_unauthorized'))
return
}
if (err.code === 'NOT_FOUND') {
showAlert(t('settings.weather_not_found'))
return
}
if (err.code === 'BAD_REQUEST') {
showAlert(t('settings.weather_bad_request'))
return
}
}
console.error('Weather prefilling failed:', err)
showAlert(t('settings.weather_error'))
@@ -1895,6 +1909,7 @@ export default function LogEntryEditor({
event={ev}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={readOnly}
/>
</td>
{!readOnly && (
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
mimeType?: string
durationSec?: number
caption?: string
transcribed?: boolean
}
interface VoiceMemoPlayerProps {
+2 -1
View File
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
caption: decrypted.caption ? String(decrypted.caption) : ''
caption: decrypted.caption ? String(decrypted.caption) : '',
transcribed: decrypted.transcribed !== false
})
} catch {
// skip corrupt memo
+6
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Stemmenotat",
"live_voice_caption_label": "Billedtekst (valgfrit)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Stemmebesked gemt, men transkribering mislykkedes.",
"live_undo_voice_hint": "Stemmenotat gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
@@ -790,6 +793,9 @@
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
"weather_success": "Vejrdata hentet med succes!",
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
"weather_unauthorized": "Hentning af vejrdata mislykkedes. API-nøglen er ugyldig eller ikke autoriseret.",
"weather_not_found": "Hentning af vejrdata mislykkedes. Den angivne placering eller koordinater blev ikke fundet.",
"weather_bad_request": "Hentning af vejrdata mislykkedes. Ingen placering eller GPS-position blev angivet.",
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)",
+6
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_voice_transcribe_action": "Transkribieren",
"live_voice_transcribing": "Transkribiere...",
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
"live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
@@ -790,6 +793,9 @@
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"share_title": "Logbuch teilen (Schreibgeschützt)",
+6
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_voice_transcribe_action": "Transcribe",
"live_voice_transcribing": "Transcribing…",
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
"live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
@@ -790,6 +793,9 @@
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.",
"share_title": "Share Logbook (Read-Only)",
+6
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Talemelding",
"live_voice_caption_label": "Bildetekst (valgfritt)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Taleopptak lagret, men transkribering mislyktes.",
"live_undo_voice_hint": "Talemelding lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
@@ -790,6 +793,9 @@
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
"weather_success": "Værdata vellykket hentet!",
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
"weather_unauthorized": "Henting av værdata mislyktes. API-nøkkelen er ugyldig eller ikke autorisert.",
"weather_not_found": "Henting av værdata mislyktes. Den angitte posisjonen eller koordinatene ble ikke funnet.",
"weather_bad_request": "Henting av værdata mislyktes. Ingen posisjon eller GPS-koordinater ble angitt.",
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)",
+6
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Röstanteckning",
"live_voice_caption_label": "Bildtext (valfritt)",
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
"live_voice_transcribe_action": "Transkribera",
"live_voice_transcribing": "Transkriberar…",
"live_voice_transcribe_failed": "Röstanteckning sparad, men transkribering misslyckades.",
"live_undo_voice_hint": "Röstanteckning sparad",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
@@ -790,6 +793,9 @@
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
"weather_success": "Väderdata har hämtats framgångsrikt!",
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
"weather_unauthorized": "Hämtning av väderdata misslyckades. API-nyckeln är ogiltig eller inte auktoriserad.",
"weather_not_found": "Hämtning av väderdata misslyckades. Den angivna platsen eller koordinaterna hittades inte.",
"weather_bad_request": "Hämtning av väderdata misslyckades. Ingen plats eller GPS-position angavs.",
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)",
+57 -2
View File
@@ -1,7 +1,7 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType: string
durationSec: number
caption?: string
transcribed?: boolean
analyticsContext?: string
}): Promise<string> {
const {
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType,
durationSec,
caption = '',
transcribed = true,
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim()
caption: caption.trim(),
transcribed: !!transcribed
}
const encrypted = await encryptJson(voicePayload, masterKey)
@@ -98,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
await deleteEntryVoiceMemo(logbookId, lastId)
return lastId
}
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
export async function updateVoiceMemoTranscript(
logbookId: string,
voiceId: string,
transcript: string
): Promise<void> {
const masterKey = await getEncryptionKey(logbookId)
const record = await db.voiceMemos.get(voiceId)
if (!record) throw new Error('Voice memo not found')
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted) throw new Error('Failed to decrypt voice memo')
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
const finalCaption = manualCaption
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
: transcript.trim()
const updatedPayload = {
...decrypted,
caption: finalCaption,
transcribed: true
}
const encrypted = await encryptJson(updatedPayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
...record,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId: record.entryId
}),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
+47
View File
@@ -69,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
it('throws UNAUTHORIZED when status is 401', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('UNAUTHORIZED')
})
it('throws NOT_FOUND when status is 404', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: 'Not Found' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('NOT_FOUND')
})
it('throws BAD_REQUEST when status is 400', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Bad Request' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
})
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
expect(apiFetch).not.toHaveBeenCalled()
})
})
+15 -3
View File
@@ -7,9 +7,12 @@ import {
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
constructor(
message: string,
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'WeatherApiError'
this.code = code
@@ -38,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
} else if (params.q?.trim()) {
searchParams.set('q', params.q.trim())
} else {
throw new WeatherApiError('lat/lon or location query required')
throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
}
const userKey = getOwmApiKeyForActiveUser().trim()
@@ -65,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
}
if (res.status === 401) {
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
}
if (res.status === 404) {
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
}
if (res.status === 400) {
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
}
const data = await res.json()
if (!res.ok) {
+8
View File
@@ -33,6 +33,7 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -66,6 +67,13 @@ services:
backend:
condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-staging-parakeet
restart: always
ports:
- "5092:5092"
volumes:
pgdata:
name: daagbox-staging-pgdata
+8
View File
@@ -34,6 +34,7 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -67,6 +68,13 @@ services:
backend:
condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-prod-parakeet
restart: always
ports:
- "5092:5092"
volumes:
pgdata:
name: daagbox-prod-pgdata
+111
View File
@@ -0,0 +1,111 @@
# Server-Backup (Produktion)
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
## Was wird gesichert?
| Inhalt | Beschreibung |
|--------|--------------|
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
| `docker-compose.yml` | Aktive Compose-Datei |
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
## Einmalige Einrichtung (Prod-Server)
```bash
ssh root@10.0.0.25
mkdir -p /var/backups/kapteins-daagbok
chmod 700 /var/backups/kapteins-daagbok
cd /opt/kapteins-daagbok
git pull
chmod +x scripts/backup.sh scripts/restore-backup.sh
./scripts/backup.sh --reason manual
```
## Manuell sichern
```bash
cd /opt/kapteins-daagbok
./scripts/backup.sh
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
```
### Staging (manueller Test)
```bash
cd /opt/kapteins-daagbok-staging
./scripts/backup.sh -dest stage --reason manual
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
./scripts/backup.sh --reason manual
```
## Crontab (unbeaufsichtigt)
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
```bash
crontab -e
# Zeile einfügen:
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
```
## Pre-Deploy-Backup
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
## Wiederherstellen
Verfügbare Backups anzeigen:
```bash
./scripts/restore-backup.sh --list
```
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
```bash
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
```
Nur Datenbank:
```bash
./scripts/restore-backup.sh --restore PATH --db-only
```
Nur `.env`:
```bash
./scripts/restore-backup.sh --restore PATH --env-only
```
Ohne Rückfragen (Notfall):
```bash
./scripts/restore-backup.sh --restore PATH --full --yes
```
## Vor Passwort-Rotation
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
```bash
./scripts/backup.sh --reason manual
```
## Umgebungsvariablen
| Variable | Prod (default) | Staging (`-dest stage`) |
|----------|----------------|-------------------------|
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
| `RETENTION` | `5` | `5` |
+1 -1
View File
@@ -8,7 +8,7 @@
## Empfohlene Schritte
1. **Backup/Snapshot** (hast du laut Vorgabe).
1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
2. Auf dem Server im Repo:
```bash
cd /opt/kapteins-daagbok
+3 -3
View File
@@ -31,12 +31,12 @@ cd server && npm test
## Nach erfolgreichem Check
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
[`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
```bash
./scripts/update-prod.sh -dest prod
./scripts/update-remotes.sh -dest prod
```
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest prod`
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest prod`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+4 -4
View File
@@ -9,7 +9,7 @@ Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbo
| Host | `10.0.0.27` | `10.0.0.25` |
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
| Deploy-Skript | `./scripts/update-prod.sh -dest stage` | `./scripts/update-prod.sh -dest prod` |
| Deploy-Skript | `./scripts/update-remotes.sh -dest stage` | `./scripts/update-remotes.sh -dest prod` |
| Release-Tag | nein | ja (`v*`) |
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
@@ -60,7 +60,7 @@ Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
```bash
./scripts/update-prod.sh -dest stage
./scripts/update-remotes.sh -dest stage
```
Konfiguration via Umgebungsvariablen:
@@ -69,10 +69,10 @@ Konfiguration via Umgebungsvariablen:
REMOTE_HOST=10.0.0.27 \
REMOTE_DIR=/opt/kapteins-daagbok-staging \
DEPLOY_BRANCH=master \
./scripts/update-prod.sh -dest stage
./scripts/update-remotes.sh -dest stage
```
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest stage`
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest stage`
## NPM (VM1)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 359 KiB

+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
#
# Run on the server in repo root (/opt/kapteins-daagbok on production).
#
# Usage:
# ./scripts/backup.sh
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
# ./scripts/backup.sh --reason cron
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
# ./scripts/backup.sh --dry-run
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
RETENTION="${RETENTION:-5}"
DEST="prod"
REASON="manual"
EXPLICIT_TAG=""
DRY_RUN=0
COMPOSE_FILE=""
DB_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
fi
}
usage() {
sed -n '2,14p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
echo " --dry-run Show actions without writing backup"
echo " -h, --help Show this help"
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--reason)
REASON="${2:?--reason requires an argument}"
shift 2
;;
--tag)
EXPLICIT_TAG="${2:?--tag requires an argument}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
case "$REASON" in
cron|pre-deploy|manual) ;;
*)
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
exit 1
;;
esac
if [ ! -f "$COMPOSE_FILE" ]; then
echo "Error: $COMPOSE_FILE not found" >&2
exit 1
fi
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_TAG="$EXPLICIT_TAG"
if [ -z "$GIT_TAG" ]; then
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
fi
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
fi
if [ -z "$GIT_TAG" ]; then
GIT_TAG="unknown"
fi
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
TAG_SLUG="${GIT_TAG}"
if [ "$REASON" = "pre-deploy" ]; then
TAG_SLUG="${GIT_TAG}-predeploy"
fi
TAG_SLUG="${TAG_SLUG//\//-}"
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8}$ARCHIVE_PATH"
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] Would dump database from $DB_CONTAINER"
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
echo "[dry-run] Would create git archive"
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
echo "[dry-run] Would apply retention (keep $RETENTION)"
exit 0
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found (run from repo root)" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
export PGPASSWORD="$POSTGRES_PASSWORD"
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
| gzip > "$WORK_DIR/database.sql.gz"; then
echo "Error: pg_dump failed" >&2
exit 1
fi
unset PGPASSWORD
cp "$ENV_FILE" "$WORK_DIR/.env"
chmod 600 "$WORK_DIR/.env"
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
echo "Creating app snapshot (git archive)..."
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
:
else
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
rm -f "$WORK_DIR/app.tar.gz"
fi
python3 - "$WORK_DIR/manifest.json" <<PY
import json
import socket
import sys
from datetime import datetime, timezone
manifest = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"local_timestamp": "${TIMESTAMP}",
"destination": "${DEST}",
"reason": "${REASON}",
"git_tag": "${GIT_TAG}",
"git_sha": "${GIT_SHA}",
"app_version": "${APP_VERSION}",
"compose_file": "${COMPOSE_FILE}",
"db_container": "${DB_CONTAINER}",
"postgres_db": "${POSTGRES_DB}",
"hostname": socket.gethostname(),
"archive_name": "${ARCHIVE_NAME}",
}
with open(sys.argv[1], "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
PY
echo "Packing backup archive..."
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
manifest.json database.sql.gz .env docker-compose.yml \
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
chmod 600 "$ARCHIVE_PATH"
echo "Applying retention (keep last $RETENTION backups)..."
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
rm -f "${ALL_BACKUPS[$i]}"
done
fi
echo "Backup complete: $ARCHIVE_PATH"
echo "$ARCHIVE_PATH"
+11
View File
@@ -0,0 +1,11 @@
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
#
# Install:
# crontab -e
# (paste the line below)
#
# Ensure log directory exists:
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env bash
# Restore server backup created by scripts/backup.sh
#
# Usage:
# ./scripts/restore-backup.sh --list
# ./scripts/restore-backup.sh -dest stage --restore PATH
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
MAX_WAIT=90
DEST="prod"
COMPOSE_FILE=""
DB_CONTAINER=""
BACKEND_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-staging-backend"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-prod-backend"
fi
fi
}
MODE="full"
RESTORE_PATH=""
LIST=0
ASSUME_YES=0
usage() {
sed -n '2,10p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --list List available backups"
echo " --restore PATH Backup archive to restore"
echo " --full Restore DB + .env (default)"
echo " --db-only Restore database only"
echo " --env-only Restore .env only"
echo " --yes Skip confirmation prompts"
echo " -h, --help Show this help"
}
confirm() {
local prompt="$1"
if [ "$ASSUME_YES" -eq 1 ]; then
return 0
fi
read -r -p "$prompt [y/N] " answer
[[ "$answer" =~ ^[yY]$ ]]
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--list)
LIST=1
shift
;;
--restore)
RESTORE_PATH="${2:?--restore requires a path}"
shift 2
;;
--full)
MODE="full"
shift
;;
--db-only)
MODE="db-only"
shift
;;
--env-only)
MODE="env-only"
shift
;;
--yes)
ASSUME_YES=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
list_backups() {
if [ ! -d "$BACKUP_DIR" ]; then
echo "No backup directory: $BACKUP_DIR"
return 0
fi
local found=0
while IFS= read -r archive; do
found=1
echo "=== $archive ==="
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
echo ""
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "$found" -eq 0 ]; then
echo "No backups found in $BACKUP_DIR"
fi
}
if [ "$LIST" -eq 1 ]; then
list_backups
exit 0
fi
if [ -z "$RESTORE_PATH" ]; then
echo "Error: --restore PATH or --list required" >&2
usage >&2
exit 1
fi
if [ ! -f "$RESTORE_PATH" ]; then
echo "Error: backup archive not found: $RESTORE_PATH" >&2
exit 1
fi
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Extracting $RESTORE_PATH..."
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
if [ ! -f "$WORK_DIR/manifest.json" ]; then
echo "Error: manifest.json missing in backup archive" >&2
exit 1
fi
MANIFEST="$WORK_DIR/manifest.json"
echo "Backup manifest:"
python3 -m json.tool "$MANIFEST"
echo ""
read_manifest_field() {
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
}
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
MANIFEST_DB="$(read_manifest_field db_container)"
MANIFEST_DEST="$(read_manifest_field destination)"
if [ -n "$MANIFEST_COMPOSE" ]; then
COMPOSE_FILE="$MANIFEST_COMPOSE"
fi
if [ -n "$MANIFEST_DB" ]; then
DB_CONTAINER="$MANIFEST_DB"
fi
if [ -n "$MANIFEST_DEST" ]; then
if [[ "$MANIFEST_DEST" == "stage" ]]; then
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
else
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
fi
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
GIT_TAG="$(read_manifest_field git_tag)"
GIT_SHA="$(read_manifest_field git_sha)"
BACKUP_TS="$(read_manifest_field local_timestamp)"
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
echo "Aborted."
exit 1
fi
restore_env() {
if [ ! -f "$WORK_DIR/.env" ]; then
echo "Error: .env missing in backup archive" >&2
exit 1
fi
if [ -f "$ENV_FILE" ]; then
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BAK"
echo "Current $ENV_FILE saved to $BAK"
fi
cp "$WORK_DIR/.env" "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "Restored $ENV_FILE"
}
restore_db() {
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
echo "Error: database.sql.gz missing in backup archive" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE required for database restore" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
echo "Stopping backend before database restore..."
docker compose -f "$COMPOSE_FILE" stop backend || true
echo "Resetting public schema..."
export PGPASSWORD="$POSTGRES_PASSWORD"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
DROP SCHEMA IF EXISTS public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
GRANT ALL ON SCHEMA public TO public;
SQL
echo "Importing database dump..."
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
unset PGPASSWORD
echo "Database restore complete."
}
wait_for_healthy() {
echo "Starting stack and waiting for health..."
docker compose -f "$COMPOSE_FILE" up -d
local counter=0
while [ "$counter" -lt "$MAX_WAIT" ]; do
local status
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
if [ "$status" = "healthy" ]; then
echo "Backend is healthy."
return 0
fi
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
echo "API health check OK."
return 0
fi
sleep 1
counter=$((counter + 1))
printf "."
done
echo ""
echo "Warning: backend did not become healthy in time." >&2
return 1
}
case "$MODE" in
env-only)
restore_env
;;
db-only)
restore_db
wait_for_healthy || exit 1
;;
full)
restore_env
restore_db
wait_for_healthy || exit 1
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
git fetch --tags origin
git checkout "$GIT_TAG"
echo "Checked out $GIT_TAG"
fi
fi
;;
*)
echo "Error: unknown mode $MODE" >&2
exit 1
;;
esac
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."
+1 -1
View File
@@ -81,7 +81,7 @@ require_node_toolchain() {
echo ""
echo "On the production host, prefer updating the running stack:"
echo " docker compose -f docker-compose.yml up -d --build"
echo " # or from your workstation: ./scripts/update-prod.sh"
echo " # or from your workstation: ./scripts/update-remotes.sh -dest prod"
exit 1
}
@@ -16,6 +16,8 @@ Environment overrides (optional):
DEPLOY_BRANCH (stage only, default: master)
SKIP_PREDEPLOY_CHECK=1
Local repo must be clean and match origin before deploy (git fetch + compare HEAD).
Examples:
$(basename "$0") -dest prod
$(basename "$0") -dest stage
@@ -158,6 +160,66 @@ ensure_clean_git_tree() {
git commit -m "$commit_message"
}
ensure_local_sync_with_origin() {
local branch="$1"
local local_sha origin_sha current_branch
if [ -z "$branch" ]; then
echo "Error: deploy branch is not set." >&2
exit 1
fi
if [ -n "$(git status --porcelain)" ]; then
echo "Error: Working tree is not clean. Commit or stash changes before deploying." >&2
git status --short
exit 1
fi
current_branch="$(git branch --show-current)"
if [ -z "$current_branch" ]; then
echo "Error: Detached HEAD — checkout branch '$branch' before deploying." >&2
exit 1
fi
if [ "$current_branch" != "$branch" ]; then
echo "Error: On branch '$current_branch', expected '$branch'." >&2
exit 1
fi
echo "Syncing with origin..."
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2
exit 1
fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2
exit 1
fi
local_sha="$(git rev-parse HEAD)"
origin_sha="$(git rev-parse "origin/${branch}")"
if [ "$local_sha" = "$origin_sha" ]; then
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
return 0
fi
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
echo "Hint: run 'git pull' to fast-forward." >&2
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
else
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
fi
exit 1
}
prepare_release() {
local current_version release_version next_version tag_name
@@ -199,7 +261,9 @@ prepare_release() {
if [[ "$DEST" == "prod" ]]; then
prepare_release
ensure_local_sync_with_origin "$(git branch --show-current)"
else
ensure_local_sync_with_origin "$DEPLOY_BRANCH"
APP_VERSION="$(read_current_version)"
fi
@@ -227,7 +291,7 @@ MAX_WAIT="$4"
APP_URL="$5"
APP_VERSION="$6"
DEST="$7"
DEPLOY_BRANCH="$8"
DEPLOY_BRANCH="${8:-}"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
@@ -235,6 +299,18 @@ if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
if [[ "$DEST" == "prod" ]]; then
echo "Creating pre-deploy backup..."
if [ -x "./scripts/backup.sh" ]; then
if ! ./scripts/backup.sh --reason pre-deploy --tag "v${APP_VERSION}"; then
echo "Error: Pre-deploy backup failed. Aborting update."
exit 1
fi
else
echo "Warning: scripts/backup.sh not found or not executable — skipping backup."
fi
fi
if [[ "$DEST" == "stage" ]]; then
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
git fetch origin
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
# Backward-compatible wrapper — prefer: ./scripts/update-prod.sh -dest stage
set -euo pipefail
for arg in "$@"; do
case "$arg" in
-dest|-dest=*)
echo "Error: update-staging.sh always deploys to staging." >&2
echo " Use ./scripts/update-prod.sh -dest prod for production." >&2
exit 1
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/update-prod.sh" -dest stage "$@"
+8
View File
@@ -59,4 +59,12 @@ describe('API smoke', () => {
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('POST /api/ai/transcribe requires session', async () => {
const res = await request(app)
.post('/api/ai/transcribe')
.send({ audioDataUrl: 'data:audio/webm;base64,abcdef' })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
})
+65
View File
@@ -4,6 +4,7 @@ import { requireUser } from '../middleware/auth.js'
const router = Router()
const PARAKEET_URL = process.env.PARAKEET_URL || 'http://localhost:5092/v1/audio/transcriptions'
const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
@@ -230,4 +231,68 @@ router.post('/summary', async (req: any, res) => {
}
})
router.post('/transcribe', async (req: any, res) => {
try {
const { audioDataUrl } = req.body ?? {}
if (!audioDataUrl || typeof audioDataUrl !== 'string') {
return res.status(400).json({ error: 'audioDataUrl is required' })
}
const match = audioDataUrl.match(/^data:([^;]+);base64,(.+)$/)
if (!match) {
return res.status(400).json({ error: 'Invalid audio data URL format' })
}
const [, mimeType, base64Data] = match
const buffer = Buffer.from(base64Data, 'base64')
let ext = 'webm'
if (mimeType.includes('mp4')) ext = 'mp4'
else if (mimeType.includes('ogg')) ext = 'ogg'
else if (mimeType.includes('wav')) ext = 'wav'
const filename = `audio.${ext}`
const file = new File([buffer], filename, { type: mimeType })
const formData = new FormData()
formData.append('file', file)
console.log(`[server] Forwarding ASR request to ${PARAKEET_URL} (${filename}, ${buffer.length} bytes)`)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const parakeetRes = await fetch(PARAKEET_URL, {
method: 'POST',
body: formData,
signal: controller.signal
})
if (!parakeetRes.ok) {
const errorText = await parakeetRes.text().catch(() => '')
console.error(`[server] Parakeet ASR error response (status=${parakeetRes.status}):`, errorText)
throw new Error(`Parakeet returned status ${parakeetRes.status}`)
}
const data: any = await parakeetRes.json()
const text = (data?.text || '').trim()
console.log(`[server] ASR completed successfully: "${text}"`)
return res.json({ text })
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
console.error('[server] Parakeet ASR request timed out')
return res.status(504).json({ error: 'Transcription request timed out' })
}
throw error
} finally {
clearTimeout(timeoutId)
}
} catch (error: unknown) {
console.error('ASR transcription failed:', error)
return res.status(503).json({ error: 'Transcription service unavailable' })
}
})
export default router