Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/architecture/data-and-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,17 @@ loss. Retention failure is logged and never fails the committed restore.
`main/services/local-backup.ts` writes one backup a day to `Documents/FloCafe/Backups`, outside
`userData`, so it survives a data wipe, a reinstall, and an uninstall. It is on unless
`local_backup_enabled` is `'false'`. A check runs two minutes after startup and then hourly; it calls
`createBackup(targetPath)` when the newest file is at least 24 hours old, and skips a database with
`createBackup()` when the newest file is at least 24 hours old, and skips a database with
no orders, products, or customers so an empty install cannot push real backups out of retention.
Files are named `flo-backup-<UTC timestamp>Z.db`. Only names matching that pattern are listed or
pruned, and the newest `LOCAL_BACKUP_RETENTION` (5) are kept. Failures are logged and retried on the
next check. These files are plain SQLite and unencrypted. They are not part of `listBackups()`; restore
one through `POST /api/db/restore` with the file picker. `POST /api/db-tools/initialize` also copies its
pre-reset backup here (`retainBackupLocally`) when the setting is on.
Files are named `flo-backup-<UTC timestamp with milliseconds>Z.db`; names from older releases
without milliseconds are still recognized. Each file is staged and then hard-linked in under the
first free millisecond name, so a snapshot never replaces an existing backup. Only names matching that pattern are listed or pruned. Files dated
ahead of the clock rank below every other file because their time cannot be trusted; the file just
written is always kept, and the newest `LOCAL_BACKUP_RETENTION` (5) are kept in total. Failures are
logged and retried on the next check. These files are plain SQLite and unencrypted. They are not part
of `listBackups()`; restore one through `POST /api/db/restore` with the file picker.
`POST /api/db-tools/initialize` also copies its pre-reset backup here (`retainBackupLocally`) when the
setting is on.

### Durability

Expand All @@ -191,7 +195,7 @@ Directory syncing is skipped on Windows, which does not support it.
| `npm run test:schema-health` | Schema shape, index health, and recovery reference caching. |
| `npm run test:schema-reference-cache` | Recovery schema reference caching and migration registry invalidation. |
| `npm run test:backup` | Backup and restore behaviour. |
| `npm run test:local-backup` | The daily local backup: naming, due logic, retention, opt-out, and empty-database skip. |
| `npm run test:local-backup` | The daily local backup: naming, due logic, retention (including future-dated files), opt-out, empty-database skip, and the Initialize copy warning. |
| `npm run test:sequence` | Order and bill sequence generation, repair performance, and no-reuse invariants. |
| `npm run test:issue-278-fail-closed-db` | The missing-database guard. |

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,7 @@ Router: `main/routes/database-tools.ts`. Full path: `/api/db-tools`.
| `POST` | `/master-pin/reset` | `ROLE_ACCESS.owner` (`database.manage`) + current Master PIN when one is configured | body: `pin`, `confirm_pin`, and `master_pin` when a PIN is already configured | First-time setup requires no `master_pin`. |
| `GET` | `/currency-reset-impact` | `ROLE_ACCESS.owner` | none | Active currency plus the invoice, order, refund, customer, product, and add-on counts the destructive warning uses. |
| `POST` | `/currency-reset` | `ROLE_ACCESS.owner` + Master PIN | body: `currency` | Also needs `current_currency` and `confirmation_phrase` of the form `CHANGE TO <CODE>`. Creates a recovery backup, recreates the local database, preserves the sanitized menu catalog with monetary fields zeroed, and returns the backup path. The active session becomes invalid. |
| `POST` | `/initialize` | `ROLE_ACCESS.owner` + Master PIN | body: `confirmation_phrase` | A stale Drive restore boundary classified as ambiguous by recovery may be discarded only by this owner-authorized reset with the exact `INITIALIZE` phrase (stored Drive credentials are dropped); an active in-process reset still conflicts. |
| `POST` | `/initialize` | `ROLE_ACCESS.owner` + Master PIN | body: `confirmation_phrase` | A stale Drive restore boundary classified as ambiguous by recovery may be discarded only by this owner-authorized reset with the exact `INITIALIZE` phrase (stored Drive credentials are dropped); an active in-process reset still conflicts. Returns `{ success, backupPath, cleanupPending, localBackupFailed }`; `localBackupFailed` is `true` when the daily local backup is on but the pre-reset copy could not be kept there, which never fails the committed reset. |

### Reports

Expand Down
8 changes: 7 additions & 1 deletion frontend/src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -993,12 +993,14 @@ export default function SettingsPage() {
setPinGate({ mode: 'delete-backup', payload: { fileName: backup.fileName } });
};

const initializeLocalBackupFailedRef = useRef(false);
const handleInitializeDatabase = async (pin: string) => {
try {
const { data } = await api.post('/db-tools/initialize', {
master_pin: pin,
confirmation_phrase: 'INITIALIZE',
});
initializeLocalBackupFailedRef.current = data.localBackupFailed === true;
return { success: true, backupPath: data.backupPath };
} catch {
return { success: false, error: t('initializeFailedGeneric') };
Expand Down Expand Up @@ -6599,7 +6601,11 @@ export default function SettingsPage() {
onConfirm={handleInitializeDatabase}
onSuccess={() => {
toast.success(t('dbInitializedRedirecting'));
setTimeout(() => window.location.replace('/setup'), 1200);
const localBackupFailed = initializeLocalBackupFailedRef.current;
if (localBackupFailed) {
toast.error(t('initializeLocalBackupFailed'), { duration: 6000 });
}
setTimeout(() => window.location.replace('/setup'), localBackupFailed ? 6000 : 1200);
}}
/>
<CurrencyResetDialog
Expand Down
47 changes: 43 additions & 4 deletions frontend/src/components/settings/LocalBackupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HardDrive } from 'lucide-react';
import { useTranslations } from 'use-intl';
import toast from 'react-hot-toast';
import { Toggle } from '@/components/settings/Toggle';
import { Button } from '@/components/ui/button';
import { useFormatDate } from '@/hooks/useFormatDate';
import api from '@/lib/api';

Expand All @@ -20,21 +21,32 @@ export function LocalBackupCard() {
const { formatDateTime } = useFormatDate();
const [status, setStatus] = useState<LocalBackupStatus | null>(null);
const [busy, setBusy] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [retrying, setRetrying] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);

useEffect(() => {
let cancelled = false;
api
.get('/db-tools/local-backup')
.then(({ data }) => {
if (!cancelled) setStatus(data);
if (!cancelled) {
setStatus(data);
setLoadFailed(false);
}
})
.catch(() => {});
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setRetrying(false);
});
return () => {
cancelled = true;
};
}, []);
}, [loadAttempt]);

if (!status) return null;
if (!status && !loadFailed) return null;

const handleChange = async (enabled: boolean) => {
setBusy(true);
Expand All @@ -48,6 +60,33 @@ export function LocalBackupCard() {
}
};

const retryLoad = () => {
if (retrying) return;
setRetrying(true);
setLoadAttempt((attempt) => attempt + 1);
};

if (!status) {
return (
<div className="bg-card rounded-xl border border-border p-6" data-testid="local-backup-card">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<HardDrive size={20} className="text-muted-foreground" />
<h2 className="font-semibold text-foreground">{t('localBackupTitle')}</h2>
</div>
<p role="alert" className="text-sm text-destructive">
{t('localBackupLoadFailed')}
</p>
</div>
<Button variant="outline" size="sm" onClick={retryLoad} disabled={retrying}>
{t('retry')}
</Button>
</div>
</div>
);
}

return (
<div className="bg-card rounded-xl border border-border p-6" data-testid="local-backup-card">
<div className="flex items-start justify-between gap-4">
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@
"localBackupLast": "آخر نسخة احتياطية: {time}",
"localBackupNone": "لا توجد نسخة احتياطية بعد",
"localBackupUpdateFailed": "تعذر تحديث إعداد النسخ الاحتياطي",
"localBackupLoadFailed": "تعذّر تحميل إعداد النسخ الاحتياطي",
"backupKindAuto": "تلقائي",
"backupSchemaVersion": "المخطط v{version}",
"billContent": "مضمون مشروع القانون",
Expand Down Expand Up @@ -2020,6 +2021,7 @@
"initializeDialogBackup": "يتم إنشاء نسخة احتياطية تلقائيًا قبل حذف أي شيء.",
"initializeDialogBody": "سيؤدي هذا إلى الحذف النهائي لجميع المنتجات والطلبات والعملاء والإعدادات، وإعادة ضبط قاعدة البيانات إلى حالة التثبيت الفارغة. وستضطر بعد ذلك إلى إجراء عملية الإعداد الأولي مرة أخرى.",
"initializeFailedGeneric": "فشل التهيئة",
"initializeLocalBackupFailed": "تمت تهيئة قاعدة البيانات، لكن تعذّر حفظ نسخة احتياطية في مجلد النسخ الاحتياطي التلقائي. لا تزال النسخة الاحتياطية السابقة لإعادة التعيين في مجلد بيانات التطبيق.",
"initializeMasterPinPrompt": "أدخل رقم التعريف الشخصي الرئيسي (PIN) الخاص بجهازك لتهيئة قاعدة البيانات بشكل نهائي.",
"initializeTypeConfirm": "اكتب {phrase} للمتابعة",
"instagramHandle": "اسم المستخدم على إنستغرام",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/messages/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@
"localBackupLast": "সর্বশেষ ব্যাকআপ: {time}",
"localBackupNone": "এখনও কোনো ব্যাকআপ নেই",
"localBackupUpdateFailed": "ব্যাকআপ সেটিং আপডেট করা যায়নি",
"localBackupLoadFailed": "ব্যাকআপ সেটিং লোড করা যায়নি",
"backupKindAuto": "অটো",
"backupSchemaVersion": "স্কিমা v{version}",
"billContent": "বিল বিষয়বস্তু",
Expand Down Expand Up @@ -2020,6 +2021,7 @@
"initializeDialogBackup": "কিছু মুছে ফেলার আগে একটি ব্যাকআপ স্বয়ংক্রিয়ভাবে তৈরি হয়।",
"initializeDialogBody": "এটি স্থায়ীভাবে প্রতিটি পণ্য, অর্ডার, গ্রাহক এবং সেটিং মুছে দেয় এবং ডাটাবেসটিকে একটি ফাঁকা ইনস্টলে পুনরায় সেট করে। আপনি পরে আবার প্রথম-রান সেটআপের মধ্য দিয়ে যাবেন।",
"initializeFailedGeneric": "সূচনা ব্যর্থ হয়েছে।",
"initializeLocalBackupFailed": "ডেটাবেস ইনিশিয়ালাইজ হয়েছে, কিন্তু স্বয়ংক্রিয় ব্যাকআপ ফোল্ডারে ব্যাকআপ কপি সংরক্ষণ করা যায়নি। রিসেটের আগের ব্যাকআপটি এখনও অ্যাপের ডেটা ফোল্ডারে আছে।",
"initializeMasterPinPrompt": "স্থায়ীভাবে ডাটাবেস আরম্ভ করতে আপনার ডিভাইস মাস্টার পিন লিখুন.",
"initializeTypeConfirm": "টাইপ {phrase} চালিয়ে যেতে",
"instagramHandle": "ইনস্টাগ্রাম হ্যান্ডেল",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@
"localBackupLast": "Letzte Sicherung: {time}",
"localBackupNone": "Noch keine Sicherung",
"localBackupUpdateFailed": "Die Sicherungseinstellung konnte nicht aktualisiert werden",
"localBackupLoadFailed": "Die Sicherungseinstellung konnte nicht geladen werden",
"backupKindAuto": "Automatisch",
"backupSchemaVersion": "Schema v{version}",
"billContent": "Rechnungsinhalt",
Expand Down Expand Up @@ -2020,6 +2021,7 @@
"initializeDialogBackup": "Vor dem Löschen von Daten wird automatisch eine Sicherung erstellt.",
"initializeDialogBody": "Dadurch werden jedes Produkt, jede Bestellung, jeder Kunde und jede Einstellung dauerhaft gelöscht und die Datenbank zurückgesetzt. Anschließend durchlaufen Sie die Ersteinrichtung erneut.",
"initializeFailedGeneric": "Initialisierung fehlgeschlagen",
"initializeLocalBackupFailed": "Die Datenbank wurde initialisiert, aber die Sicherungskopie konnte nicht im Ordner für automatische Sicherungen gespeichert werden. Die Sicherung vor dem Zurücksetzen liegt weiterhin im Datenordner der App.",
"initializeMasterPinPrompt": "Geben Sie die Master-PIN Ihres Geräts ein, um die Datenbank dauerhaft zu initialisieren.",
"initializeTypeConfirm": "Geben Sie {phrase} ein, um fortzufahren",
"instagramHandle": "Instagram-Benutzername",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@
"localBackupLast": "Last backup: {time}",
"localBackupNone": "No backup yet",
"localBackupUpdateFailed": "Could not update the backup setting",
"localBackupLoadFailed": "Could not load the backup setting",
"backupKindAuto": "Auto",
"backupSchemaVersion": "schema v{version}",
"billContent": "Bill Content",
Expand Down Expand Up @@ -2020,6 +2021,7 @@
"initializeDialogBackup": "A backup is created automatically before anything is deleted.",
"initializeDialogBody": "This permanently deletes every product, order, customer, and setting, and resets the database to a blank install. You'll go through first-run setup again afterward.",
"initializeFailedGeneric": "Initialize failed",
"initializeLocalBackupFailed": "Database initialized, but the backup copy could not be saved to the automatic backup folder. The pre-reset backup is still in the app's data folder.",
"initializeMasterPinPrompt": "Enter your device Master PIN to permanently initialize the database.",
"initializeTypeConfirm": "Type {phrase} to continue",
"instagramHandle": "Instagram Handle",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@
"localBackupLast": "Última copia de seguridad: {time}",
"localBackupNone": "Aún no hay copias de seguridad",
"localBackupUpdateFailed": "No se pudo actualizar la configuración de copias de seguridad",
"localBackupLoadFailed": "No se pudo cargar la configuración de copia de seguridad",
"backupKindAuto": "Automático",
"backupSchemaVersion": "esquema v{version}",
"billContent": "Contenido del comprobante",
Expand Down Expand Up @@ -2020,6 +2021,7 @@
"initializeDialogBackup": "Se crea una copia de seguridad automáticamente antes de borrar nada.",
"initializeDialogBody": "Esto borra de forma permanente cada producto, pedido, cliente y configuración, y reinicia la base de datos a una instalación limpia. Vas a tener que pasar por la configuración inicial de nuevo.",
"initializeFailedGeneric": "Inicialización falló",
"initializeLocalBackupFailed": "La base de datos se inicializó, pero no se pudo guardar la copia de seguridad en la carpeta de copias automáticas. La copia previa al restablecimiento sigue en la carpeta de datos de la aplicación.",
"initializeMasterPinPrompt": "Ingresá el PIN Maestro del dispositivo para inicializar la base de datos de forma permanente.",
"initializeTypeConfirm": "Escribí {phrase} para continuar",
"instagramHandle": "Usuario de Instagram",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/messages/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@
"localBackupLast": "آخرین پشتیبان: {time}",
"localBackupNone": "هنوز پشتیبانی وجود ندارد",
"localBackupUpdateFailed": "به‌روزرسانی تنظیم پشتیبان‌گیری ممکن نشد",
"localBackupLoadFailed": "بارگیری تنظیم پشتیبان‌گیری ممکن نشد",
"backupKindAuto": "خودکار",
"backupSchemaVersion": "ساختار v{version}",
"billContent": "محتوای صورتحساب",
Expand Down Expand Up @@ -2020,6 +2021,7 @@
"initializeDialogBackup": "پیش از حذف هر چیزی، به‌طور خودکار یک پشتیبان ساخته می‌شود.",
"initializeDialogBody": "این کار هر کالا، سفارش، مشتری و تنظیمی را برای همیشه پاک می‌کند و پایگاه داده را به یک نصب خالی بازنشانی می‌کند. پس از آن دوباره راه‌اندازی نخست را پشت سر می‌گذارید.",
"initializeFailedGeneric": "بازنشانی انجام نشد",
"initializeLocalBackupFailed": "پایگاه داده راه‌اندازی اولیه شد، اما نسخه پشتیبان در پوشه پشتیبان‌گیری خودکار ذخیره نشد. نسخه پشتیبان پیش از بازنشانی همچنان در پوشه داده‌های برنامه است.",
"initializeMasterPinPrompt": "برای بازنشانی دائمی پایگاه داده، رمز اصلی دستگاه را وارد کنید.",
"initializeTypeConfirm": "برای ادامه، {phrase} را بنویسید",
"instagramHandle": "نام کاربری اینستاگرام",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/messages/fil.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@
"localBackupLast": "Huling backup: {time}",
"localBackupNone": "Wala pang backup",
"localBackupUpdateFailed": "Hindi ma-update ang setting ng backup",
"localBackupLoadFailed": "Hindi ma-load ang setting ng backup",
"backupKindAuto": "Auto",
"backupSchemaVersion": "schema v{version}",
"billContent": "Nilalaman ng Bill",
Expand Down Expand Up @@ -2020,6 +2021,7 @@
"initializeDialogBackup": "Kusang gumagawa ng backup bago burahin ang anuman.",
"initializeDialogBody": "Permanenteng buburahin nito ang bawat produkto, order, customer, at setting. Pupunta ka muli sa unang setup pagkatapos.",
"initializeFailedGeneric": "Nabigo ang pag-initialize",
"initializeLocalBackupFailed": "Na-initialize ang database, pero hindi na-save ang kopya ng backup sa folder ng awtomatikong backup. Nasa data folder pa rin ng app ang backup bago ang reset.",
"initializeMasterPinPrompt": "Ilagay ang iyong Master PIN upang permanenteng i-initialize ang database.",
"initializeTypeConfirm": "I-type ang {phrase} upang magpatuloy",
"instagramHandle": "Instagram Handle",
Expand Down
Loading
Loading