فهرست منبع

Back up workshops on a schedule, restore from a backup, and run platform backups for Pro workshops (#480)

* Back up workshops on a schedule to S3-compatible storage or a local folder

Add two integrations under Files: S3-compatible storage (Amazon S3,
Cloudflare R2, Backblaze B2, Wasabi, Hetzner, or a custom endpoint on
self-hosted installs) and Local folder (self-hosted only). Each keeps an
encrypted restic repository per workshop, runs on a schedule from hourly
to monthly, uploads only what changed, keeps hourly, daily, weekly and
monthly backups, and alerts on failure or when a destination falls
behind. The export now streams data.json as it is built and leaves
secret keys out unless asked for; the query building moves to
src/lib/backup/build-data.ts, where email messages ride with the
other channels as on main.

* Stream backup imports, list regions and buckets, call the integration Cloud storage bucket

Imports no longer load the upload or the archive into memory. The request
body is written to a temporary file as it arrives, the zip's table of
contents is checked against the limits before anything is inflated, and
each entry streams out on demand: data.json straight into the JSON parser,
photos straight to disk. JSZip moves to the dev dependencies; yauzl and
stream-json come in.

The connect page for the cloud bucket now asks for the provider first.
The region is a list for Amazon S3, Backblaze B2, Wasabi and Hetzner, a
text box for other storage and absent for Cloudflare R2. The bucket is
picked from the buckets the typed key can see, read before anything is
saved, with a typed name accepted for a key that may use one bucket but
not list them. The custom endpoint is only asked for under the Other
provider. The integration is called Cloud storage bucket, since the old
name read as Amazon only, and catalog cards show the whole description.

* Show connector names and backup alerts in the workshop's language

Connectors named after what they do, the cloud storage bucket, the local
folder, speech to text, the OpenAI-compatible endpoint and the Microsoft
365 calendar, now carry a translated name under connectors.<id>.name in
every locale, and the catalog, connection page, search palette, locked
page, calendar sources, lookup sources and backup overview show it. A
vendor's product keeps its own name. Audit rows carry the connector id so
the log can name it in the reader's language.

The backup failure and overdue alerts, in-app and by email, come from
strings in the workshop's own language, with the last good time on the
workshop's clock, through a translator that reads the workshop's language
where there is no request to read one from.

* Test the typed keys before connecting, and take a typed bucket when none is listed

The connect page of the backup integrations gets a Test connection
button under the keys and the bucket, disabled until those are filled.
It proves the key can list, write and delete in the bucket with one test
object that is removed again, or that the local folder can be made and
written to, and names the step that failed with the vendor's reason.
Nothing is saved by the test; the repository is still Connect's job.

A key allowed one bucket but not to list them, the usual shape of a
Cloudflare R2 or scoped Amazon key, gets a plain text box for the bucket
name with a note that nothing was listed, instead of an empty picker.

* Send no SDK checksums to S3-compatible storage

Newer AWS SDKs add checksum headers and trailers to writes that
Cloudflare R2 and other S3-compatible services refuse. One shared client
sends them only where the operation itself requires them.

* Prune backup run rows older than ninety days, keeping a destination's newest hundred

Every run leaves a row and an hourly schedule leaves thousands a year.
Once a day the scheduler drops rows older than ninety days, except that
each destination keeps its newest hundred and a run still queued or
running is never touched.

* Point at restoring from the Backups card and say what a backup holds

The Backups card ends with a line on how to restore and a link to the
docs, and the Contents help says a backup holds the workshop's records
and files, not user accounts or passwords.

* Restore a backup from inside the app, kept inside its own workshop

The Backups card of a connected backup destination (Cloud storage bucket,
Local folder) gets a Restore section. Owners and administrators press Show
backups, see the snapshots in the restic repository newest first (when
taken, started by the schedule or a person, whether photos and files were
included, size, and whether an earlier install of the workshop took it),
and put one back after typing the workshop's name. The restore runs in the
background as a run row with trigger "restore": restic writes the snapshot
into a folder of its own under the data root, the restored data.json and
uploads folder go through the same import the uploaded zip uses, the card
says Restoring until it is done, an audit row is written once the rows
(the old audit log among them) are replaced, and the folder is removed
whichever way it ends. No backup runs while a restore does.

The import route's core (parse, replace the rows, restore the files) now
lives in src/lib/backup/import.ts as a function that takes a file source,
the zip for the upload and a folder for a snapshot; the route's behaviour
is unchanged.

Because a backup file is data a person uploaded and the cloud shares one
database, the core also keeps a restore inside its own workshop:

- Every id the file carries is checked against the database first, by
  model, through the parent chain for child rows; one that already belongs
  to another workshop refuses the whole file before anything is written.
- Every foreign key is read against the ids the file carries plus the rows
  this workshop already has; anything else is cleared, or takes its row
  with it when the link is required. Plain id columns named after a model
  (a part's inventoryPartId) count too.
- The transaction client is wrapped (tenant-guard.ts): a delete, update or
  upsert without the organisation in its where, or a create for a model
  with an organisation column that does not stamp it, throws and rolls the
  restore back.
- The two createMany calls that skipped duplicates are strict now.
- One restore at a time per server, told plainly; on Torqvoice Cloud only
  the owner may restore, self-hosted keeps owner or admin.

What the guard reads about the schema is generated into tenancy-schema.ts
by scripts/generate-tenancy-schema.ts and kept in step by a test; the
backup file's layout is spelled out in layout.ts and checked against the
manifest and the export.

Tests: the restore flow with restic mocked, the import from a folder, the
guard and the tenancy functions on crafted files, a source tripwire over
every write the core makes, and a differential test on a real PostgreSQL
(npm run test:db, .github/workflows/restore-tenancy.yml) that seeds two
workshops, restores into one three ways and compares every row of every
table of the other before and after.

Strings in all thirteen languages.

* Spread cloud backups over the hour after the chosen time

Each workshop's scheduled run started at a stable 0 to 9 minute offset from
the chosen time, so twenty workshops that chose 01:00 all came due within
ten minutes and queued behind the server-wide lock. The offset is now 0 to
59 minutes, still fixed per workshop, so the hour's runs are spread out
instead of piling up at its start. Self-hosted installs keep no offset.

* Read the restore right from the membership, not the session role

On the cloud only the workshop's owner may restore. The check compared the
session's role with 'owner', but a platform administrator's session says
super_admin whatever their membership is, so an owner who is also a platform
administrator never saw the Restore section and was refused on the route.
The membership row is what says who owns the workshop; the card, both
actions and the upload route read that now.

* Put Restore on each backup's row of the Backups card

One table instead of two: every finished backup under Recent runs has a
Restore button on its row, and Show all backups under the table adds the
repository's older backups and the ones an earlier install took to the
same table, each with the same button. Everyone sees the buttons; without
the right to restore they are disabled and a note says who has it, the
owner on the cloud and an owner or administrator self-hosted. The dialog
and the snapshot query move under features.

* Back every Pro workshop up hourly into a repository of ours, with restore points the owner can go back to

Backups by Torqvoice: on the cloud instance, one restic repository per Pro
workshop in a bucket of ours (PLATFORM_BACKUP_* in the environment, a
password derived per workshop from one master key), run by the same engine
the workshop's own destinations use. The run queue is now a queue: a run that
finds the server-wide lock held waits its turn instead of being skipped, and
the queue is drained oldest first by whoever finishes. Restore points are
stored on the workshop's platform_backups row after each run, so the card and
torqvoice.com list them without opening the repository.

Every restore, from either repository, first takes a safety copy of the
workshop as it is (tagged pre-restore, kept 90 days whatever the retention
says), holds the workshop read-only while its rows are replaced (withAuth
refuses writes), and tells the owners and administrators afterwards. A
restore queued by support (requestedBy null, reason set) runs in the owner's
name and the reason is in the mail.

Owners restore from Settings, Data behind the workshop name and their
password or two-factor code; administrators can open any restore point
read-only in the Restore Explorer and browse customers, vehicles, work
orders, quotes and parts as they were, photos included. Plans without
platform backups pause the schedule, keep the repository 30 days, then
forget it. The bucket integration stays on cloud as an extra copy and says
so up front. Migration written, not applied.

* Keep the restore explorer tidy under failure: no half files, no leaked folders, one download per snapshot

A download that fails part way no longer leaves a truncated file that the
next request would serve whole; a restore point that cannot be opened takes
its folder with it; reopening a point already open evicts nothing; two
requests to open the same snapshot share one download; rows without a date
sort last. With tests for the explorer, its file route and the source
resolver.

* Pass the platform backup secrets through the deploys and bring the file guards up to date

* Close the gaps the review found in the restore and platform backup paths

The deploy filter now lets the platform backup variables through. The Restore Explorer removes the decrypted data file right after parsing it and sweeps folders no open restore point owns. The safety copy always includes the uploads folder, and on the platform repository the restore points are listed again at once so the copy can be restored straight away. A data-only restore keeps every secret the install holds that the file does not carry. The tenant guard refuses OR, NOT and XOR in a write's where. The restore freeze now also covers the upload and import routes, the AI chat route and the per-workshop crons, including the nightly file sweep.

* Fail incomplete backups, keep the restore freeze alive and widen its reach, and let a new install find its old local folder

* Make the restore point explorer scroll, page and show what opening it does

The tabs and the search stay put while the rows scroll in a region of
their own that loads fifty more as the end comes near. Rows carry their
dataset's icon; the record sheet gets jump chips to its sections and
previous/next with the arrow keys. Opening reports the real stages from
the server (finding, pulling and decrypting, reading, indexing) and stays
on screen at least three seconds, walking the remaining steps when the
open returns at once. Both backup cards explain why later sizes are
small or why the repository stays below the sum of sizes.

* Sweep closed restore points on a timer and ignore the backup working folders

The Restore Explorer's folders were only cleaned when the next restore
point was opened, so the files of the last viewer could sit past the
quarter of an hour the page promises. The backup cron now sweeps them
every five minutes, on its own so a bad folder never holds up a backup.
restic's cache and the explorer folders under data/ are gitignored.

* Refuse a restore the server cannot hold, and take the queue lock torqvoice.com shares

A destination's repository is the workshop's own, so a snapshot in it is
trusted no further than an uploaded file: its restore size is asked for
before restic writes it out and refused when the data disk would run short,
and its data file is refused when larger than the server reads into memory.
The explorer reads under the same cap and keeps two restore points open, not
four; both limits live in lib/backup/limits.ts. Queueing a run takes a
transaction-scoped advisory lock on the workshop, the same one the admin on
torqvoice.com takes, so two writers cannot both insert into an empty queue.

* Close what the review found in the explorer, the destination restore and the limits

An open counts against the explorer's cap from the moment it is registered,
in the order the opens came, so two pulls in flight refuse a third instead of
all three parsing at once. A restore from a connected destination asks the
owner to prove who they are, as the platform restore does. The scheduler
removes the restore folders a run that died left behind. A restore point
taken under an earlier install's workshop id serves that id's uploads too.
The data file cap can be raised per server (BACKUP_MAX_DATA_FILE_MB), and a
backup whose file is over it says so in its log every run. Bucket names with
a dot or two dashes are accepted. A failure after the rows are committed
names the mixed state and the safety copy that undoes it, and the explorer's
query keys carry the workshop id.

* Delete abandoned restore folders through restoreDirFor, as the file guard requires

The sweep removed a folder by the name it found on disk. The guard that keeps
every delete outside the file manager inside its own temp folder wants the
path built by restoreDirFor, which is also the tighter version: only a folder
a restore would have made can be touched.

* Pass BACKUP_MAX_DATA_FILE_MB into the app container

The compose file for a self-hosted install and the two deploy workflows each
name the variables the container gets, so a cap set in the environment file
went nowhere. All three forward it now; on the cloud it is a repository
variable rather than a secret, empty unless set, which the app reads as the
default of 128 MiB.

* Put the backup cards first on Settings, Data, and link the bucket page to them

What keeps the data safe comes before the tools that move it: the restore
points Torqvoice keeps, then the workshop's own destinations, then import
and export. The whole-platform heartbeat card shows only for a workshop
without restore points of its own, which reads the same and more on its
Backups card. The note on the bucket integration page links to that card,
with a label in every locale.

* Run a destination daily at most on the cloud, and start waited-on runs first

The hourly restore points on the cloud are the platform's, and hourly
uploads from every workshop to its own bucket would fill the queue a restore
then waits in. The hourly and four-hourly choices are self-hosted only, so
the form does not offer them on the cloud, and the scheduler runs anything
saved that way daily. The queue starts restores and manual runs before
scheduled ones, oldest first within each, and the place in line on the
Backups card counts the same way.

* Send backup notices from the platform's address when the workshop's vendor refuses

A workshop that named a mail vendor but never finished setting it up makes
sendOrgMail refuse rather than send from the platform's address, which is
right for mail to customers. The restore and overdue notices go to the
workshop's own owners and administrators, so for them the platform's own
mail, from the address its global provider sends as, is the right fallback.
Bernt Christian Egeland 1 روز پیش
والد
کامیت
c354484557
100فایلهای تغییر یافته به همراه14176 افزوده شده و 167 حذف شده
  1. 19 0
      .devcontainer/Dockerfile
  2. 15 0
      .env.example
  3. 24 1
      .github/workflows/deploy-prod.yml
  4. 24 1
      .github/workflows/deploy-staging.yml
  5. 69 0
      .github/workflows/restore-tenancy.yml
  6. 5 0
      .gitignore
  7. 29 2
      Dockerfile
  8. 5 0
      docker-compose.yml
  9. 7 2
      messages/de/audit.json
  10. 219 2
      messages/de/integrations.json
  11. 161 1
      messages/de/settings.json
  12. 7 2
      messages/en/audit.json
  13. 219 2
      messages/en/integrations.json
  14. 161 1
      messages/en/settings.json
  15. 7 2
      messages/es/audit.json
  16. 219 2
      messages/es/integrations.json
  17. 161 1
      messages/es/settings.json
  18. 7 2
      messages/fr/audit.json
  19. 219 2
      messages/fr/integrations.json
  20. 161 1
      messages/fr/settings.json
  21. 7 2
      messages/it/audit.json
  22. 219 2
      messages/it/integrations.json
  23. 161 1
      messages/it/settings.json
  24. 7 2
      messages/lt/audit.json
  25. 219 2
      messages/lt/integrations.json
  26. 161 1
      messages/lt/settings.json
  27. 7 2
      messages/nb/audit.json
  28. 219 2
      messages/nb/integrations.json
  29. 161 1
      messages/nb/settings.json
  30. 7 2
      messages/nl/audit.json
  31. 219 2
      messages/nl/integrations.json
  32. 161 1
      messages/nl/settings.json
  33. 7 2
      messages/pl/audit.json
  34. 219 2
      messages/pl/integrations.json
  35. 161 1
      messages/pl/settings.json
  36. 7 2
      messages/pt-BR/audit.json
  37. 219 2
      messages/pt-BR/integrations.json
  38. 161 1
      messages/pt-BR/settings.json
  39. 7 2
      messages/pt-PT/audit.json
  40. 219 2
      messages/pt-PT/integrations.json
  41. 161 1
      messages/pt-PT/settings.json
  42. 7 2
      messages/ru/audit.json
  43. 219 2
      messages/ru/integrations.json
  44. 161 1
      messages/ru/settings.json
  45. 7 2
      messages/tr/audit.json
  46. 219 2
      messages/tr/integrations.json
  47. 161 1
      messages/tr/settings.json
  48. 341 56
      package-lock.json
  49. 8 1
      package.json
  50. 38 0
      prisma/migrations/20261029090000_backup_runs/migration.sql
  51. 46 0
      prisma/migrations/20261030120000_platform_backups/migration.sql
  52. 74 0
      prisma/schema/integrations.prisma
  53. 2 0
      prisma/schema/organization.prisma
  54. 7 0
      public/images/integrations/local-folder.svg
  55. 8 0
      public/images/integrations/s3.svg
  56. 33 0
      scripts/generate-tenancy-schema.ts
  57. 137 0
      src/__tests__/api/backup-explore-file-route.test.ts
  58. 9 0
      src/__tests__/api/email-webhook-ses.test.ts
  59. 224 0
      src/__tests__/app/api/restore-freeze-public-routes.test.ts
  60. 138 0
      src/__tests__/app/api/restore-freeze-routes.test.ts
  61. 195 0
      src/__tests__/app/api/webhooks-freeze.test.ts
  62. 534 0
      src/__tests__/db/restore-tenancy.db.test.ts
  63. 1 4
      src/__tests__/features/backup/inspection-restore-fields.test.ts
  64. 1 4
      src/__tests__/features/backup/part-restore-fields.test.ts
  65. 1 4
      src/__tests__/features/backup/restore-overrides.test.ts
  66. 1 4
      src/__tests__/features/backup/vehicle-restore-fields.test.ts
  67. 31 0
      src/__tests__/features/inspection-reminders/booking-actions.test.ts
  68. 253 0
      src/__tests__/features/integrations/backup-actions.test.ts
  69. 5 0
      src/__tests__/features/integrations/engine-size-lookup.test.ts
  70. 218 0
      src/__tests__/features/integrations/explore-source.test.ts
  71. 100 0
      src/__tests__/features/integrations/local-folder.test.ts
  72. 74 0
      src/__tests__/features/integrations/run-due-jobs-freeze.test.ts
  73. 167 0
      src/__tests__/features/integrations/s3-backup.test.ts
  74. 15 0
      src/__tests__/features/messaging/receive-ses-mail.test.ts
  75. 63 0
      src/__tests__/features/messaging/scheduled-message-timezone.test.ts
  76. 37 1
      src/__tests__/features/public/quoteRequestActions.test.ts
  77. 32 1
      src/__tests__/features/reminder-alerts.test.ts
  78. 3 3
      src/__tests__/features/service-concerns.test.ts
  79. 60 0
      src/__tests__/features/status-reports/submit-feedback-freeze.test.ts
  80. 23 0
      src/__tests__/features/telegram/webhook-start.test.ts
  81. 3 9
      src/__tests__/features/tire-hotel/backup-coverage.test.ts
  82. 1 0
      src/__tests__/i18n/audit-summaries.test.ts
  83. 2 8
      src/__tests__/lib/backup-file-urls.test.ts
  84. 3 3
      src/__tests__/lib/backup-manifest.test.ts
  85. 248 0
      src/__tests__/lib/backup/build-data.test.ts
  86. 1426 0
      src/__tests__/lib/backup/engine.test.ts
  87. 891 0
      src/__tests__/lib/backup/explore.test.ts
  88. 124 0
      src/__tests__/lib/backup/export-route.test.ts
  89. 124 0
      src/__tests__/lib/backup/freeze.test.ts
  90. 152 0
      src/__tests__/lib/backup/import-from-disk.test.ts
  91. 230 0
      src/__tests__/lib/backup/import-redaction.test.ts
  92. 203 0
      src/__tests__/lib/backup/import-tenancy.test.ts
  93. 171 0
      src/__tests__/lib/backup/platform.test.ts
  94. 568 0
      src/__tests__/lib/backup/restic.test.ts
  95. 202 0
      src/__tests__/lib/backup/restore-tenancy-gates.test.ts
  96. 778 0
      src/__tests__/lib/backup/restore.test.ts
  97. 367 0
      src/__tests__/lib/backup/schedule.test.ts
  98. 218 0
      src/__tests__/lib/backup/secrets.test.ts
  99. 113 0
      src/__tests__/lib/backup/tenancy-schema.test.ts
  100. 252 0
      src/__tests__/lib/backup/tenancy.test.ts

+ 19 - 0
.devcontainer/Dockerfile

@@ -6,6 +6,25 @@ RUN apt-get update && apt-get install -y \
     ffmpeg \
     ffmpeg \
     && rm -rf /var/lib/apt/lists/*
     && rm -rf /var/lib/apt/lists/*
 
 
+# restic, for the scheduled backups a workshop sends to its own storage. The
+# same pinned release as the production Dockerfile, checked against its
+# SHA-256 so a changed download fails the build. Bump both files together.
+ARG RESTIC_VERSION=0.19.1
+RUN set -e; \
+    arch="$(dpkg --print-architecture)"; \
+    case "$arch" in \
+      amd64) sum=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c ;; \
+      arm64) sum=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465 ;; \
+      *) echo "no pinned restic checksum for architecture '${arch}'"; exit 1 ;; \
+    esac; \
+    curl -fsSL -o /tmp/restic.bz2 \
+      "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_${arch}.bz2"; \
+    echo "$sum  /tmp/restic.bz2" | sha256sum -c -; \
+    bunzip2 /tmp/restic.bz2; \
+    install -m 755 /tmp/restic /usr/local/bin/restic; \
+    rm -f /tmp/restic; \
+    restic version
+
 # Set up non-root user
 # Set up non-root user
 ARG USERNAME=node
 ARG USERNAME=node
 ARG USER_UID=1000
 ARG USER_UID=1000

+ 15 - 0
.env.example

@@ -17,6 +17,21 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
 # the container instead, where a redeploy loses it.
 # the container instead, where a redeploy loses it.
 # DATA_ROOT="/var/lib/torqvoice"
 # DATA_ROOT="/var/lib/torqvoice"
 
 
+# Scheduled backups (Settings → Integrations). Both are optional.
+# The Docker image ships restic at /usr/local/bin/restic. An install that runs
+# outside Docker points this at its own restic binary; unset, `restic` is
+# looked up on the PATH.
+# RESTIC_BIN="/usr/local/bin/restic"
+# Where the Local folder destination keeps its repositories, for self-hosted
+# installs only. Unset, it is `backups` under DATA_ROOT, which the compose file
+# mounts as a volume of its own. A folder on another disk is a better place.
+# BACKUP_LOCAL_ROOT="/mnt/backup-disk/torqvoice"
+# The largest backup data file a restore or the restore point explorer reads
+# into memory, in MiB; 128 unless set. Parsed, a file takes three to five times
+# its size in heap, so raise this only on a server with the memory for it. A
+# backup whose data file is over the limit still runs, and says so in its log.
+# BACKUP_MAX_DATA_FILE_MB=128
+
 # Set to true only when Cloudflare proxies every request AND the origin
 # Set to true only when Cloudflare proxies every request AND the origin
 # refuses traffic that did not come through it (Cloudflare IP ranges allowed
 # refuses traffic that did not come through it (Cloudflare IP ranges allowed
 # at the firewall or in nginx).
 # at the firewall or in nginx).

+ 24 - 1
.github/workflows/deploy-prod.yml

@@ -63,6 +63,21 @@ jobs:
           INBOUND_MAIL_BUCKET: ${{ secrets.CLOUD_INBOUND_MAIL_BUCKET }}
           INBOUND_MAIL_BUCKET: ${{ secrets.CLOUD_INBOUND_MAIL_BUCKET }}
           INBOUND_MAIL_REGION: ${{ secrets.CLOUD_INBOUND_MAIL_REGION }}
           INBOUND_MAIL_REGION: ${{ secrets.CLOUD_INBOUND_MAIL_REGION }}
           INBOUND_MAIL_TOPIC_ARN: ${{ secrets.CLOUD_INBOUND_MAIL_TOPIC_ARN }}
           INBOUND_MAIL_TOPIC_ARN: ${{ secrets.CLOUD_INBOUND_MAIL_TOPIC_ARN }}
+          # Backups by Torqvoice: one restic repository per Pro workshop in a bucket of ours.
+          # Endpoint, bucket, access key, secret key and master key go together, or the schedule
+          # stays off. The master key derives every repository password: keep it in the DR vault.
+          # Region is optional and defaults to auto, right for Cloudflare R2; set it for a
+          # regional bucket anywhere else. The alert email is optional.
+          PLATFORM_BACKUP_S3_ENDPOINT: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_ENDPOINT }}
+          PLATFORM_BACKUP_S3_BUCKET: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_BUCKET }}
+          PLATFORM_BACKUP_S3_REGION: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_REGION }}
+          PLATFORM_BACKUP_S3_ACCESS_KEY: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_ACCESS_KEY }}
+          PLATFORM_BACKUP_S3_SECRET_KEY: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_SECRET_KEY }}
+          PLATFORM_BACKUP_MASTER_KEY: ${{ secrets.CLOUD_PLATFORM_BACKUP_MASTER_KEY }}
+          PLATFORM_BACKUP_ALERT_EMAIL: ${{ secrets.CLOUD_PLATFORM_BACKUP_ALERT_EMAIL }}
+          # Optional, a repository variable rather than a secret: the largest backup data
+          # file a restore reads into memory, in MiB (128 unless set, see .env.example).
+          BACKUP_MAX_DATA_FILE_MB: ${{ vars.CLOUD_BACKUP_MAX_DATA_FILE_MB }}
           # Optional. Next encrypts what a server action closes over with a key
           # Optional. Next encrypts what a server action closes over with a key
           # made fresh by every build, so a tab left open across a deploy can
           # made fresh by every build, so a tab left open across a deploy can
           # no longer be read by the new container. A fixed key keeps those tabs
           # no longer be read by the new container. A fixed key keeps those tabs
@@ -85,7 +100,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/prod
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
 
 
           cat > docker-compose.yml << 'COMPOSE'
           cat > docker-compose.yml << 'COMPOSE'
           networks:
           networks:
@@ -124,6 +139,14 @@ jobs:
                 INBOUND_MAIL_BUCKET: ${INBOUND_MAIL_BUCKET}
                 INBOUND_MAIL_BUCKET: ${INBOUND_MAIL_BUCKET}
                 INBOUND_MAIL_REGION: ${INBOUND_MAIL_REGION}
                 INBOUND_MAIL_REGION: ${INBOUND_MAIL_REGION}
                 INBOUND_MAIL_TOPIC_ARN: ${INBOUND_MAIL_TOPIC_ARN}
                 INBOUND_MAIL_TOPIC_ARN: ${INBOUND_MAIL_TOPIC_ARN}
+                PLATFORM_BACKUP_S3_ENDPOINT: ${PLATFORM_BACKUP_S3_ENDPOINT}
+                PLATFORM_BACKUP_S3_BUCKET: ${PLATFORM_BACKUP_S3_BUCKET}
+                PLATFORM_BACKUP_S3_REGION: ${PLATFORM_BACKUP_S3_REGION}
+                PLATFORM_BACKUP_S3_ACCESS_KEY: ${PLATFORM_BACKUP_S3_ACCESS_KEY}
+                PLATFORM_BACKUP_S3_SECRET_KEY: ${PLATFORM_BACKUP_S3_SECRET_KEY}
+                PLATFORM_BACKUP_MASTER_KEY: ${PLATFORM_BACKUP_MASTER_KEY}
+                PLATFORM_BACKUP_ALERT_EMAIL: ${PLATFORM_BACKUP_ALERT_EMAIL}
+                BACKUP_MAX_DATA_FILE_MB: ${BACKUP_MAX_DATA_FILE_MB}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}

+ 24 - 1
.github/workflows/deploy-staging.yml

@@ -45,6 +45,21 @@ jobs:
           INBOUND_MAIL_BUCKET: ${{ secrets.STAGING_INBOUND_MAIL_BUCKET }}
           INBOUND_MAIL_BUCKET: ${{ secrets.STAGING_INBOUND_MAIL_BUCKET }}
           INBOUND_MAIL_REGION: ${{ secrets.STAGING_INBOUND_MAIL_REGION }}
           INBOUND_MAIL_REGION: ${{ secrets.STAGING_INBOUND_MAIL_REGION }}
           INBOUND_MAIL_TOPIC_ARN: ${{ secrets.STAGING_INBOUND_MAIL_TOPIC_ARN }}
           INBOUND_MAIL_TOPIC_ARN: ${{ secrets.STAGING_INBOUND_MAIL_TOPIC_ARN }}
+          # Backups by Torqvoice: one restic repository per Pro workshop in a bucket of ours.
+          # Endpoint, bucket, access key, secret key and master key go together, or the schedule
+          # stays off. The master key derives every repository password: keep it in the DR vault.
+          # Region is optional and defaults to auto, right for Cloudflare R2; set it for a
+          # regional bucket anywhere else. The alert email is optional.
+          PLATFORM_BACKUP_S3_ENDPOINT: ${{ secrets.STAGING_PLATFORM_BACKUP_S3_ENDPOINT }}
+          PLATFORM_BACKUP_S3_BUCKET: ${{ secrets.STAGING_PLATFORM_BACKUP_S3_BUCKET }}
+          PLATFORM_BACKUP_S3_REGION: ${{ secrets.STAGING_PLATFORM_BACKUP_S3_REGION }}
+          PLATFORM_BACKUP_S3_ACCESS_KEY: ${{ secrets.STAGING_PLATFORM_BACKUP_S3_ACCESS_KEY }}
+          PLATFORM_BACKUP_S3_SECRET_KEY: ${{ secrets.STAGING_PLATFORM_BACKUP_S3_SECRET_KEY }}
+          PLATFORM_BACKUP_MASTER_KEY: ${{ secrets.STAGING_PLATFORM_BACKUP_MASTER_KEY }}
+          PLATFORM_BACKUP_ALERT_EMAIL: ${{ secrets.STAGING_PLATFORM_BACKUP_ALERT_EMAIL }}
+          # Optional, a repository variable rather than a secret: the largest backup data
+          # file a restore reads into memory, in MiB (128 unless set, see .env.example).
+          BACKUP_MAX_DATA_FILE_MB: ${{ vars.STAGING_BACKUP_MAX_DATA_FILE_MB }}
           # Optional. Next encrypts what a server action closes over with a key
           # Optional. Next encrypts what a server action closes over with a key
           # made fresh by every build, so a tab left open across a deploy can
           # made fresh by every build, so a tab left open across a deploy can
           # no longer be read by the new container. A fixed key keeps those tabs
           # no longer be read by the new container. A fixed key keeps those tabs
@@ -67,7 +82,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/staging
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
 
 
           cat > docker-compose.yml << 'COMPOSE'
           cat > docker-compose.yml << 'COMPOSE'
           networks:
           networks:
@@ -103,6 +118,14 @@ jobs:
                 INBOUND_MAIL_BUCKET: ${INBOUND_MAIL_BUCKET}
                 INBOUND_MAIL_BUCKET: ${INBOUND_MAIL_BUCKET}
                 INBOUND_MAIL_REGION: ${INBOUND_MAIL_REGION}
                 INBOUND_MAIL_REGION: ${INBOUND_MAIL_REGION}
                 INBOUND_MAIL_TOPIC_ARN: ${INBOUND_MAIL_TOPIC_ARN}
                 INBOUND_MAIL_TOPIC_ARN: ${INBOUND_MAIL_TOPIC_ARN}
+                PLATFORM_BACKUP_S3_ENDPOINT: ${PLATFORM_BACKUP_S3_ENDPOINT}
+                PLATFORM_BACKUP_S3_BUCKET: ${PLATFORM_BACKUP_S3_BUCKET}
+                PLATFORM_BACKUP_S3_REGION: ${PLATFORM_BACKUP_S3_REGION}
+                PLATFORM_BACKUP_S3_ACCESS_KEY: ${PLATFORM_BACKUP_S3_ACCESS_KEY}
+                PLATFORM_BACKUP_S3_SECRET_KEY: ${PLATFORM_BACKUP_S3_SECRET_KEY}
+                PLATFORM_BACKUP_MASTER_KEY: ${PLATFORM_BACKUP_MASTER_KEY}
+                PLATFORM_BACKUP_ALERT_EMAIL: ${PLATFORM_BACKUP_ALERT_EMAIL}
+                BACKUP_MAX_DATA_FILE_MB: ${BACKUP_MAX_DATA_FILE_MB}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}

+ 69 - 0
.github/workflows/restore-tenancy.yml

@@ -0,0 +1,69 @@
+name: Restore tenancy
+
+# The differential test of the backup restore against a real PostgreSQL:
+# two workshops, a restore into one of them, and a row-by-row comparison
+# that the other was never touched. It runs when the restore or the schema
+# changes, since that is when it could start being wrong.
+
+on:
+  pull_request:
+    branches: [main]
+    paths:
+      - 'src/lib/backup/**'
+      - 'src/app/api/protected/backup/**'
+      - 'src/__tests__/db/**'
+      - 'prisma/**'
+      - 'vitest.db.config.ts'
+      - '.github/workflows/restore-tenancy.yml'
+  workflow_dispatch:
+
+concurrency:
+  group: restore-tenancy-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  restore-tenancy:
+    name: Restore never touches another workshop
+    runs-on: ubuntu-latest
+    timeout-minutes: 20
+
+    services:
+      postgres:
+        image: postgres:16-alpine
+        env:
+          POSTGRES_USER: torqvoice
+          POSTGRES_PASSWORD: torqvoice
+          # The name carries "test": the suite refuses a database that does not.
+          POSTGRES_DB: torqvoice_restore_test
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U torqvoice -d torqvoice_restore_test"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+
+    env:
+      E2E_DATABASE_URL: postgresql://torqvoice:torqvoice@127.0.0.1:5432/torqvoice_restore_test
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 22
+          cache: npm
+
+      - name: Install
+        run: npm ci
+
+      - name: Generate the Prisma client
+        run: npx prisma generate
+
+      - name: Apply every migration to the empty database
+        run: npx prisma migrate deploy
+        env:
+          DATABASE_URL: ${{ env.E2E_DATABASE_URL }}
+
+      - name: Run the differential restore test
+        run: npm run test:db

+ 5 - 0
.gitignore

@@ -48,6 +48,11 @@ next-env.d.ts
 /data/uploads/*
 /data/uploads/*
 !/data/uploads/.gitkeep
 !/data/uploads/.gitkeep
 
 
+# restic's index cache and the Restore Explorer's working files, per workshop
+/data/restic-cache
+/data/restore-explore
+/data/restore-tmp
+
 # end-to-end tests
 # end-to-end tests
 /e2e/.auth
 /e2e/.auth
 /e2e/.data
 /e2e/.data

+ 29 - 2
Dockerfile

@@ -38,6 +38,25 @@ WORKDIR /tools
 RUN npm install --no-save --no-audit --no-fund \
 RUN npm install --no-save --no-audit --no-fund \
       prisma@7.6.0 @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0 pg dotenv tsx
       prisma@7.6.0 @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0 pg dotenv tsx
 
 
+# restic, for the scheduled backups a workshop sends to its own storage. The
+# release binary is fetched for the platform being built and checked against a
+# pinned SHA-256, so a changed or tampered download fails the build instead of
+# ending up in the image. Bumping the version means bumping both sums with it.
+FROM base AS restic
+ARG TARGETARCH
+ARG RESTIC_VERSION=0.19.1
+RUN set -e; \
+    case "$TARGETARCH" in \
+      amd64) sum=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c ;; \
+      arm64) sum=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465 ;; \
+      *) echo "no pinned restic checksum for TARGETARCH '${TARGETARCH}'"; exit 1 ;; \
+    esac; \
+    wget -q -O /restic.bz2 \
+      "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
+    echo "$sum  /restic.bz2" | sha256sum -c -; \
+    bunzip2 /restic.bz2; \
+    chmod 755 /restic
+
 # Production image, copy all the files and run next
 # Production image, copy all the files and run next
 FROM base AS runner
 FROM base AS runner
 WORKDIR /app
 WORKDIR /app
@@ -95,8 +114,16 @@ RUN node -e "require('sharp'); console.log('sharp loads')"
 COPY --chown=nextjs:nodejs init-db.sh ./init-db.sh
 COPY --chown=nextjs:nodejs init-db.sh ./init-db.sh
 RUN chmod +x ./init-db.sh
 RUN chmod +x ./init-db.sh
 
 
-# Create uploads directories
-RUN mkdir -p /app/data/uploads && chown -R nextjs:nodejs /app/data
+# restic runs as a child of the server for scheduled backups. Run it once here
+# so a binary that cannot start on this platform fails the build, not the
+# first backup a workshop relies on.
+COPY --from=restic /restic /usr/local/bin/restic
+RUN restic version
+
+# Create the data directories: uploads, the local backup repositories (the
+# compose file mounts a volume there) and restic's cache
+RUN mkdir -p /app/data/uploads /app/data/backups /app/data/restic-cache \
+      && chown -R nextjs:nodejs /app/data
 
 
 USER nextjs
 USER nextjs
 
 

+ 5 - 0
docker-compose.yml

@@ -11,8 +11,12 @@ services:
       - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
       - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
       - RESEND_API_KEY=${RESEND_API_KEY:-}
       - RESEND_API_KEY=${RESEND_API_KEY:-}
       - RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL:-}
       - RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL:-}
+      # Optional, see .env.example: the largest backup data file a restore reads
+      # into memory, in MiB (128 unless set).
+      - BACKUP_MAX_DATA_FILE_MB=${BACKUP_MAX_DATA_FILE_MB:-}
     volumes:
     volumes:
       - uploads:/app/data/uploads
       - uploads:/app/data/uploads
+      - backups:/app/data/backups
     depends_on:
     depends_on:
       db:
       db:
         condition: service_healthy
         condition: service_healthy
@@ -38,3 +42,4 @@ services:
 volumes:
 volumes:
   postgres-data:
   postgres-data:
   uploads:
   uploads:
+  backups:

+ 7 - 2
messages/de/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Markierungsart hinzugefügt",
     "settings_markType_create": "Markierungsart hinzugefügt",
     "settings_markType_update": "Markierungsart geändert",
     "settings_markType_update": "Markierungsart geändert",
     "settings_markType_remove": "Markierungsart entfernt",
     "settings_markType_remove": "Markierungsart entfernt",
-    "settings_markType_reorder": "Markierungsarten neu geordnet"
+    "settings_markType_reorder": "Markierungsarten neu geordnet",
+    "integration_backup_run": "Sicherung gestartet",
+    "integration_backup_restore": "Sicherung wiederhergestellt"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Markierung von der Zustandskarte entfernt",
     "condition_mark_remove": "Markierung von der Zustandskarte entfernt",
     "condition_mark_carry": "{count, plural, one {# frühere Markierung} other {# frühere Markierungen}} auf der Zustandskarte als noch vorhanden bestätigt",
     "condition_mark_carry": "{count, plural, one {# frühere Markierung} other {# frühere Markierungen}} auf der Zustandskarte als noch vorhanden bestätigt",
     "condition_map_on_invoice_on": "Zustandskarte auf die Rechnung gesetzt",
     "condition_map_on_invoice_on": "Zustandskarte auf die Rechnung gesetzt",
-    "condition_map_on_invoice_off": "Zustandskarte von der Rechnung genommen"
+    "condition_map_on_invoice_off": "Zustandskarte von der Rechnung genommen",
+    "backup_run_started": "Sicherung nach {name} gestartet",
+    "backup_restore_started": "Wiederherstellung der Sicherung {snapshot} von {name} gestartet",
+    "backup_restored": "Sicherung {snapshot} von {name} wiederhergestellt"
   }
   }
 }
 }

+ 219 - 2
messages/de/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Rechnungen ins Buchhaltungssystem",
       "invoices": "Rechnungen ins Buchhaltungssystem",
       "customers": "Kunden ins Buchhaltungssystem",
       "customers": "Kunden ins Buchhaltungssystem",
       "payments": "Zahlungen in beide Richtungen"
       "payments": "Zahlungen in beide Richtungen"
+    },
+    "backup": {
+      "target": "Sicherungsziel"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Alle Fahrzeuge mit Kennzeichen wurden kürzlich geprüft, es gibt noch nichts abzufragen."
       "nothingDue": "Alle Fahrzeuge mit Kennzeichen wurden kürzlich geprüft, es gibt noch nichts abzufragen."
     },
     },
     "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt.",
     "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt.",
+    "platformHandlesBackups": "Torqvoice sichert diese Werkstatt bereits stündlich und bewahrt Wiederherstellungspunkte 30 Tage lang auf. Verbinden Sie einen Bucket, wenn Sie zusätzlich eine eigene Kopie möchten, die auch ohne Torqvoice lesbar ist.",
+    "platformHandlesBackupsLink": "Wiederherstellungspunkte ansehen",
     "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}",
     "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}",
-    "advanced": "Erweitert"
+    "advanced": "Erweitert",
+    "generate": "Erzeugen",
+    "show": "Anzeigen",
+    "hide": "Ausblenden",
+    "generatedPasswordWarning": "Bewahren Sie eine Kopie dieses Passworts an einem sicheren Ort auf, getrennt von diesem Server. Ohne das Passwort kann niemand diese Sicherungen öffnen, auch Torqvoice nicht.",
+    "backup": {
+      "title": "Sicherungen",
+      "description": "Wann dieses Ziel zuletzt eine erfolgreiche Sicherung erhalten hat, wann die nächste läuft, und die letzten Läufe.",
+      "lastGood": "Letzte erfolgreiche Sicherung",
+      "added": "{size} hinzugefügt",
+      "never": "Noch keine",
+      "nextRun": "Nächster Lauf",
+      "notScheduled": "Nicht geplant",
+      "repositorySize": "Am Ziel gespeichert",
+      "runNow": "Jetzt sichern",
+      "running": "Sicherung läuft",
+      "runningHint": "Eine Sicherung läuft. Diese Seite aktualisiert sich selbst, bis sie fertig ist.",
+      "started": "Sicherung gestartet",
+      "recentTitle": "Letzte Läufe",
+      "noRuns": "Es wurden noch keine Sicherungen ausgeführt.",
+      "addedNote": "Der erste Lauf lädt alles hoch. Jeder weitere fügt nur die Teile hinzu, die sich seitdem geändert haben, und ist deshalb klein. Jedes Backup ist trotzdem vollständig und kann für sich wiederhergestellt oder angesehen werden.",
+      "notStarted": "Nicht gestartet",
+      "filesNewCount": "{count, plural, one {# neue Datei} other {# neue Dateien}}",
+      "columns": {
+        "status": "Status",
+        "started": "Gestartet",
+        "trigger": "Gestartet von",
+        "duration": "Dauer",
+        "filesNew": "Neue Dateien",
+        "bytesAdded": "Hinzugefügt"
+      },
+      "trigger": {
+        "scheduled": "Zeitplan",
+        "manual": "Manuell",
+        "restore": "Wiederherstellung"
+      },
+      "status": {
+        "queued": "In der Warteschlange",
+        "running": "Läuft",
+        "done": "Fertig",
+        "failed": "Fehlgeschlagen",
+        "skipped": "Übersprungen"
+      },
+      "restoring": "Wird wiederhergestellt",
+      "restoringHint": "Eine Wiederherstellung läuft: Die Daten der Werkstatt werden durch die Sicherung ersetzt. Diese Seite aktualisiert sich selbst, bis sie fertig ist. Arbeiten Sie bis dahin nicht in der Werkstatt.",
+      "explore": "Ansehen",
+      "restore": {
+        "showAll": "Alle Sicherungen anzeigen",
+        "refresh": "Aktualisieren",
+        "loading": "Die Sicherungen am Ziel werden gelesen",
+        "loadFailed": "Die Sicherungen konnten nicht aufgelistet werden",
+        "empty": "An diesem Ziel gibt es noch keine Sicherungen.",
+        "needsOwner": "Nur der Inhaber der Werkstatt kann hier eine Sicherung wiederherstellen.",
+        "needsAdmin": "Nur ein Inhaber oder Administrator kann eine Sicherung wiederherstellen.",
+        "filesIncluded": "Fotos und Dateien eingeschlossen",
+        "filesNotIncluded": "Fotos und Dateien nicht eingeschlossen",
+        "earlierInstall": "Frühere Installation",
+        "action": "Wiederherstellen",
+        "dialogTitle": "Die Sicherung vom {time} wiederherstellen?",
+        "warning": "Eine Wiederherstellung <bold>ersetzt alles, was diese Werkstatt jetzt enthält</bold>: Kunden, Fahrzeuge, Arbeitsaufträge, Rechnungen, Einstellungen und, wenn die Sicherung sie enthält, Fotos und Dateien. Alles, was seit der Sicherung hinzugekommen ist, ist weg. Das lässt sich nicht rückgängig machen.",
+        "secretsNote": "Geheime Schlüssel, die die Sicherung ausgelassen hat, behalten die Werte, die diese Installation jetzt hat. Benutzerkonten und Passwörter bleiben unberührt.",
+        "confirmPrompt": "Geben Sie zur Bestätigung den Namen der Werkstatt ein, <bold>{name}</bold>.",
+        "whileRunning": "Die Wiederherstellung läuft im Hintergrund und dauert bei vielen Fotos einige Minuten. Die Seite aktualisiert sich selbst, bis sie fertig ist.",
+        "cancel": "Abbrechen",
+        "confirm": "Diese Sicherung wiederherstellen",
+        "started": "Wiederherstellung gestartet"
+      }
+    },
+    "lockedBackups": "Geplante, verschlüsselte Sicherungen Ihrer Werkstatt in Ihren eigenen Speicher.",
+    "searchOrType": "Suchen oder Namen eingeben",
+    "useTyped": "„{value}“ verwenden",
+    "listNeedsKeys": "Geben Sie oben die Schlüssel ein, um die Liste zu sehen",
+    "listEmpty": "Es wurde nichts aufgelistet. Geben Sie stattdessen einen Namen ein.",
+    "testing": "Verbindung wird getestet",
+    "testOk": "Die Verbindung funktioniert: Ein Testobjekt wurde geschrieben und wieder entfernt.",
+    "testFailed": "Der Test ist fehlgeschlagen",
+    "testSteps": {
+      "settings": "Prüfen Sie die Anbietereinstellungen",
+      "list": "Der Schlüssel kann den Bucket nicht auflisten",
+      "write": "Der Schlüssel kann nicht in den Bucket schreiben",
+      "delete": "Der Schlüssel kann nicht aus dem Bucket löschen",
+      "folder": "Der Ordner kann nicht erstellt oder geöffnet werden"
+    },
+    "testHint": "Füllen Sie die Schlüssel und den Bucket aus, um sie vor dem Verbinden zu testen. Der Test schreibt ein kleines Objekt und entfernt es wieder."
   },
   },
   "activity": {
   "activity": {
     "title": "Aktivität",
     "title": "Aktivität",
@@ -199,7 +287,10 @@
     "invalid": "Die Antwort des Anbieters war unvollständig. Versuchen Sie es erneut.",
     "invalid": "Die Antwort des Anbieters war unvollständig. Versuchen Sie es erneut.",
     "state": "Diese Anmeldung ist abgelaufen oder gehört zu einer anderen Sitzung. Beginnen Sie erneut.",
     "state": "Diese Anmeldung ist abgelaufen oder gehört zu einer anderen Sitzung. Beginnen Sie erneut.",
     "exchange": "Der Anbieter hat die Anmeldung abgelehnt. Prüfen Sie die App-Zugangsdaten und versuchen Sie es erneut.",
     "exchange": "Der Anbieter hat die Anmeldung abgelehnt. Prüfen Sie die App-Zugangsdaten und versuchen Sie es erneut.",
-    "identify": "Verbunden, aber das Konto konnte nicht gelesen werden. Versuchen Sie, die Verbindung neu herzustellen."
+    "identify": "Verbunden, aber das Konto konnte nicht gelesen werden. Versuchen Sie, die Verbindung neu herzustellen.",
+    "backupRunning": "Es läuft bereits eine Sicherung.",
+    "restoreOwnerOnly": "In der Torqvoice Cloud kann nur der Inhaber der Werkstatt eine Sicherung wiederherstellen.",
+    "restoreBusy": "Auf diesem Server läuft bereits eine Sicherung oder Wiederherstellung. Versuchen Sie es erneut, wenn sie fertig ist."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Videoanruf beitreten",
     "join": "Videoanruf beitreten",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Microsoft 365-Kalender",
       "description": "Geplante Aufträge in einen Outlook-Kalender eintragen, einen Microsoft Teams-Link hinzufügen und die belegten Zeiten des Kalenders in Torqvoice sehen.",
       "description": "Geplante Aufträge in einen Outlook-Kalender eintragen, einen Microsoft Teams-Link hinzufügen und die belegten Zeiten des Kalenders in Torqvoice sehen.",
       "tenantHelp": "Registrieren Sie eine Anwendung in Microsoft Entra (App-Registrierungen), erlauben Sie persönliche und Geschäftskonten, fügen Sie die untenstehende Redirect-URI als Web-Plattform hinzu, erstellen Sie ein Client-Secret und tragen Sie beides hier ein.",
       "tenantHelp": "Registrieren Sie eine Anwendung in Microsoft Entra (App-Registrierungen), erlauben Sie persönliche und Geschäftskonten, fügen Sie die untenstehende Redirect-URI als Web-Plattform hinzu, erstellen Sie ein Client-Secret und tragen Sie beides hier ein.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "OpenAI-kompatibel",
       "description": "Servicebeschreibungen, Verlaufszusammenfassungen, Dokumentenerkennung und der Assistent, beantwortet von jedem Server, der die Chat-API von OpenAI spricht: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM und ähnliche. Ihre Daten bleiben auf Ihrer eigenen Hardware.",
       "description": "Servicebeschreibungen, Verlaufszusammenfassungen, Dokumentenerkennung und der Assistent, beantwortet von jedem Server, der die Chat-API von OpenAI spricht: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM und ähnliche. Ihre Daten bleiben auf Ihrer eigenen Hardware.",
       "fields": {
       "fields": {
         "baseUrl": "Basis-URL",
         "baseUrl": "Basis-URL",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Sprache zu Text",
       "description": "Diktat auf einem Arbeitsauftrag, aufgeschrieben von einem Sprachmodell statt vom Browser: viel genauer, in jeder Sprache, und es kennt die Wörter einer Werkstatt. Getrennt von Ihrem KI-Anbieter, den Sie also so lassen können, wie er ist. Nutzt Ihren eigenen OpenAI-Schlüssel oder einen eigenen Server, der die Transkriptions-API von OpenAI spricht. Die Aufnahme wird nur zum Aufschreiben gesendet und nicht aufbewahrt.",
       "description": "Diktat auf einem Arbeitsauftrag, aufgeschrieben von einem Sprachmodell statt vom Browser: viel genauer, in jeder Sprache, und es kennt die Wörter einer Werkstatt. Getrennt von Ihrem KI-Anbieter, den Sie also so lassen können, wie er ist. Nutzt Ihren eigenen OpenAI-Schlüssel oder einen eigenen Server, der die Transkriptions-API von OpenAI spricht. Die Aufnahme wird nur zum Aufschreiben gesendet und nicht aufbewahrt.",
       "fields": {
       "fields": {
         "apiKey": "API-Schlüssel",
         "apiKey": "API-Schlüssel",
@@ -653,6 +747,108 @@
         "modeAi": "Immer das Sprachmodell",
         "modeAi": "Immer das Sprachmodell",
         "modeChoice": "Jede Person wählt: Sprachmodell oder Browser"
         "modeChoice": "Jede Person wählt: Sprachmodell oder Browser"
       }
       }
+    },
+    "s3": {
+      "name": "Cloud-Speicher-Bucket",
+      "description": "Verschlüsselte Sicherungen dieser Werkstatt in einen Bucket, der Ihnen gehört, bei Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage oder jedem anderen S3-kompatiblen Speicher, nach einem Zeitplan Ihrer Wahl. Nach dem ersten Lauf wird nur hochgeladen, was sich geändert hat, daher bleiben stündliche Sicherungen günstig.",
+      "fields": {
+        "accessKeyId": "Zugriffsschlüssel-ID",
+        "accessKeyIdHelp": "Ein Schlüssel Ihres Speicheranbieters mit der Berechtigung, Objekte im Bucket aufzulisten, zu lesen, zu schreiben und zu löschen. Erstellen Sie einen eigenen Schlüssel nur für Torqvoice, damit er sich einzeln widerrufen lässt.",
+        "secretAccessKey": "Geheimer Zugriffsschlüssel",
+        "password": "Sicherungspasswort",
+        "passwordHelp": "Jede Sicherung wird mit diesem Passwort verschlüsselt. Denken Sie sich eines aus oder klicken Sie auf Erzeugen, und bewahren Sie eine Kopie getrennt von diesem Server auf: Ohne das Passwort kann niemand diese Sicherungen öffnen, auch Torqvoice nicht. Um einen Speicher erneut zu verbinden, der bereits Torqvoice-Sicherungen enthält, geben Sie das Passwort ein, mit dem sie erstellt wurden.",
+        "endpoint": "Eigener Endpunkt",
+        "endpointHelp": "Nur für die Option Anderer S3-kompatibler Speicher: die S3-Adresse Ihres eigenen Speichers, etwa eines MinIO-Servers, als Hostname, bei Bedarf mit Port."
+      },
+      "settings": {
+        "provider": "Anbieter",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Anderer S3-kompatibler Speicher",
+        "region": "Region",
+        "regionHelp": "Wo der Bucket liegt. Wählen Sie die Region aus der Liste des Anbieters; bei anderem Speicher geben Sie ein, was dieser erwartet, falls er eine Region braucht. Wird von Cloudflare R2 nicht verwendet.",
+        "accountId": "Cloudflare-Konto-ID",
+        "accountIdHelp": "Die 32-stellige ID auf der R2-Übersichtsseite Ihres Cloudflare-Dashboards.",
+        "bucket": "Bucket",
+        "bucketHelp": "Die Liste kommt vom Schlüssel, sobald er eingegeben ist. Darf der Schlüssel einen Bucket nutzen, aber nicht auflisten, geben Sie den Namen selbst ein.",
+        "prefix": "Ordner im Bucket",
+        "prefixHelp": "Die Sicherungen liegen in diesem Ordner. Verwenden Sie für jede Werkstatt, die den Bucket mitbenutzt, einen eigenen Ordner.",
+        "frequency": "Wie oft",
+        "frequencyHourly": "Jede Stunde",
+        "frequencyEvery4h": "Alle 4 Stunden",
+        "frequencyDaily": "Jeden Tag",
+        "frequencyWeekly": "Jede Woche",
+        "frequencyMonthly": "Jeden Monat",
+        "time": "Uhrzeit",
+        "timeHelp": "Nach der Uhr der Werkstatt, im Format HH:MM. Gilt für tägliche, wöchentliche und monatliche Sicherungen.",
+        "weekday": "Wochentag",
+        "sunday": "Sonntag",
+        "monday": "Montag",
+        "tuesday": "Dienstag",
+        "wednesday": "Mittwoch",
+        "thursday": "Donnerstag",
+        "friday": "Freitag",
+        "saturday": "Samstag",
+        "monthDay": "Tag im Monat",
+        "monthDayLast": "Letzter Tag",
+        "contents": "Inhalt",
+        "contentsHelp": "Geheime Schlüssel sind die, die diese Werkstatt in Torqvoice eingegeben hat: Zahlungs-, KI- und Webhook-Schlüssel. Jede Sicherung wird in jedem Fall mit dem Sicherungspasswort verschlüsselt. Eine Sicherung enthält die Datensätze der Werkstatt und, wenn ausgewählt, ihre Dateien. Benutzerkonten und Passwörter sind nicht enthalten: Auf einem neuen Server melden Sie sich an, laden das Team ein und stellen die Daten in dieser Werkstatt wieder her.",
+        "contentsData": "Nur Daten",
+        "contentsDataAndSecrets": "Daten und geheime Schlüssel",
+        "includeFiles": "Fotos und Dateien einschließen",
+        "includeFilesHelp": "Jede Datei wird einmal hochgeladen und von späteren Sicherungen wiederverwendet, daher kostet das nach dem ersten Lauf wenig.",
+        "keepHourly": "Stündliche Sicherungen behalten",
+        "keepHourlyHelp": "Wie viele der neuesten stündlichen Sicherungen behalten werden. Nur relevant bei stündlichem oder vierstündlichem Zeitplan.",
+        "keepDaily": "Tägliche Sicherungen behalten",
+        "keepWeekly": "Wöchentliche Sicherungen behalten",
+        "keepMonthly": "Monatliche Sicherungen behalten",
+        "keepMonthlyHelp": "Ältere Sicherungen werden nach jedem Lauf entfernt, wobei jeweils die neuesten jeder Art erhalten bleiben. Setzen Sie eine Zahl auf 0, um von dieser Art keine zu behalten."
+      }
+    },
+    "local-folder": {
+      "name": "Lokaler Ordner",
+      "description": "Verschlüsselte Sicherungen dieser Werkstatt in einen Ordner auf diesem Server: das Sicherungs-Volume in der Compose-Datei oder ein dort eingebundenes Netzlaufwerk. Eine Kopie auf derselben Festplatte wie die Datenbank ist allein nicht sicher; kombinieren Sie sie mit Speicher an einem anderen Ort.",
+      "fields": {
+        "password": "Sicherungspasswort",
+        "passwordHelp": "Jede Sicherung wird mit diesem Passwort verschlüsselt. Denken Sie sich eines aus oder klicken Sie auf Erzeugen, und bewahren Sie eine Kopie getrennt von diesem Server auf: Ohne das Passwort kann niemand diese Sicherungen öffnen, auch Torqvoice nicht. Um einen Speicher erneut zu verbinden, der bereits Torqvoice-Sicherungen enthält, geben Sie das Passwort ein, mit dem sie erstellt wurden."
+      },
+      "settings": {
+        "folder": "Ordner",
+        "folderHelp": "Der Ordner im Backup-Stammverzeichnis des Servers, der die Backups dieser Werkstatt enthält. Leer lassen, um einen nach dieser Werkstatt benannten Ordner zu verwenden. Auf einem neuen Server den Ordnernamen einer früheren Installation eingeben, um deren Backups zurückzuholen.",
+        "frequency": "Wie oft",
+        "frequencyHourly": "Jede Stunde",
+        "frequencyEvery4h": "Alle 4 Stunden",
+        "frequencyDaily": "Jeden Tag",
+        "frequencyWeekly": "Jede Woche",
+        "frequencyMonthly": "Jeden Monat",
+        "time": "Uhrzeit",
+        "timeHelp": "Nach der Uhr der Werkstatt, im Format HH:MM. Gilt für tägliche, wöchentliche und monatliche Sicherungen.",
+        "weekday": "Wochentag",
+        "sunday": "Sonntag",
+        "monday": "Montag",
+        "tuesday": "Dienstag",
+        "wednesday": "Mittwoch",
+        "thursday": "Donnerstag",
+        "friday": "Freitag",
+        "saturday": "Samstag",
+        "monthDay": "Tag im Monat",
+        "monthDayLast": "Letzter Tag",
+        "contents": "Inhalt",
+        "contentsHelp": "Geheime Schlüssel sind die, die diese Werkstatt in Torqvoice eingegeben hat: Zahlungs-, KI- und Webhook-Schlüssel. Jede Sicherung wird in jedem Fall mit dem Sicherungspasswort verschlüsselt. Eine Sicherung enthält die Datensätze der Werkstatt und, wenn ausgewählt, ihre Dateien. Benutzerkonten und Passwörter sind nicht enthalten: Auf einem neuen Server melden Sie sich an, laden das Team ein und stellen die Daten in dieser Werkstatt wieder her.",
+        "contentsData": "Nur Daten",
+        "contentsDataAndSecrets": "Daten und geheime Schlüssel",
+        "includeFiles": "Fotos und Dateien einschließen",
+        "includeFilesHelp": "Jede Datei wird einmal hochgeladen und von späteren Sicherungen wiederverwendet, daher kostet das nach dem ersten Lauf wenig.",
+        "keepHourly": "Stündliche Sicherungen behalten",
+        "keepHourlyHelp": "Wie viele der neuesten stündlichen Sicherungen behalten werden. Nur relevant bei stündlichem oder vierstündlichem Zeitplan.",
+        "keepDaily": "Tägliche Sicherungen behalten",
+        "keepWeekly": "Wöchentliche Sicherungen behalten",
+        "keepMonthly": "Monatliche Sicherungen behalten",
+        "keepMonthlyHelp": "Ältere Sicherungen werden nach jedem Lauf entfernt, wobei jeweils die neuesten jeder Art erhalten bleiben. Setzen Sie eine Zahl auf 0, um von dieser Art keine zu behalten."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Aktualisierung der Rückrufdaten",
       "refresh": "Aktualisierung der Rückrufdaten",
       "vehicle": "Rückrufprüfung für ein neues Fahrzeug"
       "vehicle": "Rückrufprüfung für ein neues Fahrzeug"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "Sicherungsziel",
+    "failedTitle": "Sicherung fehlgeschlagen",
+    "failedMessage": "Die Sicherung nach {name} wurde nicht abgeschlossen: {reason}",
+    "failedSubject": "Sicherung nach {name} fehlgeschlagen",
+    "failedEmailIntro": "Die geplante Sicherung Ihrer Werkstatt nach {name} wurde nicht abgeschlossen.",
+    "openToRetry": "Öffnen Sie Einstellungen, Integrationen, {name}, um den Lauf zu sehen und es erneut zu versuchen.",
+    "overdueTitle": "Sicherung überfällig",
+    "overdueSince": "Seit {time} wurde keine Sicherung nach {name} abgeschlossen.",
+    "overdueNever": "Bisher wurde keine Sicherung nach {name} abgeschlossen.",
+    "overdueSubject": "Sicherung nach {name} ist überfällig",
+    "openToSee": "Öffnen Sie Einstellungen, Integrationen, {name}, um zu sehen, was schiefgelaufen ist.",
+    "platformName": "Sicherungen von Torqvoice",
+    "restoredTitle": "Werkstatt wiederhergestellt",
+    "restoredSubject": "Ihre Werkstatt wurde aus einer Sicherung wiederhergestellt",
+    "restoredIntro": "{name}: Die Werkstatt wurde am {time} auf den Wiederherstellungspunkt {snapshot} zurückgesetzt. Alles, was nach diesem Punkt eingegeben wurde, ist nicht mehr in der Werkstatt.",
+    "restoredByMember": "Ein Mitglied Ihrer Werkstatt hat diese Wiederherstellung angefordert.",
+    "restoredBySupport": "Der Torqvoice-Support hat dies auf Ihre Anfrage hin durchgeführt. Angegebener Grund: {reason}",
+    "noReason": "keiner angegeben",
+    "restoredUndo": "Vorher wurde eine Sicherheitskopie der Werkstatt im Zustand direkt vor der Wiederherstellung erstellt, die 90 Tage lang aufbewahrt wird. Wenden Sie sich an den Support, um zu ihr zurückzukehren."
   }
   }
 }
 }

+ 161 - 1
messages/de/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Zu viele Versuche. Warten Sie einige Minuten und versuchen Sie es erneut.",
     "reauthTooManyAttempts": "Zu viele Versuche. Warten Sie einige Minuten und versuchen Sie es erneut.",
     "reauthSignInAgainAccount": "Melden Sie sich erneut an, bevor Sie Ihr Konto löschen.",
     "reauthSignInAgainAccount": "Melden Sie sich erneut an, bevor Sie Ihr Konto löschen.",
     "reauthSignInAgainWorkshop": "Melden Sie sich erneut an, bevor Sie die Werkstatt löschen.",
     "reauthSignInAgainWorkshop": "Melden Sie sich erneut an, bevor Sie die Werkstatt löschen.",
+    "reauthSignInAgainRestore": "Melden Sie sich erneut an, bevor Sie die Werkstatt wiederherstellen.",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
     "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
     "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
     "signatureTitle": "Unterschrift",
     "signatureTitle": "Unterschrift",
@@ -1399,6 +1400,11 @@
     "importComplete": "Import abgeschlossen",
     "importComplete": "Import abgeschlossen",
     "importCompleteMessage": "Alle Daten wurden aus der Backup-Datei wiederhergestellt.",
     "importCompleteMessage": "Alle Daten wurden aus der Backup-Datei wiederhergestellt.",
     "importFailed": "Import fehlgeschlagen",
     "importFailed": "Import fehlgeschlagen",
+    "restoreRefused": {
+      "busy": "Auf diesem Server läuft bereits eine Sicherung oder Wiederherstellung. Versuchen Sie es erneut, wenn sie fertig ist.",
+      "ownerOnly": "In der Torqvoice Cloud kann nur der Inhaber der Werkstatt eine Sicherung wiederherstellen.",
+      "foreignIds": "Die Datei verweist auf Datensätze, die zu einer anderen Werkstatt gehören, daher wurde nichts wiederhergestellt."
+    },
     "importFromOther": "Von anderen Diensten importieren",
     "importFromOther": "Von anderen Diensten importieren",
     "importFromOtherDescription": "Migrieren Sie Ihre Daten von einer anderen Plattform. Wählen Sie einen Dienst, um zu beginnen.",
     "importFromOtherDescription": "Migrieren Sie Ihre Daten von einer anderen Plattform. Wählen Sie einen Dienst, um zu beginnen.",
     "lubelogTitle": "Von LubeLog importieren",
     "lubelogTitle": "Von LubeLog importieren",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Torqvoice-Sicherung",
     "sourceBackup": "Torqvoice-Sicherung",
     "sourceBackupDesc": "Eine auf dieser Seite heruntergeladene .zip- oder .json-Datei. Die Wiederherstellung ersetzt alles in dieser Werkstatt.",
     "sourceBackupDesc": "Eine auf dieser Seite heruntergeladene .zip- oder .json-Datei. Die Wiederherstellung ersetzt alles in dieser Werkstatt.",
     "restore": "Wiederherstellen",
     "restore": "Wiederherstellen",
-    "restoreTitle": "Sicherung wiederherstellen"
+    "restoreTitle": "Sicherung wiederherstellen",
+    "includeSecrets": "Geheime Schlüssel einschließen",
+    "includeSecretsHelp": "Geheime Schlüssel, etwa für Zahlungen und KI, werden weggelassen, sofern Sie dies nicht ankreuzen. Eine Wiederherstellung behält dann die Schlüssel, die diese Installation bereits hat.",
+    "platformBackups": {
+      "title": "Sicherungen von Torqvoice",
+      "description": "Torqvoice sichert diese Werkstatt stündlich und bewahrt Wiederherstellungspunkte 30 Tage lang auf, danach wöchentliche und monatliche.",
+      "runNow": "Jetzt sichern",
+      "started": "Sicherung gestartet",
+      "lastGood": "Letzte erfolgreiche Sicherung",
+      "never": "Noch keine Sicherung",
+      "nextRun": "Nächster Lauf",
+      "notScheduled": "Nicht geplant",
+      "repositorySize": "Bei Torqvoice gespeichert",
+      "running": "Läuft",
+      "restoring": "Wird wiederhergestellt",
+      "queued": "In der Warteschlange",
+      "queuedAhead": "{count, plural, one {In der Warteschlange, # Lauf davor} other {In der Warteschlange, # Läufe davor}}",
+      "runningHint": "Eine Sicherung läuft. Diese Karte aktualisiert sich selbst, bis sie fertig ist.",
+      "restoringHint": "Eine Wiederherstellung läuft: Die Daten der Werkstatt werden durch den Wiederherstellungspunkt ersetzt. Diese Seite aktualisiert sich selbst, bis sie fertig ist. Arbeiten Sie bis dahin nicht in der Werkstatt.",
+      "paused": "Ihr Tarif enthält keine Sicherungen von Torqvoice mehr. Die Läufe wurden gestoppt; die Wiederherstellungspunkte werden bis {date} aufbewahrt und danach gelöscht.",
+      "upsell": "Verfügbar mit Pro: stündliche Sicherungen durch Torqvoice mit Wiederherstellungspunkten, zu denen Sie zurückkehren können.",
+      "planLink": "Tarife ansehen",
+      "pointsTitle": "Wiederherstellungspunkte",
+      "pointsEmpty": "Die erste Sicherung ist noch nicht gelaufen.",
+      "showAll": "Alle {count} anzeigen",
+      "showFewer": "Weniger anzeigen",
+      "needsOwner": "Nur der Inhaber der Werkstatt kann wiederherstellen.",
+      "sizeNote": "Jeder Wiederherstellungspunkt ist für sich vollständig; seine Größe ist alles, was er umfasst. Das Repository speichert jedes Teil nur einmal, und spätere Läufe fügen nur hinzu, was sich geändert hat. Deshalb bleibt „Bei Torqvoice gespeichert“ weit unter der Summe der Größen.",
+      "recentTitle": "Letzte Läufe",
+      "noRuns": "Es wurden noch keine Sicherungen ausgeführt.",
+      "notStarted": "Nicht gestartet",
+      "supportReason": "Vom Torqvoice-Support wiederhergestellt: {reason}",
+      "columns": {
+        "when": "Zeitpunkt",
+        "kind": "Art",
+        "size": "Größe",
+        "status": "Status",
+        "trigger": "Gestartet von",
+        "duration": "Dauer"
+      },
+      "kind": {
+        "scheduled": "Geplant",
+        "manual": "Manuell",
+        "safety": "Sicherheitskopie",
+        "safetyHint": "Direkt vor einer Wiederherstellung erstellt, damit sie rückgängig gemacht werden kann"
+      },
+      "trigger": {
+        "scheduled": "Geplant",
+        "manual": "Manuell",
+        "restore": "Wiederherstellung"
+      },
+      "status": {
+        "queued": "In der Warteschlange",
+        "running": "Läuft",
+        "done": "Fertig",
+        "failed": "Fehlgeschlagen",
+        "skipped": "Übersprungen"
+      },
+      "explore": "Ansehen",
+      "restore": {
+        "action": "Wiederherstellen",
+        "dialogTitle": "Die Werkstatt auf den Stand vom {time} zurücksetzen?",
+        "warning": "Der Wiederherstellungspunkt vom {time} <bold>ersetzt alles in dieser Werkstatt</bold>: Kunden, Fahrzeuge, Arbeitsaufträge, Rechnungen, Einstellungen, Fotos und Dateien. Alles, was seitdem hinzugekommen ist, ist weg.",
+        "safetyNote": "Zuerst wird eine Sicherheitskopie der Werkstatt im jetzigen Zustand erstellt und 90 Tage lang aufbewahrt, sodass sich die Wiederherstellung über die Wiederherstellungspunkte rückgängig machen lässt.",
+        "untouched": "Mitglieder, Konten, das Abonnement und Integrationen bleiben unberührt. Geheime Schlüssel, die nicht in der Sicherung enthalten sind, behalten ihre aktuellen Werte.",
+        "confirmPrompt": "Geben Sie zur Bestätigung den Namen der Werkstatt ein, <bold>{name}</bold>.",
+        "whileRunning": "Die Wiederherstellung läuft im Hintergrund und dauert bei vielen Fotos einige Minuten. Die Seite aktualisiert sich selbst, bis sie fertig ist.",
+        "cancel": "Abbrechen",
+        "confirm": "Wiederherstellen",
+        "started": "Wiederherstellung gestartet"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Zurück zu den Sicherungen",
+      "title": "Wiederherstellungspunkt vom {date}",
+      "titleShort": "Wiederherstellungspunkt {id}",
+      "readOnly": "Nur lesen: Hier wird nichts an der Werkstatt geändert. Die Ansicht schließt sich nach 15 Minuten ohne Aktivität.",
+      "readOnlyBadge": "Nur lesen",
+      "opening": "Wiederherstellungspunkt wird geöffnet",
+      "openingHint": "Das Backup wird hier auf dem Server entschlüsselt und eine Viertelstunde im Arbeitsspeicher gehalten. In der Werkstatt ändert sich nichts.",
+      "openingSteps": {
+        "connecting": "Verbindung zum Backup-Repository wird hergestellt",
+        "locating": "Datendatei im Snapshot wird gesucht",
+        "pulling": "Daten werden abgerufen und entschlüsselt",
+        "reading": "Datensätze werden gelesen",
+        "indexing": "Kunden, Fahrzeuge und Aufträge werden indexiert"
+      },
+      "openingReady": "Bereit",
+      "openFailed": "Der Wiederherstellungspunkt konnte nicht geöffnet werden",
+      "tryAgain": "Erneut versuchen",
+      "ownerOnly": "Nur der Inhaber der Werkstatt oder ein Administrator kann in einen Wiederherstellungspunkt hineinsehen.",
+      "closed": "Der Wiederherstellungspunkt wurde geschlossen; öffnen Sie ihn erneut",
+      "openAgain": "Erneut öffnen",
+      "datasets": {
+        "customers": "Kunden",
+        "vehicles": "Fahrzeuge",
+        "serviceRecords": "Arbeitsaufträge",
+        "quotes": "Angebote",
+        "inventoryParts": "Teile"
+      },
+      "searchPlaceholder": "Nach Name, Nummer, Kennzeichen, FIN, E-Mail oder Telefon suchen",
+      "loading": "Wird geladen…",
+      "loadFailed": "Diese Liste konnte nicht geladen werden",
+      "empty": "Im Wiederherstellungspunkt gibt es nichts dieser Art.",
+      "noMatches": "Keine Treffer für „{query}“.",
+      "showing": "{shown} von {total} angezeigt",
+      "loadMore": "Mehr anzeigen",
+      "clearSearch": "Suche löschen",
+      "record": {
+        "loading": "Datensatz wird geladen…",
+        "loadFailed": "Der Datensatz konnte nicht geladen werden",
+        "notFound": "Dieser Datensatz ist nicht im Wiederherstellungspunkt enthalten.",
+        "fields": "Details",
+        "jumpTo": "Springen zu",
+        "previous": "Vorheriger Datensatz",
+        "next": "Nächster Datensatz",
+        "position": "{index} von {total}",
+        "noColumns": "In diesen Zeilen gibt es nichts anzuzeigen.",
+        "yes": "Ja",
+        "no": "Nein"
+      },
+      "sections": {
+        "concerns": "Kundenanliegen",
+        "partItems": "Teile",
+        "laborItems": "Arbeit",
+        "attachments": "Fotos und Dokumente",
+        "payments": "Zahlungen",
+        "statusReports": "Statusberichte",
+        "timeEntries": "Zeiteinträge",
+        "notes": "Notizen",
+        "fuelLogs": "Tankvorgänge",
+        "reminders": "Erinnerungen",
+        "serviceRequests": "Serviceanfragen",
+        "inspectionStatus": "HU-Status",
+        "findings": "Befunde",
+        "conditionMarks": "Zustandsmarkierungen",
+        "aiMessages": "KI-Nachrichten",
+        "recurringInvoices": "Wiederkehrende Rechnungen",
+        "serviceRecords": "Arbeitsaufträge",
+        "movements": "Bestandsbewegungen",
+        "gallery": "Galerie"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Geplante Sicherungen",
+      "description": "Automatische Sicherungen in Ihren eigenen Speicher, nach einem Zeitplan Ihrer Wahl.",
+      "empty": "Es ist noch kein Sicherungsziel verbunden.",
+      "setUp": "Sicherungsziel einrichten",
+      "lastGood": "Letzte erfolgreiche Sicherung: {when}",
+      "noGoodBackup": "Noch keine erfolgreiche Sicherung",
+      "nextRun": "nächster Lauf: {when}",
+      "notScheduled": "nicht geplant",
+      "lastError": "Letzter Fehler: {error}",
+      "extraCopy": "Eine zusätzliche Kopie in Ihrem eigenen Speicher, nach einem Zeitplan Ihrer Wahl, ergänzend zu den Sicherungen, die Torqvoice ausführt."
+    }
   },
   },
   "license": {
   "license": {
     "title": "White-Label-Lizenz",
     "title": "White-Label-Lizenz",

+ 7 - 2
messages/en/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Added a kind of mark",
     "settings_markType_create": "Added a kind of mark",
     "settings_markType_update": "Changed a kind of mark",
     "settings_markType_update": "Changed a kind of mark",
     "settings_markType_remove": "Removed a kind of mark",
     "settings_markType_remove": "Removed a kind of mark",
-    "settings_markType_reorder": "Reordered the kinds of mark"
+    "settings_markType_reorder": "Reordered the kinds of mark",
+    "integration_backup_run": "Started Backup",
+    "integration_backup_restore": "Restored Backup"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
     "customField_create": "Created custom field \"{name}\"",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Removed a mark from the condition map",
     "condition_mark_remove": "Removed a mark from the condition map",
     "condition_mark_carry": "Confirmed {count, plural, one {# earlier mark} other {# earlier marks}} as still there on the condition map",
     "condition_mark_carry": "Confirmed {count, plural, one {# earlier mark} other {# earlier marks}} as still there on the condition map",
     "condition_map_on_invoice_on": "Printed the condition map on the invoice",
     "condition_map_on_invoice_on": "Printed the condition map on the invoice",
-    "condition_map_on_invoice_off": "Left the condition map off the invoice"
+    "condition_map_on_invoice_off": "Left the condition map off the invoice",
+    "backup_run_started": "Started a backup to {name}",
+    "backup_restore_started": "Started restoring backup {snapshot} from {name}",
+    "backup_restored": "Restored backup {snapshot} from {name}"
   }
   }
 }
 }

+ 219 - 2
messages/en/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Invoices to the ledger",
       "invoices": "Invoices to the ledger",
       "customers": "Customers to the ledger",
       "customers": "Customers to the ledger",
       "payments": "Payments both ways"
       "payments": "Payments both ways"
+    },
+    "backup": {
+      "target": "Backup destination"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Every vehicle with a plate was checked recently, so there is nothing to look up yet."
       "nothingDue": "Every vehicle with a plate was checked recently, so there is nothing to look up yet."
     },
     },
     "supersedes": "Connecting {next} disconnects {current}. Only one at a time can be in charge, so {current} stops being used the moment {next} goes live.",
     "supersedes": "Connecting {next} disconnects {current}. Only one at a time can be in charge, so {current} stops being used the moment {next} goes live.",
+    "platformHandlesBackups": "Torqvoice already backs this workshop up every hour and keeps restore points for 30 days. Connect a bucket if you also want a copy you own, readable without Torqvoice.",
+    "platformHandlesBackupsLink": "See your restore points",
     "batchRetried": "{count, plural, =0 {Nothing to retry} one {# job queued again} other {# jobs queued again}}",
     "batchRetried": "{count, plural, =0 {Nothing to retry} one {# job queued again} other {# jobs queued again}}",
-    "advanced": "Advanced"
+    "advanced": "Advanced",
+    "generate": "Generate",
+    "show": "Show",
+    "hide": "Hide",
+    "generatedPasswordWarning": "Keep a copy of this password somewhere safe, away from this server. Without it nobody can open these backups, not even Torqvoice.",
+    "backup": {
+      "title": "Backups",
+      "description": "When this destination last got a good backup, when the next one runs, and the latest runs.",
+      "lastGood": "Last good backup",
+      "added": "{size} added",
+      "never": "None yet",
+      "nextRun": "Next run",
+      "notScheduled": "Not scheduled",
+      "repositorySize": "Stored at the destination",
+      "runNow": "Back up now",
+      "running": "Backing up",
+      "runningHint": "A backup is running. This page updates by itself until it is done.",
+      "started": "Backup started",
+      "recentTitle": "Recent runs",
+      "noRuns": "No backups have run yet.",
+      "addedNote": "The first run uploads everything. Each run after it adds only the pieces that changed since, which is why it is small. Every backup is still complete and can be restored or explored on its own.",
+      "notStarted": "Not started",
+      "filesNewCount": "{count, plural, one {# new file} other {# new files}}",
+      "columns": {
+        "status": "Status",
+        "started": "Started",
+        "trigger": "Started by",
+        "duration": "Duration",
+        "filesNew": "New files",
+        "bytesAdded": "Added"
+      },
+      "trigger": {
+        "scheduled": "Schedule",
+        "manual": "Manual",
+        "restore": "Restore"
+      },
+      "status": {
+        "queued": "Queued",
+        "running": "Running",
+        "done": "Done",
+        "failed": "Failed",
+        "skipped": "Skipped"
+      },
+      "restoring": "Restoring",
+      "restoringHint": "A restore is running: the workshop's data is being replaced with the backup. This page updates by itself until it is done. Do not work in the workshop until then.",
+      "explore": "Explore",
+      "restore": {
+        "showAll": "Show all backups",
+        "refresh": "Refresh",
+        "loading": "Reading the backups at the destination",
+        "loadFailed": "The backups could not be listed",
+        "empty": "No backups at this destination yet.",
+        "needsOwner": "Only the workshop's owner can restore a backup here.",
+        "needsAdmin": "Only an owner or administrator can restore a backup.",
+        "filesIncluded": "Photos and files included",
+        "filesNotIncluded": "Photos and files not included",
+        "earlierInstall": "Earlier install",
+        "action": "Restore",
+        "dialogTitle": "Restore the backup from {time}?",
+        "warning": "Restoring <bold>replaces everything this workshop has now</bold>: customers, vehicles, jobs, invoices, settings and, when the backup includes them, photos and files. Anything added since the backup was taken is gone. This cannot be undone.",
+        "secretsNote": "Secret keys the backup left out keep the values this install has now. User accounts and passwords are not touched.",
+        "confirmPrompt": "Type the workshop's name, <bold>{name}</bold>, to confirm.",
+        "whileRunning": "The restore runs in the background and takes a few minutes when there are many photos. The page updates by itself until it is done.",
+        "cancel": "Cancel",
+        "confirm": "Restore this backup",
+        "started": "Restore started"
+      }
+    },
+    "lockedBackups": "Scheduled, encrypted backups of your workshop to storage you own.",
+    "searchOrType": "Search or type a name",
+    "useTyped": "Use \"{value}\"",
+    "listNeedsKeys": "Enter the keys above to see the list",
+    "listEmpty": "Nothing was listed. Type a name instead.",
+    "testing": "Testing the connection",
+    "testOk": "The connection works: a test object was written and removed again.",
+    "testFailed": "The test failed",
+    "testSteps": {
+      "settings": "Check the provider settings",
+      "list": "The key cannot list the bucket",
+      "write": "The key cannot write to the bucket",
+      "delete": "The key cannot delete from the bucket",
+      "folder": "The folder cannot be created or opened"
+    },
+    "testHint": "Fill in the keys and the bucket to test them before connecting. The test writes one small object and removes it."
   },
   },
   "activity": {
   "activity": {
     "title": "Activity",
     "title": "Activity",
@@ -199,7 +287,10 @@
     "invalid": "The provider's response was incomplete. Try again.",
     "invalid": "The provider's response was incomplete. Try again.",
     "state": "This sign-in has expired or belongs to another session. Start again.",
     "state": "This sign-in has expired or belongs to another session. Start again.",
     "exchange": "The provider refused the sign-in. Check the app credentials and try again.",
     "exchange": "The provider refused the sign-in. Check the app credentials and try again.",
-    "identify": "Connected, but the account could not be read. Try reconnecting."
+    "identify": "Connected, but the account could not be read. Try reconnecting.",
+    "backupRunning": "A backup is already running.",
+    "restoreOwnerOnly": "On Torqvoice Cloud only the workshop's owner can restore a backup.",
+    "restoreBusy": "A backup or restore is already running on this server. Try again when it is done."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Join video call",
     "join": "Join video call",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Microsoft 365 Calendar",
       "description": "Put scheduled work orders on an Outlook calendar, add a Microsoft Teams link, and see the calendar's busy time in Torqvoice.",
       "description": "Put scheduled work orders on an Outlook calendar, add a Microsoft Teams link, and see the calendar's busy time in Torqvoice.",
       "tenantHelp": "Register an application in Microsoft Entra (App registrations), allow personal and work accounts, add the redirect URI below as a Web platform, create a client secret, and paste both here.",
       "tenantHelp": "Register an application in Microsoft Entra (App registrations), allow personal and work accounts, add the redirect URI below as a Web platform, create a client secret, and paste both here.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "OpenAI-compatible",
       "description": "Service descriptions, history summaries, document scanning and the assistant, answered by any server that speaks OpenAI's chat API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM and the like. Your data stays on your own hardware.",
       "description": "Service descriptions, history summaries, document scanning and the assistant, answered by any server that speaks OpenAI's chat API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM and the like. Your data stays on your own hardware.",
       "fields": {
       "fields": {
         "baseUrl": "Base URL",
         "baseUrl": "Base URL",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Speech to text",
       "description": "Dictation on a work order, written down by a speech model instead of the browser: far more accurate, any language, and it knows a workshop's words. Separate from your AI provider, so you can keep that one as it is. Uses your own OpenAI key, or a server of your own that speaks OpenAI's transcription API. The recording is sent to be written down and is not kept.",
       "description": "Dictation on a work order, written down by a speech model instead of the browser: far more accurate, any language, and it knows a workshop's words. Separate from your AI provider, so you can keep that one as it is. Uses your own OpenAI key, or a server of your own that speaks OpenAI's transcription API. The recording is sent to be written down and is not kept.",
       "fields": {
       "fields": {
         "apiKey": "API key",
         "apiKey": "API key",
@@ -653,6 +747,108 @@
         "modeAi": "Always the speech model",
         "modeAi": "Always the speech model",
         "modeChoice": "Each person chooses: speech model or browser"
         "modeChoice": "Each person chooses: speech model or browser"
       }
       }
+    },
+    "s3": {
+      "name": "Cloud storage bucket",
+      "description": "Encrypted backups of this workshop to a bucket you own at Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage or any other S3-compatible storage, on a schedule you choose. After the first run only what changed is uploaded, so hourly backups stay cheap.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "accessKeyIdHelp": "A key from your storage provider with permission to list, read, write and delete objects in the bucket. Make a key for Torqvoice alone, so it can be revoked on its own.",
+        "secretAccessKey": "Secret access key",
+        "password": "Backup password",
+        "passwordHelp": "Every backup is encrypted with this password. Make one up or press Generate, and keep a copy away from this server: without it nobody can open these backups, not even Torqvoice. To reconnect storage that already holds Torqvoice backups, enter the password they were made with.",
+        "endpoint": "Custom endpoint",
+        "endpointHelp": "Only for the Other provider: the S3 address of your own storage, such as a MinIO server, as a host name with a port if needed."
+      },
+      "settings": {
+        "provider": "Provider",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Other S3-compatible storage",
+        "region": "Region",
+        "regionHelp": "Where the bucket lives. Pick it from the provider's list; for other storage, type what it expects, if anything. Not used by Cloudflare R2.",
+        "accountId": "Cloudflare account ID",
+        "accountIdHelp": "The 32-character id shown on the R2 overview page of your Cloudflare dashboard.",
+        "bucket": "Bucket",
+        "bucketHelp": "Listed from the key as soon as it is entered. A key that may use one bucket but not list them takes the name typed in.",
+        "prefix": "Folder in the bucket",
+        "prefixHelp": "The backups live under this folder. Use a different folder for each workshop that shares the bucket.",
+        "frequency": "How often",
+        "frequencyHourly": "Every hour",
+        "frequencyEvery4h": "Every 4 hours",
+        "frequencyDaily": "Every day",
+        "frequencyWeekly": "Every week",
+        "frequencyMonthly": "Every month",
+        "time": "Time of day",
+        "timeHelp": "On the workshop clock, as HH:MM. Used by daily, weekly and monthly backups.",
+        "weekday": "Day of the week",
+        "sunday": "Sunday",
+        "monday": "Monday",
+        "tuesday": "Tuesday",
+        "wednesday": "Wednesday",
+        "thursday": "Thursday",
+        "friday": "Friday",
+        "saturday": "Saturday",
+        "monthDay": "Day of the month",
+        "monthDayLast": "Last day",
+        "contents": "Contents",
+        "contentsHelp": "Secret keys are the ones this workshop typed into Torqvoice: payment, AI and webhook keys. Every backup is encrypted with the backup password either way. A backup holds the workshop's records and, when chosen, its files. User accounts and passwords are not in it: on a new server you sign in, invite the team and restore the data into that workshop.",
+        "contentsData": "Data only",
+        "contentsDataAndSecrets": "Data and secret keys",
+        "includeFiles": "Include photos and files",
+        "includeFilesHelp": "Every file is uploaded once and reused by later backups, so this costs little after the first run.",
+        "keepHourly": "Keep hourly backups",
+        "keepHourlyHelp": "How many of the most recent hourly backups to keep. Only matters for hourly and 4-hourly schedules.",
+        "keepDaily": "Keep daily backups",
+        "keepWeekly": "Keep weekly backups",
+        "keepMonthly": "Keep monthly backups",
+        "keepMonthlyHelp": "Older backups are removed after each run, keeping the newest of each kind. Set a number to 0 to keep none of that kind."
+      }
+    },
+    "local-folder": {
+      "name": "Local folder",
+      "description": "Encrypted backups of this workshop to a folder on this server: the backups volume in the compose file, or a network drive mounted there. A copy on the same disk as the database is not safe on its own; pair it with storage elsewhere.",
+      "fields": {
+        "password": "Backup password",
+        "passwordHelp": "Every backup is encrypted with this password. Make one up or press Generate, and keep a copy away from this server: without it nobody can open these backups, not even Torqvoice. To reconnect storage that already holds Torqvoice backups, enter the password they were made with."
+      },
+      "settings": {
+        "folder": "Folder",
+        "folderHelp": "The folder under the server's backup root that holds this workshop's backups. Leave it empty to use a folder named after this workshop. On a new server, type the folder name an earlier install used to get its backups back.",
+        "frequency": "How often",
+        "frequencyHourly": "Every hour",
+        "frequencyEvery4h": "Every 4 hours",
+        "frequencyDaily": "Every day",
+        "frequencyWeekly": "Every week",
+        "frequencyMonthly": "Every month",
+        "time": "Time of day",
+        "timeHelp": "On the workshop clock, as HH:MM. Used by daily, weekly and monthly backups.",
+        "weekday": "Day of the week",
+        "sunday": "Sunday",
+        "monday": "Monday",
+        "tuesday": "Tuesday",
+        "wednesday": "Wednesday",
+        "thursday": "Thursday",
+        "friday": "Friday",
+        "saturday": "Saturday",
+        "monthDay": "Day of the month",
+        "monthDayLast": "Last day",
+        "contents": "Contents",
+        "contentsHelp": "Secret keys are the ones this workshop typed into Torqvoice: payment, AI and webhook keys. Every backup is encrypted with the backup password either way. A backup holds the workshop's records and, when chosen, its files. User accounts and passwords are not in it: on a new server you sign in, invite the team and restore the data into that workshop.",
+        "contentsData": "Data only",
+        "contentsDataAndSecrets": "Data and secret keys",
+        "includeFiles": "Include photos and files",
+        "includeFilesHelp": "Every file is uploaded once and reused by later backups, so this costs little after the first run.",
+        "keepHourly": "Keep hourly backups",
+        "keepHourlyHelp": "How many of the most recent hourly backups to keep. Only matters for hourly and 4-hourly schedules.",
+        "keepDaily": "Keep daily backups",
+        "keepWeekly": "Keep weekly backups",
+        "keepMonthly": "Keep monthly backups",
+        "keepMonthlyHelp": "Older backups are removed after each run, keeping the newest of each kind. Set a number to 0 to keep none of that kind."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Recall data refresh",
       "refresh": "Recall data refresh",
       "vehicle": "Recall check for a new vehicle"
       "vehicle": "Recall check for a new vehicle"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "backup destination",
+    "failedTitle": "Backup failed",
+    "failedMessage": "The backup to {name} did not finish: {reason}",
+    "failedSubject": "Backup to {name} failed",
+    "failedEmailIntro": "The scheduled backup of your workshop to {name} did not finish.",
+    "openToRetry": "Open Settings, Integrations, {name} to see the run and try again.",
+    "overdueTitle": "Backup overdue",
+    "overdueSince": "No backup to {name} has finished since {time}.",
+    "overdueNever": "No backup to {name} has finished yet.",
+    "overdueSubject": "Backup to {name} is overdue",
+    "openToSee": "Open Settings, Integrations, {name} to see what went wrong.",
+    "platformName": "Backups by Torqvoice",
+    "restoredTitle": "Workshop restored",
+    "restoredSubject": "Your workshop was restored from a backup",
+    "restoredIntro": "{name}: the workshop was put back to restore point {snapshot} on {time}. Everything entered after that point is no longer in the workshop.",
+    "restoredByMember": "A member of your workshop asked for this restore.",
+    "restoredBySupport": "Torqvoice support did this at your request. Reason given: {reason}",
+    "noReason": "none given",
+    "restoredUndo": "A safety copy of the workshop as it was right before the restore was taken first and is kept for 90 days. Contact support to go back to it."
   }
   }
 }
 }

+ 161 - 1
messages/en/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Too many attempts. Wait a few minutes and try again.",
     "reauthTooManyAttempts": "Too many attempts. Wait a few minutes and try again.",
     "reauthSignInAgainAccount": "Sign in again before deleting your account.",
     "reauthSignInAgainAccount": "Sign in again before deleting your account.",
     "reauthSignInAgainWorkshop": "Sign in again before deleting the workshop.",
     "reauthSignInAgainWorkshop": "Sign in again before deleting the workshop.",
+    "reauthSignInAgainRestore": "Sign in again before restoring the workshop.",
     "deleteAccountMoved": "Want to delete your account?",
     "deleteAccountMoved": "Want to delete your account?",
     "deleteAccountMovedLink": "You can do that in Data settings.",
     "deleteAccountMovedLink": "You can do that in Data settings.",
     "signatureTitle": "Signature",
     "signatureTitle": "Signature",
@@ -1399,6 +1400,11 @@
     "importComplete": "Import Complete",
     "importComplete": "Import Complete",
     "importCompleteMessage": "All data has been restored from the backup file.",
     "importCompleteMessage": "All data has been restored from the backup file.",
     "importFailed": "Import Failed",
     "importFailed": "Import Failed",
+    "restoreRefused": {
+      "busy": "A backup or restore is already running on this server. Try again when it is done.",
+      "ownerOnly": "On Torqvoice Cloud only the workshop's owner can restore a backup.",
+      "foreignIds": "The file refers to records that belong to another workshop, so nothing was restored."
+    },
     "importFromOther": "Import from Other Services",
     "importFromOther": "Import from Other Services",
     "importFromOtherDescription": "Migrate your data from another platform. Select a service to get started.",
     "importFromOtherDescription": "Migrate your data from another platform. Select a service to get started.",
     "lubelogTitle": "Import from LubeLog",
     "lubelogTitle": "Import from LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Torqvoice backup",
     "sourceBackup": "Torqvoice backup",
     "sourceBackupDesc": "A .zip or .json file downloaded from this page. Restoring replaces everything in this workshop.",
     "sourceBackupDesc": "A .zip or .json file downloaded from this page. Restoring replaces everything in this workshop.",
     "restore": "Restore",
     "restore": "Restore",
-    "restoreTitle": "Restore a backup"
+    "restoreTitle": "Restore a backup",
+    "includeSecrets": "Include secret keys",
+    "includeSecretsHelp": "Secret keys, such as payment and AI keys, are left out unless you tick this. A restore then keeps the keys this install already has.",
+    "platformBackups": {
+      "title": "Backups by Torqvoice",
+      "description": "Torqvoice backs this workshop up every hour and keeps restore points for 30 days, then weekly and monthly ones.",
+      "runNow": "Back up now",
+      "started": "Backup started",
+      "lastGood": "Last good backup",
+      "never": "No backup yet",
+      "nextRun": "Next run",
+      "notScheduled": "Not scheduled",
+      "repositorySize": "Stored by Torqvoice",
+      "running": "Running",
+      "restoring": "Restoring",
+      "queued": "Queued",
+      "queuedAhead": "{count, plural, one {Queued, # ahead} other {Queued, # ahead}}",
+      "runningHint": "A backup is running. This card updates by itself until it is done.",
+      "restoringHint": "A restore is running: the workshop's data is being replaced with the restore point. This page updates by itself until it is done. Do not work in the workshop until then.",
+      "paused": "Your plan no longer includes backups by Torqvoice. Runs have stopped; the restore points are kept until {date} and then deleted.",
+      "upsell": "Available on Pro: hourly backups run by Torqvoice with restore points you can go back to.",
+      "planLink": "See plans",
+      "pointsTitle": "Restore points",
+      "pointsEmpty": "The first backup has not run yet.",
+      "showAll": "Show all {count}",
+      "showFewer": "Show fewer",
+      "needsOwner": "Only the workshop owner can restore.",
+      "sizeNote": "Each restore point is complete on its own; its size is everything it covers. The repository stores every piece once and later runs add only what changed, so “Stored by Torqvoice” stays far below the sizes added together.",
+      "recentTitle": "Recent runs",
+      "noRuns": "No backups have run yet.",
+      "notStarted": "Not started",
+      "supportReason": "Restored by Torqvoice support: {reason}",
+      "columns": {
+        "when": "When",
+        "kind": "Kind",
+        "size": "Size",
+        "status": "Status",
+        "trigger": "Started by",
+        "duration": "Duration"
+      },
+      "kind": {
+        "scheduled": "Scheduled",
+        "manual": "Manual",
+        "safety": "Safety copy",
+        "safetyHint": "Taken right before a restore, so it can be undone"
+      },
+      "trigger": {
+        "scheduled": "Scheduled",
+        "manual": "Manual",
+        "restore": "Restore"
+      },
+      "status": {
+        "queued": "Queued",
+        "running": "Running",
+        "done": "Done",
+        "failed": "Failed",
+        "skipped": "Skipped"
+      },
+      "explore": "Explore",
+      "restore": {
+        "action": "Restore",
+        "dialogTitle": "Restore the workshop to {time}?",
+        "warning": "<bold>Everything in this workshop is replaced</bold> by the restore point from {time}: customers, vehicles, jobs, invoices, settings, photos and files. Anything added since then is gone.",
+        "safetyNote": "A safety copy of the workshop as it is now is taken first and kept for 90 days, so the restore can be undone from the restore points.",
+        "untouched": "Members, accounts, the subscription and integrations are not touched. Secret keys that are not in the backup keep their current values.",
+        "confirmPrompt": "Type the workshop's name, <bold>{name}</bold>, to confirm.",
+        "whileRunning": "The restore runs in the background and takes a few minutes when there are many photos. The page updates by itself until it is done.",
+        "cancel": "Cancel",
+        "confirm": "Restore",
+        "started": "Restore started"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Back to backups",
+      "title": "Restore point from {date}",
+      "titleShort": "Restore point {id}",
+      "readOnly": "Read-only: nothing here changes the workshop. The view closes after 15 minutes of no activity.",
+      "readOnlyBadge": "Read-only",
+      "opening": "Opening the restore point",
+      "openingHint": "The backup is decrypted here on the server and held in memory for a quarter of an hour. Nothing in the workshop changes.",
+      "openingSteps": {
+        "connecting": "Connecting to the backup repository",
+        "locating": "Finding the data file in the snapshot",
+        "pulling": "Pulling and decrypting the data",
+        "reading": "Reading the records",
+        "indexing": "Indexing customers, vehicles and work orders"
+      },
+      "openingReady": "Ready",
+      "openFailed": "The restore point could not be opened",
+      "tryAgain": "Try again",
+      "ownerOnly": "Only the workshop's owner or an administrator can look inside a restore point.",
+      "closed": "The restore point was closed; open it again",
+      "openAgain": "Open again",
+      "datasets": {
+        "customers": "Customers",
+        "vehicles": "Vehicles",
+        "serviceRecords": "Work orders",
+        "quotes": "Quotes",
+        "inventoryParts": "Parts"
+      },
+      "searchPlaceholder": "Search by name, number, plate, VIN, email or phone",
+      "loading": "Loading…",
+      "loadFailed": "This list could not be loaded",
+      "empty": "There is nothing of this kind in the restore point.",
+      "noMatches": "Nothing matches “{query}”.",
+      "showing": "{shown} of {total} shown",
+      "loadMore": "Show more",
+      "clearSearch": "Clear search",
+      "record": {
+        "loading": "Loading the record…",
+        "loadFailed": "The record could not be loaded",
+        "notFound": "This record is not in the restore point.",
+        "fields": "Details",
+        "jumpTo": "Jump to",
+        "previous": "Previous record",
+        "next": "Next record",
+        "position": "{index} of {total}",
+        "noColumns": "Nothing to show in these rows.",
+        "yes": "Yes",
+        "no": "No"
+      },
+      "sections": {
+        "concerns": "Concerns",
+        "partItems": "Parts",
+        "laborItems": "Labour",
+        "attachments": "Photos and documents",
+        "payments": "Payments",
+        "statusReports": "Status reports",
+        "timeEntries": "Time entries",
+        "notes": "Notes",
+        "fuelLogs": "Fuel logs",
+        "reminders": "Reminders",
+        "serviceRequests": "Service requests",
+        "inspectionStatus": "Inspection status",
+        "findings": "Findings",
+        "conditionMarks": "Condition marks",
+        "aiMessages": "AI messages",
+        "recurringInvoices": "Recurring invoices",
+        "serviceRecords": "Work orders",
+        "movements": "Stock movements",
+        "gallery": "Gallery"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Scheduled backups",
+      "description": "Automatic backups to storage you own, on a schedule you choose.",
+      "empty": "No backup destination is connected yet.",
+      "setUp": "Set up a backup destination",
+      "lastGood": "Last good backup {when}",
+      "noGoodBackup": "No good backup yet",
+      "nextRun": "next run {when}",
+      "notScheduled": "not scheduled",
+      "lastError": "Last error: {error}",
+      "extraCopy": "An extra copy in storage you own, on a schedule you choose, on top of the backups Torqvoice runs."
+    }
   },
   },
   "license": {
   "license": {
     "title": "White-Label License",
     "title": "White-Label License",

+ 7 - 2
messages/es/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Tipo de marca añadido",
     "settings_markType_create": "Tipo de marca añadido",
     "settings_markType_update": "Tipo de marca modificado",
     "settings_markType_update": "Tipo de marca modificado",
     "settings_markType_remove": "Tipo de marca eliminado",
     "settings_markType_remove": "Tipo de marca eliminado",
-    "settings_markType_reorder": "Tipos de marca reordenados"
+    "settings_markType_reorder": "Tipos de marca reordenados",
+    "integration_backup_run": "Copia de seguridad iniciada",
+    "integration_backup_restore": "Copia de seguridad restaurada"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
     "customField_create": "Campo personalizado «{name}» creado",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Eliminó una marca del mapa de estado",
     "condition_mark_remove": "Eliminó una marca del mapa de estado",
     "condition_mark_carry": "Confirmó que {count, plural, one {# marca anterior sigue} other {# marcas anteriores siguen}} ahí en el mapa de estado",
     "condition_mark_carry": "Confirmó que {count, plural, one {# marca anterior sigue} other {# marcas anteriores siguen}} ahí en el mapa de estado",
     "condition_map_on_invoice_on": "Puso el mapa de estado en la factura",
     "condition_map_on_invoice_on": "Puso el mapa de estado en la factura",
-    "condition_map_on_invoice_off": "Quitó el mapa de estado de la factura"
+    "condition_map_on_invoice_off": "Quitó el mapa de estado de la factura",
+    "backup_run_started": "Inició una copia de seguridad en {name}",
+    "backup_restore_started": "Inició la restauración de la copia de seguridad {snapshot} desde {name}",
+    "backup_restored": "Restauró la copia de seguridad {snapshot} desde {name}"
   }
   }
 }
 }

+ 219 - 2
messages/es/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Facturas al libro contable",
       "invoices": "Facturas al libro contable",
       "customers": "Clientes al libro contable",
       "customers": "Clientes al libro contable",
       "payments": "Pagos en ambos sentidos"
       "payments": "Pagos en ambos sentidos"
+    },
+    "backup": {
+      "target": "Destino de copias de seguridad"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Todos los vehículos con matrícula se comprobaron hace poco, así que aún no hay nada que consultar."
       "nothingDue": "Todos los vehículos con matrícula se comprobaron hace poco, así que aún no hay nada que consultar."
     },
     },
     "supersedes": "Conectar {next} desconecta {current}. Solo uno puede estar activo a la vez, así que {current} deja de usarse en cuanto {next} entra en servicio.",
     "supersedes": "Conectar {next} desconecta {current}. Solo uno puede estar activo a la vez, así que {current} deja de usarse en cuanto {next} entra en servicio.",
+    "platformHandlesBackups": "Torqvoice ya hace una copia de seguridad de este taller cada hora y conserva los puntos de restauración durante 30 días. Conecte un bucket si además quiere una copia de su propiedad que se pueda leer sin Torqvoice.",
+    "platformHandlesBackupsLink": "Ver tus puntos de restauración",
     "batchRetried": "{count, plural, =0 {Nada que reintentar} one {# tarea vuelta a la cola} other {# tareas vueltas a la cola}}",
     "batchRetried": "{count, plural, =0 {Nada que reintentar} one {# tarea vuelta a la cola} other {# tareas vueltas a la cola}}",
-    "advanced": "Avanzado"
+    "advanced": "Avanzado",
+    "generate": "Generar",
+    "show": "Mostrar",
+    "hide": "Ocultar",
+    "generatedPasswordWarning": "Guarde una copia de esta contraseña en un lugar seguro, fuera de este servidor. Sin ella nadie puede abrir estas copias de seguridad, ni siquiera Torqvoice.",
+    "backup": {
+      "title": "Copias de seguridad",
+      "description": "Cuándo recibió este destino la última copia correcta, cuándo se ejecuta la siguiente y las ejecuciones más recientes.",
+      "lastGood": "Última copia correcta",
+      "added": "{size} añadidos",
+      "never": "Ninguna todavía",
+      "nextRun": "Próxima ejecución",
+      "notScheduled": "Sin programar",
+      "repositorySize": "Almacenado en el destino",
+      "runNow": "Hacer copia ahora",
+      "running": "Haciendo copia",
+      "runningHint": "Se está haciendo una copia de seguridad. Esta página se actualiza sola hasta que termine.",
+      "started": "Copia de seguridad iniciada",
+      "recentTitle": "Ejecuciones recientes",
+      "noRuns": "Todavía no se ha hecho ninguna copia de seguridad.",
+      "addedNote": "La primera ejecución sube todo. Cada una de las siguientes añade solo las piezas que cambiaron desde entonces, por eso es pequeña. Cada copia sigue estando completa y puede restaurarse o explorarse por sí sola.",
+      "notStarted": "Sin iniciar",
+      "filesNewCount": "{count, plural, one {# archivo nuevo} other {# archivos nuevos}}",
+      "columns": {
+        "status": "Estado",
+        "started": "Inicio",
+        "trigger": "Iniciada por",
+        "duration": "Duración",
+        "filesNew": "Archivos nuevos",
+        "bytesAdded": "Añadido"
+      },
+      "trigger": {
+        "scheduled": "Programación",
+        "manual": "Manual",
+        "restore": "Restauración"
+      },
+      "status": {
+        "queued": "En cola",
+        "running": "En curso",
+        "done": "Completada",
+        "failed": "Fallida",
+        "skipped": "Omitida"
+      },
+      "restoring": "Restaurando",
+      "restoringHint": "Se está restaurando una copia: los datos del taller se están sustituyendo por los de la copia de seguridad. Esta página se actualiza sola hasta que termine. No trabaje en el taller hasta entonces.",
+      "explore": "Explorar",
+      "restore": {
+        "showAll": "Mostrar todas las copias",
+        "refresh": "Actualizar",
+        "loading": "Leyendo las copias del destino",
+        "loadFailed": "No se pudieron listar las copias",
+        "empty": "Todavía no hay copias en este destino.",
+        "needsOwner": "Solo el propietario del taller puede restaurar una copia aquí.",
+        "needsAdmin": "Solo un propietario o un administrador puede restaurar una copia.",
+        "filesIncluded": "Incluye fotos y archivos",
+        "filesNotIncluded": "No incluye fotos ni archivos",
+        "earlierInstall": "Instalación anterior",
+        "action": "Restaurar",
+        "dialogTitle": "¿Restaurar la copia de seguridad de {time}?",
+        "warning": "Restaurar <bold>sustituye todo lo que tiene ahora este taller</bold>: clientes, vehículos, trabajos, facturas, configuración y, cuando la copia los incluye, fotos y archivos. Todo lo añadido desde que se hizo la copia se pierde. Esto no se puede deshacer.",
+        "secretsNote": "Las claves secretas que la copia dejó fuera conservan los valores que tiene ahora esta instalación. Las cuentas de usuario y las contraseñas no se tocan.",
+        "confirmPrompt": "Escriba el nombre del taller, <bold>{name}</bold>, para confirmar.",
+        "whileRunning": "La restauración se ejecuta en segundo plano y tarda unos minutos cuando hay muchas fotos. La página se actualiza sola hasta que termine.",
+        "cancel": "Cancelar",
+        "confirm": "Restaurar esta copia",
+        "started": "Restauración iniciada"
+      }
+    },
+    "lockedBackups": "Copias de seguridad cifradas y programadas de su taller en un almacenamiento propio.",
+    "searchOrType": "Buscar o escribir un nombre",
+    "useTyped": "Usar \"{value}\"",
+    "listNeedsKeys": "Introduzca las claves de arriba para ver la lista",
+    "listEmpty": "La lista está vacía. Escriba un nombre.",
+    "testing": "Probando la conexión",
+    "testOk": "La conexión funciona: se escribió un objeto de prueba y se volvió a eliminar.",
+    "testFailed": "La prueba falló",
+    "testSteps": {
+      "settings": "Revise los ajustes del proveedor",
+      "list": "La clave no puede listar el bucket",
+      "write": "La clave no puede escribir en el bucket",
+      "delete": "La clave no puede eliminar del bucket",
+      "folder": "No se puede crear ni abrir la carpeta"
+    },
+    "testHint": "Introduzca las claves y el bucket para probarlos antes de conectar. La prueba escribe un objeto pequeño y lo elimina."
   },
   },
   "activity": {
   "activity": {
     "title": "Actividad",
     "title": "Actividad",
@@ -199,7 +287,10 @@
     "invalid": "La respuesta del proveedor fue incompleta. Inténtelo de nuevo.",
     "invalid": "La respuesta del proveedor fue incompleta. Inténtelo de nuevo.",
     "state": "Este inicio de sesión ha caducado o pertenece a otra sesión. Empiece de nuevo.",
     "state": "Este inicio de sesión ha caducado o pertenece a otra sesión. Empiece de nuevo.",
     "exchange": "El proveedor rechazó el inicio de sesión. Compruebe las credenciales de la aplicación e inténtelo de nuevo.",
     "exchange": "El proveedor rechazó el inicio de sesión. Compruebe las credenciales de la aplicación e inténtelo de nuevo.",
-    "identify": "Conectado, pero no se pudo leer la cuenta. Intente volver a conectar."
+    "identify": "Conectado, pero no se pudo leer la cuenta. Intente volver a conectar.",
+    "backupRunning": "Ya hay una copia de seguridad en curso.",
+    "restoreOwnerOnly": "En Torqvoice Cloud solo el propietario del taller puede restaurar una copia de seguridad.",
+    "restoreBusy": "Ya hay una copia de seguridad o una restauración en curso en este servidor. Inténtelo de nuevo cuando termine."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Unirse a la videollamada",
     "join": "Unirse a la videollamada",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Calendario de Microsoft 365",
       "description": "Añada las órdenes de trabajo programadas a un calendario de Outlook, incluya un enlace de Microsoft Teams y vea el tiempo ocupado del calendario en Torqvoice.",
       "description": "Añada las órdenes de trabajo programadas a un calendario de Outlook, incluya un enlace de Microsoft Teams y vea el tiempo ocupado del calendario en Torqvoice.",
       "tenantHelp": "Registre una aplicación en Microsoft Entra (Registros de aplicaciones), permita cuentas personales y profesionales, añada la URI de redirección que aparece abajo como plataforma Web, cree un secreto de cliente y pegue ambos aquí.",
       "tenantHelp": "Registre una aplicación en Microsoft Entra (Registros de aplicaciones), permita cuentas personales y profesionales, añada la URI de redirección que aparece abajo como plataforma Web, cree un secreto de cliente y pegue ambos aquí.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Compatible con OpenAI",
       "description": "Descripciones de servicio, resúmenes de historial, lectura de documentos y el asistente, respondidos por cualquier servidor que hable la API de chat de OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM y similares. Sus datos se quedan en su propio hardware.",
       "description": "Descripciones de servicio, resúmenes de historial, lectura de documentos y el asistente, respondidos por cualquier servidor que hable la API de chat de OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM y similares. Sus datos se quedan en su propio hardware.",
       "fields": {
       "fields": {
         "baseUrl": "URL base",
         "baseUrl": "URL base",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Voz a texto",
       "description": "El dictado en una orden de trabajo lo transcribe un modelo de voz en lugar del navegador: mucho más preciso, en cualquier idioma y conoce las palabras de un taller. Es independiente de tu proveedor de IA, así que puedes dejar ese como está. Usa tu propia clave de OpenAI o un servidor propio que hable la API de transcripción de OpenAI. La grabación se envía para transcribirla y no se guarda.",
       "description": "El dictado en una orden de trabajo lo transcribe un modelo de voz en lugar del navegador: mucho más preciso, en cualquier idioma y conoce las palabras de un taller. Es independiente de tu proveedor de IA, así que puedes dejar ese como está. Usa tu propia clave de OpenAI o un servidor propio que hable la API de transcripción de OpenAI. La grabación se envía para transcribirla y no se guarda.",
       "fields": {
       "fields": {
         "apiKey": "Clave de API",
         "apiKey": "Clave de API",
@@ -653,6 +747,108 @@
         "modeAi": "Siempre el modelo de voz",
         "modeAi": "Siempre el modelo de voz",
         "modeChoice": "Cada persona elige: modelo de voz o navegador"
         "modeChoice": "Cada persona elige: modelo de voz o navegador"
       }
       }
+    },
+    "s3": {
+      "name": "Bucket de almacenamiento en la nube",
+      "description": "Copias de seguridad cifradas de este taller en un bucket de su propiedad en Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage o cualquier otro almacenamiento compatible con S3, con la frecuencia que usted elija. Tras la primera ejecución solo se sube lo que ha cambiado, así que las copias cada hora salen baratas.",
+      "fields": {
+        "accessKeyId": "ID de clave de acceso",
+        "accessKeyIdHelp": "Una clave de su proveedor de almacenamiento con permiso para listar, leer, escribir y eliminar objetos del bucket. Cree una clave solo para Torqvoice, para poder revocarla por separado.",
+        "secretAccessKey": "Clave de acceso secreta",
+        "password": "Contraseña de las copias",
+        "passwordHelp": "Cada copia de seguridad se cifra con esta contraseña. Invente una o pulse Generar, y guarde una copia fuera de este servidor: sin ella nadie puede abrir estas copias, ni siquiera Torqvoice. Para volver a conectar un almacenamiento que ya contiene copias de Torqvoice, introduzca la contraseña con la que se hicieron.",
+        "endpoint": "Endpoint personalizado",
+        "endpointHelp": "Solo para la opción Otro almacenamiento compatible con S3: la dirección S3 de su propio almacenamiento, como un servidor MinIO, como nombre de host con puerto si hace falta."
+      },
+      "settings": {
+        "provider": "Proveedor",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Otro almacenamiento compatible con S3",
+        "region": "Región",
+        "regionHelp": "Dónde está el bucket. Elíjala de la lista del proveedor; para otro almacenamiento, escriba la que este pida, si pide alguna. Cloudflare R2 no la usa.",
+        "accountId": "ID de cuenta de Cloudflare",
+        "accountIdHelp": "El ID de 32 caracteres que aparece en la página de resumen de R2 de su panel de Cloudflare.",
+        "bucket": "Bucket",
+        "bucketHelp": "Los buckets se listan con la clave en cuanto se introduce. Si la clave puede usar un bucket pero no listarlos, escriba el nombre.",
+        "prefix": "Carpeta en el bucket",
+        "prefixHelp": "Las copias se guardan en esta carpeta. Use una carpeta distinta para cada taller que comparta el bucket.",
+        "frequency": "Frecuencia",
+        "frequencyHourly": "Cada hora",
+        "frequencyEvery4h": "Cada 4 horas",
+        "frequencyDaily": "Cada día",
+        "frequencyWeekly": "Cada semana",
+        "frequencyMonthly": "Cada mes",
+        "time": "Hora del día",
+        "timeHelp": "Según el reloj del taller, en formato HH:MM. Se usa en las copias diarias, semanales y mensuales.",
+        "weekday": "Día de la semana",
+        "sunday": "Domingo",
+        "monday": "Lunes",
+        "tuesday": "Martes",
+        "wednesday": "Miércoles",
+        "thursday": "Jueves",
+        "friday": "Viernes",
+        "saturday": "Sábado",
+        "monthDay": "Día del mes",
+        "monthDayLast": "Último día",
+        "contents": "Contenido",
+        "contentsHelp": "Las claves secretas son las que este taller ha introducido en Torqvoice: claves de pagos, de IA y de webhooks. En cualquier caso, cada copia se cifra con la contraseña de las copias. Una copia contiene los registros del taller y, si así lo eliges, sus archivos. Las cuentas de usuario y las contraseñas no van en ella: en un servidor nuevo inicias sesión, invitas al equipo y restauras los datos en ese taller.",
+        "contentsData": "Solo datos",
+        "contentsDataAndSecrets": "Datos y claves secretas",
+        "includeFiles": "Incluir fotos y archivos",
+        "includeFilesHelp": "Cada archivo se sube una vez y las copias posteriores lo reutilizan, así que esto cuesta poco después de la primera ejecución.",
+        "keepHourly": "Conservar copias horarias",
+        "keepHourlyHelp": "Cuántas de las copias horarias más recientes se conservan. Solo importa con las frecuencias de cada hora y cada 4 horas.",
+        "keepDaily": "Conservar copias diarias",
+        "keepWeekly": "Conservar copias semanales",
+        "keepMonthly": "Conservar copias mensuales",
+        "keepMonthlyHelp": "Las copias más antiguas se eliminan después de cada ejecución y se conservan las más recientes de cada tipo. Ponga un número a 0 para no conservar ninguna de ese tipo."
+      }
+    },
+    "local-folder": {
+      "name": "Carpeta local",
+      "description": "Copias de seguridad cifradas de este taller en una carpeta de este servidor: el volumen de copias del archivo compose o una unidad de red montada allí. Una copia en el mismo disco que la base de datos no es segura por sí sola; combínela con un almacenamiento en otro lugar.",
+      "fields": {
+        "password": "Contraseña de las copias",
+        "passwordHelp": "Cada copia de seguridad se cifra con esta contraseña. Invente una o pulse Generar, y guarde una copia fuera de este servidor: sin ella nadie puede abrir estas copias, ni siquiera Torqvoice. Para volver a conectar un almacenamiento que ya contiene copias de Torqvoice, introduzca la contraseña con la que se hicieron."
+      },
+      "settings": {
+        "folder": "Carpeta",
+        "folderHelp": "La carpeta dentro de la raíz de copias de seguridad del servidor que guarda las copias de este taller. Déjala vacía para usar una carpeta con el nombre de este taller. En un servidor nuevo, escribe el nombre de la carpeta que usaba una instalación anterior para recuperar sus copias.",
+        "frequency": "Frecuencia",
+        "frequencyHourly": "Cada hora",
+        "frequencyEvery4h": "Cada 4 horas",
+        "frequencyDaily": "Cada día",
+        "frequencyWeekly": "Cada semana",
+        "frequencyMonthly": "Cada mes",
+        "time": "Hora del día",
+        "timeHelp": "Según el reloj del taller, en formato HH:MM. Se usa en las copias diarias, semanales y mensuales.",
+        "weekday": "Día de la semana",
+        "sunday": "Domingo",
+        "monday": "Lunes",
+        "tuesday": "Martes",
+        "wednesday": "Miércoles",
+        "thursday": "Jueves",
+        "friday": "Viernes",
+        "saturday": "Sábado",
+        "monthDay": "Día del mes",
+        "monthDayLast": "Último día",
+        "contents": "Contenido",
+        "contentsHelp": "Las claves secretas son las que este taller ha introducido en Torqvoice: claves de pagos, de IA y de webhooks. En cualquier caso, cada copia se cifra con la contraseña de las copias. Una copia contiene los registros del taller y, si así lo eliges, sus archivos. Las cuentas de usuario y las contraseñas no van en ella: en un servidor nuevo inicias sesión, invitas al equipo y restauras los datos en ese taller.",
+        "contentsData": "Solo datos",
+        "contentsDataAndSecrets": "Datos y claves secretas",
+        "includeFiles": "Incluir fotos y archivos",
+        "includeFilesHelp": "Cada archivo se sube una vez y las copias posteriores lo reutilizan, así que esto cuesta poco después de la primera ejecución.",
+        "keepHourly": "Conservar copias horarias",
+        "keepHourlyHelp": "Cuántas de las copias horarias más recientes se conservan. Solo importa con las frecuencias de cada hora y cada 4 horas.",
+        "keepDaily": "Conservar copias diarias",
+        "keepWeekly": "Conservar copias semanales",
+        "keepMonthly": "Conservar copias mensuales",
+        "keepMonthlyHelp": "Las copias más antiguas se eliminan después de cada ejecución y se conservan las más recientes de cada tipo. Ponga un número a 0 para no conservar ninguna de ese tipo."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Actualización de datos de llamadas a revisión",
       "refresh": "Actualización de datos de llamadas a revisión",
       "vehicle": "Comprobación de llamadas a revisión para un vehículo nuevo"
       "vehicle": "Comprobación de llamadas a revisión para un vehículo nuevo"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "destino de copias de seguridad",
+    "failedTitle": "Error en la copia de seguridad",
+    "failedMessage": "La copia de seguridad en {name} no terminó: {reason}",
+    "failedSubject": "Error en la copia de seguridad en {name}",
+    "failedEmailIntro": "La copia de seguridad programada de su taller en {name} no terminó.",
+    "openToRetry": "Abra Configuración, Integraciones, {name} para ver la ejecución y volver a intentarlo.",
+    "overdueTitle": "Copia de seguridad atrasada",
+    "overdueSince": "Ninguna copia de seguridad en {name} ha terminado desde {time}.",
+    "overdueNever": "Todavía no ha terminado ninguna copia de seguridad en {name}.",
+    "overdueSubject": "La copia de seguridad en {name} está atrasada",
+    "openToSee": "Abra Configuración, Integraciones, {name} para ver qué salió mal.",
+    "platformName": "Copias de seguridad de Torqvoice",
+    "restoredTitle": "Taller restaurado",
+    "restoredSubject": "Su taller se ha restaurado a partir de una copia de seguridad",
+    "restoredIntro": "{name}: el taller se devolvió al punto de restauración {snapshot} el {time}. Todo lo introducido después de ese punto ya no está en el taller.",
+    "restoredByMember": "Un miembro de su taller solicitó esta restauración.",
+    "restoredBySupport": "El soporte de Torqvoice lo hizo a petición suya. Motivo indicado: {reason}",
+    "noReason": "no se indicó ninguno",
+    "restoredUndo": "Antes se hizo una copia de protección del taller tal como estaba justo antes de la restauración, que se conserva durante 90 días. Contacte con el soporte para volver a ella."
   }
   }
 }
 }

+ 161 - 1
messages/es/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Demasiados intentos. Espere unos minutos e inténtelo de nuevo.",
     "reauthTooManyAttempts": "Demasiados intentos. Espere unos minutos e inténtelo de nuevo.",
     "reauthSignInAgainAccount": "Vuelva a iniciar sesión antes de eliminar su cuenta.",
     "reauthSignInAgainAccount": "Vuelva a iniciar sesión antes de eliminar su cuenta.",
     "reauthSignInAgainWorkshop": "Vuelva a iniciar sesión antes de eliminar el taller.",
     "reauthSignInAgainWorkshop": "Vuelva a iniciar sesión antes de eliminar el taller.",
+    "reauthSignInAgainRestore": "Vuelva a iniciar sesión antes de restaurar el taller.",
     "deleteAccountMoved": "¿Quiere eliminar su cuenta?",
     "deleteAccountMoved": "¿Quiere eliminar su cuenta?",
     "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos.",
     "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos.",
     "signatureTitle": "Firma",
     "signatureTitle": "Firma",
@@ -1399,6 +1400,11 @@
     "importComplete": "Importación completada",
     "importComplete": "Importación completada",
     "importCompleteMessage": "Todos los datos han sido restaurados del archivo de respaldo.",
     "importCompleteMessage": "Todos los datos han sido restaurados del archivo de respaldo.",
     "importFailed": "Importación fallida",
     "importFailed": "Importación fallida",
+    "restoreRefused": {
+      "busy": "Ya hay una copia de seguridad o una restauración en curso en este servidor. Inténtelo de nuevo cuando termine.",
+      "ownerOnly": "En Torqvoice Cloud solo el propietario del taller puede restaurar una copia de seguridad.",
+      "foreignIds": "El archivo hace referencia a registros que pertenecen a otro taller, así que no se ha restaurado nada."
+    },
     "importFromOther": "Importar de otros servicios",
     "importFromOther": "Importar de otros servicios",
     "importFromOtherDescription": "Migre sus datos desde otra plataforma. Seleccione un servicio para comenzar.",
     "importFromOtherDescription": "Migre sus datos desde otra plataforma. Seleccione un servicio para comenzar.",
     "lubelogTitle": "Importar de LubeLog",
     "lubelogTitle": "Importar de LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Copia de seguridad de Torqvoice",
     "sourceBackup": "Copia de seguridad de Torqvoice",
     "sourceBackupDesc": "Un archivo .zip o .json descargado desde esta página. Restaurar reemplaza todo en este taller.",
     "sourceBackupDesc": "Un archivo .zip o .json descargado desde esta página. Restaurar reemplaza todo en este taller.",
     "restore": "Restaurar",
     "restore": "Restaurar",
-    "restoreTitle": "Restaurar una copia de seguridad"
+    "restoreTitle": "Restaurar una copia de seguridad",
+    "includeSecrets": "Incluir claves secretas",
+    "includeSecretsHelp": "Las claves secretas, como las de pagos y de IA, se omiten a menos que marque esta opción. Una restauración conserva entonces las claves que ya tiene esta instalación.",
+    "platformBackups": {
+      "title": "Copias de seguridad de Torqvoice",
+      "description": "Torqvoice hace una copia de seguridad de este taller cada hora y conserva los puntos de restauración durante 30 días; después, conserva puntos semanales y mensuales.",
+      "runNow": "Hacer copia ahora",
+      "started": "Copia de seguridad iniciada",
+      "lastGood": "Última copia correcta",
+      "never": "Todavía no hay copias",
+      "nextRun": "Próxima ejecución",
+      "notScheduled": "Sin programar",
+      "repositorySize": "Almacenado por Torqvoice",
+      "running": "En curso",
+      "restoring": "Restaurando",
+      "queued": "En cola",
+      "queuedAhead": "{count, plural, one {En cola, # por delante} other {En cola, # por delante}}",
+      "runningHint": "Se está haciendo una copia de seguridad. Esta tarjeta se actualiza sola hasta que termine.",
+      "restoringHint": "Se está restaurando: los datos del taller se están sustituyendo por los del punto de restauración. Esta página se actualiza sola hasta que termine. No trabaje en el taller hasta entonces.",
+      "paused": "Su plan ya no incluye las copias de seguridad de Torqvoice. Las ejecuciones se han detenido; los puntos de restauración se conservan hasta el {date} y después se eliminan.",
+      "upsell": "Disponible en Pro: copias de seguridad cada hora hechas por Torqvoice, con puntos de restauración a los que puede volver.",
+      "planLink": "Ver planes",
+      "pointsTitle": "Puntos de restauración",
+      "pointsEmpty": "La primera copia de seguridad aún no se ha hecho.",
+      "showAll": "Mostrar los {count}",
+      "showFewer": "Mostrar menos",
+      "needsOwner": "Solo el propietario del taller puede restaurar.",
+      "sizeNote": "Cada punto de restauración está completo por sí solo; su tamaño es todo lo que abarca. El repositorio guarda cada pieza una sola vez y las ejecuciones posteriores añaden solo lo que cambió, por eso «Almacenado por Torqvoice» queda muy por debajo de la suma de los tamaños.",
+      "recentTitle": "Ejecuciones recientes",
+      "noRuns": "Todavía no se ha hecho ninguna copia de seguridad.",
+      "notStarted": "Sin iniciar",
+      "supportReason": "Restaurado por el soporte de Torqvoice: {reason}",
+      "columns": {
+        "when": "Cuándo",
+        "kind": "Tipo",
+        "size": "Tamaño",
+        "status": "Estado",
+        "trigger": "Iniciada por",
+        "duration": "Duración"
+      },
+      "kind": {
+        "scheduled": "Programada",
+        "manual": "Manual",
+        "safety": "Copia de protección",
+        "safetyHint": "Hecha justo antes de una restauración, para poder deshacerla"
+      },
+      "trigger": {
+        "scheduled": "Programada",
+        "manual": "Manual",
+        "restore": "Restauración"
+      },
+      "status": {
+        "queued": "En cola",
+        "running": "En curso",
+        "done": "Completada",
+        "failed": "Fallida",
+        "skipped": "Omitida"
+      },
+      "explore": "Explorar",
+      "restore": {
+        "action": "Restaurar",
+        "dialogTitle": "¿Restaurar el taller al estado del {time}?",
+        "warning": "El punto de restauración del {time} <bold>sustituye todo lo que hay en este taller</bold>: clientes, vehículos, trabajos, facturas, configuración, fotos y archivos. Todo lo añadido desde entonces se pierde.",
+        "safetyNote": "Primero se hace una copia de protección del taller tal como está ahora, que se conserva durante 90 días, para poder deshacer la restauración desde los puntos de restauración.",
+        "untouched": "Los miembros, las cuentas, la suscripción y las integraciones no se tocan. Las claves secretas que no están en la copia conservan sus valores actuales.",
+        "confirmPrompt": "Escriba el nombre del taller, <bold>{name}</bold>, para confirmar.",
+        "whileRunning": "La restauración se ejecuta en segundo plano y tarda unos minutos cuando hay muchas fotos. La página se actualiza sola hasta que termine.",
+        "cancel": "Cancelar",
+        "confirm": "Restaurar",
+        "started": "Restauración iniciada"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Volver a las copias de seguridad",
+      "title": "Punto de restauración del {date}",
+      "titleShort": "Punto de restauración {id}",
+      "readOnly": "Solo lectura: nada de lo que haga aquí cambia el taller. La vista se cierra tras 15 minutos sin actividad.",
+      "readOnlyBadge": "Solo lectura",
+      "opening": "Abriendo el punto de restauración",
+      "openingHint": "La copia se descifra aquí, en el servidor, y se mantiene en memoria durante un cuarto de hora. Nada cambia en el taller.",
+      "openingSteps": {
+        "connecting": "Conectando con el repositorio de copias",
+        "locating": "Buscando el archivo de datos en la instantánea",
+        "pulling": "Descargando y descifrando los datos",
+        "reading": "Leyendo los registros",
+        "indexing": "Indexando clientes, vehículos y órdenes de trabajo"
+      },
+      "openingReady": "Listo",
+      "openFailed": "No se pudo abrir el punto de restauración",
+      "tryAgain": "Intentar de nuevo",
+      "ownerOnly": "Solo el propietario del taller o un administrador puede ver el contenido de un punto de restauración.",
+      "closed": "El punto de restauración se cerró; vuelva a abrirlo",
+      "openAgain": "Volver a abrir",
+      "datasets": {
+        "customers": "Clientes",
+        "vehicles": "Vehículos",
+        "serviceRecords": "Órdenes de trabajo",
+        "quotes": "Presupuestos",
+        "inventoryParts": "Piezas"
+      },
+      "searchPlaceholder": "Buscar por nombre, número, matrícula, VIN, correo electrónico o teléfono",
+      "loading": "Cargando…",
+      "loadFailed": "No se pudo cargar esta lista",
+      "empty": "No hay nada de este tipo en el punto de restauración.",
+      "noMatches": "Nada coincide con «{query}».",
+      "showing": "{shown} de {total} mostrados",
+      "loadMore": "Mostrar más",
+      "clearSearch": "Borrar búsqueda",
+      "record": {
+        "loading": "Cargando el registro…",
+        "loadFailed": "No se pudo cargar el registro",
+        "notFound": "Este registro no está en el punto de restauración.",
+        "fields": "Detalles",
+        "jumpTo": "Ir a",
+        "previous": "Registro anterior",
+        "next": "Registro siguiente",
+        "position": "{index} de {total}",
+        "noColumns": "No hay nada que mostrar en estas filas.",
+        "yes": "Sí",
+        "no": "No"
+      },
+      "sections": {
+        "concerns": "Motivos del cliente",
+        "partItems": "Piezas",
+        "laborItems": "Mano de obra",
+        "attachments": "Fotos y documentos",
+        "payments": "Pagos",
+        "statusReports": "Informes de estado",
+        "timeEntries": "Entradas de tiempo",
+        "notes": "Notas",
+        "fuelLogs": "Registros de combustible",
+        "reminders": "Recordatorios",
+        "serviceRequests": "Solicitudes de servicio",
+        "inspectionStatus": "Estado de la inspección",
+        "findings": "Hallazgos",
+        "conditionMarks": "Marcas de estado",
+        "aiMessages": "Mensajes de IA",
+        "recurringInvoices": "Facturas recurrentes",
+        "serviceRecords": "Órdenes de trabajo",
+        "movements": "Movimientos de stock",
+        "gallery": "Galería"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Copias de seguridad programadas",
+      "description": "Copias de seguridad automáticas en un almacenamiento propio, con la frecuencia que usted elija.",
+      "empty": "Todavía no hay ningún destino de copias de seguridad conectado.",
+      "setUp": "Configurar un destino de copias de seguridad",
+      "lastGood": "Última copia correcta: {when}",
+      "noGoodBackup": "Todavía no hay ninguna copia correcta",
+      "nextRun": "próxima ejecución: {when}",
+      "notScheduled": "sin programar",
+      "lastError": "Último error: {error}",
+      "extraCopy": "Una copia adicional en un almacenamiento propio, con la frecuencia que usted elija, además de las copias de seguridad que hace Torqvoice."
+    }
   },
   },
   "license": {
   "license": {
     "title": "Licencia marca blanca",
     "title": "Licencia marca blanca",

+ 7 - 2
messages/fr/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Type de marque ajouté",
     "settings_markType_create": "Type de marque ajouté",
     "settings_markType_update": "Type de marque modifié",
     "settings_markType_update": "Type de marque modifié",
     "settings_markType_remove": "Type de marque supprimé",
     "settings_markType_remove": "Type de marque supprimé",
-    "settings_markType_reorder": "Types de marque réordonnés"
+    "settings_markType_reorder": "Types de marque réordonnés",
+    "integration_backup_run": "Sauvegarde lancée",
+    "integration_backup_restore": "Sauvegarde restaurée"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "A supprimé une marque de la carte d'état",
     "condition_mark_remove": "A supprimé une marque de la carte d'état",
     "condition_mark_carry": "A confirmé que {count, plural, one {# marque antérieure est} other {# marques antérieures sont}} toujours là sur la carte d'état",
     "condition_mark_carry": "A confirmé que {count, plural, one {# marque antérieure est} other {# marques antérieures sont}} toujours là sur la carte d'état",
     "condition_map_on_invoice_on": "A mis la carte d'état sur la facture",
     "condition_map_on_invoice_on": "A mis la carte d'état sur la facture",
-    "condition_map_on_invoice_off": "A retiré la carte d'état de la facture"
+    "condition_map_on_invoice_off": "A retiré la carte d'état de la facture",
+    "backup_run_started": "A lancé une sauvegarde vers {name}",
+    "backup_restore_started": "A lancé la restauration de la sauvegarde {snapshot} depuis {name}",
+    "backup_restored": "A restauré la sauvegarde {snapshot} depuis {name}"
   }
   }
 }
 }

+ 219 - 2
messages/fr/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Factures vers la comptabilité",
       "invoices": "Factures vers la comptabilité",
       "customers": "Clients vers la comptabilité",
       "customers": "Clients vers la comptabilité",
       "payments": "Paiements dans les deux sens"
       "payments": "Paiements dans les deux sens"
+    },
+    "backup": {
+      "target": "Destination de sauvegarde"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Tous les véhicules qui ont une plaque ont été vérifiés récemment, il n'y a encore rien à rechercher."
       "nothingDue": "Tous les véhicules qui ont une plaque ont été vérifiés récemment, il n'y a encore rien à rechercher."
     },
     },
     "supersedes": "Connecter {next} déconnecte {current}. Un seul peut être actif à la fois : {current} cesse d'être utilisé dès que {next} est en service.",
     "supersedes": "Connecter {next} déconnecte {current}. Un seul peut être actif à la fois : {current} cesse d'être utilisé dès que {next} est en service.",
+    "platformHandlesBackups": "Torqvoice sauvegarde déjà cet atelier toutes les heures et conserve des points de restauration pendant 30 jours. Connectez un bucket si vous voulez aussi une copie qui vous appartient, lisible sans Torqvoice.",
+    "platformHandlesBackupsLink": "Voir vos points de restauration",
     "batchRetried": "{count, plural, =0 {Rien à réessayer} one {# tâche remise en file} other {# tâches remises en file}}",
     "batchRetried": "{count, plural, =0 {Rien à réessayer} one {# tâche remise en file} other {# tâches remises en file}}",
-    "advanced": "Avancé"
+    "advanced": "Avancé",
+    "generate": "Générer",
+    "show": "Afficher",
+    "hide": "Masquer",
+    "generatedPasswordWarning": "Conservez une copie de ce mot de passe en lieu sûr, ailleurs que sur ce serveur. Sans lui, personne ne peut ouvrir ces sauvegardes, pas même Torqvoice.",
+    "backup": {
+      "title": "Sauvegardes",
+      "description": "La dernière sauvegarde réussie vers cette destination, la prochaine exécution et les exécutions les plus récentes.",
+      "lastGood": "Dernière sauvegarde réussie",
+      "added": "{size} ajoutés",
+      "never": "Aucune pour l'instant",
+      "nextRun": "Prochaine exécution",
+      "notScheduled": "Non planifiée",
+      "repositorySize": "Stocké à la destination",
+      "runNow": "Sauvegarder maintenant",
+      "running": "Sauvegarde en cours",
+      "runningHint": "Une sauvegarde est en cours. Cette page se met à jour toute seule jusqu'à la fin.",
+      "started": "Sauvegarde lancée",
+      "recentTitle": "Exécutions récentes",
+      "noRuns": "Aucune sauvegarde n'a encore été effectuée.",
+      "addedNote": "La première exécution envoie tout. Chacune des suivantes n’ajoute que les éléments modifiés depuis, c’est pourquoi elle est petite. Chaque sauvegarde reste complète et peut être restaurée ou explorée seule.",
+      "notStarted": "Non démarrée",
+      "filesNewCount": "{count, plural, one {# nouveau fichier} other {# nouveaux fichiers}}",
+      "columns": {
+        "status": "Statut",
+        "started": "Démarrée",
+        "trigger": "Lancée par",
+        "duration": "Durée",
+        "filesNew": "Nouveaux fichiers",
+        "bytesAdded": "Ajouté"
+      },
+      "trigger": {
+        "scheduled": "Planification",
+        "manual": "Manuel",
+        "restore": "Restauration"
+      },
+      "status": {
+        "queued": "En file d'attente",
+        "running": "En cours",
+        "done": "Terminée",
+        "failed": "Échouée",
+        "skipped": "Ignorée"
+      },
+      "restoring": "Restauration en cours",
+      "restoringHint": "Une restauration est en cours : les données de l'atelier sont remplacées par la sauvegarde. Cette page se met à jour toute seule jusqu'à la fin. Ne travaillez pas dans l'atelier d'ici là.",
+      "explore": "Explorer",
+      "restore": {
+        "showAll": "Afficher toutes les sauvegardes",
+        "refresh": "Actualiser",
+        "loading": "Lecture des sauvegardes à la destination",
+        "loadFailed": "Impossible de lister les sauvegardes",
+        "empty": "Aucune sauvegarde à cette destination pour l'instant.",
+        "needsOwner": "Seul le propriétaire de l'atelier peut restaurer une sauvegarde ici.",
+        "needsAdmin": "Seul un propriétaire ou un administrateur peut restaurer une sauvegarde.",
+        "filesIncluded": "Photos et fichiers inclus",
+        "filesNotIncluded": "Photos et fichiers non inclus",
+        "earlierInstall": "Installation précédente",
+        "action": "Restaurer",
+        "dialogTitle": "Restaurer la sauvegarde du {time} ?",
+        "warning": "La restauration <bold>remplace tout ce que cet atelier contient aujourd'hui</bold> : clients, véhicules, ordres de réparation, factures, paramètres et, quand la sauvegarde les inclut, photos et fichiers. Tout ce qui a été ajouté depuis la sauvegarde est perdu. Cette action est irréversible.",
+        "secretsNote": "Les clés secrètes que la sauvegarde a laissées de côté conservent les valeurs que cette installation possède aujourd'hui. Les comptes utilisateurs et les mots de passe ne sont pas modifiés.",
+        "confirmPrompt": "Saisissez le nom de l'atelier, <bold>{name}</bold>, pour confirmer.",
+        "whileRunning": "La restauration s'exécute en arrière-plan et prend quelques minutes quand il y a beaucoup de photos. La page se met à jour toute seule jusqu'à la fin.",
+        "cancel": "Annuler",
+        "confirm": "Restaurer cette sauvegarde",
+        "started": "Restauration lancée"
+      }
+    },
+    "lockedBackups": "Des sauvegardes chiffrées et planifiées de votre atelier vers un stockage qui vous appartient.",
+    "searchOrType": "Rechercher ou saisir un nom",
+    "useTyped": "Utiliser « {value} »",
+    "listNeedsKeys": "Saisissez les clés ci-dessus pour voir la liste",
+    "listEmpty": "Rien n'a été listé. Saisissez plutôt un nom.",
+    "testing": "Test de la connexion en cours",
+    "testOk": "La connexion fonctionne : un objet de test a été écrit puis supprimé.",
+    "testFailed": "Le test a échoué",
+    "testSteps": {
+      "settings": "Vérifiez les paramètres du fournisseur",
+      "list": "La clé ne peut pas lister le bucket",
+      "write": "La clé ne peut pas écrire dans le bucket",
+      "delete": "La clé ne peut pas supprimer d'objets du bucket",
+      "folder": "Le dossier ne peut être ni créé ni ouvert"
+    },
+    "testHint": "Saisissez les clés et le bucket pour les tester avant de connecter. Le test écrit un petit objet puis le supprime."
   },
   },
   "activity": {
   "activity": {
     "title": "Activité",
     "title": "Activité",
@@ -199,7 +287,10 @@
     "invalid": "La réponse du fournisseur était incomplète. Réessayez.",
     "invalid": "La réponse du fournisseur était incomplète. Réessayez.",
     "state": "Cette connexion a expiré ou appartient à une autre session. Recommencez.",
     "state": "Cette connexion a expiré ou appartient à une autre session. Recommencez.",
     "exchange": "Le fournisseur a refusé la connexion. Vérifiez les identifiants de l'application et réessayez.",
     "exchange": "Le fournisseur a refusé la connexion. Vérifiez les identifiants de l'application et réessayez.",
-    "identify": "Connecté, mais le compte n'a pas pu être lu. Essayez de vous reconnecter."
+    "identify": "Connecté, mais le compte n'a pas pu être lu. Essayez de vous reconnecter.",
+    "backupRunning": "Une sauvegarde est déjà en cours.",
+    "restoreOwnerOnly": "Sur Torqvoice Cloud, seul le propriétaire de l'atelier peut restaurer une sauvegarde.",
+    "restoreBusy": "Une sauvegarde ou une restauration est déjà en cours sur ce serveur. Réessayez quand elle sera terminée."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Rejoindre l'appel vidéo",
     "join": "Rejoindre l'appel vidéo",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Calendrier Microsoft 365",
       "description": "Ajoutez les ordres de travail planifiés à un calendrier Outlook, joignez un lien Microsoft Teams et consultez les plages occupées du calendrier dans Torqvoice.",
       "description": "Ajoutez les ordres de travail planifiés à un calendrier Outlook, joignez un lien Microsoft Teams et consultez les plages occupées du calendrier dans Torqvoice.",
       "tenantHelp": "Enregistrez une application dans Microsoft Entra (Inscriptions d'applications), autorisez les comptes personnels et professionnels, ajoutez l'URI de redirection ci-dessous comme plateforme Web, créez un secret client, puis collez les deux ici.",
       "tenantHelp": "Enregistrez une application dans Microsoft Entra (Inscriptions d'applications), autorisez les comptes personnels et professionnels, ajoutez l'URI de redirection ci-dessous comme plateforme Web, créez un secret client, puis collez les deux ici.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Compatible OpenAI",
       "description": "Descriptions de service, résumés d'historique, lecture de documents et assistant, fournis par tout serveur parlant l'API de chat d'OpenAI : Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM et consorts. Vos données restent sur votre propre matériel.",
       "description": "Descriptions de service, résumés d'historique, lecture de documents et assistant, fournis par tout serveur parlant l'API de chat d'OpenAI : Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM et consorts. Vos données restent sur votre propre matériel.",
       "fields": {
       "fields": {
         "baseUrl": "URL de base",
         "baseUrl": "URL de base",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Transcription vocale",
       "description": "La dictée sur un ordre de travail, transcrite par un modèle vocal plutôt que par le navigateur : bien plus précise, dans toutes les langues, et elle connaît le vocabulaire de l'atelier. Indépendant de votre fournisseur d'IA, que vous pouvez garder tel quel. Utilise votre propre clé OpenAI, ou votre propre serveur compatible avec l'API de transcription d'OpenAI. L'enregistrement est envoyé pour être transcrit et n'est pas conservé.",
       "description": "La dictée sur un ordre de travail, transcrite par un modèle vocal plutôt que par le navigateur : bien plus précise, dans toutes les langues, et elle connaît le vocabulaire de l'atelier. Indépendant de votre fournisseur d'IA, que vous pouvez garder tel quel. Utilise votre propre clé OpenAI, ou votre propre serveur compatible avec l'API de transcription d'OpenAI. L'enregistrement est envoyé pour être transcrit et n'est pas conservé.",
       "fields": {
       "fields": {
         "apiKey": "Clé API",
         "apiKey": "Clé API",
@@ -653,6 +747,108 @@
         "modeAi": "Toujours le modèle vocal",
         "modeAi": "Toujours le modèle vocal",
         "modeChoice": "Chacun choisit : modèle vocal ou navigateur"
         "modeChoice": "Chacun choisit : modèle vocal ou navigateur"
       }
       }
+    },
+    "s3": {
+      "name": "Bucket de stockage cloud",
+      "description": "Des sauvegardes chiffrées de cet atelier vers un bucket qui vous appartient chez Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage ou tout autre stockage compatible S3, selon le calendrier de votre choix. Après la première exécution, seules les modifications sont envoyées, si bien que les sauvegardes horaires restent bon marché.",
+      "fields": {
+        "accessKeyId": "ID de clé d'accès",
+        "accessKeyIdHelp": "Une clé de votre fournisseur de stockage autorisée à lister, lire, écrire et supprimer des objets dans le bucket. Créez une clé réservée à Torqvoice, pour pouvoir la révoquer séparément.",
+        "secretAccessKey": "Clé d'accès secrète",
+        "password": "Mot de passe des sauvegardes",
+        "passwordHelp": "Chaque sauvegarde est chiffrée avec ce mot de passe. Inventez-en un ou cliquez sur Générer, et conservez-en une copie ailleurs que sur ce serveur : sans lui, personne ne peut ouvrir ces sauvegardes, pas même Torqvoice. Pour reconnecter un stockage qui contient déjà des sauvegardes Torqvoice, saisissez le mot de passe avec lequel elles ont été créées.",
+        "endpoint": "Point de terminaison personnalisé",
+        "endpointHelp": "Uniquement pour l'option Autre stockage compatible S3 : l'adresse S3 de votre propre stockage, comme un serveur MinIO, sous forme de nom d'hôte, avec un port si nécessaire."
+      },
+      "settings": {
+        "provider": "Fournisseur",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Autre stockage compatible S3",
+        "region": "Région",
+        "regionHelp": "L'emplacement du bucket. Choisissez la région dans la liste du fournisseur ; pour un autre stockage, saisissez ce qu'il attend, s'il en demande une. Non utilisée par Cloudflare R2.",
+        "accountId": "ID de compte Cloudflare",
+        "accountIdHelp": "L'identifiant de 32 caractères affiché sur la page de présentation R2 de votre tableau de bord Cloudflare.",
+        "bucket": "Bucket",
+        "bucketHelp": "La liste vient de la clé dès qu'elle est saisie. Si la clé peut utiliser un bucket sans pouvoir les lister, saisissez son nom.",
+        "prefix": "Dossier dans le bucket",
+        "prefixHelp": "Les sauvegardes sont rangées dans ce dossier. Utilisez un dossier différent pour chaque atelier qui partage le bucket.",
+        "frequency": "Fréquence",
+        "frequencyHourly": "Toutes les heures",
+        "frequencyEvery4h": "Toutes les 4 heures",
+        "frequencyDaily": "Tous les jours",
+        "frequencyWeekly": "Toutes les semaines",
+        "frequencyMonthly": "Tous les mois",
+        "time": "Heure",
+        "timeHelp": "Selon l'horloge de l'atelier, au format HH:MM. Utilisée pour les sauvegardes quotidiennes, hebdomadaires et mensuelles.",
+        "weekday": "Jour de la semaine",
+        "sunday": "Dimanche",
+        "monday": "Lundi",
+        "tuesday": "Mardi",
+        "wednesday": "Mercredi",
+        "thursday": "Jeudi",
+        "friday": "Vendredi",
+        "saturday": "Samedi",
+        "monthDay": "Jour du mois",
+        "monthDayLast": "Dernier jour",
+        "contents": "Contenu",
+        "contentsHelp": "Les clés secrètes sont celles que cet atelier a saisies dans Torqvoice : clés de paiement, d'IA et de webhook. Dans tous les cas, chaque sauvegarde est chiffrée avec le mot de passe des sauvegardes. Une sauvegarde contient les données de l'atelier et, si vous le choisissez, ses fichiers. Les comptes utilisateurs et les mots de passe n'en font pas partie : sur un nouveau serveur, vous vous connectez, invitez l'équipe et restaurez les données dans cet atelier.",
+        "contentsData": "Données uniquement",
+        "contentsDataAndSecrets": "Données et clés secrètes",
+        "includeFiles": "Inclure les photos et les fichiers",
+        "includeFilesHelp": "Chaque fichier est envoyé une seule fois puis réutilisé par les sauvegardes suivantes, ce qui coûte peu après la première exécution.",
+        "keepHourly": "Sauvegardes horaires à conserver",
+        "keepHourlyHelp": "Combien des sauvegardes horaires les plus récentes conserver. Ne compte que pour les calendriers toutes les heures et toutes les 4 heures.",
+        "keepDaily": "Sauvegardes quotidiennes à conserver",
+        "keepWeekly": "Sauvegardes hebdomadaires à conserver",
+        "keepMonthly": "Sauvegardes mensuelles à conserver",
+        "keepMonthlyHelp": "Les sauvegardes plus anciennes sont supprimées après chaque exécution, en gardant les plus récentes de chaque type. Mettez un nombre à 0 pour n'en garder aucune de ce type."
+      }
+    },
+    "local-folder": {
+      "name": "Dossier local",
+      "description": "Des sauvegardes chiffrées de cet atelier vers un dossier de ce serveur : le volume de sauvegardes du fichier compose, ou un lecteur réseau monté à cet endroit. Une copie sur le même disque que la base de données n'est pas sûre à elle seule ; associez-la à un stockage situé ailleurs.",
+      "fields": {
+        "password": "Mot de passe des sauvegardes",
+        "passwordHelp": "Chaque sauvegarde est chiffrée avec ce mot de passe. Inventez-en un ou cliquez sur Générer, et conservez-en une copie ailleurs que sur ce serveur : sans lui, personne ne peut ouvrir ces sauvegardes, pas même Torqvoice. Pour reconnecter un stockage qui contient déjà des sauvegardes Torqvoice, saisissez le mot de passe avec lequel elles ont été créées."
+      },
+      "settings": {
+        "folder": "Dossier",
+        "folderHelp": "Le dossier, sous la racine des sauvegardes du serveur, qui contient les sauvegardes de cet atelier. Laissez vide pour utiliser un dossier nommé d'après cet atelier. Sur un nouveau serveur, saisissez le nom du dossier utilisé par une installation précédente pour récupérer ses sauvegardes.",
+        "frequency": "Fréquence",
+        "frequencyHourly": "Toutes les heures",
+        "frequencyEvery4h": "Toutes les 4 heures",
+        "frequencyDaily": "Tous les jours",
+        "frequencyWeekly": "Toutes les semaines",
+        "frequencyMonthly": "Tous les mois",
+        "time": "Heure",
+        "timeHelp": "Selon l'horloge de l'atelier, au format HH:MM. Utilisée pour les sauvegardes quotidiennes, hebdomadaires et mensuelles.",
+        "weekday": "Jour de la semaine",
+        "sunday": "Dimanche",
+        "monday": "Lundi",
+        "tuesday": "Mardi",
+        "wednesday": "Mercredi",
+        "thursday": "Jeudi",
+        "friday": "Vendredi",
+        "saturday": "Samedi",
+        "monthDay": "Jour du mois",
+        "monthDayLast": "Dernier jour",
+        "contents": "Contenu",
+        "contentsHelp": "Les clés secrètes sont celles que cet atelier a saisies dans Torqvoice : clés de paiement, d'IA et de webhook. Dans tous les cas, chaque sauvegarde est chiffrée avec le mot de passe des sauvegardes. Une sauvegarde contient les données de l'atelier et, si vous le choisissez, ses fichiers. Les comptes utilisateurs et les mots de passe n'en font pas partie : sur un nouveau serveur, vous vous connectez, invitez l'équipe et restaurez les données dans cet atelier.",
+        "contentsData": "Données uniquement",
+        "contentsDataAndSecrets": "Données et clés secrètes",
+        "includeFiles": "Inclure les photos et les fichiers",
+        "includeFilesHelp": "Chaque fichier est envoyé une seule fois puis réutilisé par les sauvegardes suivantes, ce qui coûte peu après la première exécution.",
+        "keepHourly": "Sauvegardes horaires à conserver",
+        "keepHourlyHelp": "Combien des sauvegardes horaires les plus récentes conserver. Ne compte que pour les calendriers toutes les heures et toutes les 4 heures.",
+        "keepDaily": "Sauvegardes quotidiennes à conserver",
+        "keepWeekly": "Sauvegardes hebdomadaires à conserver",
+        "keepMonthly": "Sauvegardes mensuelles à conserver",
+        "keepMonthlyHelp": "Les sauvegardes plus anciennes sont supprimées après chaque exécution, en gardant les plus récentes de chaque type. Mettez un nombre à 0 pour n'en garder aucune de ce type."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Actualisation des données de rappel",
       "refresh": "Actualisation des données de rappel",
       "vehicle": "Vérification des rappels pour un nouveau véhicule"
       "vehicle": "Vérification des rappels pour un nouveau véhicule"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "destination de sauvegarde",
+    "failedTitle": "Échec de la sauvegarde",
+    "failedMessage": "La sauvegarde vers {name} n'a pas abouti : {reason}",
+    "failedSubject": "Échec de la sauvegarde vers {name}",
+    "failedEmailIntro": "La sauvegarde planifiée de votre atelier vers {name} n'a pas abouti.",
+    "openToRetry": "Ouvrez Paramètres, Intégrations, {name} pour consulter l'exécution et réessayer.",
+    "overdueTitle": "Sauvegarde en retard",
+    "overdueSince": "Aucune sauvegarde vers {name} n'a abouti depuis {time}.",
+    "overdueNever": "Aucune sauvegarde vers {name} n'a encore abouti.",
+    "overdueSubject": "La sauvegarde vers {name} est en retard",
+    "openToSee": "Ouvrez Paramètres, Intégrations, {name} pour voir ce qui n'a pas fonctionné.",
+    "platformName": "Sauvegardes par Torqvoice",
+    "restoredTitle": "Atelier restauré",
+    "restoredSubject": "Votre atelier a été restauré à partir d'une sauvegarde",
+    "restoredIntro": "{name} : l'atelier a été ramené au point de restauration {snapshot} le {time}. Tout ce qui a été saisi après ce point ne figure plus dans l'atelier.",
+    "restoredByMember": "Un membre de votre atelier a demandé cette restauration.",
+    "restoredBySupport": "L'assistance Torqvoice l'a fait à votre demande. Motif indiqué : {reason}",
+    "noReason": "aucun",
+    "restoredUndo": "Une copie de sécurité de l'atelier tel qu'il était juste avant la restauration a d'abord été réalisée et est conservée pendant 90 jours. Contactez l'assistance pour y revenir."
   }
   }
 }
 }

+ 161 - 1
messages/fr/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Trop de tentatives. Patientez quelques minutes puis réessayez.",
     "reauthTooManyAttempts": "Trop de tentatives. Patientez quelques minutes puis réessayez.",
     "reauthSignInAgainAccount": "Reconnectez-vous avant de supprimer votre compte.",
     "reauthSignInAgainAccount": "Reconnectez-vous avant de supprimer votre compte.",
     "reauthSignInAgainWorkshop": "Reconnectez-vous avant de supprimer l'atelier.",
     "reauthSignInAgainWorkshop": "Reconnectez-vous avant de supprimer l'atelier.",
+    "reauthSignInAgainRestore": "Reconnectez-vous avant de restaurer l'atelier.",
     "deleteAccountMoved": "Vous souhaitez supprimer votre compte ?",
     "deleteAccountMoved": "Vous souhaitez supprimer votre compte ?",
     "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données.",
     "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données.",
     "signatureTitle": "Signature",
     "signatureTitle": "Signature",
@@ -1399,6 +1400,11 @@
     "importComplete": "Importation terminée",
     "importComplete": "Importation terminée",
     "importCompleteMessage": "Toutes les données ont ete restaurees a partir du fichier de sauvegarde.",
     "importCompleteMessage": "Toutes les données ont ete restaurees a partir du fichier de sauvegarde.",
     "importFailed": "Importation échouée",
     "importFailed": "Importation échouée",
+    "restoreRefused": {
+      "busy": "Une sauvegarde ou une restauration est déjà en cours sur ce serveur. Réessayez quand elle sera terminée.",
+      "ownerOnly": "Sur Torqvoice Cloud, seul le propriétaire de l'atelier peut restaurer une sauvegarde.",
+      "foreignIds": "Le fichier fait référence à des enregistrements qui appartiennent à un autre atelier, rien n'a donc été restauré."
+    },
     "importFromOther": "Importer d'autres services",
     "importFromOther": "Importer d'autres services",
     "importFromOtherDescription": "Migrez vos données depuis une autre plateforme. Sélectionnez un service pour commencer.",
     "importFromOtherDescription": "Migrez vos données depuis une autre plateforme. Sélectionnez un service pour commencer.",
     "lubelogTitle": "Importer de LubeLog",
     "lubelogTitle": "Importer de LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Sauvegarde Torqvoice",
     "sourceBackup": "Sauvegarde Torqvoice",
     "sourceBackupDesc": "Un fichier .zip ou .json téléchargé depuis cette page. La restauration remplace tout dans cet atelier.",
     "sourceBackupDesc": "Un fichier .zip ou .json téléchargé depuis cette page. La restauration remplace tout dans cet atelier.",
     "restore": "Restaurer",
     "restore": "Restaurer",
-    "restoreTitle": "Restaurer une sauvegarde"
+    "restoreTitle": "Restaurer une sauvegarde",
+    "includeSecrets": "Inclure les clés secrètes",
+    "includeSecretsHelp": "Les clés secrètes, comme celles des paiements et de l'IA, sont exclues sauf si vous cochez cette case. Une restauration conserve alors les clés que cette installation possède déjà.",
+    "platformBackups": {
+      "title": "Sauvegardes par Torqvoice",
+      "description": "Torqvoice sauvegarde cet atelier toutes les heures et conserve des points de restauration pendant 30 jours, puis des points hebdomadaires et mensuels.",
+      "runNow": "Sauvegarder maintenant",
+      "started": "Sauvegarde lancée",
+      "lastGood": "Dernière sauvegarde réussie",
+      "never": "Aucune sauvegarde pour l'instant",
+      "nextRun": "Prochaine exécution",
+      "notScheduled": "Non planifiée",
+      "repositorySize": "Stocké par Torqvoice",
+      "running": "En cours",
+      "restoring": "Restauration en cours",
+      "queued": "En file d'attente",
+      "queuedAhead": "{count, plural, one {En file d'attente, # avant} other {En file d'attente, # avant}}",
+      "runningHint": "Une sauvegarde est en cours. Cette carte se met à jour toute seule jusqu'à la fin.",
+      "restoringHint": "Une restauration est en cours : les données de l'atelier sont remplacées par le point de restauration. Cette page se met à jour toute seule jusqu'à la fin. Ne travaillez pas dans l'atelier d'ici là.",
+      "paused": "Votre offre n'inclut plus les sauvegardes par Torqvoice. Les exécutions sont arrêtées ; les points de restauration sont conservés jusqu'au {date}, puis supprimés.",
+      "upsell": "Disponible avec Pro : des sauvegardes horaires effectuées par Torqvoice, avec des points de restauration auxquels revenir.",
+      "planLink": "Voir les offres",
+      "pointsTitle": "Points de restauration",
+      "pointsEmpty": "La première sauvegarde n'a pas encore eu lieu.",
+      "showAll": "Afficher les {count}",
+      "showFewer": "Afficher moins",
+      "needsOwner": "Seul le propriétaire de l'atelier peut restaurer.",
+      "sizeNote": "Chaque point de restauration est complet à lui seul ; sa taille correspond à tout ce qu’il couvre. Le dépôt ne stocke chaque élément qu’une fois et les exécutions suivantes n’ajoutent que ce qui a changé, c’est pourquoi « Stocké par Torqvoice » reste bien en dessous de la somme des tailles.",
+      "recentTitle": "Exécutions récentes",
+      "noRuns": "Aucune sauvegarde n'a encore été effectuée.",
+      "notStarted": "Non démarrée",
+      "supportReason": "Restauré par l'assistance Torqvoice : {reason}",
+      "columns": {
+        "when": "Date",
+        "kind": "Type",
+        "size": "Taille",
+        "status": "Statut",
+        "trigger": "Lancée par",
+        "duration": "Durée"
+      },
+      "kind": {
+        "scheduled": "Planifiée",
+        "manual": "Manuelle",
+        "safety": "Copie de sécurité",
+        "safetyHint": "Réalisée juste avant une restauration, pour pouvoir l'annuler"
+      },
+      "trigger": {
+        "scheduled": "Planifiée",
+        "manual": "Manuelle",
+        "restore": "Restauration"
+      },
+      "status": {
+        "queued": "En file d'attente",
+        "running": "En cours",
+        "done": "Terminée",
+        "failed": "Échouée",
+        "skipped": "Ignorée"
+      },
+      "explore": "Explorer",
+      "restore": {
+        "action": "Restaurer",
+        "dialogTitle": "Restaurer l'atelier à l'état du {time} ?",
+        "warning": "Le point de restauration du {time} <bold>remplace tout ce que contient cet atelier</bold> : clients, véhicules, ordres de réparation, factures, paramètres, photos et fichiers. Tout ce qui a été ajouté depuis est perdu.",
+        "safetyNote": "Une copie de sécurité de l'atelier dans son état actuel est d'abord réalisée et conservée pendant 90 jours, afin de pouvoir annuler la restauration depuis les points de restauration.",
+        "untouched": "Les membres, les comptes, l'abonnement et les intégrations ne sont pas modifiés. Les clés secrètes absentes de la sauvegarde conservent leurs valeurs actuelles.",
+        "confirmPrompt": "Saisissez le nom de l'atelier, <bold>{name}</bold>, pour confirmer.",
+        "whileRunning": "La restauration s'exécute en arrière-plan et prend quelques minutes quand il y a beaucoup de photos. La page se met à jour toute seule jusqu'à la fin.",
+        "cancel": "Annuler",
+        "confirm": "Restaurer",
+        "started": "Restauration lancée"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Retour aux sauvegardes",
+      "title": "Point de restauration du {date}",
+      "titleShort": "Point de restauration {id}",
+      "readOnly": "Lecture seule : rien ici ne modifie l'atelier. La vue se ferme après 15 minutes d'inactivité.",
+      "readOnlyBadge": "Lecture seule",
+      "opening": "Ouverture du point de restauration",
+      "openingHint": "La sauvegarde est déchiffrée ici, sur le serveur, et gardée en mémoire un quart d’heure. Rien ne change dans l’atelier.",
+      "openingSteps": {
+        "connecting": "Connexion au dépôt de sauvegarde",
+        "locating": "Recherche du fichier de données dans l’instantané",
+        "pulling": "Récupération et déchiffrement des données",
+        "reading": "Lecture des enregistrements",
+        "indexing": "Indexation des clients, véhicules et ordres de travail"
+      },
+      "openingReady": "Prêt",
+      "openFailed": "Le point de restauration n'a pas pu être ouvert",
+      "tryAgain": "Réessayer",
+      "ownerOnly": "Seul le propriétaire de l'atelier ou un administrateur peut consulter le contenu d'un point de restauration.",
+      "closed": "Le point de restauration a été fermé ; ouvrez-le à nouveau",
+      "openAgain": "Rouvrir",
+      "datasets": {
+        "customers": "Clients",
+        "vehicles": "Véhicules",
+        "serviceRecords": "Ordres de travail",
+        "quotes": "Devis",
+        "inventoryParts": "Pièces"
+      },
+      "searchPlaceholder": "Rechercher par nom, numéro, plaque, NIV, e-mail ou téléphone",
+      "loading": "Chargement…",
+      "loadFailed": "Cette liste n'a pas pu être chargée",
+      "empty": "Il n'y a rien de ce type dans le point de restauration.",
+      "noMatches": "Rien ne correspond à « {query} ».",
+      "showing": "{shown} sur {total} affichés",
+      "loadMore": "Afficher plus",
+      "clearSearch": "Effacer la recherche",
+      "record": {
+        "loading": "Chargement de l'enregistrement…",
+        "loadFailed": "L'enregistrement n'a pas pu être chargé",
+        "notFound": "Cet enregistrement ne figure pas dans le point de restauration.",
+        "fields": "Détails",
+        "jumpTo": "Aller à",
+        "previous": "Fiche précédente",
+        "next": "Fiche suivante",
+        "position": "{index} sur {total}",
+        "noColumns": "Rien à afficher dans ces lignes.",
+        "yes": "Oui",
+        "no": "Non"
+      },
+      "sections": {
+        "concerns": "Demandes du client",
+        "partItems": "Pièces",
+        "laborItems": "Main-d'œuvre",
+        "attachments": "Photos et documents",
+        "payments": "Paiements",
+        "statusReports": "Rapports d'état",
+        "timeEntries": "Entrées de temps",
+        "notes": "Notes",
+        "fuelLogs": "Pleins de carburant",
+        "reminders": "Rappels",
+        "serviceRequests": "Demandes de service",
+        "inspectionStatus": "Statut du contrôle technique",
+        "findings": "Constatations",
+        "conditionMarks": "Marques d'état",
+        "aiMessages": "Messages IA",
+        "recurringInvoices": "Factures récurrentes",
+        "serviceRecords": "Ordres de travail",
+        "movements": "Mouvements de stock",
+        "gallery": "Galerie"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Sauvegardes planifiées",
+      "description": "Des sauvegardes automatiques vers un stockage qui vous appartient, selon le calendrier de votre choix.",
+      "empty": "Aucune destination de sauvegarde n'est encore connectée.",
+      "setUp": "Configurer une destination de sauvegarde",
+      "lastGood": "Dernière sauvegarde réussie : {when}",
+      "noGoodBackup": "Aucune sauvegarde réussie pour l'instant",
+      "nextRun": "prochaine exécution : {when}",
+      "notScheduled": "non planifiée",
+      "lastError": "Dernière erreur : {error}",
+      "extraCopy": "Une copie supplémentaire dans un stockage qui vous appartient, selon le calendrier de votre choix, en plus des sauvegardes effectuées par Torqvoice."
+    }
   },
   },
   "license": {
   "license": {
     "title": "Licence marque blanche",
     "title": "Licence marque blanche",

+ 7 - 2
messages/it/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Tipo di segno aggiunto",
     "settings_markType_create": "Tipo di segno aggiunto",
     "settings_markType_update": "Tipo di segno modificato",
     "settings_markType_update": "Tipo di segno modificato",
     "settings_markType_remove": "Tipo di segno rimosso",
     "settings_markType_remove": "Tipo di segno rimosso",
-    "settings_markType_reorder": "Tipi di segno riordinati"
+    "settings_markType_reorder": "Tipi di segno riordinati",
+    "integration_backup_run": "Backup avviato",
+    "integration_backup_restore": "Backup ripristinato"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Ha rimosso un segno dalla mappa dello stato",
     "condition_mark_remove": "Ha rimosso un segno dalla mappa dello stato",
     "condition_mark_carry": "Ha confermato {count, plural, one {# segno precedente come ancora presente} other {# segni precedenti come ancora presenti}} sulla mappa dello stato",
     "condition_mark_carry": "Ha confermato {count, plural, one {# segno precedente come ancora presente} other {# segni precedenti come ancora presenti}} sulla mappa dello stato",
     "condition_map_on_invoice_on": "Ha messo la mappa dello stato sulla fattura",
     "condition_map_on_invoice_on": "Ha messo la mappa dello stato sulla fattura",
-    "condition_map_on_invoice_off": "Ha tolto la mappa dello stato dalla fattura"
+    "condition_map_on_invoice_off": "Ha tolto la mappa dello stato dalla fattura",
+    "backup_run_started": "Ha avviato un backup su {name}",
+    "backup_restore_started": "Ha avviato il ripristino del backup {snapshot} da {name}",
+    "backup_restored": "Ha ripristinato il backup {snapshot} da {name}"
   }
   }
 }
 }

+ 219 - 2
messages/it/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Fatture in contabilità",
       "invoices": "Fatture in contabilità",
       "customers": "Clienti in contabilità",
       "customers": "Clienti in contabilità",
       "payments": "Pagamenti in entrambe le direzioni"
       "payments": "Pagamenti in entrambe le direzioni"
+    },
+    "backup": {
+      "target": "Destinazione dei backup"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Tutti i veicoli con targa sono stati controllati di recente, quindi non c'è ancora niente da cercare."
       "nothingDue": "Tutti i veicoli con targa sono stati controllati di recente, quindi non c'è ancora niente da cercare."
     },
     },
     "supersedes": "Collegare {next} scollega {current}. Solo uno può essere attivo alla volta, quindi {current} smette di essere usato appena {next} entra in servizio.",
     "supersedes": "Collegare {next} scollega {current}. Solo uno può essere attivo alla volta, quindi {current} smette di essere usato appena {next} entra in servizio.",
+    "platformHandlesBackups": "Torqvoice esegue già un backup di questa officina ogni ora e conserva i punti di ripristino per 30 giorni. Collega un bucket se vuoi anche una copia tua, leggibile senza Torqvoice.",
+    "platformHandlesBackupsLink": "Vedi i tuoi punti di ripristino",
     "batchRetried": "{count, plural, =0 {Niente da riprovare} one {# attività rimessa in coda} other {# attività rimesse in coda}}",
     "batchRetried": "{count, plural, =0 {Niente da riprovare} one {# attività rimessa in coda} other {# attività rimesse in coda}}",
-    "advanced": "Avanzate"
+    "advanced": "Avanzate",
+    "generate": "Genera",
+    "show": "Mostra",
+    "hide": "Nascondi",
+    "generatedPasswordWarning": "Conserva una copia di questa password in un posto sicuro, lontano da questo server. Senza di essa nessuno può aprire questi backup, nemmeno Torqvoice.",
+    "backup": {
+      "title": "Backup",
+      "description": "Quando questa destinazione ha ricevuto l'ultimo backup riuscito, quando parte il prossimo e le esecuzioni più recenti.",
+      "lastGood": "Ultimo backup riuscito",
+      "added": "{size} aggiunti",
+      "never": "Ancora nessuno",
+      "nextRun": "Prossima esecuzione",
+      "notScheduled": "Non pianificato",
+      "repositorySize": "Archiviato nella destinazione",
+      "runNow": "Esegui backup ora",
+      "running": "Backup in corso",
+      "runningHint": "È in corso un backup. Questa pagina si aggiorna da sola finché non è terminato.",
+      "started": "Backup avviato",
+      "recentTitle": "Esecuzioni recenti",
+      "noRuns": "Non è ancora stato eseguito nessun backup.",
+      "addedNote": "La prima esecuzione carica tutto. Ognuna delle successive aggiunge solo le parti cambiate da allora, per questo è piccola. Ogni backup resta comunque completo e può essere ripristinato o esplorato da solo.",
+      "notStarted": "Non avviato",
+      "filesNewCount": "{count, plural, one {# nuovo file} other {# nuovi file}}",
+      "columns": {
+        "status": "Stato",
+        "started": "Avviato",
+        "trigger": "Avviato da",
+        "duration": "Durata",
+        "filesNew": "Nuovi file",
+        "bytesAdded": "Aggiunti"
+      },
+      "trigger": {
+        "scheduled": "Pianificazione",
+        "manual": "Manuale",
+        "restore": "Ripristino"
+      },
+      "status": {
+        "queued": "In coda",
+        "running": "In corso",
+        "done": "Completato",
+        "failed": "Non riuscito",
+        "skipped": "Saltato"
+      },
+      "restoring": "Ripristino in corso",
+      "restoringHint": "È in corso un ripristino: i dati dell'officina vengono sostituiti con il backup. Questa pagina si aggiorna da sola finché non è terminato. Fino ad allora non lavorare nell'officina.",
+      "explore": "Esplora",
+      "restore": {
+        "showAll": "Mostra tutti i backup",
+        "refresh": "Aggiorna",
+        "loading": "Lettura dei backup nella destinazione",
+        "loadFailed": "Non è stato possibile elencare i backup",
+        "empty": "Ancora nessun backup in questa destinazione.",
+        "needsOwner": "Qui solo il proprietario dell'officina può ripristinare un backup.",
+        "needsAdmin": "Solo un proprietario o un amministratore può ripristinare un backup.",
+        "filesIncluded": "Foto e file inclusi",
+        "filesNotIncluded": "Foto e file non inclusi",
+        "earlierInstall": "Installazione precedente",
+        "action": "Ripristina",
+        "dialogTitle": "Ripristinare il backup del {time}?",
+        "warning": "Il ripristino <bold>sostituisce tutto ciò che questa officina contiene ora</bold>: clienti, veicoli, lavori, fatture, impostazioni e, quando il backup li include, foto e file. Tutto ciò che è stato aggiunto dopo il backup va perso. L'operazione non è reversibile.",
+        "secretsNote": "Le chiavi segrete lasciate fuori dal backup mantengono i valori che questa installazione ha ora. Account utente e password non vengono toccati.",
+        "confirmPrompt": "Digita il nome dell'officina, <bold>{name}</bold>, per confermare.",
+        "whileRunning": "Il ripristino avviene in background e richiede qualche minuto quando ci sono molte foto. La pagina si aggiorna da sola finché non è terminato.",
+        "cancel": "Annulla",
+        "confirm": "Ripristina questo backup",
+        "started": "Ripristino avviato"
+      }
+    },
+    "lockedBackups": "Backup pianificati e cifrati della tua officina su uno spazio di archiviazione tuo.",
+    "searchOrType": "Cerca o digita un nome",
+    "useTyped": "Usa \"{value}\"",
+    "listNeedsKeys": "Inserisci le chiavi qui sopra per vedere l'elenco",
+    "listEmpty": "L'elenco è vuoto. Digita un nome.",
+    "testing": "Prova della connessione in corso",
+    "testOk": "La connessione funziona: un oggetto di prova è stato scritto e poi eliminato.",
+    "testFailed": "La prova non è riuscita",
+    "testSteps": {
+      "settings": "Controlla le impostazioni del fornitore",
+      "list": "La chiave non può elencare il bucket",
+      "write": "La chiave non può scrivere nel bucket",
+      "delete": "La chiave non può eliminare dal bucket",
+      "folder": "Impossibile creare o aprire la cartella"
+    },
+    "testHint": "Inserisci le chiavi e il bucket per provarli prima di collegare. La prova scrive un piccolo oggetto e lo elimina."
   },
   },
   "activity": {
   "activity": {
     "title": "Attività",
     "title": "Attività",
@@ -199,7 +287,10 @@
     "invalid": "La risposta del provider era incompleta. Riprova.",
     "invalid": "La risposta del provider era incompleta. Riprova.",
     "state": "Questo accesso è scaduto o appartiene a un'altra sessione. Ricomincia da capo.",
     "state": "Questo accesso è scaduto o appartiene a un'altra sessione. Ricomincia da capo.",
     "exchange": "Il provider ha rifiutato l'accesso. Controlla le credenziali dell'app e riprova.",
     "exchange": "Il provider ha rifiutato l'accesso. Controlla le credenziali dell'app e riprova.",
-    "identify": "Collegato, ma non è stato possibile leggere l'account. Prova a ricollegarlo."
+    "identify": "Collegato, ma non è stato possibile leggere l'account. Prova a ricollegarlo.",
+    "backupRunning": "È già in corso un backup.",
+    "restoreOwnerOnly": "Su Torqvoice Cloud solo il proprietario dell'officina può ripristinare un backup.",
+    "restoreBusy": "Su questo server è già in corso un backup o un ripristino. Riprova quando è terminato."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Partecipa alla videochiamata",
     "join": "Partecipa alla videochiamata",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Calendario Microsoft 365",
       "description": "Inserisci gli ordini di lavoro pianificati in un calendario Outlook, aggiungi un link Microsoft Teams e vedi gli impegni del calendario in Torqvoice.",
       "description": "Inserisci gli ordini di lavoro pianificati in un calendario Outlook, aggiungi un link Microsoft Teams e vedi gli impegni del calendario in Torqvoice.",
       "tenantHelp": "Registra un'applicazione in Microsoft Entra (Registrazioni app), consenti gli account personali e aziendali, aggiungi l'URI di reindirizzamento qui sotto come piattaforma Web, crea un segreto client e incolla entrambi qui.",
       "tenantHelp": "Registra un'applicazione in Microsoft Entra (Registrazioni app), consenti gli account personali e aziendali, aggiungi l'URI di reindirizzamento qui sotto come piattaforma Web, crea un segreto client e incolla entrambi qui.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Compatibile con OpenAI",
       "description": "Descrizioni di servizio, riepiloghi dello storico, lettura di documenti e assistente, forniti da qualsiasi server che parli l'API chat di OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e simili. I tuoi dati restano sul tuo hardware.",
       "description": "Descrizioni di servizio, riepiloghi dello storico, lettura di documenti e assistente, forniti da qualsiasi server che parli l'API chat di OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e simili. I tuoi dati restano sul tuo hardware.",
       "fields": {
       "fields": {
         "baseUrl": "URL di base",
         "baseUrl": "URL di base",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Da voce a testo",
       "description": "La dettatura su un ordine di lavoro viene trascritta da un modello vocale invece che dal browser: molto più precisa, in qualsiasi lingua e conosce le parole dell'officina. È separata dal tuo fornitore di IA, quindi puoi lasciarlo com'è. Usa la tua chiave OpenAI o un tuo server che parli l'API di trascrizione di OpenAI. La registrazione viene inviata per la trascrizione e non viene conservata.",
       "description": "La dettatura su un ordine di lavoro viene trascritta da un modello vocale invece che dal browser: molto più precisa, in qualsiasi lingua e conosce le parole dell'officina. È separata dal tuo fornitore di IA, quindi puoi lasciarlo com'è. Usa la tua chiave OpenAI o un tuo server che parli l'API di trascrizione di OpenAI. La registrazione viene inviata per la trascrizione e non viene conservata.",
       "fields": {
       "fields": {
         "apiKey": "Chiave API",
         "apiKey": "Chiave API",
@@ -653,6 +747,108 @@
         "modeAi": "Sempre il modello vocale",
         "modeAi": "Sempre il modello vocale",
         "modeChoice": "Sceglie ciascuno: modello vocale o browser"
         "modeChoice": "Sceglie ciascuno: modello vocale o browser"
       }
       }
+    },
+    "s3": {
+      "name": "Bucket di archiviazione cloud",
+      "description": "Backup cifrati di questa officina in un bucket di tua proprietà su Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage o qualsiasi altro storage compatibile S3, con la frequenza che scegli. Dopo la prima esecuzione viene caricato solo ciò che è cambiato, quindi i backup orari restano economici.",
+      "fields": {
+        "accessKeyId": "ID chiave di accesso",
+        "accessKeyIdHelp": "Una chiave del tuo fornitore di archiviazione con il permesso di elencare, leggere, scrivere ed eliminare gli oggetti nel bucket. Crea una chiave solo per Torqvoice, così puoi revocarla separatamente.",
+        "secretAccessKey": "Chiave di accesso segreta",
+        "password": "Password dei backup",
+        "passwordHelp": "Ogni backup è cifrato con questa password. Inventane una o premi Genera, e conservane una copia lontano da questo server: senza di essa nessuno può aprire questi backup, nemmeno Torqvoice. Per ricollegare uno spazio che contiene già backup di Torqvoice, inserisci la password con cui sono stati creati.",
+        "endpoint": "Endpoint personalizzato",
+        "endpointHelp": "Solo per l'opzione Altro storage compatibile S3: l'indirizzo S3 del tuo storage, ad esempio un server MinIO, come nome host con la porta se serve."
+      },
+      "settings": {
+        "provider": "Fornitore",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Altro storage compatibile S3",
+        "region": "Regione",
+        "regionHelp": "Dove si trova il bucket. Sceglila dall'elenco del fornitore; per un altro storage, digita quella che richiede, se ne richiede una. Non usata da Cloudflare R2.",
+        "accountId": "ID account Cloudflare",
+        "accountIdHelp": "L'ID di 32 caratteri mostrato nella pagina di panoramica di R2 nella dashboard di Cloudflare.",
+        "bucket": "Bucket",
+        "bucketHelp": "I bucket vengono elencati con la chiave appena la inserisci. Se la chiave può usare un bucket ma non elencarli, digita il nome.",
+        "prefix": "Cartella nel bucket",
+        "prefixHelp": "I backup vengono salvati in questa cartella. Usa una cartella diversa per ogni officina che condivide il bucket.",
+        "frequency": "Frequenza",
+        "frequencyHourly": "Ogni ora",
+        "frequencyEvery4h": "Ogni 4 ore",
+        "frequencyDaily": "Ogni giorno",
+        "frequencyWeekly": "Ogni settimana",
+        "frequencyMonthly": "Ogni mese",
+        "time": "Ora del giorno",
+        "timeHelp": "Secondo l'orologio dell'officina, nel formato HH:MM. Usata dai backup giornalieri, settimanali e mensili.",
+        "weekday": "Giorno della settimana",
+        "sunday": "Domenica",
+        "monday": "Lunedì",
+        "tuesday": "Martedì",
+        "wednesday": "Mercoledì",
+        "thursday": "Giovedì",
+        "friday": "Venerdì",
+        "saturday": "Sabato",
+        "monthDay": "Giorno del mese",
+        "monthDayLast": "Ultimo giorno",
+        "contents": "Contenuto",
+        "contentsHelp": "Le chiavi segrete sono quelle che questa officina ha inserito in Torqvoice: chiavi di pagamento, IA e webhook. In ogni caso, ogni backup è cifrato con la password dei backup. Un backup contiene i dati dell'officina e, se lo scegli, i suoi file. Account utente e password non ne fanno parte: su un nuovo server accedi, inviti il team e ripristini i dati in quell'officina.",
+        "contentsData": "Solo dati",
+        "contentsDataAndSecrets": "Dati e chiavi segrete",
+        "includeFiles": "Includi foto e file",
+        "includeFilesHelp": "Ogni file viene caricato una sola volta e riutilizzato dai backup successivi, quindi dopo la prima esecuzione costa poco.",
+        "keepHourly": "Backup orari da conservare",
+        "keepHourlyHelp": "Quanti dei backup orari più recenti conservare. Conta solo con le pianificazioni ogni ora e ogni 4 ore.",
+        "keepDaily": "Backup giornalieri da conservare",
+        "keepWeekly": "Backup settimanali da conservare",
+        "keepMonthly": "Backup mensili da conservare",
+        "keepMonthlyHelp": "I backup più vecchi vengono rimossi dopo ogni esecuzione, mantenendo i più recenti di ogni tipo. Imposta un numero a 0 per non conservarne nessuno di quel tipo."
+      }
+    },
+    "local-folder": {
+      "name": "Cartella locale",
+      "description": "Backup cifrati di questa officina in una cartella su questo server: il volume dei backup nel file compose, o un'unità di rete montata lì. Una copia sullo stesso disco del database non è sicura da sola; abbinala a uno spazio di archiviazione altrove.",
+      "fields": {
+        "password": "Password dei backup",
+        "passwordHelp": "Ogni backup è cifrato con questa password. Inventane una o premi Genera, e conservane una copia lontano da questo server: senza di essa nessuno può aprire questi backup, nemmeno Torqvoice. Per ricollegare uno spazio che contiene già backup di Torqvoice, inserisci la password con cui sono stati creati."
+      },
+      "settings": {
+        "folder": "Cartella",
+        "folderHelp": "La cartella, sotto la radice dei backup del server, che contiene i backup di questa officina. Lasciala vuota per usare una cartella con il nome di questa officina. Su un nuovo server, inserisci il nome della cartella usata da un'installazione precedente per recuperarne i backup.",
+        "frequency": "Frequenza",
+        "frequencyHourly": "Ogni ora",
+        "frequencyEvery4h": "Ogni 4 ore",
+        "frequencyDaily": "Ogni giorno",
+        "frequencyWeekly": "Ogni settimana",
+        "frequencyMonthly": "Ogni mese",
+        "time": "Ora del giorno",
+        "timeHelp": "Secondo l'orologio dell'officina, nel formato HH:MM. Usata dai backup giornalieri, settimanali e mensili.",
+        "weekday": "Giorno della settimana",
+        "sunday": "Domenica",
+        "monday": "Lunedì",
+        "tuesday": "Martedì",
+        "wednesday": "Mercoledì",
+        "thursday": "Giovedì",
+        "friday": "Venerdì",
+        "saturday": "Sabato",
+        "monthDay": "Giorno del mese",
+        "monthDayLast": "Ultimo giorno",
+        "contents": "Contenuto",
+        "contentsHelp": "Le chiavi segrete sono quelle che questa officina ha inserito in Torqvoice: chiavi di pagamento, IA e webhook. In ogni caso, ogni backup è cifrato con la password dei backup. Un backup contiene i dati dell'officina e, se lo scegli, i suoi file. Account utente e password non ne fanno parte: su un nuovo server accedi, inviti il team e ripristini i dati in quell'officina.",
+        "contentsData": "Solo dati",
+        "contentsDataAndSecrets": "Dati e chiavi segrete",
+        "includeFiles": "Includi foto e file",
+        "includeFilesHelp": "Ogni file viene caricato una sola volta e riutilizzato dai backup successivi, quindi dopo la prima esecuzione costa poco.",
+        "keepHourly": "Backup orari da conservare",
+        "keepHourlyHelp": "Quanti dei backup orari più recenti conservare. Conta solo con le pianificazioni ogni ora e ogni 4 ore.",
+        "keepDaily": "Backup giornalieri da conservare",
+        "keepWeekly": "Backup settimanali da conservare",
+        "keepMonthly": "Backup mensili da conservare",
+        "keepMonthlyHelp": "I backup più vecchi vengono rimossi dopo ogni esecuzione, mantenendo i più recenti di ogni tipo. Imposta un numero a 0 per non conservarne nessuno di quel tipo."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Aggiornamento dei dati sui richiami",
       "refresh": "Aggiornamento dei dati sui richiami",
       "vehicle": "Controllo richiami per un nuovo veicolo"
       "vehicle": "Controllo richiami per un nuovo veicolo"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "destinazione dei backup",
+    "failedTitle": "Backup non riuscito",
+    "failedMessage": "Il backup su {name} non è stato completato: {reason}",
+    "failedSubject": "Backup su {name} non riuscito",
+    "failedEmailIntro": "Il backup programmato della tua officina su {name} non è stato completato.",
+    "openToRetry": "Apri Impostazioni, Integrazioni, {name} per vedere l'esecuzione e riprovare.",
+    "overdueTitle": "Backup in ritardo",
+    "overdueSince": "Nessun backup su {name} è stato completato dal {time}.",
+    "overdueNever": "Nessun backup su {name} è stato ancora completato.",
+    "overdueSubject": "Il backup su {name} è in ritardo",
+    "openToSee": "Apri Impostazioni, Integrazioni, {name} per vedere cosa è andato storto.",
+    "platformName": "Backup di Torqvoice",
+    "restoredTitle": "Officina ripristinata",
+    "restoredSubject": "La tua officina è stata ripristinata da un backup",
+    "restoredIntro": "{name}: l'officina è stata riportata al punto di ripristino {snapshot} il {time}. Tutto ciò che è stato inserito dopo quel punto non è più presente nell'officina.",
+    "restoredByMember": "Un membro della tua officina ha richiesto questo ripristino.",
+    "restoredBySupport": "L'assistenza Torqvoice lo ha fatto su tua richiesta. Motivo indicato: {reason}",
+    "noReason": "nessuno",
+    "restoredUndo": "Prima è stata creata una copia di sicurezza dell'officina com'era subito prima del ripristino, che viene conservata per 90 giorni. Contatta l'assistenza per tornarci."
   }
   }
 }
 }

+ 161 - 1
messages/it/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Troppi tentativi. Attendi qualche minuto e riprova.",
     "reauthTooManyAttempts": "Troppi tentativi. Attendi qualche minuto e riprova.",
     "reauthSignInAgainAccount": "Accedi di nuovo prima di eliminare il tuo account.",
     "reauthSignInAgainAccount": "Accedi di nuovo prima di eliminare il tuo account.",
     "reauthSignInAgainWorkshop": "Accedi di nuovo prima di eliminare l'officina.",
     "reauthSignInAgainWorkshop": "Accedi di nuovo prima di eliminare l'officina.",
+    "reauthSignInAgainRestore": "Accedi di nuovo prima di ripristinare l'officina.",
     "deleteAccountMoved": "Vuoi eliminare il tuo account?",
     "deleteAccountMoved": "Vuoi eliminare il tuo account?",
     "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati.",
     "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati.",
     "signatureTitle": "Firma",
     "signatureTitle": "Firma",
@@ -1399,6 +1400,11 @@
     "importComplete": "Importazione completata",
     "importComplete": "Importazione completata",
     "importCompleteMessage": "Tutti i dati sono stati ripristinati dal file di backup.",
     "importCompleteMessage": "Tutti i dati sono stati ripristinati dal file di backup.",
     "importFailed": "Importazione non riuscita",
     "importFailed": "Importazione non riuscita",
+    "restoreRefused": {
+      "busy": "Su questo server è già in corso un backup o un ripristino. Riprova quando è terminato.",
+      "ownerOnly": "Su Torqvoice Cloud solo il proprietario dell'officina può ripristinare un backup.",
+      "foreignIds": "Il file fa riferimento a record che appartengono a un'altra officina, quindi non è stato ripristinato nulla."
+    },
     "importFromOther": "Importa da altri servizi",
     "importFromOther": "Importa da altri servizi",
     "importFromOtherDescription": "Migra i tuoi dati da un'altra piattaforma. Seleziona un servizio per iniziare.",
     "importFromOtherDescription": "Migra i tuoi dati da un'altra piattaforma. Seleziona un servizio per iniziare.",
     "lubelogTitle": "Importa da LubeLog",
     "lubelogTitle": "Importa da LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Backup Torqvoice",
     "sourceBackup": "Backup Torqvoice",
     "sourceBackupDesc": "Un file .zip o .json scaricato da questa pagina. Il ripristino sostituisce tutto in questa officina.",
     "sourceBackupDesc": "Un file .zip o .json scaricato da questa pagina. Il ripristino sostituisce tutto in questa officina.",
     "restore": "Ripristina",
     "restore": "Ripristina",
-    "restoreTitle": "Ripristina un backup"
+    "restoreTitle": "Ripristina un backup",
+    "includeSecrets": "Includi le chiavi segrete",
+    "includeSecretsHelp": "Le chiavi segrete, come quelle per i pagamenti e l'IA, vengono escluse a meno che tu non selezioni questa opzione. Un ripristino mantiene quindi le chiavi già presenti in questa installazione.",
+    "platformBackups": {
+      "title": "Backup di Torqvoice",
+      "description": "Torqvoice esegue un backup di questa officina ogni ora e conserva i punti di ripristino per 30 giorni, poi quelli settimanali e mensili.",
+      "runNow": "Esegui backup ora",
+      "started": "Backup avviato",
+      "lastGood": "Ultimo backup riuscito",
+      "never": "Ancora nessun backup",
+      "nextRun": "Prossima esecuzione",
+      "notScheduled": "Non pianificato",
+      "repositorySize": "Archiviato da Torqvoice",
+      "running": "In corso",
+      "restoring": "Ripristino in corso",
+      "queued": "In coda",
+      "queuedAhead": "{count, plural, one {In coda, # davanti} other {In coda, # davanti}}",
+      "runningHint": "È in corso un backup. Questa scheda si aggiorna da sola finché non è terminato.",
+      "restoringHint": "È in corso un ripristino: i dati dell'officina vengono sostituiti con il punto di ripristino. Questa pagina si aggiorna da sola finché non è terminato. Fino ad allora non lavorare nell'officina.",
+      "paused": "Il tuo piano non include più i backup di Torqvoice. Le esecuzioni si sono fermate; i punti di ripristino vengono conservati fino al {date} e poi eliminati.",
+      "upsell": "Disponibile con Pro: backup orari eseguiti da Torqvoice, con punti di ripristino a cui puoi tornare.",
+      "planLink": "Vedi i piani",
+      "pointsTitle": "Punti di ripristino",
+      "pointsEmpty": "Il primo backup non è ancora stato eseguito.",
+      "showAll": "Mostra tutti i {count}",
+      "showFewer": "Mostra meno",
+      "needsOwner": "Solo il proprietario dell'officina può eseguire il ripristino.",
+      "sizeNote": "Ogni punto di ripristino è completo di per sé; la sua dimensione è tutto ciò che copre. Il repository conserva ogni parte una sola volta e le esecuzioni successive aggiungono solo ciò che è cambiato, perciò «Archiviato da Torqvoice» resta molto al di sotto della somma delle dimensioni.",
+      "recentTitle": "Esecuzioni recenti",
+      "noRuns": "Non è ancora stato eseguito nessun backup.",
+      "notStarted": "Non avviato",
+      "supportReason": "Ripristinato dall'assistenza Torqvoice: {reason}",
+      "columns": {
+        "when": "Quando",
+        "kind": "Tipo",
+        "size": "Dimensione",
+        "status": "Stato",
+        "trigger": "Avviato da",
+        "duration": "Durata"
+      },
+      "kind": {
+        "scheduled": "Pianificato",
+        "manual": "Manuale",
+        "safety": "Copia di sicurezza",
+        "safetyHint": "Creata subito prima di un ripristino, per poterlo annullare"
+      },
+      "trigger": {
+        "scheduled": "Pianificato",
+        "manual": "Manuale",
+        "restore": "Ripristino"
+      },
+      "status": {
+        "queued": "In coda",
+        "running": "In corso",
+        "done": "Completato",
+        "failed": "Non riuscito",
+        "skipped": "Saltato"
+      },
+      "explore": "Esplora",
+      "restore": {
+        "action": "Ripristina",
+        "dialogTitle": "Ripristinare l'officina allo stato del {time}?",
+        "warning": "Il punto di ripristino del {time} <bold>sostituisce tutto ciò che c'è in questa officina</bold>: clienti, veicoli, lavori, fatture, impostazioni, foto e file. Tutto ciò che è stato aggiunto da allora va perso.",
+        "safetyNote": "Prima viene creata una copia di sicurezza dell'officina com'è ora, conservata per 90 giorni, così il ripristino può essere annullato dai punti di ripristino.",
+        "untouched": "Membri, account, abbonamento e integrazioni non vengono toccati. Le chiavi segrete non presenti nel backup mantengono i valori attuali.",
+        "confirmPrompt": "Digita il nome dell'officina, <bold>{name}</bold>, per confermare.",
+        "whileRunning": "Il ripristino avviene in background e richiede qualche minuto quando ci sono molte foto. La pagina si aggiorna da sola finché non è terminato.",
+        "cancel": "Annulla",
+        "confirm": "Ripristina",
+        "started": "Ripristino avviato"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Torna ai backup",
+      "title": "Punto di ripristino del {date}",
+      "titleShort": "Punto di ripristino {id}",
+      "readOnly": "Sola lettura: niente qui modifica l'officina. La vista si chiude dopo 15 minuti di inattività.",
+      "readOnlyBadge": "Sola lettura",
+      "opening": "Apertura del punto di ripristino",
+      "openingHint": "Il backup viene decifrato qui sul server e tenuto in memoria per un quarto d’ora. Nell’officina non cambia nulla.",
+      "openingSteps": {
+        "connecting": "Connessione al repository di backup",
+        "locating": "Ricerca del file dati nello snapshot",
+        "pulling": "Recupero e decifrazione dei dati",
+        "reading": "Lettura dei record",
+        "indexing": "Indicizzazione di clienti, veicoli e ordini di lavoro"
+      },
+      "openingReady": "Pronto",
+      "openFailed": "Impossibile aprire il punto di ripristino",
+      "tryAgain": "Riprova",
+      "ownerOnly": "Solo il proprietario dell'officina o un amministratore può vedere il contenuto di un punto di ripristino.",
+      "closed": "Il punto di ripristino è stato chiuso; aprilo di nuovo",
+      "openAgain": "Riapri",
+      "datasets": {
+        "customers": "Clienti",
+        "vehicles": "Veicoli",
+        "serviceRecords": "Ordini di lavoro",
+        "quotes": "Preventivi",
+        "inventoryParts": "Ricambi"
+      },
+      "searchPlaceholder": "Cerca per nome, numero, targa, VIN, e-mail o telefono",
+      "loading": "Caricamento…",
+      "loadFailed": "Impossibile caricare questo elenco",
+      "empty": "Nel punto di ripristino non c'è niente di questo tipo.",
+      "noMatches": "Nessun risultato per «{query}».",
+      "showing": "{shown} di {total} mostrati",
+      "loadMore": "Mostra altri",
+      "clearSearch": "Cancella ricerca",
+      "record": {
+        "loading": "Caricamento del record…",
+        "loadFailed": "Impossibile caricare il record",
+        "notFound": "Questo record non è presente nel punto di ripristino.",
+        "fields": "Dettagli",
+        "jumpTo": "Vai a",
+        "previous": "Record precedente",
+        "next": "Record successivo",
+        "position": "{index} di {total}",
+        "noColumns": "Niente da mostrare in queste righe.",
+        "yes": "Sì",
+        "no": "No"
+      },
+      "sections": {
+        "concerns": "Segnalazioni del cliente",
+        "partItems": "Ricambi",
+        "laborItems": "Manodopera",
+        "attachments": "Foto e documenti",
+        "payments": "Pagamenti",
+        "statusReports": "Rapporti di stato",
+        "timeEntries": "Voci orarie",
+        "notes": "Note",
+        "fuelLogs": "Rifornimenti",
+        "reminders": "Promemoria",
+        "serviceRequests": "Richieste di servizio",
+        "inspectionStatus": "Stato revisione",
+        "findings": "Anomalie",
+        "conditionMarks": "Segni di stato",
+        "aiMessages": "Messaggi IA",
+        "recurringInvoices": "Fatture ricorrenti",
+        "serviceRecords": "Ordini di lavoro",
+        "movements": "Movimenti di magazzino",
+        "gallery": "Galleria"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Backup pianificati",
+      "description": "Backup automatici su uno spazio di archiviazione tuo, con la frequenza che scegli.",
+      "empty": "Nessuna destinazione di backup è ancora collegata.",
+      "setUp": "Configura una destinazione di backup",
+      "lastGood": "Ultimo backup riuscito: {when}",
+      "noGoodBackup": "Ancora nessun backup riuscito",
+      "nextRun": "prossima esecuzione: {when}",
+      "notScheduled": "non pianificato",
+      "lastError": "Ultimo errore: {error}",
+      "extraCopy": "Una copia in più su uno spazio di archiviazione tuo, con la frequenza che scegli, oltre ai backup eseguiti da Torqvoice."
+    }
   },
   },
   "license": {
   "license": {
     "title": "Licenza White-Label",
     "title": "Licenza White-Label",

+ 7 - 2
messages/lt/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Pridėtas žymos tipas",
     "settings_markType_create": "Pridėtas žymos tipas",
     "settings_markType_update": "Pakeistas žymos tipas",
     "settings_markType_update": "Pakeistas žymos tipas",
     "settings_markType_remove": "Pašalintas žymos tipas",
     "settings_markType_remove": "Pašalintas žymos tipas",
-    "settings_markType_reorder": "Pakeista žymų tipų tvarka"
+    "settings_markType_reorder": "Pakeista žymų tipų tvarka",
+    "integration_backup_run": "Pradėtas atsarginės kopijos kūrimas",
+    "integration_backup_restore": "Atkurta atsarginė kopija"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Pašalino žymą iš būklės žemėlapio",
     "condition_mark_remove": "Pašalino žymą iš būklės žemėlapio",
     "condition_mark_carry": "Patvirtino, kad {count, plural, one {# ankstesnė žyma} few {# ankstesnės žymos} other {# ankstesnių žymų}} vis dar yra būklės žemėlapyje",
     "condition_mark_carry": "Patvirtino, kad {count, plural, one {# ankstesnė žyma} few {# ankstesnės žymos} other {# ankstesnių žymų}} vis dar yra būklės žemėlapyje",
     "condition_map_on_invoice_on": "Įtraukė būklės žemėlapį į sąskaitą",
     "condition_map_on_invoice_on": "Įtraukė būklės žemėlapį į sąskaitą",
-    "condition_map_on_invoice_off": "Pašalino būklės žemėlapį iš sąskaitos"
+    "condition_map_on_invoice_off": "Pašalino būklės žemėlapį iš sąskaitos",
+    "backup_run_started": "Pradėjo kurti atsarginę kopiją į {name}",
+    "backup_restore_started": "Pradėjo atkurti atsarginę kopiją {snapshot} iš {name}",
+    "backup_restored": "Atkūrė atsarginę kopiją {snapshot} iš {name}"
   }
   }
 }
 }

+ 219 - 2
messages/lt/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Sąskaitos į apskaitą",
       "invoices": "Sąskaitos į apskaitą",
       "customers": "Klientai į apskaitą",
       "customers": "Klientai į apskaitą",
       "payments": "Mokėjimai abiem kryptimis"
       "payments": "Mokėjimai abiem kryptimis"
+    },
+    "backup": {
+      "target": "Atsarginių kopijų paskirties vieta"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Visos transporto priemonės su numeriu neseniai patikrintos, todėl kol kas nėra ką tikrinti."
       "nothingDue": "Visos transporto priemonės su numeriu neseniai patikrintos, todėl kol kas nėra ką tikrinti."
     },
     },
     "supersedes": "Prijungus {next}, {current} bus atjungtas. Vienu metu gali veikti tik vienas, todėl {current} nustos būti naudojamas, kai tik {next} pradės veikti.",
     "supersedes": "Prijungus {next}, {current} bus atjungtas. Vienu metu gali veikti tik vienas, todėl {current} nustos būti naudojamas, kai tik {next} pradės veikti.",
+    "platformHandlesBackups": "Torqvoice jau kas valandą kuria šios dirbtuvės atsargines kopijas ir 30 dienų saugo atkūrimo taškus. Prijunkite talpyklą, jei taip pat norite jums priklausančios kopijos, kurią galima perskaityti be Torqvoice.",
+    "platformHandlesBackupsLink": "Peržiūrėti atkūrimo taškus",
     "batchRetried": "{count, plural, =0 {Nėra ko kartoti} one {# užduotis vėl eilėje} few {# užduotys vėl eilėje} many {# užduoties vėl eilėje} other {# užduočių vėl eilėje}}",
     "batchRetried": "{count, plural, =0 {Nėra ko kartoti} one {# užduotis vėl eilėje} few {# užduotys vėl eilėje} many {# užduoties vėl eilėje} other {# užduočių vėl eilėje}}",
-    "advanced": "Išplėstiniai"
+    "advanced": "Išplėstiniai",
+    "generate": "Generuoti",
+    "show": "Rodyti",
+    "hide": "Slėpti",
+    "generatedPasswordWarning": "Laikykite šio slaptažodžio kopiją saugioje vietoje, ne šiame serveryje. Be jo niekas negali atidaryti šių atsarginių kopijų, net ir „Torqvoice“.",
+    "backup": {
+      "title": "Atsarginės kopijos",
+      "description": "Kada ši paskirties vieta paskutinį kartą gavo sėkmingą atsarginę kopiją, kada vyks kita ir paskutiniai vykdymai.",
+      "lastGood": "Paskutinė sėkminga atsarginė kopija",
+      "added": "Pridėta {size}",
+      "never": "Kol kas nėra",
+      "nextRun": "Kitas vykdymas",
+      "notScheduled": "Nesuplanuota",
+      "repositorySize": "Saugoma paskirties vietoje",
+      "runNow": "Kurti atsarginę kopiją dabar",
+      "running": "Kuriama atsarginė kopija",
+      "runningHint": "Kuriama atsarginė kopija. Šis puslapis atsinaujina pats, kol ji bus baigta.",
+      "started": "Atsarginės kopijos kūrimas pradėtas",
+      "recentTitle": "Paskutiniai vykdymai",
+      "noRuns": "Atsarginės kopijos dar nebuvo kuriamos.",
+      "addedNote": "Pirmasis paleidimas įkelia viską. Kiekvienas vėlesnis prideda tik nuo to laiko pasikeitusias dalis, todėl yra mažas. Kiekviena atsarginė kopija vis tiek yra pilna ir gali būti atkurta ar peržiūrėta atskirai.",
+      "notStarted": "Nepradėta",
+      "filesNewCount": "{count, plural, one {# naujas failas} few {# nauji failai} many {# naujo failo} other {# naujų failų}}",
+      "columns": {
+        "status": "Būsena",
+        "started": "Pradėta",
+        "trigger": "Kas pradėjo",
+        "duration": "Trukmė",
+        "filesNew": "Nauji failai",
+        "bytesAdded": "Pridėta"
+      },
+      "trigger": {
+        "scheduled": "Pagal grafiką",
+        "manual": "Rankiniu būdu",
+        "restore": "Atkūrimas"
+      },
+      "status": {
+        "queued": "Eilėje",
+        "running": "Vykdoma",
+        "done": "Atlikta",
+        "failed": "Nepavyko",
+        "skipped": "Praleista"
+      },
+      "restoring": "Atkuriama",
+      "restoringHint": "Vykdomas atkūrimas: dirbtuvės duomenys keičiami atsarginės kopijos duomenimis. Šis puslapis atsinaujina pats, kol jis bus baigtas. Iki tol nieko nekeiskite dirbtuvėje.",
+      "explore": "Peržiūrėti",
+      "restore": {
+        "showAll": "Rodyti visas atsargines kopijas",
+        "refresh": "Atnaujinti",
+        "loading": "Skaitomos atsarginės kopijos paskirties vietoje",
+        "loadFailed": "Nepavyko gauti atsarginių kopijų sąrašo",
+        "empty": "Šioje paskirties vietoje dar nėra atsarginių kopijų.",
+        "needsOwner": "Čia atkurti atsarginę kopiją gali tik dirbtuvės savininkas.",
+        "needsAdmin": "Atkurti atsarginę kopiją gali tik savininkas arba administratorius.",
+        "filesIncluded": "Nuotraukos ir failai įtraukti",
+        "filesNotIncluded": "Nuotraukos ir failai neįtraukti",
+        "earlierInstall": "Ankstesnis diegimas",
+        "action": "Atkurti",
+        "dialogTitle": "Atkurti atsarginę kopiją, sukurtą {time}?",
+        "warning": "Atkūrimas <bold>pakeičia viską, ką ši dirbtuvė turi dabar</bold>: klientus, transporto priemones, darbus, sąskaitas, nustatymus ir, jei atsarginėje kopijoje jų yra, nuotraukas bei failus. Viskas, kas pridėta po atsarginės kopijos sukūrimo, dings. Šio veiksmo atšaukti negalima.",
+        "secretsNote": "Slaptieji raktai, kurių atsarginė kopija neapėmė, išlaiko reikšmes, kurias šis diegimas turi dabar. Naudotojų paskyros ir slaptažodžiai nekeičiami.",
+        "confirmPrompt": "Norėdami patvirtinti, įveskite dirbtuvės pavadinimą <bold>{name}</bold>.",
+        "whileRunning": "Atkūrimas vykdomas fone ir, kai nuotraukų daug, užtrunka kelias minutes. Puslapis atsinaujina pats, kol jis bus baigtas.",
+        "cancel": "Atšaukti",
+        "confirm": "Atkurti šią atsarginę kopiją",
+        "started": "Atkūrimas pradėtas"
+      }
+    },
+    "lockedBackups": "Suplanuotos, šifruotos jūsų dirbtuvės atsarginės kopijos į jums priklausančią saugyklą.",
+    "searchOrType": "Ieškoti arba įvesti pavadinimą",
+    "useTyped": "Naudoti „{value}“",
+    "listNeedsKeys": "Įveskite raktus aukščiau, kad pamatytumėte sąrašą",
+    "listEmpty": "Sąrašas tuščias. Įveskite pavadinimą ranka.",
+    "testing": "Tikrinamas ryšys",
+    "testOk": "Ryšys veikia: bandomasis objektas buvo įrašytas ir vėl ištrintas.",
+    "testFailed": "Patikra nepavyko",
+    "testSteps": {
+      "settings": "Patikrinkite teikėjo nustatymus",
+      "list": "Raktas negali išvardyti talpyklos turinio",
+      "write": "Raktas negali rašyti į talpyklą",
+      "delete": "Raktas negali trinti iš talpyklos",
+      "folder": "Nepavyksta sukurti ar atverti aplanko"
+    },
+    "testHint": "Užpildykite raktus ir talpyklą, kad galėtumėte juos patikrinti prieš prijungiant. Patikra įrašo vieną nedidelį objektą ir jį ištrina."
   },
   },
   "activity": {
   "activity": {
     "title": "Veikla",
     "title": "Veikla",
@@ -199,7 +287,10 @@
     "invalid": "Tiekėjo atsakymas buvo neišsamus. Bandykite dar kartą.",
     "invalid": "Tiekėjo atsakymas buvo neišsamus. Bandykite dar kartą.",
     "state": "Šis prisijungimas baigė galioti arba priklauso kitai sesijai. Pradėkite iš naujo.",
     "state": "Šis prisijungimas baigė galioti arba priklauso kitai sesijai. Pradėkite iš naujo.",
     "exchange": "Tiekėjas atmetė prisijungimą. Patikrinkite programos prisijungimo duomenis ir bandykite dar kartą.",
     "exchange": "Tiekėjas atmetė prisijungimą. Patikrinkite programos prisijungimo duomenis ir bandykite dar kartą.",
-    "identify": "Prijungta, bet paskyros nuskaityti nepavyko. Pabandykite prijungti iš naujo."
+    "identify": "Prijungta, bet paskyros nuskaityti nepavyko. Pabandykite prijungti iš naujo.",
+    "backupRunning": "Atsarginė kopija jau kuriama.",
+    "restoreOwnerOnly": "Torqvoice Cloud atsarginę kopiją atkurti gali tik dirbtuvės savininkas.",
+    "restoreBusy": "Šiame serveryje jau vykdomas atsarginės kopijos kūrimas arba atkūrimas. Bandykite dar kartą, kai jis bus baigtas."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Prisijungti prie vaizdo skambučio",
     "join": "Prisijungti prie vaizdo skambučio",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Microsoft 365 kalendorius",
       "description": "Įtraukite suplanuotus darbo užsakymus į Outlook kalendorių, pridėkite Microsoft Teams nuorodą ir matykite kalendoriaus užimtą laiką Torqvoice.",
       "description": "Įtraukite suplanuotus darbo užsakymus į Outlook kalendorių, pridėkite Microsoft Teams nuorodą ir matykite kalendoriaus užimtą laiką Torqvoice.",
       "tenantHelp": "Užregistruokite programą Microsoft Entra (App registrations), leiskite asmenines ir darbo paskyras, pridėkite žemiau nurodytą nukreipimo URI kaip Web platformą, sukurkite kliento slaptažodį ir įklijuokite abu čia.",
       "tenantHelp": "Užregistruokite programą Microsoft Entra (App registrations), leiskite asmenines ir darbo paskyras, pridėkite žemiau nurodytą nukreipimo URI kaip Web platformą, sukurkite kliento slaptažodį ir įklijuokite abu čia.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Suderinamas su OpenAI",
       "description": "Paslaugų aprašymai, istorijos santraukos, dokumentų nuskaitymas ir asistentas, kuriuos atsako bet kuris serveris, kalbantis OpenAI pokalbių API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM ir panašūs. Jūsų duomenys lieka jūsų pačių įrangoje.",
       "description": "Paslaugų aprašymai, istorijos santraukos, dokumentų nuskaitymas ir asistentas, kuriuos atsako bet kuris serveris, kalbantis OpenAI pokalbių API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM ir panašūs. Jūsų duomenys lieka jūsų pačių įrangoje.",
       "fields": {
       "fields": {
         "baseUrl": "Bazinis URL",
         "baseUrl": "Bazinis URL",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Kalba į tekstą",
       "description": "Diktavimas darbo užsakyme, kurį užrašo kalbos modelis, o ne naršyklė: daug tiksliau, bet kuria kalba, ir jis žino dirbtuvių žodžius. Atskirta nuo jūsų DI paslaugų teikėjo, todėl jį galite palikti tokį, koks yra. Naudojamas jūsų pačių OpenAI raktas arba jūsų serveris, kalbantis OpenAI transkripcijos API. Įrašas siunčiamas užrašyti ir nesaugomas.",
       "description": "Diktavimas darbo užsakyme, kurį užrašo kalbos modelis, o ne naršyklė: daug tiksliau, bet kuria kalba, ir jis žino dirbtuvių žodžius. Atskirta nuo jūsų DI paslaugų teikėjo, todėl jį galite palikti tokį, koks yra. Naudojamas jūsų pačių OpenAI raktas arba jūsų serveris, kalbantis OpenAI transkripcijos API. Įrašas siunčiamas užrašyti ir nesaugomas.",
       "fields": {
       "fields": {
         "apiKey": "API raktas",
         "apiKey": "API raktas",
@@ -653,6 +747,108 @@
         "modeAi": "Visada kalbos modelis",
         "modeAi": "Visada kalbos modelis",
         "modeChoice": "Kiekvienas renkasi: kalbos modelis ar naršyklė"
         "modeChoice": "Kiekvienas renkasi: kalbos modelis ar naršyklė"
       }
       }
+    },
+    "s3": {
+      "name": "Debesų saugyklos bucket'as",
+      "description": "Šifruotos šios dirbtuvės atsarginės kopijos į jums priklausančią talpyklą (bucket) Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage ar bet kurioje kitoje su S3 suderinamoje saugykloje pagal jūsų pasirinktą grafiką. Po pirmojo vykdymo įkeliama tik tai, kas pasikeitė, todėl kas valandą daromos kopijos išlieka pigios.",
+      "fields": {
+        "accessKeyId": "Prieigos rakto ID",
+        "accessKeyIdHelp": "Jūsų saugyklos teikėjo raktas su leidimu išvardyti, skaityti, rašyti ir trinti objektus talpykloje. Sukurkite atskirą raktą tik „Torqvoice“, kad jį būtų galima atšaukti atskirai.",
+        "secretAccessKey": "Slaptasis prieigos raktas",
+        "password": "Atsarginių kopijų slaptažodis",
+        "passwordHelp": "Kiekviena atsarginė kopija šifruojama šiuo slaptažodžiu. Sugalvokite jį arba spustelėkite Generuoti ir laikykite kopiją ne šiame serveryje: be jo niekas negali atidaryti šių atsarginių kopijų, net ir „Torqvoice“. Norėdami vėl prijungti saugyklą, kurioje jau yra „Torqvoice“ atsarginių kopijų, įveskite slaptažodį, kuriuo jos buvo sukurtos.",
+        "endpoint": "Pasirinktinis galinis taškas",
+        "endpointHelp": "Tik parinkčiai Kita su S3 suderinama saugykla: jūsų pačių saugyklos S3 adresas, pavyzdžiui, MinIO serverio, kaip pagrindinio kompiuterio vardas, jei reikia, su prievadu."
+      },
+      "settings": {
+        "provider": "Teikėjas",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Kita su S3 suderinama saugykla",
+        "region": "Regionas",
+        "regionHelp": "Kur yra talpykla. Pasirinkite iš teikėjo sąrašo; kitai saugyklai įveskite reikšmę, kurios ji tikisi, jei jos reikia. Cloudflare R2 jo nenaudoja.",
+        "accountId": "Cloudflare paskyros ID",
+        "accountIdHelp": "32 simbolių ID, rodomas jūsų Cloudflare valdymo skydelio R2 apžvalgos puslapyje.",
+        "bucket": "Talpykla (bucket)",
+        "bucketHelp": "Talpyklų sąrašas parodomas vos įvedus raktą. Jei raktas gali naudoti vieną talpyklą, bet negali gauti jų sąrašo, įveskite jos pavadinimą.",
+        "prefix": "Aplankas talpykloje",
+        "prefixHelp": "Atsarginės kopijos laikomos šiame aplanke. Kiekvienai dirbtuvei, kuri naudoja tą pačią talpyklą, naudokite atskirą aplanką.",
+        "frequency": "Kaip dažnai",
+        "frequencyHourly": "Kas valandą",
+        "frequencyEvery4h": "Kas 4 valandas",
+        "frequencyDaily": "Kasdien",
+        "frequencyWeekly": "Kas savaitę",
+        "frequencyMonthly": "Kas mėnesį",
+        "time": "Paros laikas",
+        "timeHelp": "Pagal dirbtuvės laikrodį, formatu HH:MM. Naudojama kasdienėms, savaitinėms ir mėnesinėms atsarginėms kopijoms.",
+        "weekday": "Savaitės diena",
+        "sunday": "Sekmadienis",
+        "monday": "Pirmadienis",
+        "tuesday": "Antradienis",
+        "wednesday": "Trečiadienis",
+        "thursday": "Ketvirtadienis",
+        "friday": "Penktadienis",
+        "saturday": "Šeštadienis",
+        "monthDay": "Mėnesio diena",
+        "monthDayLast": "Paskutinė diena",
+        "contents": "Turinys",
+        "contentsHelp": "Slaptieji raktai yra tie, kuriuos ši dirbtuvė įvedė į „Torqvoice“: mokėjimų, DI ir webhook raktai. Bet kuriuo atveju kiekviena atsarginė kopija šifruojama atsarginių kopijų slaptažodžiu. Atsarginėje kopijoje yra dirbtuvės įrašai ir, jei pasirinkta, jos failai. Naudotojų paskyrų ir slaptažodžių joje nėra: naujame serveryje prisijungiate, pakviečiate komandą ir atkuriate duomenis į tą dirbtuvę.",
+        "contentsData": "Tik duomenys",
+        "contentsDataAndSecrets": "Duomenys ir slaptieji raktai",
+        "includeFiles": "Įtraukti nuotraukas ir failus",
+        "includeFilesHelp": "Kiekvienas failas įkeliamas vieną kartą ir pakartotinai naudojamas vėlesnėse atsarginėse kopijose, todėl po pirmojo vykdymo tai kainuoja nedaug.",
+        "keepHourly": "Laikyti valandines atsargines kopijas",
+        "keepHourlyHelp": "Kiek naujausių valandinių atsarginių kopijų laikyti. Svarbu tik kas valandą ir kas 4 valandas vykdomiems grafikams.",
+        "keepDaily": "Laikyti dienines atsargines kopijas",
+        "keepWeekly": "Laikyti savaitines atsargines kopijas",
+        "keepMonthly": "Laikyti mėnesines atsargines kopijas",
+        "keepMonthlyHelp": "Po kiekvieno vykdymo senesnės atsarginės kopijos pašalinamos, paliekant naujausias kiekvienos rūšies. Nustatykite skaičių 0, kad tos rūšies nebūtų laikoma nė vienos."
+      }
+    },
+    "local-folder": {
+      "name": "Vietinis aplankas",
+      "description": "Šifruotos šios dirbtuvės atsarginės kopijos į aplanką šiame serveryje: atsarginių kopijų tomą compose faile arba ten prijungtą tinklo diską. Kopija tame pačiame diske kaip duomenų bazė viena pati nėra saugi; derinkite ją su saugykla kitoje vietoje.",
+      "fields": {
+        "password": "Atsarginių kopijų slaptažodis",
+        "passwordHelp": "Kiekviena atsarginė kopija šifruojama šiuo slaptažodžiu. Sugalvokite jį arba spustelėkite Generuoti ir laikykite kopiją ne šiame serveryje: be jo niekas negali atidaryti šių atsarginių kopijų, net ir „Torqvoice“. Norėdami vėl prijungti saugyklą, kurioje jau yra „Torqvoice“ atsarginių kopijų, įveskite slaptažodį, kuriuo jos buvo sukurtos."
+      },
+      "settings": {
+        "folder": "Aplankas",
+        "folderHelp": "Aplankas serverio atsarginių kopijų šakniniame kataloge, kuriame laikomos šios dirbtuvės atsarginės kopijos. Palikite tuščią, kad būtų naudojamas aplankas, pavadintas pagal šią dirbtuvę. Naujame serveryje įveskite ankstesnio diegimo naudoto aplanko pavadinimą, kad atgautumėte jo atsargines kopijas.",
+        "frequency": "Kaip dažnai",
+        "frequencyHourly": "Kas valandą",
+        "frequencyEvery4h": "Kas 4 valandas",
+        "frequencyDaily": "Kasdien",
+        "frequencyWeekly": "Kas savaitę",
+        "frequencyMonthly": "Kas mėnesį",
+        "time": "Paros laikas",
+        "timeHelp": "Pagal dirbtuvės laikrodį, formatu HH:MM. Naudojama kasdienėms, savaitinėms ir mėnesinėms atsarginėms kopijoms.",
+        "weekday": "Savaitės diena",
+        "sunday": "Sekmadienis",
+        "monday": "Pirmadienis",
+        "tuesday": "Antradienis",
+        "wednesday": "Trečiadienis",
+        "thursday": "Ketvirtadienis",
+        "friday": "Penktadienis",
+        "saturday": "Šeštadienis",
+        "monthDay": "Mėnesio diena",
+        "monthDayLast": "Paskutinė diena",
+        "contents": "Turinys",
+        "contentsHelp": "Slaptieji raktai yra tie, kuriuos ši dirbtuvė įvedė į „Torqvoice“: mokėjimų, DI ir webhook raktai. Bet kuriuo atveju kiekviena atsarginė kopija šifruojama atsarginių kopijų slaptažodžiu. Atsarginėje kopijoje yra dirbtuvės įrašai ir, jei pasirinkta, jos failai. Naudotojų paskyrų ir slaptažodžių joje nėra: naujame serveryje prisijungiate, pakviečiate komandą ir atkuriate duomenis į tą dirbtuvę.",
+        "contentsData": "Tik duomenys",
+        "contentsDataAndSecrets": "Duomenys ir slaptieji raktai",
+        "includeFiles": "Įtraukti nuotraukas ir failus",
+        "includeFilesHelp": "Kiekvienas failas įkeliamas vieną kartą ir pakartotinai naudojamas vėlesnėse atsarginėse kopijose, todėl po pirmojo vykdymo tai kainuoja nedaug.",
+        "keepHourly": "Laikyti valandines atsargines kopijas",
+        "keepHourlyHelp": "Kiek naujausių valandinių atsarginių kopijų laikyti. Svarbu tik kas valandą ir kas 4 valandas vykdomiems grafikams.",
+        "keepDaily": "Laikyti dienines atsargines kopijas",
+        "keepWeekly": "Laikyti savaitines atsargines kopijas",
+        "keepMonthly": "Laikyti mėnesines atsargines kopijas",
+        "keepMonthlyHelp": "Po kiekvieno vykdymo senesnės atsarginės kopijos pašalinamos, paliekant naujausias kiekvienos rūšies. Nustatykite skaičių 0, kad tos rūšies nebūtų laikoma nė vienos."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Atšaukimų duomenų atnaujinimas",
       "refresh": "Atšaukimų duomenų atnaujinimas",
       "vehicle": "Naujos transporto priemonės atšaukimų patikra"
       "vehicle": "Naujos transporto priemonės atšaukimų patikra"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "atsarginių kopijų paskirties vieta",
+    "failedTitle": "Atsarginė kopija nepavyko",
+    "failedMessage": "Atsarginė kopija į {name} nebaigta: {reason}",
+    "failedSubject": "Atsarginė kopija į {name} nepavyko",
+    "failedEmailIntro": "Suplanuota jūsų dirbtuvės atsarginė kopija į {name} nebuvo baigta.",
+    "openToRetry": "Atidarykite Nustatymai, Integracijos, {name}, kad pamatytumėte vykdymą ir bandytumėte dar kartą.",
+    "overdueTitle": "Atsarginė kopija vėluoja",
+    "overdueSince": "Nuo {time} nebaigta nė viena atsarginė kopija į {name}.",
+    "overdueNever": "Dar nebaigta nė viena atsarginė kopija į {name}.",
+    "overdueSubject": "Atsarginė kopija į {name} vėluoja",
+    "openToSee": "Atidarykite Nustatymai, Integracijos, {name}, kad pamatytumėte, kas nepavyko.",
+    "platformName": "Torqvoice atsarginės kopijos",
+    "restoredTitle": "Dirbtuvė atkurta",
+    "restoredSubject": "Jūsų dirbtuvė atkurta iš atsarginės kopijos",
+    "restoredIntro": "{name}: {time} dirbtuvė grąžinta į atkūrimo tašką {snapshot}. Viso, kas įvesta po šio taško, dirbtuvėje nebėra.",
+    "restoredByMember": "Šio atkūrimo paprašė jūsų dirbtuvės narys.",
+    "restoredBySupport": "Torqvoice pagalbos komanda tai atliko jūsų prašymu. Nurodyta priežastis: {reason}",
+    "noReason": "nenurodyta",
+    "restoredUndo": "Prieš atkuriant buvo sukurta dirbtuvės apsauginė kopija, kokia ji buvo prieš pat atkūrimą; ji saugoma 90 dienų. Norėdami prie jos grįžti, susisiekite su pagalbos komanda."
   }
   }
 }
 }

+ 161 - 1
messages/lt/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Per daug bandymų. Palaukite kelias minutes ir bandykite dar kartą.",
     "reauthTooManyAttempts": "Per daug bandymų. Palaukite kelias minutes ir bandykite dar kartą.",
     "reauthSignInAgainAccount": "Prieš ištrindami paskyrą, prisijunkite iš naujo.",
     "reauthSignInAgainAccount": "Prieš ištrindami paskyrą, prisijunkite iš naujo.",
     "reauthSignInAgainWorkshop": "Prieš ištrindami dirbtuves, prisijunkite iš naujo.",
     "reauthSignInAgainWorkshop": "Prieš ištrindami dirbtuves, prisijunkite iš naujo.",
+    "reauthSignInAgainRestore": "Prieš atkurdami dirbtuvę, prisijunkite iš naujo.",
     "deleteAccountMoved": "Norite ištrinti savo paskyrą?",
     "deleteAccountMoved": "Norite ištrinti savo paskyrą?",
     "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose.",
     "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose.",
     "signatureTitle": "Parašas",
     "signatureTitle": "Parašas",
@@ -1399,6 +1400,11 @@
     "importComplete": "Importas baigtas",
     "importComplete": "Importas baigtas",
     "importCompleteMessage": "Visi duomenys atkurti iš atsarginės kopijos failo.",
     "importCompleteMessage": "Visi duomenys atkurti iš atsarginės kopijos failo.",
     "importFailed": "Importas nepavyko",
     "importFailed": "Importas nepavyko",
+    "restoreRefused": {
+      "busy": "Šiame serveryje jau vykdomas atsarginės kopijos kūrimas arba atkūrimas. Bandykite dar kartą, kai jis bus baigtas.",
+      "ownerOnly": "Torqvoice Cloud atsarginę kopiją atkurti gali tik dirbtuvės savininkas.",
+      "foreignIds": "Faile nurodyti įrašai, priklausantys kitai dirbtuvei, todėl niekas nebuvo atkurta."
+    },
     "importFromOther": "Importuoti iš kitų paslaugų",
     "importFromOther": "Importuoti iš kitų paslaugų",
     "importFromOtherDescription": "Perkelkite duomenis iš kitos platformos. Pasirinkite paslaugą, kad pradėtumėte.",
     "importFromOtherDescription": "Perkelkite duomenis iš kitos platformos. Pasirinkite paslaugą, kad pradėtumėte.",
     "lubelogTitle": "Importuoti iš LubeLog",
     "lubelogTitle": "Importuoti iš LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "„Torqvoice“ atsarginė kopija",
     "sourceBackup": "„Torqvoice“ atsarginė kopija",
     "sourceBackupDesc": ".zip arba .json failas, atsisiųstas iš šio puslapio. Atkūrimas pakeičia viską šiose dirbtuvėse.",
     "sourceBackupDesc": ".zip arba .json failas, atsisiųstas iš šio puslapio. Atkūrimas pakeičia viską šiose dirbtuvėse.",
     "restore": "Atkurti",
     "restore": "Atkurti",
-    "restoreTitle": "Atkurti atsarginę kopiją"
+    "restoreTitle": "Atkurti atsarginę kopiją",
+    "includeSecrets": "Įtraukti slaptuosius raktus",
+    "includeSecretsHelp": "Slaptieji raktai, pavyzdžiui, mokėjimų ir DI raktai, neįtraukiami, nebent pažymėsite šią parinktį. Tada atkuriant išlaikomi raktai, kuriuos ši įdiegtis jau turi.",
+    "platformBackups": {
+      "title": "Torqvoice atsarginės kopijos",
+      "description": "Torqvoice kas valandą kuria šios dirbtuvės atsarginę kopiją ir 30 dienų saugo atkūrimo taškus, vėliau – savaitinius ir mėnesinius.",
+      "runNow": "Kurti atsarginę kopiją dabar",
+      "started": "Atsarginės kopijos kūrimas pradėtas",
+      "lastGood": "Paskutinė sėkminga atsarginė kopija",
+      "never": "Atsarginės kopijos dar nėra",
+      "nextRun": "Kitas vykdymas",
+      "notScheduled": "Nesuplanuota",
+      "repositorySize": "Saugo Torqvoice",
+      "running": "Vykdoma",
+      "restoring": "Atkuriama",
+      "queued": "Eilėje",
+      "queuedAhead": "{count, plural, one {Eilėje, prieš ją # užduotis} few {Eilėje, prieš ją # užduotys} many {Eilėje, prieš ją # užduoties} other {Eilėje, prieš ją # užduočių}}",
+      "runningHint": "Kuriama atsarginė kopija. Ši kortelė atsinaujina pati, kol ji bus baigta.",
+      "restoringHint": "Vykdomas atkūrimas: dirbtuvės duomenys keičiami atkūrimo taško duomenimis. Šis puslapis atsinaujina pats, kol jis bus baigtas. Iki tol nedirbkite dirbtuvėje.",
+      "paused": "Jūsų plane nebėra Torqvoice atsarginių kopijų. Vykdymai sustabdyti; atkūrimo taškai saugomi iki {date}, o tada ištrinami.",
+      "upsell": "Prieinama su Pro planu: Torqvoice kas valandą kuriamos atsarginės kopijos su atkūrimo taškais, į kuriuos galite grįžti.",
+      "planLink": "Peržiūrėti planus",
+      "pointsTitle": "Atkūrimo taškai",
+      "pointsEmpty": "Pirmoji atsarginė kopija dar nesukurta.",
+      "showAll": "Rodyti visus ({count})",
+      "showFewer": "Rodyti mažiau",
+      "needsOwner": "Atkurti gali tik dirbtuvės savininkas.",
+      "sizeNote": "Kiekvienas atkūrimo taškas yra pilnas pats savaime; jo dydis – viskas, ką jis apima. Saugykla kiekvieną dalį laiko tik kartą, o vėlesni paleidimai prideda tik tai, kas pasikeitė, todėl „Saugo Torqvoice“ lieka gerokai mažesnis už dydžių sumą.",
+      "recentTitle": "Paskutiniai vykdymai",
+      "noRuns": "Atsarginės kopijos dar nebuvo kuriamos.",
+      "notStarted": "Nepradėta",
+      "supportReason": "Atkūrė Torqvoice pagalbos komanda: {reason}",
+      "columns": {
+        "when": "Kada",
+        "kind": "Tipas",
+        "size": "Dydis",
+        "status": "Būsena",
+        "trigger": "Kas pradėjo",
+        "duration": "Trukmė"
+      },
+      "kind": {
+        "scheduled": "Suplanuota",
+        "manual": "Rankinė",
+        "safety": "Apsauginė kopija",
+        "safetyHint": "Sukurta prieš pat atkūrimą, kad jį būtų galima atšaukti"
+      },
+      "trigger": {
+        "scheduled": "Pagal grafiką",
+        "manual": "Rankiniu būdu",
+        "restore": "Atkūrimas"
+      },
+      "status": {
+        "queued": "Eilėje",
+        "running": "Vykdoma",
+        "done": "Atlikta",
+        "failed": "Nepavyko",
+        "skipped": "Praleista"
+      },
+      "explore": "Peržiūrėti",
+      "restore": {
+        "action": "Atkurti",
+        "dialogTitle": "Atkurti dirbtuvės būseną, buvusią {time}?",
+        "warning": "{time} sukurtas atkūrimo taškas <bold>pakeičia viską šioje dirbtuvėje</bold>: klientus, transporto priemones, darbus, sąskaitas, nustatymus, nuotraukas ir failus. Viskas, kas pridėta nuo tada, dings.",
+        "safetyNote": "Pirmiausia sukuriama dabartinės dirbtuvės būsenos apsauginė kopija, kuri saugoma 90 dienų, todėl atkūrimą galima atšaukti iš atkūrimo taškų.",
+        "untouched": "Nariai, paskyros, prenumerata ir integracijos nekeičiami. Slaptieji raktai, kurių nėra atsarginėje kopijoje, išlaiko dabartines reikšmes.",
+        "confirmPrompt": "Norėdami patvirtinti, įveskite dirbtuvės pavadinimą <bold>{name}</bold>.",
+        "whileRunning": "Atkūrimas vykdomas fone ir, kai nuotraukų daug, užtrunka kelias minutes. Puslapis atsinaujina pats, kol jis bus baigtas.",
+        "cancel": "Atšaukti",
+        "confirm": "Atkurti",
+        "started": "Atkūrimas pradėtas"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Atgal į atsargines kopijas",
+      "title": "Atkūrimo taškas, sukurtas {date}",
+      "titleShort": "Atkūrimo taškas {id}",
+      "readOnly": "Tik skaityti: niekas čia nekeičia dirbtuvės. Vaizdas užsidaro po 15 minučių neveiklumo.",
+      "readOnlyBadge": "Tik skaitymui",
+      "opening": "Atidaromas atkūrimo taškas",
+      "openingHint": "Atsarginė kopija iššifruojama čia, serveryje, ir ketvirtį valandos laikoma atmintyje. Dirbtuvėje niekas nesikeičia.",
+      "openingSteps": {
+        "connecting": "Jungiamasi prie atsarginių kopijų saugyklos",
+        "locating": "Ieškoma duomenų failo momentinėje kopijoje",
+        "pulling": "Duomenys atsiunčiami ir iššifruojami",
+        "reading": "Skaitomi įrašai",
+        "indexing": "Indeksuojami klientai, transporto priemonės ir darbo užsakymai"
+      },
+      "openingReady": "Paruošta",
+      "openFailed": "Nepavyko atverti atkūrimo taško",
+      "tryAgain": "Bandyti dar kartą",
+      "ownerOnly": "Atkūrimo taško turinį gali peržiūrėti tik dirbtuvės savininkas arba administratorius.",
+      "closed": "Atkūrimo taškas uždarytas; atverkite jį iš naujo",
+      "openAgain": "Atverti iš naujo",
+      "datasets": {
+        "customers": "Klientai",
+        "vehicles": "Transporto priemonės",
+        "serviceRecords": "Darbo užsakymai",
+        "quotes": "Pasiūlymai",
+        "inventoryParts": "Dalys"
+      },
+      "searchPlaceholder": "Ieškoti pagal vardą, numerį, valst. numerį, VIN, el. paštą ar telefoną",
+      "loading": "Įkeliama…",
+      "loadFailed": "Nepavyko įkelti šio sąrašo",
+      "empty": "Atkūrimo taške nieko tokio nėra.",
+      "noMatches": "Nieko nerasta pagal „{query}“.",
+      "showing": "Rodoma {shown} iš {total}",
+      "loadMore": "Rodyti daugiau",
+      "clearSearch": "Išvalyti paiešką",
+      "record": {
+        "loading": "Įkeliamas įrašas…",
+        "loadFailed": "Nepavyko įkelti įrašo",
+        "notFound": "Šio įrašo atkūrimo taške nėra.",
+        "fields": "Informacija",
+        "jumpTo": "Pereiti į",
+        "previous": "Ankstesnis įrašas",
+        "next": "Kitas įrašas",
+        "position": "{index} iš {total}",
+        "noColumns": "Šiose eilutėse nėra ką rodyti.",
+        "yes": "Taip",
+        "no": "Ne"
+      },
+      "sections": {
+        "concerns": "Kliento pranešimai",
+        "partItems": "Dalys",
+        "laborItems": "Darbai",
+        "attachments": "Nuotraukos ir dokumentai",
+        "payments": "Mokėjimai",
+        "statusReports": "Būsenos ataskaitos",
+        "timeEntries": "Laiko įrašai",
+        "notes": "Pastabos",
+        "fuelLogs": "Degalų įrašai",
+        "reminders": "Priminimai",
+        "serviceRequests": "Paslaugų užklausos",
+        "inspectionStatus": "Apžiūros būsena",
+        "findings": "Pastebėjimai",
+        "conditionMarks": "Būklės žymos",
+        "aiMessages": "DI žinutės",
+        "recurringInvoices": "Periodinės sąskaitos",
+        "serviceRecords": "Darbo užsakymai",
+        "movements": "Atsargų judėjimas",
+        "gallery": "Galerija"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Suplanuotos atsarginės kopijos",
+      "description": "Automatinės atsarginės kopijos į jums priklausančią saugyklą pagal jūsų pasirinktą grafiką.",
+      "empty": "Dar neprijungta jokia atsarginių kopijų paskirties vieta.",
+      "setUp": "Nustatyti atsarginių kopijų paskirties vietą",
+      "lastGood": "Paskutinė sėkminga atsarginė kopija: {when}",
+      "noGoodBackup": "Dar nėra sėkmingos atsarginės kopijos",
+      "nextRun": "kitas vykdymas: {when}",
+      "notScheduled": "nesuplanuota",
+      "lastError": "Paskutinė klaida: {error}",
+      "extraCopy": "Papildoma kopija jums priklausančioje saugykloje pagal jūsų pasirinktą grafiką, be Torqvoice kuriamų atsarginių kopijų."
+    }
   },
   },
   "license": {
   "license": {
     "title": "\"White-Label\" licencija",
     "title": "\"White-Label\" licencija",

+ 7 - 2
messages/nb/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "La til en merketype",
     "settings_markType_create": "La til en merketype",
     "settings_markType_update": "Endret en merketype",
     "settings_markType_update": "Endret en merketype",
     "settings_markType_remove": "Fjernet en merketype",
     "settings_markType_remove": "Fjernet en merketype",
-    "settings_markType_reorder": "Endret rekkefølgen på merketypene"
+    "settings_markType_reorder": "Endret rekkefølgen på merketypene",
+    "integration_backup_run": "Startet sikkerhetskopiering",
+    "integration_backup_restore": "Gjenopprettet sikkerhetskopi"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Fjernet et merke fra tilstandskartet",
     "condition_mark_remove": "Fjernet et merke fra tilstandskartet",
     "condition_mark_carry": "Bekreftet at {count, plural, one {# tidligere merke} other {# tidligere merker}} fortsatt er der på tilstandskartet",
     "condition_mark_carry": "Bekreftet at {count, plural, one {# tidligere merke} other {# tidligere merker}} fortsatt er der på tilstandskartet",
     "condition_map_on_invoice_on": "Satte tilstandskartet på fakturaen",
     "condition_map_on_invoice_on": "Satte tilstandskartet på fakturaen",
-    "condition_map_on_invoice_off": "Tok tilstandskartet av fakturaen"
+    "condition_map_on_invoice_off": "Tok tilstandskartet av fakturaen",
+    "backup_run_started": "Startet en sikkerhetskopi til {name}",
+    "backup_restore_started": "Startet gjenoppretting av sikkerhetskopi {snapshot} fra {name}",
+    "backup_restored": "Gjenopprettet sikkerhetskopi {snapshot} fra {name}"
   }
   }
 }
 }

+ 219 - 2
messages/nb/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Fakturaer til regnskapet",
       "invoices": "Fakturaer til regnskapet",
       "customers": "Kunder til regnskapet",
       "customers": "Kunder til regnskapet",
       "payments": "Betalinger begge veier"
       "payments": "Betalinger begge veier"
+    },
+    "backup": {
+      "target": "Mål for sikkerhetskopier"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Alle kjøretøy med skilt er sjekket nylig, så det er ingenting å slå opp ennå."
       "nothingDue": "Alle kjøretøy med skilt er sjekket nylig, så det er ingenting å slå opp ennå."
     },
     },
     "supersedes": "Å koble til {next} kobler fra {current}. Bare én kan være i bruk om gangen, så {current} tas ut av bruk i det {next} settes i gang.",
     "supersedes": "Å koble til {next} kobler fra {current}. Bare én kan være i bruk om gangen, så {current} tas ut av bruk i det {next} settes i gang.",
+    "platformHandlesBackups": "Torqvoice tar allerede sikkerhetskopi av dette verkstedet hver time og beholder gjenopprettingspunkter i 30 dager. Koble til en bøtte hvis du også vil ha en kopi du eier selv, som kan leses uten Torqvoice.",
+    "platformHandlesBackupsLink": "Se gjenopprettingspunktene",
     "batchRetried": "{count, plural, =0 {Ingenting å prøve på nytt} one {# jobb lagt i kø igjen} other {# jobber lagt i kø igjen}}",
     "batchRetried": "{count, plural, =0 {Ingenting å prøve på nytt} one {# jobb lagt i kø igjen} other {# jobber lagt i kø igjen}}",
-    "advanced": "Avansert"
+    "advanced": "Avansert",
+    "generate": "Generer",
+    "show": "Vis",
+    "hide": "Skjul",
+    "generatedPasswordWarning": "Ta vare på en kopi av dette passordet et trygt sted, utenfor denne serveren. Uten det kan ingen åpne disse sikkerhetskopiene, heller ikke Torqvoice.",
+    "backup": {
+      "title": "Sikkerhetskopier",
+      "description": "Når dette målet sist fikk en vellykket sikkerhetskopi, når neste kjører, og de siste kjøringene.",
+      "lastGood": "Siste vellykkede sikkerhetskopi",
+      "added": "{size} lagt til",
+      "never": "Ingen ennå",
+      "nextRun": "Neste kjøring",
+      "notScheduled": "Ikke planlagt",
+      "repositorySize": "Lagret på målet",
+      "runNow": "Ta sikkerhetskopi nå",
+      "running": "Tar sikkerhetskopi",
+      "runningHint": "En sikkerhetskopi kjører. Siden oppdaterer seg selv til den er ferdig.",
+      "started": "Sikkerhetskopiering startet",
+      "recentTitle": "Siste kjøringer",
+      "noRuns": "Ingen sikkerhetskopier har kjørt ennå.",
+      "addedNote": "Den første kjøringen laster opp alt. Hver kjøring etterpå legger bare til delene som er endret siden, og er derfor liten. Hver sikkerhetskopi er likevel komplett og kan gjenopprettes eller utforskes for seg.",
+      "notStarted": "Ikke startet",
+      "filesNewCount": "{count, plural, one {# ny fil} other {# nye filer}}",
+      "columns": {
+        "status": "Status",
+        "started": "Startet",
+        "trigger": "Startet av",
+        "duration": "Varighet",
+        "filesNew": "Nye filer",
+        "bytesAdded": "Lagt til"
+      },
+      "trigger": {
+        "scheduled": "Tidsplan",
+        "manual": "Manuelt",
+        "restore": "Gjenoppretting"
+      },
+      "status": {
+        "queued": "I kø",
+        "running": "Kjører",
+        "done": "Ferdig",
+        "failed": "Feilet",
+        "skipped": "Hoppet over"
+      },
+      "restoring": "Gjenoppretter",
+      "restoringHint": "En gjenoppretting kjører: verkstedets data erstattes med sikkerhetskopien. Siden oppdaterer seg selv til den er ferdig. Ikke arbeid i verkstedet før da.",
+      "explore": "Utforsk",
+      "restore": {
+        "showAll": "Vis alle sikkerhetskopier",
+        "refresh": "Oppdater",
+        "loading": "Leser sikkerhetskopiene på målet",
+        "loadFailed": "Sikkerhetskopiene kunne ikke listes opp",
+        "empty": "Ingen sikkerhetskopier på dette målet ennå.",
+        "needsOwner": "Bare verkstedets eier kan gjenopprette en sikkerhetskopi her.",
+        "needsAdmin": "Bare en eier eller administrator kan gjenopprette en sikkerhetskopi.",
+        "filesIncluded": "Bilder og filer er tatt med",
+        "filesNotIncluded": "Bilder og filer er ikke tatt med",
+        "earlierInstall": "Tidligere installasjon",
+        "action": "Gjenopprett",
+        "dialogTitle": "Gjenopprette sikkerhetskopien fra {time}?",
+        "warning": "Gjenoppretting <bold>erstatter alt dette verkstedet har nå</bold>: kunder, kjøretøy, arbeidsordrer, fakturaer, innstillinger og, når sikkerhetskopien inneholder dem, bilder og filer. Alt som er lagt til etter at sikkerhetskopien ble tatt, forsvinner. Dette kan ikke angres.",
+        "secretsNote": "Hemmelige nøkler som sikkerhetskopien utelot, beholder verdiene denne installasjonen har nå. Brukerkontoer og passord røres ikke.",
+        "confirmPrompt": "Skriv inn verkstedets navn, <bold>{name}</bold>, for å bekrefte.",
+        "whileRunning": "Gjenopprettingen kjører i bakgrunnen og tar noen minutter når det er mange bilder. Siden oppdaterer seg selv til den er ferdig.",
+        "cancel": "Avbryt",
+        "confirm": "Gjenopprett denne sikkerhetskopien",
+        "started": "Gjenoppretting startet"
+      }
+    },
+    "lockedBackups": "Planlagte, krypterte sikkerhetskopier av verkstedet ditt til lagring du eier selv.",
+    "searchOrType": "Søk eller skriv et navn",
+    "useTyped": "Bruk «{value}»",
+    "listNeedsKeys": "Skriv inn nøklene ovenfor for å se listen",
+    "listEmpty": "Ingenting ble listet opp. Skriv inn et navn i stedet.",
+    "testing": "Tester tilkoblingen",
+    "testOk": "Tilkoblingen fungerer: et testobjekt ble skrevet og fjernet igjen.",
+    "testFailed": "Testen feilet",
+    "testSteps": {
+      "settings": "Sjekk innstillingene hos leverandøren",
+      "list": "Nøkkelen kan ikke liste bøtta",
+      "write": "Nøkkelen kan ikke skrive til bøtta",
+      "delete": "Nøkkelen kan ikke slette fra bøtta",
+      "folder": "Mappen kan ikke opprettes eller åpnes"
+    },
+    "testHint": "Fyll inn nøklene og bøtta for å teste dem før du kobler til. Testen skriver ett lite objekt og fjerner det igjen."
   },
   },
   "activity": {
   "activity": {
     "title": "Aktivitet",
     "title": "Aktivitet",
@@ -199,7 +287,10 @@
     "invalid": "Svaret fra leverandøren var ufullstendig. Prøv igjen.",
     "invalid": "Svaret fra leverandøren var ufullstendig. Prøv igjen.",
     "state": "Denne påloggingen har utløpt eller tilhører en annen økt. Start på nytt.",
     "state": "Denne påloggingen har utløpt eller tilhører en annen økt. Start på nytt.",
     "exchange": "Leverandøren avviste påloggingen. Sjekk app-legitimasjonen og prøv igjen.",
     "exchange": "Leverandøren avviste påloggingen. Sjekk app-legitimasjonen og prøv igjen.",
-    "identify": "Tilkoblet, men kontoen kunne ikke leses. Prøv å koble til på nytt."
+    "identify": "Tilkoblet, men kontoen kunne ikke leses. Prøv å koble til på nytt.",
+    "backupRunning": "En sikkerhetskopi kjører allerede.",
+    "restoreOwnerOnly": "På Torqvoice Cloud kan bare verkstedets eier gjenopprette en sikkerhetskopi.",
+    "restoreBusy": "En sikkerhetskopi eller gjenoppretting kjører allerede på denne serveren. Prøv igjen når den er ferdig."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Bli med i videosamtale",
     "join": "Bli med i videosamtale",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Microsoft 365-kalender",
       "description": "Legg planlagte arbeidsordre i en Outlook-kalender, legg til en Microsoft Teams-lenke og se kalenderens opptatte tid i Torqvoice.",
       "description": "Legg planlagte arbeidsordre i en Outlook-kalender, legg til en Microsoft Teams-lenke og se kalenderens opptatte tid i Torqvoice.",
       "tenantHelp": "Registrer en applikasjon i Microsoft Entra (App registrations), tillat både personlige kontoer og jobbkontoer, legg til omdirigerings-URI-en nedenfor som en Web-plattform, opprett en klienthemmelighet og lim inn begge her.",
       "tenantHelp": "Registrer en applikasjon i Microsoft Entra (App registrations), tillat både personlige kontoer og jobbkontoer, legg til omdirigerings-URI-en nedenfor som en Web-plattform, opprett en klienthemmelighet og lim inn begge her.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "OpenAI-kompatibel",
       "description": "Servicebeskrivelser, historikksammendrag, dokumentlesing og assistenten, besvart av en hvilken som helst server som snakker OpenAIs chat-API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM og lignende. Dataene dine blir på din egen maskinvare.",
       "description": "Servicebeskrivelser, historikksammendrag, dokumentlesing og assistenten, besvart av en hvilken som helst server som snakker OpenAIs chat-API: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM og lignende. Dataene dine blir på din egen maskinvare.",
       "fields": {
       "fields": {
         "baseUrl": "Base-URL",
         "baseUrl": "Base-URL",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Tale til tekst",
       "description": "Diktering på en arbeidsordre, skrevet ned av en talemodell i stedet for nettleseren: langt mer nøyaktig, alle språk, og den kan verkstedets ord. Atskilt fra AI-leverandøren din, så den kan du beholde som den er. Bruker din egen OpenAI-nøkkel, eller en egen server som snakker OpenAIs API for transkribering. Opptaket sendes for å skrives ned og lagres ikke.",
       "description": "Diktering på en arbeidsordre, skrevet ned av en talemodell i stedet for nettleseren: langt mer nøyaktig, alle språk, og den kan verkstedets ord. Atskilt fra AI-leverandøren din, så den kan du beholde som den er. Bruker din egen OpenAI-nøkkel, eller en egen server som snakker OpenAIs API for transkribering. Opptaket sendes for å skrives ned og lagres ikke.",
       "fields": {
       "fields": {
         "apiKey": "API-nøkkel",
         "apiKey": "API-nøkkel",
@@ -653,6 +747,108 @@
         "modeAi": "Alltid talemodellen",
         "modeAi": "Alltid talemodellen",
         "modeChoice": "Hver person velger: talemodell eller nettleser"
         "modeChoice": "Hver person velger: talemodell eller nettleser"
       }
       }
+    },
+    "s3": {
+      "name": "Bucket i skyen",
+      "description": "Krypterte sikkerhetskopier av dette verkstedet til en bøtte du eier hos Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage eller annen S3-kompatibel lagring, etter en tidsplan du velger. Etter første kjøring lastes bare det som er endret opp, så sikkerhetskopier hver time forblir billige.",
+      "fields": {
+        "accessKeyId": "Tilgangsnøkkel-ID",
+        "accessKeyIdHelp": "En nøkkel fra lagringsleverandøren din med tillatelse til å liste, lese, skrive og slette objekter i bøtta. Lag en egen nøkkel bare for Torqvoice, så den kan trekkes tilbake for seg selv.",
+        "secretAccessKey": "Hemmelig tilgangsnøkkel",
+        "password": "Passord for sikkerhetskopier",
+        "passwordHelp": "Hver sikkerhetskopi krypteres med dette passordet. Finn på et eller trykk Generer, og ta vare på en kopi utenfor denne serveren: uten det kan ingen åpne disse sikkerhetskopiene, heller ikke Torqvoice. For å koble til igjen lagring som allerede har sikkerhetskopier fra Torqvoice, skriv inn passordet de ble laget med.",
+        "endpoint": "Egendefinert endepunkt",
+        "endpointHelp": "Bare for valget Annen S3-kompatibel lagring: S3-adressen til din egen lagring, for eksempel en MinIO-server, som vertsnavn med port om nødvendig."
+      },
+      "settings": {
+        "provider": "Leverandør",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Annen S3-kompatibel lagring",
+        "region": "Region",
+        "regionHelp": "Hvor bøtta ligger. Velg den fra leverandørens liste; for annen lagring skriver du inn det lagringen forventer, hvis den trenger en region. Brukes ikke av Cloudflare R2.",
+        "accountId": "Cloudflare-konto-ID",
+        "accountIdHelp": "ID-en på 32 tegn som vises på R2-oversiktssiden i Cloudflare-dashbordet ditt.",
+        "bucket": "Bøtte",
+        "bucketHelp": "Listen hentes med nøkkelen så snart den er skrevet inn. Kan nøkkelen bruke én bøtte, men ikke liste dem, skriver du inn navnet selv.",
+        "prefix": "Mappe i bøtta",
+        "prefixHelp": "Sikkerhetskopiene ligger under denne mappen. Bruk en egen mappe for hvert verksted som deler bøtta.",
+        "frequency": "Hvor ofte",
+        "frequencyHourly": "Hver time",
+        "frequencyEvery4h": "Hver 4. time",
+        "frequencyDaily": "Hver dag",
+        "frequencyWeekly": "Hver uke",
+        "frequencyMonthly": "Hver måned",
+        "time": "Klokkeslett",
+        "timeHelp": "Etter verkstedets klokke, som HH:MM. Brukes av daglige, ukentlige og månedlige sikkerhetskopier.",
+        "weekday": "Ukedag",
+        "sunday": "Søndag",
+        "monday": "Mandag",
+        "tuesday": "Tirsdag",
+        "wednesday": "Onsdag",
+        "thursday": "Torsdag",
+        "friday": "Fredag",
+        "saturday": "Lørdag",
+        "monthDay": "Dag i måneden",
+        "monthDayLast": "Siste dag",
+        "contents": "Innhold",
+        "contentsHelp": "Hemmelige nøkler er de dette verkstedet har skrevet inn i Torqvoice: nøkler for betaling, AI og webhooks. Hver sikkerhetskopi krypteres uansett med passordet for sikkerhetskopier. En sikkerhetskopi inneholder verkstedets data og, hvis du velger det, filene. Brukerkontoer og passord er ikke med: på en ny server logger du inn, inviterer teamet og gjenoppretter dataene i det verkstedet.",
+        "contentsData": "Bare data",
+        "contentsDataAndSecrets": "Data og hemmelige nøkler",
+        "includeFiles": "Ta med bilder og filer",
+        "includeFilesHelp": "Hver fil lastes opp én gang og gjenbrukes av senere sikkerhetskopier, så dette koster lite etter første kjøring.",
+        "keepHourly": "Behold timesvise sikkerhetskopier",
+        "keepHourlyHelp": "Hvor mange av de nyeste timesvise sikkerhetskopiene som beholdes. Gjelder bare for tidsplanene hver time og hver 4. time.",
+        "keepDaily": "Behold daglige sikkerhetskopier",
+        "keepWeekly": "Behold ukentlige sikkerhetskopier",
+        "keepMonthly": "Behold månedlige sikkerhetskopier",
+        "keepMonthlyHelp": "Eldre sikkerhetskopier fjernes etter hver kjøring, og de nyeste av hver type beholdes. Sett et tall til 0 for ikke å beholde noen av den typen."
+      }
+    },
+    "local-folder": {
+      "name": "Lokal mappe",
+      "description": "Krypterte sikkerhetskopier av dette verkstedet til en mappe på denne serveren: volumet for sikkerhetskopier i compose-filen, eller en nettverksdisk montert der. En kopi på samme disk som databasen er ikke trygg alene; kombiner den med lagring et annet sted.",
+      "fields": {
+        "password": "Passord for sikkerhetskopier",
+        "passwordHelp": "Hver sikkerhetskopi krypteres med dette passordet. Finn på et eller trykk Generer, og ta vare på en kopi utenfor denne serveren: uten det kan ingen åpne disse sikkerhetskopiene, heller ikke Torqvoice. For å koble til igjen lagring som allerede har sikkerhetskopier fra Torqvoice, skriv inn passordet de ble laget med."
+      },
+      "settings": {
+        "folder": "Mappe",
+        "folderHelp": "Mappen under serverens sikkerhetskopirot som holder dette verkstedets sikkerhetskopier. La feltet stå tomt for å bruke en mappe oppkalt etter dette verkstedet. På en ny server skriver du inn mappenavnet en tidligere installasjon brukte, for å få tilbake sikkerhetskopiene.",
+        "frequency": "Hvor ofte",
+        "frequencyHourly": "Hver time",
+        "frequencyEvery4h": "Hver 4. time",
+        "frequencyDaily": "Hver dag",
+        "frequencyWeekly": "Hver uke",
+        "frequencyMonthly": "Hver måned",
+        "time": "Klokkeslett",
+        "timeHelp": "Etter verkstedets klokke, som HH:MM. Brukes av daglige, ukentlige og månedlige sikkerhetskopier.",
+        "weekday": "Ukedag",
+        "sunday": "Søndag",
+        "monday": "Mandag",
+        "tuesday": "Tirsdag",
+        "wednesday": "Onsdag",
+        "thursday": "Torsdag",
+        "friday": "Fredag",
+        "saturday": "Lørdag",
+        "monthDay": "Dag i måneden",
+        "monthDayLast": "Siste dag",
+        "contents": "Innhold",
+        "contentsHelp": "Hemmelige nøkler er de dette verkstedet har skrevet inn i Torqvoice: nøkler for betaling, AI og webhooks. Hver sikkerhetskopi krypteres uansett med passordet for sikkerhetskopier. En sikkerhetskopi inneholder verkstedets data og, hvis du velger det, filene. Brukerkontoer og passord er ikke med: på en ny server logger du inn, inviterer teamet og gjenoppretter dataene i det verkstedet.",
+        "contentsData": "Bare data",
+        "contentsDataAndSecrets": "Data og hemmelige nøkler",
+        "includeFiles": "Ta med bilder og filer",
+        "includeFilesHelp": "Hver fil lastes opp én gang og gjenbrukes av senere sikkerhetskopier, så dette koster lite etter første kjøring.",
+        "keepHourly": "Behold timesvise sikkerhetskopier",
+        "keepHourlyHelp": "Hvor mange av de nyeste timesvise sikkerhetskopiene som beholdes. Gjelder bare for tidsplanene hver time og hver 4. time.",
+        "keepDaily": "Behold daglige sikkerhetskopier",
+        "keepWeekly": "Behold ukentlige sikkerhetskopier",
+        "keepMonthly": "Behold månedlige sikkerhetskopier",
+        "keepMonthlyHelp": "Eldre sikkerhetskopier fjernes etter hver kjøring, og de nyeste av hver type beholdes. Sett et tall til 0 for ikke å beholde noen av den typen."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Oppdatering av tilbakekallingsdata",
       "refresh": "Oppdatering av tilbakekallingsdata",
       "vehicle": "Tilbakekallingssjekk for nytt kjøretøy"
       "vehicle": "Tilbakekallingssjekk for nytt kjøretøy"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "mål for sikkerhetskopier",
+    "failedTitle": "Sikkerhetskopiering mislyktes",
+    "failedMessage": "Sikkerhetskopien til {name} ble ikke fullført: {reason}",
+    "failedSubject": "Sikkerhetskopiering til {name} mislyktes",
+    "failedEmailIntro": "Den planlagte sikkerhetskopien av verkstedet ditt til {name} ble ikke fullført.",
+    "openToRetry": "Åpne Innstillinger, Integrasjoner, {name} for å se kjøringen og prøve igjen.",
+    "overdueTitle": "Sikkerhetskopi forsinket",
+    "overdueSince": "Ingen sikkerhetskopi til {name} er fullført siden {time}.",
+    "overdueNever": "Ingen sikkerhetskopi til {name} er fullført ennå.",
+    "overdueSubject": "Sikkerhetskopi til {name} er forsinket",
+    "openToSee": "Åpne Innstillinger, Integrasjoner, {name} for å se hva som gikk galt.",
+    "platformName": "Sikkerhetskopier fra Torqvoice",
+    "restoredTitle": "Verkstedet er gjenopprettet",
+    "restoredSubject": "Verkstedet ditt ble gjenopprettet fra en sikkerhetskopi",
+    "restoredIntro": "{name}: verkstedet ble satt tilbake til gjenopprettingspunkt {snapshot} {time}. Alt som ble lagt inn etter det punktet, finnes ikke lenger i verkstedet.",
+    "restoredByMember": "Et medlem av verkstedet ditt ba om denne gjenopprettingen.",
+    "restoredBySupport": "Torqvoice-supporten gjorde dette etter forespørsel fra deg. Oppgitt grunn: {reason}",
+    "noReason": "ingen oppgitt",
+    "restoredUndo": "Først ble det tatt en kopi av verkstedet slik det var rett før gjenopprettingen, og den beholdes i 90 dager. Kontakt supporten for å gå tilbake til den."
   }
   }
 }
 }

+ 161 - 1
messages/nb/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "For mange forsøk. Vent noen minutter og prøv igjen.",
     "reauthTooManyAttempts": "For mange forsøk. Vent noen minutter og prøv igjen.",
     "reauthSignInAgainAccount": "Logg inn på nytt før du sletter kontoen din.",
     "reauthSignInAgainAccount": "Logg inn på nytt før du sletter kontoen din.",
     "reauthSignInAgainWorkshop": "Logg inn på nytt før du sletter verkstedet.",
     "reauthSignInAgainWorkshop": "Logg inn på nytt før du sletter verkstedet.",
+    "reauthSignInAgainRestore": "Logg inn på nytt før du gjenoppretter verkstedet.",
     "deleteAccountMoved": "Vil du slette kontoen din?",
     "deleteAccountMoved": "Vil du slette kontoen din?",
     "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene.",
     "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene.",
     "signatureTitle": "Signatur",
     "signatureTitle": "Signatur",
@@ -1399,6 +1400,11 @@
     "importComplete": "Import fullført",
     "importComplete": "Import fullført",
     "importCompleteMessage": "Alle data er gjenopprettet fra sikkerhetskopifilen.",
     "importCompleteMessage": "Alle data er gjenopprettet fra sikkerhetskopifilen.",
     "importFailed": "Import mislyktes",
     "importFailed": "Import mislyktes",
+    "restoreRefused": {
+      "busy": "En sikkerhetskopi eller gjenoppretting kjører allerede på denne serveren. Prøv igjen når den er ferdig.",
+      "ownerOnly": "På Torqvoice Cloud kan bare verkstedets eier gjenopprette en sikkerhetskopi.",
+      "foreignIds": "Filen viser til oppføringer som tilhører et annet verksted, så ingenting ble gjenopprettet."
+    },
     "importFromOther": "Importer fra andre tjenester",
     "importFromOther": "Importer fra andre tjenester",
     "importFromOtherDescription": "Migrer dataene dine fra en annen plattform. Velg en tjeneste for å komme i gang.",
     "importFromOtherDescription": "Migrer dataene dine fra en annen plattform. Velg en tjeneste for å komme i gang.",
     "lubelogTitle": "Importer fra LubeLog",
     "lubelogTitle": "Importer fra LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Torqvoice-sikkerhetskopi",
     "sourceBackup": "Torqvoice-sikkerhetskopi",
     "sourceBackupDesc": "En .zip- eller .json-fil lastet ned fra denne siden. Gjenoppretting erstatter alt i dette verkstedet.",
     "sourceBackupDesc": "En .zip- eller .json-fil lastet ned fra denne siden. Gjenoppretting erstatter alt i dette verkstedet.",
     "restore": "Gjenopprett",
     "restore": "Gjenopprett",
-    "restoreTitle": "Gjenopprett en sikkerhetskopi"
+    "restoreTitle": "Gjenopprett en sikkerhetskopi",
+    "includeSecrets": "Ta med hemmelige nøkler",
+    "includeSecretsHelp": "Hemmelige nøkler, som betalings- og AI-nøkler, utelates med mindre du krysser av her. En gjenoppretting beholder da nøklene denne installasjonen allerede har.",
+    "platformBackups": {
+      "title": "Sikkerhetskopier fra Torqvoice",
+      "description": "Torqvoice tar sikkerhetskopi av dette verkstedet hver time og beholder gjenopprettingspunkter i 30 dager, deretter ukentlige og månedlige.",
+      "runNow": "Ta sikkerhetskopi nå",
+      "started": "Sikkerhetskopiering startet",
+      "lastGood": "Siste vellykkede sikkerhetskopi",
+      "never": "Ingen sikkerhetskopi ennå",
+      "nextRun": "Neste kjøring",
+      "notScheduled": "Ikke planlagt",
+      "repositorySize": "Lagret hos Torqvoice",
+      "running": "Kjører",
+      "restoring": "Gjenoppretter",
+      "queued": "I kø",
+      "queuedAhead": "{count, plural, one {I kø, # foran} other {I kø, # foran}}",
+      "runningHint": "En sikkerhetskopi kjører. Kortet oppdaterer seg selv til den er ferdig.",
+      "restoringHint": "En gjenoppretting kjører: verkstedets data erstattes med gjenopprettingspunktet. Siden oppdaterer seg selv til den er ferdig. Ikke arbeid i verkstedet før da.",
+      "paused": "Planen din inkluderer ikke lenger sikkerhetskopier fra Torqvoice. Kjøringene er stoppet; gjenopprettingspunktene beholdes til {date} og slettes deretter.",
+      "upsell": "Tilgjengelig med Pro: sikkerhetskopier hver time fra Torqvoice, med gjenopprettingspunkter du kan gå tilbake til.",
+      "planLink": "Se planer",
+      "pointsTitle": "Gjenopprettingspunkter",
+      "pointsEmpty": "Den første sikkerhetskopien har ikke kjørt ennå.",
+      "showAll": "Vis alle {count}",
+      "showFewer": "Vis færre",
+      "needsOwner": "Bare verkstedets eier kan gjenopprette.",
+      "sizeNote": "Hvert gjenopprettingspunkt er komplett i seg selv; størrelsen er alt det omfatter. Arkivet lagrer hver del bare én gang, og senere kjøringer legger bare til det som er endret. Derfor holder «Lagret hos Torqvoice» seg langt under summen av størrelsene.",
+      "recentTitle": "Siste kjøringer",
+      "noRuns": "Ingen sikkerhetskopier har kjørt ennå.",
+      "notStarted": "Ikke startet",
+      "supportReason": "Gjenopprettet av Torqvoice-supporten: {reason}",
+      "columns": {
+        "when": "Når",
+        "kind": "Type",
+        "size": "Størrelse",
+        "status": "Status",
+        "trigger": "Startet av",
+        "duration": "Varighet"
+      },
+      "kind": {
+        "scheduled": "Planlagt",
+        "manual": "Manuell",
+        "safety": "Kopi før gjenoppretting",
+        "safetyHint": "Tatt rett før en gjenoppretting, så den kan angres"
+      },
+      "trigger": {
+        "scheduled": "Planlagt",
+        "manual": "Manuelt",
+        "restore": "Gjenoppretting"
+      },
+      "status": {
+        "queued": "I kø",
+        "running": "Kjører",
+        "done": "Ferdig",
+        "failed": "Feilet",
+        "skipped": "Hoppet over"
+      },
+      "explore": "Utforsk",
+      "restore": {
+        "action": "Gjenopprett",
+        "dialogTitle": "Gjenopprette verkstedet til {time}?",
+        "warning": "Gjenopprettingspunktet fra {time} <bold>erstatter alt i dette verkstedet</bold>: kunder, kjøretøy, arbeidsordrer, fakturaer, innstillinger, bilder og filer. Alt som er lagt til siden da, forsvinner.",
+        "safetyNote": "Først tas en kopi av verkstedet slik det er nå, og den beholdes i 90 dager, så gjenopprettingen kan angres fra gjenopprettingspunktene.",
+        "untouched": "Medlemmer, kontoer, abonnementet og integrasjoner røres ikke. Hemmelige nøkler som ikke er i sikkerhetskopien, beholder verdiene de har nå.",
+        "confirmPrompt": "Skriv inn verkstedets navn, <bold>{name}</bold>, for å bekrefte.",
+        "whileRunning": "Gjenopprettingen kjører i bakgrunnen og tar noen minutter når det er mange bilder. Siden oppdaterer seg selv til den er ferdig.",
+        "cancel": "Avbryt",
+        "confirm": "Gjenopprett",
+        "started": "Gjenoppretting startet"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Tilbake til sikkerhetskopiene",
+      "title": "Gjenopprettingspunkt fra {date}",
+      "titleShort": "Gjenopprettingspunkt {id}",
+      "readOnly": "Skrivebeskyttet: ingenting her endrer verkstedet. Visningen lukkes etter 15 minutter uten aktivitet.",
+      "readOnlyBadge": "Skrivebeskyttet",
+      "opening": "Åpner gjenopprettingspunktet",
+      "openingHint": "Sikkerhetskopien dekrypteres her på serveren og holdes i minnet i et kvarter. Ingenting i verkstedet endres.",
+      "openingSteps": {
+        "connecting": "Kobler til sikkerhetskopiarkivet",
+        "locating": "Finner datafilen i øyeblikksbildet",
+        "pulling": "Henter og dekrypterer dataene",
+        "reading": "Leser postene",
+        "indexing": "Indekserer kunder, kjøretøy og arbeidsordrer"
+      },
+      "openingReady": "Klar",
+      "openFailed": "Gjenopprettingspunktet kunne ikke åpnes",
+      "tryAgain": "Prøv igjen",
+      "ownerOnly": "Bare verkstedets eier eller en administrator kan se inn i et gjenopprettingspunkt.",
+      "closed": "Gjenopprettingspunktet ble lukket; åpne det igjen",
+      "openAgain": "Åpne igjen",
+      "datasets": {
+        "customers": "Kunder",
+        "vehicles": "Kjøretøy",
+        "serviceRecords": "Arbeidsordrer",
+        "quotes": "Tilbud",
+        "inventoryParts": "Deler"
+      },
+      "searchPlaceholder": "Søk etter navn, nummer, skilt, VIN, e-post eller telefon",
+      "loading": "Laster…",
+      "loadFailed": "Listen kunne ikke lastes",
+      "empty": "Det finnes ingenting av denne typen i gjenopprettingspunktet.",
+      "noMatches": "Ingen treff på «{query}».",
+      "showing": "{shown} av {total} vist",
+      "loadMore": "Vis flere",
+      "clearSearch": "Tøm søket",
+      "record": {
+        "loading": "Laster posten…",
+        "loadFailed": "Posten kunne ikke lastes",
+        "notFound": "Denne posten finnes ikke i gjenopprettingspunktet.",
+        "fields": "Detaljer",
+        "jumpTo": "Gå til",
+        "previous": "Forrige post",
+        "next": "Neste post",
+        "position": "{index} av {total}",
+        "noColumns": "Ingenting å vise i disse radene.",
+        "yes": "Ja",
+        "no": "Nei"
+      },
+      "sections": {
+        "concerns": "Kundens beskrivelse",
+        "partItems": "Deler",
+        "laborItems": "Arbeid",
+        "attachments": "Bilder og dokumenter",
+        "payments": "Betalinger",
+        "statusReports": "Statusrapporter",
+        "timeEntries": "Tidsoppføringer",
+        "notes": "Notater",
+        "fuelLogs": "Drivstofflogg",
+        "reminders": "Påminnelser",
+        "serviceRequests": "Serviceforespørsler",
+        "inspectionStatus": "EU-kontroll-status",
+        "findings": "Funn",
+        "conditionMarks": "Tilstandsmerker",
+        "aiMessages": "AI-meldinger",
+        "recurringInvoices": "Gjentakende fakturaer",
+        "serviceRecords": "Arbeidsordrer",
+        "movements": "Lagerbevegelser",
+        "gallery": "Galleri"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Planlagte sikkerhetskopier",
+      "description": "Automatiske sikkerhetskopier til lagring du eier selv, etter en tidsplan du velger.",
+      "empty": "Ingen mål for sikkerhetskopier er koblet til ennå.",
+      "setUp": "Sett opp et mål for sikkerhetskopier",
+      "lastGood": "Siste vellykkede sikkerhetskopi: {when}",
+      "noGoodBackup": "Ingen vellykket sikkerhetskopi ennå",
+      "nextRun": "neste kjøring: {when}",
+      "notScheduled": "ikke planlagt",
+      "lastError": "Siste feil: {error}",
+      "extraCopy": "En ekstra kopi i lagring du eier selv, etter en tidsplan du velger, i tillegg til sikkerhetskopiene Torqvoice tar."
+    }
   },
   },
   "license": {
   "license": {
     "title": "White-label-lisens",
     "title": "White-label-lisens",

+ 7 - 2
messages/nl/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Markeringstype toegevoegd",
     "settings_markType_create": "Markeringstype toegevoegd",
     "settings_markType_update": "Markeringstype gewijzigd",
     "settings_markType_update": "Markeringstype gewijzigd",
     "settings_markType_remove": "Markeringstype verwijderd",
     "settings_markType_remove": "Markeringstype verwijderd",
-    "settings_markType_reorder": "Markeringstypen herschikt"
+    "settings_markType_reorder": "Markeringstypen herschikt",
+    "integration_backup_run": "Back-up gestart",
+    "integration_backup_restore": "Back-up teruggezet"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Markering van de staatkaart verwijderd",
     "condition_mark_remove": "Markering van de staatkaart verwijderd",
     "condition_mark_carry": "{count, plural, one {# eerdere markering} other {# eerdere markeringen}} op de staatkaart bevestigd als nog aanwezig",
     "condition_mark_carry": "{count, plural, one {# eerdere markering} other {# eerdere markeringen}} op de staatkaart bevestigd als nog aanwezig",
     "condition_map_on_invoice_on": "Staatkaart op de factuur gezet",
     "condition_map_on_invoice_on": "Staatkaart op de factuur gezet",
-    "condition_map_on_invoice_off": "Staatkaart van de factuur gehaald"
+    "condition_map_on_invoice_off": "Staatkaart van de factuur gehaald",
+    "backup_run_started": "Back-up naar {name} gestart",
+    "backup_restore_started": "Terugzetten van back-up {snapshot} uit {name} gestart",
+    "backup_restored": "Back-up {snapshot} uit {name} teruggezet"
   }
   }
 }
 }

+ 219 - 2
messages/nl/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Facturen naar de boekhouding",
       "invoices": "Facturen naar de boekhouding",
       "customers": "Klanten naar de boekhouding",
       "customers": "Klanten naar de boekhouding",
       "payments": "Betalingen in beide richtingen"
       "payments": "Betalingen in beide richtingen"
+    },
+    "backup": {
+      "target": "Back-upbestemming"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Alle voertuigen met een kenteken zijn onlangs gecontroleerd, er valt nog niets op te zoeken."
       "nothingDue": "Alle voertuigen met een kenteken zijn onlangs gecontroleerd, er valt nog niets op te zoeken."
     },
     },
     "supersedes": "Als u {next} verbindt, wordt {current} losgekoppeld. Er kan er maar één tegelijk actief zijn, dus {current} wordt niet meer gebruikt zodra {next} live gaat.",
     "supersedes": "Als u {next} verbindt, wordt {current} losgekoppeld. Er kan er maar één tegelijk actief zijn, dus {current} wordt niet meer gebruikt zodra {next} live gaat.",
+    "platformHandlesBackups": "Torqvoice maakt al elk uur een back-up van deze werkplaats en bewaart herstelpunten 30 dagen. Koppel een bucket als u ook een kopie wilt die van u is en zonder Torqvoice te lezen is.",
+    "platformHandlesBackupsLink": "Bekijk uw herstelpunten",
     "batchRetried": "{count, plural, =0 {Niets om opnieuw te proberen} one {# taak opnieuw in de wachtrij} other {# taken opnieuw in de wachtrij}}",
     "batchRetried": "{count, plural, =0 {Niets om opnieuw te proberen} one {# taak opnieuw in de wachtrij} other {# taken opnieuw in de wachtrij}}",
-    "advanced": "Geavanceerd"
+    "advanced": "Geavanceerd",
+    "generate": "Genereren",
+    "show": "Tonen",
+    "hide": "Verbergen",
+    "generatedPasswordWarning": "Bewaar een kopie van dit wachtwoord op een veilige plek, niet op deze server. Zonder dit wachtwoord kan niemand deze back-ups openen, ook Torqvoice niet.",
+    "backup": {
+      "title": "Back-ups",
+      "description": "Wanneer deze bestemming voor het laatst een geslaagde back-up kreeg, wanneer de volgende draait en de laatste runs.",
+      "lastGood": "Laatste geslaagde back-up",
+      "added": "{size} toegevoegd",
+      "never": "Nog geen",
+      "nextRun": "Volgende run",
+      "notScheduled": "Niet gepland",
+      "repositorySize": "Opgeslagen op de bestemming",
+      "runNow": "Nu back-up maken",
+      "running": "Back-up bezig",
+      "runningHint": "Er loopt een back-up. Deze pagina werkt zichzelf bij tot hij klaar is.",
+      "started": "Back-up gestart",
+      "recentTitle": "Recente runs",
+      "noRuns": "Er zijn nog geen back-ups gemaakt.",
+      "addedNote": "De eerste run uploadt alles. Elke run daarna voegt alleen de stukken toe die sindsdien zijn veranderd, en is daarom klein. Elke back-up is toch compleet en kan op zichzelf worden hersteld of bekeken.",
+      "notStarted": "Niet gestart",
+      "filesNewCount": "{count, plural, one {# nieuw bestand} other {# nieuwe bestanden}}",
+      "columns": {
+        "status": "Status",
+        "started": "Gestart",
+        "trigger": "Gestart door",
+        "duration": "Duur",
+        "filesNew": "Nieuwe bestanden",
+        "bytesAdded": "Toegevoegd"
+      },
+      "trigger": {
+        "scheduled": "Schema",
+        "manual": "Handmatig",
+        "restore": "Terugzetten"
+      },
+      "status": {
+        "queued": "In de wachtrij",
+        "running": "Bezig",
+        "done": "Klaar",
+        "failed": "Mislukt",
+        "skipped": "Overgeslagen"
+      },
+      "restoring": "Terugzetten bezig",
+      "restoringHint": "Er wordt een back-up teruggezet: de gegevens van de werkplaats worden vervangen door de back-up. Deze pagina werkt zichzelf bij tot het klaar is. Werk tot die tijd niet in de werkplaats.",
+      "explore": "Bekijken",
+      "restore": {
+        "showAll": "Alle back-ups tonen",
+        "refresh": "Vernieuwen",
+        "loading": "De back-ups op de bestemming worden gelezen",
+        "loadFailed": "De back-ups konden niet worden opgehaald",
+        "empty": "Nog geen back-ups op deze bestemming.",
+        "needsOwner": "Alleen de eigenaar van de werkplaats kan hier een back-up terugzetten.",
+        "needsAdmin": "Alleen een eigenaar of beheerder kan een back-up terugzetten.",
+        "filesIncluded": "Foto's en bestanden meegenomen",
+        "filesNotIncluded": "Foto's en bestanden niet meegenomen",
+        "earlierInstall": "Eerdere installatie",
+        "action": "Terugzetten",
+        "dialogTitle": "De back-up van {time} terugzetten?",
+        "warning": "Terugzetten <bold>vervangt alles wat deze werkplaats nu heeft</bold>: klanten, voertuigen, werkorders, facturen, instellingen en, als de back-up ze bevat, foto's en bestanden. Alles wat sinds de back-up is toegevoegd, gaat verloren. Dit kan niet ongedaan worden gemaakt.",
+        "secretsNote": "Geheime sleutels die de back-up heeft weggelaten, houden de waarden die deze installatie nu heeft. Gebruikersaccounts en wachtwoorden blijven ongemoeid.",
+        "confirmPrompt": "Typ de naam van de werkplaats, <bold>{name}</bold>, om te bevestigen.",
+        "whileRunning": "Het terugzetten draait op de achtergrond en duurt een paar minuten als er veel foto's zijn. De pagina werkt zichzelf bij tot het klaar is.",
+        "cancel": "Annuleren",
+        "confirm": "Deze back-up terugzetten",
+        "started": "Terugzetten gestart"
+      }
+    },
+    "lockedBackups": "Geplande, versleutelde back-ups van uw werkplaats naar opslag die van u is.",
+    "searchOrType": "Zoek of typ een naam",
+    "useTyped": "\"{value}\" gebruiken",
+    "listNeedsKeys": "Vul hierboven de sleutels in om de lijst te zien",
+    "listEmpty": "Er is niets opgehaald. Typ in plaats daarvan een naam.",
+    "testing": "Verbinding wordt getest",
+    "testOk": "De verbinding werkt: er is een testobject geschreven en weer verwijderd.",
+    "testFailed": "De test is mislukt",
+    "testSteps": {
+      "settings": "Controleer de instellingen van de aanbieder",
+      "list": "De sleutel kan de bucket niet opsommen",
+      "write": "De sleutel kan niet naar de bucket schrijven",
+      "delete": "De sleutel kan niets uit de bucket verwijderen",
+      "folder": "De map kan niet worden aangemaakt of geopend"
+    },
+    "testHint": "Vul de sleutels en de bucket in om ze te testen voordat u koppelt. De test schrijft één klein object en verwijdert het weer."
   },
   },
   "activity": {
   "activity": {
     "title": "Activiteit",
     "title": "Activiteit",
@@ -199,7 +287,10 @@
     "invalid": "Het antwoord van de provider was onvolledig. Probeer het opnieuw.",
     "invalid": "Het antwoord van de provider was onvolledig. Probeer het opnieuw.",
     "state": "Deze aanmelding is verlopen of hoort bij een andere sessie. Begin opnieuw.",
     "state": "Deze aanmelding is verlopen of hoort bij een andere sessie. Begin opnieuw.",
     "exchange": "De provider heeft de aanmelding geweigerd. Controleer de app-gegevens en probeer het opnieuw.",
     "exchange": "De provider heeft de aanmelding geweigerd. Controleer de app-gegevens en probeer het opnieuw.",
-    "identify": "Gekoppeld, maar het account kon niet worden gelezen. Probeer opnieuw te koppelen."
+    "identify": "Gekoppeld, maar het account kon niet worden gelezen. Probeer opnieuw te koppelen.",
+    "backupRunning": "Er loopt al een back-up.",
+    "restoreOwnerOnly": "In Torqvoice Cloud kan alleen de eigenaar van de werkplaats een back-up terugzetten.",
+    "restoreBusy": "Er loopt al een back-up of herstel op deze server. Probeer het opnieuw als dat klaar is."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Deelnemen aan videogesprek",
     "join": "Deelnemen aan videogesprek",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Microsoft 365-agenda",
       "description": "Zet geplande werkorders in een Outlook-agenda, voeg een Microsoft Teams-link toe en bekijk de bezette tijd van de agenda in Torqvoice.",
       "description": "Zet geplande werkorders in een Outlook-agenda, voeg een Microsoft Teams-link toe en bekijk de bezette tijd van de agenda in Torqvoice.",
       "tenantHelp": "Registreer een toepassing in Microsoft Entra (App registrations), sta persoonlijke en werkaccounts toe, voeg de onderstaande redirect-URI toe als Web-platform, maak een clientgeheim aan en plak beide hier.",
       "tenantHelp": "Registreer een toepassing in Microsoft Entra (App registrations), sta persoonlijke en werkaccounts toe, voeg de onderstaande redirect-URI toe als Web-platform, maak een clientgeheim aan en plak beide hier.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "OpenAI-compatibel",
       "description": "Servicebeschrijvingen, historieksamenvattingen, documentherkenning en de assistent, beantwoord door elke server die de chat-API van OpenAI spreekt: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM en dergelijke. Uw gegevens blijven op uw eigen hardware.",
       "description": "Servicebeschrijvingen, historieksamenvattingen, documentherkenning en de assistent, beantwoord door elke server die de chat-API van OpenAI spreekt: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM en dergelijke. Uw gegevens blijven op uw eigen hardware.",
       "fields": {
       "fields": {
         "baseUrl": "Basis-URL",
         "baseUrl": "Basis-URL",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Spraak naar tekst",
       "description": "Dicteren op een werkorder, uitgeschreven door een spraakmodel in plaats van door de browser: veel nauwkeuriger, in elke taal, en het kent de woorden van een werkplaats. Los van uw AI-provider, dus die kunt u laten zoals hij is. Gebruikt uw eigen OpenAI-sleutel, of een eigen server die de transcriptie-API van OpenAI spreekt. De opname wordt alleen verstuurd om uit te schrijven en wordt niet bewaard.",
       "description": "Dicteren op een werkorder, uitgeschreven door een spraakmodel in plaats van door de browser: veel nauwkeuriger, in elke taal, en het kent de woorden van een werkplaats. Los van uw AI-provider, dus die kunt u laten zoals hij is. Gebruikt uw eigen OpenAI-sleutel, of een eigen server die de transcriptie-API van OpenAI spreekt. De opname wordt alleen verstuurd om uit te schrijven en wordt niet bewaard.",
       "fields": {
       "fields": {
         "apiKey": "API-sleutel",
         "apiKey": "API-sleutel",
@@ -653,6 +747,108 @@
         "modeAi": "Altijd het spraakmodel",
         "modeAi": "Altijd het spraakmodel",
         "modeChoice": "Ieder kiest zelf: spraakmodel of browser"
         "modeChoice": "Ieder kiest zelf: spraakmodel of browser"
       }
       }
+    },
+    "s3": {
+      "name": "Cloudopslag-bucket",
+      "description": "Versleutelde back-ups van deze werkplaats naar een bucket die van u is, bij Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage of andere S3-compatibele opslag, volgens een schema dat u kiest. Na de eerste run wordt alleen geüpload wat er veranderd is, dus back-ups elk uur blijven goedkoop.",
+      "fields": {
+        "accessKeyId": "Toegangssleutel-ID",
+        "accessKeyIdHelp": "Een sleutel van uw opslagaanbieder met toestemming om objecten in de bucket op te sommen, te lezen, te schrijven en te verwijderen. Maak een sleutel alleen voor Torqvoice, zodat u die los kunt intrekken.",
+        "secretAccessKey": "Geheime toegangssleutel",
+        "password": "Back-upwachtwoord",
+        "passwordHelp": "Elke back-up wordt versleuteld met dit wachtwoord. Verzin er een of klik op Genereren, en bewaar een kopie buiten deze server: zonder dit wachtwoord kan niemand deze back-ups openen, ook Torqvoice niet. Om opslag opnieuw te koppelen die al Torqvoice-back-ups bevat, voert u het wachtwoord in waarmee ze zijn gemaakt.",
+        "endpoint": "Eigen endpoint",
+        "endpointHelp": "Alleen voor de optie Andere S3-compatibele opslag: het S3-adres van uw eigen opslag, zoals een MinIO-server, als hostnaam met eventueel een poort."
+      },
+      "settings": {
+        "provider": "Aanbieder",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Andere S3-compatibele opslag",
+        "region": "Regio",
+        "regionHelp": "Waar de bucket staat. Kies hem uit de lijst van de aanbieder; voor andere opslag typt u wat die verwacht, als die een regio nodig heeft. Niet gebruikt door Cloudflare R2.",
+        "accountId": "Cloudflare-account-ID",
+        "accountIdHelp": "De ID van 32 tekens op de R2-overzichtspagina van uw Cloudflare-dashboard.",
+        "bucket": "Bucket",
+        "bucketHelp": "De lijst komt van de sleutel zodra die is ingevuld. Mag de sleutel één bucket gebruiken maar ze niet opsommen, typ dan zelf de naam.",
+        "prefix": "Map in de bucket",
+        "prefixHelp": "De back-ups staan in deze map. Gebruik een andere map voor elke werkplaats die de bucket deelt.",
+        "frequency": "Hoe vaak",
+        "frequencyHourly": "Elk uur",
+        "frequencyEvery4h": "Elke 4 uur",
+        "frequencyDaily": "Elke dag",
+        "frequencyWeekly": "Elke week",
+        "frequencyMonthly": "Elke maand",
+        "time": "Tijdstip",
+        "timeHelp": "Volgens de klok van de werkplaats, als HH:MM. Gebruikt voor dagelijkse, wekelijkse en maandelijkse back-ups.",
+        "weekday": "Dag van de week",
+        "sunday": "Zondag",
+        "monday": "Maandag",
+        "tuesday": "Dinsdag",
+        "wednesday": "Woensdag",
+        "thursday": "Donderdag",
+        "friday": "Vrijdag",
+        "saturday": "Zaterdag",
+        "monthDay": "Dag van de maand",
+        "monthDayLast": "Laatste dag",
+        "contents": "Inhoud",
+        "contentsHelp": "Geheime sleutels zijn de sleutels die deze werkplaats in Torqvoice heeft ingevoerd: betaal-, AI- en webhooksleutels. Elke back-up wordt hoe dan ook versleuteld met het back-upwachtwoord. Een back-up bevat de gegevens van de werkplaats en, als u dat kiest, de bestanden. Gebruikersaccounts en wachtwoorden zitten er niet in: op een nieuwe server meldt u zich aan, nodigt u het team uit en zet u de gegevens terug in die werkplaats.",
+        "contentsData": "Alleen gegevens",
+        "contentsDataAndSecrets": "Gegevens en geheime sleutels",
+        "includeFiles": "Foto's en bestanden meenemen",
+        "includeFilesHelp": "Elk bestand wordt één keer geüpload en door latere back-ups hergebruikt, dus dit kost weinig na de eerste run.",
+        "keepHourly": "Uurlijkse back-ups bewaren",
+        "keepHourlyHelp": "Hoeveel van de recentste uurlijkse back-ups bewaard blijven. Alleen van belang bij een schema van elk uur of elke 4 uur.",
+        "keepDaily": "Dagelijkse back-ups bewaren",
+        "keepWeekly": "Wekelijkse back-ups bewaren",
+        "keepMonthly": "Maandelijkse back-ups bewaren",
+        "keepMonthlyHelp": "Oudere back-ups worden na elke run verwijderd, waarbij de nieuwste van elk soort blijven. Zet een aantal op 0 om van dat soort niets te bewaren."
+      }
+    },
+    "local-folder": {
+      "name": "Lokale map",
+      "description": "Versleutelde back-ups van deze werkplaats naar een map op deze server: het back-upvolume in het compose-bestand, of een netwerkschijf die daar is gekoppeld. Een kopie op dezelfde schijf als de database is op zichzelf niet veilig; combineer die met opslag elders.",
+      "fields": {
+        "password": "Back-upwachtwoord",
+        "passwordHelp": "Elke back-up wordt versleuteld met dit wachtwoord. Verzin er een of klik op Genereren, en bewaar een kopie buiten deze server: zonder dit wachtwoord kan niemand deze back-ups openen, ook Torqvoice niet. Om opslag opnieuw te koppelen die al Torqvoice-back-ups bevat, voert u het wachtwoord in waarmee ze zijn gemaakt."
+      },
+      "settings": {
+        "folder": "Map",
+        "folderHelp": "De map onder de back-uproot van de server waarin de back-ups van deze werkplaats staan. Laat leeg om een map te gebruiken die naar deze werkplaats is genoemd. Typ op een nieuwe server de mapnaam die een eerdere installatie gebruikte om de back-ups terug te krijgen.",
+        "frequency": "Hoe vaak",
+        "frequencyHourly": "Elk uur",
+        "frequencyEvery4h": "Elke 4 uur",
+        "frequencyDaily": "Elke dag",
+        "frequencyWeekly": "Elke week",
+        "frequencyMonthly": "Elke maand",
+        "time": "Tijdstip",
+        "timeHelp": "Volgens de klok van de werkplaats, als HH:MM. Gebruikt voor dagelijkse, wekelijkse en maandelijkse back-ups.",
+        "weekday": "Dag van de week",
+        "sunday": "Zondag",
+        "monday": "Maandag",
+        "tuesday": "Dinsdag",
+        "wednesday": "Woensdag",
+        "thursday": "Donderdag",
+        "friday": "Vrijdag",
+        "saturday": "Zaterdag",
+        "monthDay": "Dag van de maand",
+        "monthDayLast": "Laatste dag",
+        "contents": "Inhoud",
+        "contentsHelp": "Geheime sleutels zijn de sleutels die deze werkplaats in Torqvoice heeft ingevoerd: betaal-, AI- en webhooksleutels. Elke back-up wordt hoe dan ook versleuteld met het back-upwachtwoord. Een back-up bevat de gegevens van de werkplaats en, als u dat kiest, de bestanden. Gebruikersaccounts en wachtwoorden zitten er niet in: op een nieuwe server meldt u zich aan, nodigt u het team uit en zet u de gegevens terug in die werkplaats.",
+        "contentsData": "Alleen gegevens",
+        "contentsDataAndSecrets": "Gegevens en geheime sleutels",
+        "includeFiles": "Foto's en bestanden meenemen",
+        "includeFilesHelp": "Elk bestand wordt één keer geüpload en door latere back-ups hergebruikt, dus dit kost weinig na de eerste run.",
+        "keepHourly": "Uurlijkse back-ups bewaren",
+        "keepHourlyHelp": "Hoeveel van de recentste uurlijkse back-ups bewaard blijven. Alleen van belang bij een schema van elk uur of elke 4 uur.",
+        "keepDaily": "Dagelijkse back-ups bewaren",
+        "keepWeekly": "Wekelijkse back-ups bewaren",
+        "keepMonthly": "Maandelijkse back-ups bewaren",
+        "keepMonthlyHelp": "Oudere back-ups worden na elke run verwijderd, waarbij de nieuwste van elk soort blijven. Zet een aantal op 0 om van dat soort niets te bewaren."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Vernieuwing van terugroepgegevens",
       "refresh": "Vernieuwing van terugroepgegevens",
       "vehicle": "Terugroepcontrole voor een nieuw voertuig"
       "vehicle": "Terugroepcontrole voor een nieuw voertuig"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "back-upbestemming",
+    "failedTitle": "Back-up mislukt",
+    "failedMessage": "De back-up naar {name} is niet voltooid: {reason}",
+    "failedSubject": "Back-up naar {name} mislukt",
+    "failedEmailIntro": "De geplande back-up van uw werkplaats naar {name} is niet voltooid.",
+    "openToRetry": "Open Instellingen, Integraties, {name} om de run te bekijken en het opnieuw te proberen.",
+    "overdueTitle": "Back-up te laat",
+    "overdueSince": "Sinds {time} is er geen back-up naar {name} voltooid.",
+    "overdueNever": "Er is nog geen back-up naar {name} voltooid.",
+    "overdueSubject": "Back-up naar {name} is te laat",
+    "openToSee": "Open Instellingen, Integraties, {name} om te zien wat er misging.",
+    "platformName": "Back-ups door Torqvoice",
+    "restoredTitle": "Werkplaats teruggezet",
+    "restoredSubject": "Uw werkplaats is teruggezet vanuit een back-up",
+    "restoredIntro": "{name}: de werkplaats is op {time} teruggezet naar herstelpunt {snapshot}. Alles wat na dat punt is ingevoerd, staat niet meer in de werkplaats.",
+    "restoredByMember": "Een lid van uw werkplaats heeft om dit terugzetten gevraagd.",
+    "restoredBySupport": "Torqvoice-support heeft dit op uw verzoek gedaan. Opgegeven reden: {reason}",
+    "noReason": "geen opgegeven",
+    "restoredUndo": "Eerst is een veiligheidskopie gemaakt van de werkplaats zoals die vlak voor het terugzetten was; die wordt 90 dagen bewaard. Neem contact op met support om ernaar terug te gaan."
   }
   }
 }
 }

+ 161 - 1
messages/nl/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
     "reauthTooManyAttempts": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
     "reauthSignInAgainAccount": "Log opnieuw in voordat u uw account verwijdert.",
     "reauthSignInAgainAccount": "Log opnieuw in voordat u uw account verwijdert.",
     "reauthSignInAgainWorkshop": "Log opnieuw in voordat u de werkplaats verwijdert.",
     "reauthSignInAgainWorkshop": "Log opnieuw in voordat u de werkplaats verwijdert.",
+    "reauthSignInAgainRestore": "Log opnieuw in voordat u de werkplaats terugzet.",
     "deleteAccountMoved": "Wilt u uw account verwijderen?",
     "deleteAccountMoved": "Wilt u uw account verwijderen?",
     "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen.",
     "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen.",
     "signatureTitle": "Handtekening",
     "signatureTitle": "Handtekening",
@@ -1399,6 +1400,11 @@
     "importComplete": "Import voltooid",
     "importComplete": "Import voltooid",
     "importCompleteMessage": "Alle gegevens zijn hersteld uit het back-upbestand.",
     "importCompleteMessage": "Alle gegevens zijn hersteld uit het back-upbestand.",
     "importFailed": "Import mislukt",
     "importFailed": "Import mislukt",
+    "restoreRefused": {
+      "busy": "Er loopt al een back-up of herstel op deze server. Probeer het opnieuw als dat klaar is.",
+      "ownerOnly": "In Torqvoice Cloud kan alleen de eigenaar van de werkplaats een back-up terugzetten.",
+      "foreignIds": "Het bestand verwijst naar records die bij een andere werkplaats horen, dus er is niets teruggezet."
+    },
     "importFromOther": "Importeren van andere diensten",
     "importFromOther": "Importeren van andere diensten",
     "importFromOtherDescription": "Migreer uw gegevens van een ander platform. Selecteer een dienst om te beginnen.",
     "importFromOtherDescription": "Migreer uw gegevens van een ander platform. Selecteer een dienst om te beginnen.",
     "lubelogTitle": "Importeren van LubeLog",
     "lubelogTitle": "Importeren van LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Torqvoice-back-up",
     "sourceBackup": "Torqvoice-back-up",
     "sourceBackupDesc": "Een .zip- of .json-bestand dat u op deze pagina hebt gedownload. Terugzetten vervangt alles in deze werkplaats.",
     "sourceBackupDesc": "Een .zip- of .json-bestand dat u op deze pagina hebt gedownload. Terugzetten vervangt alles in deze werkplaats.",
     "restore": "Terugzetten",
     "restore": "Terugzetten",
-    "restoreTitle": "Een back-up terugzetten"
+    "restoreTitle": "Een back-up terugzetten",
+    "includeSecrets": "Geheime sleutels meenemen",
+    "includeSecretsHelp": "Geheime sleutels, zoals betaal- en AI-sleutels, worden weggelaten tenzij u dit aanvinkt. Bij terugzetten blijven dan de sleutels behouden die deze installatie al heeft.",
+    "platformBackups": {
+      "title": "Back-ups door Torqvoice",
+      "description": "Torqvoice maakt elk uur een back-up van deze werkplaats en bewaart herstelpunten 30 dagen, daarna wekelijkse en maandelijkse.",
+      "runNow": "Nu back-up maken",
+      "started": "Back-up gestart",
+      "lastGood": "Laatste geslaagde back-up",
+      "never": "Nog geen back-up",
+      "nextRun": "Volgende run",
+      "notScheduled": "Niet gepland",
+      "repositorySize": "Opgeslagen door Torqvoice",
+      "running": "Bezig",
+      "restoring": "Terugzetten bezig",
+      "queued": "In de wachtrij",
+      "queuedAhead": "{count, plural, one {In de wachtrij, # ervoor} other {In de wachtrij, # ervoor}}",
+      "runningHint": "Er loopt een back-up. Deze kaart werkt zichzelf bij tot hij klaar is.",
+      "restoringHint": "Er wordt een herstelpunt teruggezet: de gegevens van de werkplaats worden vervangen door het herstelpunt. Deze pagina werkt zichzelf bij tot het klaar is. Werk tot die tijd niet in de werkplaats.",
+      "paused": "Uw abonnement bevat geen back-ups door Torqvoice meer. De runs zijn gestopt; de herstelpunten worden bewaard tot {date} en daarna verwijderd.",
+      "upsell": "Beschikbaar met Pro: back-ups elk uur door Torqvoice, met herstelpunten waarnaar u terug kunt gaan.",
+      "planLink": "Abonnementen bekijken",
+      "pointsTitle": "Herstelpunten",
+      "pointsEmpty": "De eerste back-up is nog niet gemaakt.",
+      "showAll": "Alle {count} tonen",
+      "showFewer": "Minder tonen",
+      "needsOwner": "Alleen de eigenaar van de werkplaats kan terugzetten.",
+      "sizeNote": "Elk herstelpunt is op zichzelf compleet; de grootte is alles wat het omvat. De opslag bewaart elk stuk één keer en latere runs voegen alleen toe wat is veranderd, daarom blijft ‘Opgeslagen door Torqvoice’ ver onder de som van de groottes.",
+      "recentTitle": "Recente runs",
+      "noRuns": "Er zijn nog geen back-ups gemaakt.",
+      "notStarted": "Niet gestart",
+      "supportReason": "Teruggezet door Torqvoice-support: {reason}",
+      "columns": {
+        "when": "Wanneer",
+        "kind": "Soort",
+        "size": "Grootte",
+        "status": "Status",
+        "trigger": "Gestart door",
+        "duration": "Duur"
+      },
+      "kind": {
+        "scheduled": "Gepland",
+        "manual": "Handmatig",
+        "safety": "Veiligheidskopie",
+        "safetyHint": "Vlak voor een terugzetting gemaakt, zodat die ongedaan kan worden gemaakt"
+      },
+      "trigger": {
+        "scheduled": "Gepland",
+        "manual": "Handmatig",
+        "restore": "Terugzetten"
+      },
+      "status": {
+        "queued": "In de wachtrij",
+        "running": "Bezig",
+        "done": "Klaar",
+        "failed": "Mislukt",
+        "skipped": "Overgeslagen"
+      },
+      "explore": "Bekijken",
+      "restore": {
+        "action": "Terugzetten",
+        "dialogTitle": "De werkplaats terugzetten naar {time}?",
+        "warning": "Het herstelpunt van {time} <bold>vervangt alles in deze werkplaats</bold>: klanten, voertuigen, werkorders, facturen, instellingen, foto's en bestanden. Alles wat sindsdien is toegevoegd, gaat verloren.",
+        "safetyNote": "Eerst wordt een veiligheidskopie gemaakt van de werkplaats zoals die nu is; die wordt 90 dagen bewaard, zodat het terugzetten vanuit de herstelpunten ongedaan kan worden gemaakt.",
+        "untouched": "Leden, accounts, het abonnement en integraties blijven ongemoeid. Geheime sleutels die niet in de back-up staan, houden hun huidige waarden.",
+        "confirmPrompt": "Typ de naam van de werkplaats, <bold>{name}</bold>, om te bevestigen.",
+        "whileRunning": "Het terugzetten draait op de achtergrond en duurt een paar minuten als er veel foto's zijn. De pagina werkt zichzelf bij tot het klaar is.",
+        "cancel": "Annuleren",
+        "confirm": "Terugzetten",
+        "started": "Terugzetten gestart"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Terug naar de back-ups",
+      "title": "Herstelpunt van {date}",
+      "titleShort": "Herstelpunt {id}",
+      "readOnly": "Alleen-lezen: niets hier verandert de werkplaats. De weergave sluit na 15 minuten zonder activiteit.",
+      "readOnlyBadge": "Alleen-lezen",
+      "opening": "Herstelpunt wordt geopend",
+      "openingHint": "De back-up wordt hier op de server ontsleuteld en een kwartier in het geheugen gehouden. Er verandert niets in de werkplaats.",
+      "openingSteps": {
+        "connecting": "Verbinden met de back-upopslag",
+        "locating": "Gegevensbestand in de momentopname zoeken",
+        "pulling": "Gegevens ophalen en ontsleutelen",
+        "reading": "Records lezen",
+        "indexing": "Klanten, voertuigen en werkorders indexeren"
+      },
+      "openingReady": "Klaar",
+      "openFailed": "Het herstelpunt kon niet worden geopend",
+      "tryAgain": "Opnieuw proberen",
+      "ownerOnly": "Alleen de eigenaar van de werkplaats of een beheerder kan in een herstelpunt kijken.",
+      "closed": "Het herstelpunt is gesloten; open het opnieuw",
+      "openAgain": "Opnieuw openen",
+      "datasets": {
+        "customers": "Klanten",
+        "vehicles": "Voertuigen",
+        "serviceRecords": "Werkorders",
+        "quotes": "Offertes",
+        "inventoryParts": "Onderdelen"
+      },
+      "searchPlaceholder": "Zoeken op naam, nummer, kenteken, VIN, e-mail of telefoon",
+      "loading": "Laden…",
+      "loadFailed": "Deze lijst kon niet worden geladen",
+      "empty": "Er is niets van deze soort in het herstelpunt.",
+      "noMatches": "Niets komt overeen met \"{query}\".",
+      "showing": "{shown} van {total} getoond",
+      "loadMore": "Meer tonen",
+      "clearSearch": "Zoekopdracht wissen",
+      "record": {
+        "loading": "Record laden…",
+        "loadFailed": "Het record kon niet worden geladen",
+        "notFound": "Dit record staat niet in het herstelpunt.",
+        "fields": "Details",
+        "jumpTo": "Ga naar",
+        "previous": "Vorig record",
+        "next": "Volgend record",
+        "position": "{index} van {total}",
+        "noColumns": "Niets te tonen in deze rijen.",
+        "yes": "Ja",
+        "no": "Nee"
+      },
+      "sections": {
+        "concerns": "Klantmeldingen",
+        "partItems": "Onderdelen",
+        "laborItems": "Arbeid",
+        "attachments": "Foto's en documenten",
+        "payments": "Betalingen",
+        "statusReports": "Statusrapporten",
+        "timeEntries": "Tijdregistraties",
+        "notes": "Notities",
+        "fuelLogs": "Tankbeurten",
+        "reminders": "Herinneringen",
+        "serviceRequests": "Serviceaanvragen",
+        "inspectionStatus": "Keuringsstatus",
+        "findings": "Bevindingen",
+        "conditionMarks": "Staatmarkeringen",
+        "aiMessages": "AI-berichten",
+        "recurringInvoices": "Terugkerende facturen",
+        "serviceRecords": "Werkorders",
+        "movements": "Voorraadmutaties",
+        "gallery": "Galerij"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Geplande back-ups",
+      "description": "Automatische back-ups naar opslag die van u is, volgens een schema dat u kiest.",
+      "empty": "Er is nog geen back-upbestemming gekoppeld.",
+      "setUp": "Een back-upbestemming instellen",
+      "lastGood": "Laatste geslaagde back-up: {when}",
+      "noGoodBackup": "Nog geen geslaagde back-up",
+      "nextRun": "volgende run: {when}",
+      "notScheduled": "niet gepland",
+      "lastError": "Laatste fout: {error}",
+      "extraCopy": "Een extra kopie in opslag die van u is, volgens een schema dat u kiest, naast de back-ups die Torqvoice maakt."
+    }
   },
   },
   "license": {
   "license": {
     "title": "White-label licentie",
     "title": "White-label licentie",

+ 7 - 2
messages/pl/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Dodano rodzaj oznaczenia",
     "settings_markType_create": "Dodano rodzaj oznaczenia",
     "settings_markType_update": "Zmieniono rodzaj oznaczenia",
     "settings_markType_update": "Zmieniono rodzaj oznaczenia",
     "settings_markType_remove": "Usunięto rodzaj oznaczenia",
     "settings_markType_remove": "Usunięto rodzaj oznaczenia",
-    "settings_markType_reorder": "Zmieniono kolejność rodzajów oznaczeń"
+    "settings_markType_reorder": "Zmieniono kolejność rodzajów oznaczeń",
+    "integration_backup_run": "Rozpoczęto kopię zapasową",
+    "integration_backup_restore": "Przywrócono kopię zapasową"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Usunięto oznaczenie z mapy stanu",
     "condition_mark_remove": "Usunięto oznaczenie z mapy stanu",
     "condition_mark_carry": "Potwierdzono, że {count, plural, one {# wcześniejsze oznaczenie nadal jest} few {# wcześniejsze oznaczenia nadal są} many {# wcześniejszych oznaczeń nadal jest} other {# wcześniejszego oznaczenia nadal jest}} na mapie stanu",
     "condition_mark_carry": "Potwierdzono, że {count, plural, one {# wcześniejsze oznaczenie nadal jest} few {# wcześniejsze oznaczenia nadal są} many {# wcześniejszych oznaczeń nadal jest} other {# wcześniejszego oznaczenia nadal jest}} na mapie stanu",
     "condition_map_on_invoice_on": "Umieszczono mapę stanu na fakturze",
     "condition_map_on_invoice_on": "Umieszczono mapę stanu na fakturze",
-    "condition_map_on_invoice_off": "Usunięto mapę stanu z faktury"
+    "condition_map_on_invoice_off": "Usunięto mapę stanu z faktury",
+    "backup_run_started": "Rozpoczęto kopię zapasową do {name}",
+    "backup_restore_started": "Rozpoczęto przywracanie kopii zapasowej {snapshot} z {name}",
+    "backup_restored": "Przywrócono kopię zapasową {snapshot} z {name}"
   }
   }
 }
 }

+ 219 - 2
messages/pl/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Faktury do księgowości",
       "invoices": "Faktury do księgowości",
       "customers": "Klienci do księgowości",
       "customers": "Klienci do księgowości",
       "payments": "Płatności w obie strony"
       "payments": "Płatności w obie strony"
+    },
+    "backup": {
+      "target": "Miejsce docelowe kopii zapasowych"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Wszystkie pojazdy z tablicą zostały niedawno sprawdzone, więc na razie nie ma czego sprawdzać."
       "nothingDue": "Wszystkie pojazdy z tablicą zostały niedawno sprawdzone, więc na razie nie ma czego sprawdzać."
     },
     },
     "supersedes": "Podłączenie {next} odłączy {current}. Naraz może działać tylko jedno, więc {current} przestanie być używane, gdy tylko {next} zacznie działać.",
     "supersedes": "Podłączenie {next} odłączy {current}. Naraz może działać tylko jedno, więc {current} przestanie być używane, gdy tylko {next} zacznie działać.",
+    "platformHandlesBackups": "Torqvoice już co godzinę tworzy kopię zapasową tego warsztatu i przechowuje punkty przywracania przez 30 dni. Połącz bucket, jeśli chcesz mieć także własną kopię, którą można odczytać bez Torqvoice.",
+    "platformHandlesBackupsLink": "Zobacz punkty przywracania",
     "batchRetried": "{count, plural, =0 {Nie ma czego ponawiać} one {# zadanie ponownie w kolejce} few {# zadania ponownie w kolejce} many {# zadań ponownie w kolejce} other {# zadania ponownie w kolejce}}",
     "batchRetried": "{count, plural, =0 {Nie ma czego ponawiać} one {# zadanie ponownie w kolejce} few {# zadania ponownie w kolejce} many {# zadań ponownie w kolejce} other {# zadania ponownie w kolejce}}",
-    "advanced": "Zaawansowane"
+    "advanced": "Zaawansowane",
+    "generate": "Wygeneruj",
+    "show": "Pokaż",
+    "hide": "Ukryj",
+    "generatedPasswordWarning": "Przechowuj kopię tego hasła w bezpiecznym miejscu, poza tym serwerem. Bez niego nikt nie otworzy tych kopii zapasowych, nawet Torqvoice.",
+    "backup": {
+      "title": "Kopie zapasowe",
+      "description": "Kiedy to miejsce docelowe ostatnio otrzymało udaną kopię zapasową, kiedy będzie następna i ostatnie uruchomienia.",
+      "lastGood": "Ostatnia udana kopia zapasowa",
+      "added": "Dodano {size}",
+      "never": "Jeszcze brak",
+      "nextRun": "Następne uruchomienie",
+      "notScheduled": "Nie zaplanowano",
+      "repositorySize": "Zapisane w miejscu docelowym",
+      "runNow": "Utwórz kopię teraz",
+      "running": "Tworzenie kopii",
+      "runningHint": "Trwa tworzenie kopii zapasowej. Ta strona odświeża się sama, dopóki się nie zakończy.",
+      "started": "Rozpoczęto tworzenie kopii zapasowej",
+      "recentTitle": "Ostatnie uruchomienia",
+      "noRuns": "Nie utworzono jeszcze żadnej kopii zapasowej.",
+      "addedNote": "Pierwsze uruchomienie wysyła wszystko. Każde kolejne dodaje tylko fragmenty zmienione od tamtej pory, dlatego jest małe. Każda kopia jest mimo to kompletna i można ją osobno przywrócić lub przejrzeć.",
+      "notStarted": "Nie rozpoczęto",
+      "filesNewCount": "{count, plural, one {# nowy plik} few {# nowe pliki} many {# nowych plików} other {# nowego pliku}}",
+      "columns": {
+        "status": "Status",
+        "started": "Rozpoczęto",
+        "trigger": "Uruchomione przez",
+        "duration": "Czas trwania",
+        "filesNew": "Nowe pliki",
+        "bytesAdded": "Dodano"
+      },
+      "trigger": {
+        "scheduled": "Harmonogram",
+        "manual": "Ręcznie",
+        "restore": "Przywracanie"
+      },
+      "status": {
+        "queued": "W kolejce",
+        "running": "W toku",
+        "done": "Gotowe",
+        "failed": "Nieudane",
+        "skipped": "Pominięte"
+      },
+      "restoring": "Przywracanie",
+      "restoringHint": "Trwa przywracanie: dane warsztatu są zastępowane kopią zapasową. Ta strona odświeża się sama, dopóki się nie zakończy. Do tego czasu nie pracuj w warsztacie.",
+      "explore": "Przeglądaj",
+      "restore": {
+        "showAll": "Pokaż wszystkie kopie zapasowe",
+        "refresh": "Odśwież",
+        "loading": "Odczytywanie kopii zapasowych w miejscu docelowym",
+        "loadFailed": "Nie udało się wyświetlić listy kopii zapasowych",
+        "empty": "W tym miejscu docelowym nie ma jeszcze kopii zapasowych.",
+        "needsOwner": "Tutaj kopię zapasową może przywrócić tylko właściciel warsztatu.",
+        "needsAdmin": "Kopię zapasową może przywrócić tylko właściciel lub administrator.",
+        "filesIncluded": "Dołączono zdjęcia i pliki",
+        "filesNotIncluded": "Nie dołączono zdjęć ani plików",
+        "earlierInstall": "Wcześniejsza instalacja",
+        "action": "Przywróć",
+        "dialogTitle": "Przywrócić kopię zapasową z {time}?",
+        "warning": "Przywrócenie <bold>zastępuje wszystko, co ten warsztat ma teraz</bold>: klientów, pojazdy, zlecenia, faktury, ustawienia oraz, jeśli kopia je zawiera, zdjęcia i pliki. Wszystko, co dodano od utworzenia kopii, przepadnie. Tej operacji nie można cofnąć.",
+        "secretsNote": "Klucze tajne, które kopia pominęła, zachowują wartości, jakie ta instalacja ma teraz. Konta użytkowników i hasła pozostają nienaruszone.",
+        "confirmPrompt": "Wpisz nazwę warsztatu, <bold>{name}</bold>, aby potwierdzić.",
+        "whileRunning": "Przywracanie działa w tle, a przy wielu zdjęciach trwa kilka minut. Strona odświeża się sama, dopóki się nie zakończy.",
+        "cancel": "Anuluj",
+        "confirm": "Przywróć tę kopię zapasową",
+        "started": "Rozpoczęto przywracanie"
+      }
+    },
+    "lockedBackups": "Zaplanowane, szyfrowane kopie zapasowe warsztatu do własnej przestrzeni dyskowej.",
+    "searchOrType": "Szukaj lub wpisz nazwę",
+    "useTyped": "Użyj „{value}”",
+    "listNeedsKeys": "Wpisz klucze powyżej, aby zobaczyć listę",
+    "listEmpty": "Lista jest pusta. Wpisz nazwę ręcznie.",
+    "testing": "Testowanie połączenia",
+    "testOk": "Połączenie działa: obiekt testowy został zapisany i ponownie usunięty.",
+    "testFailed": "Test nie powiódł się",
+    "testSteps": {
+      "settings": "Sprawdź ustawienia dostawcy",
+      "list": "Klucz nie może listować zawartości bucketu",
+      "write": "Klucz nie może zapisywać w buckecie",
+      "delete": "Klucz nie może usuwać z bucketu",
+      "folder": "Nie można utworzyć ani otworzyć folderu"
+    },
+    "testHint": "Wpisz klucze i bucket, aby przetestować je przed połączeniem. Test zapisuje jeden mały obiekt i go usuwa."
   },
   },
   "activity": {
   "activity": {
     "title": "Aktywność",
     "title": "Aktywność",
@@ -199,7 +287,10 @@
     "invalid": "Odpowiedź dostawcy była niekompletna. Spróbuj ponownie.",
     "invalid": "Odpowiedź dostawcy była niekompletna. Spróbuj ponownie.",
     "state": "To logowanie wygasło lub należy do innej sesji. Zacznij od nowa.",
     "state": "To logowanie wygasło lub należy do innej sesji. Zacznij od nowa.",
     "exchange": "Dostawca odrzucił logowanie. Sprawdź dane aplikacji i spróbuj ponownie.",
     "exchange": "Dostawca odrzucił logowanie. Sprawdź dane aplikacji i spróbuj ponownie.",
-    "identify": "Połączono, ale nie udało się odczytać konta. Spróbuj połączyć ponownie."
+    "identify": "Połączono, ale nie udało się odczytać konta. Spróbuj połączyć ponownie.",
+    "backupRunning": "Kopia zapasowa jest już tworzona.",
+    "restoreOwnerOnly": "W Torqvoice Cloud kopię zapasową może przywrócić tylko właściciel warsztatu.",
+    "restoreBusy": "Na tym serwerze trwa już tworzenie kopii zapasowej lub przywracanie. Spróbuj ponownie, gdy się zakończy."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Dołącz do rozmowy wideo",
     "join": "Dołącz do rozmowy wideo",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Kalendarz Microsoft 365",
       "description": "Dodawaj zaplanowane zlecenia do kalendarza Outlook, dołączaj link Microsoft Teams i zobacz zajęty czas z kalendarza w Torqvoice.",
       "description": "Dodawaj zaplanowane zlecenia do kalendarza Outlook, dołączaj link Microsoft Teams i zobacz zajęty czas z kalendarza w Torqvoice.",
       "tenantHelp": "Zarejestruj aplikację w Microsoft Entra (App registrations), zezwól na konta osobiste i służbowe, dodaj poniższy adres przekierowania jako platformę Web, utwórz sekret klienta i wklej oba tutaj.",
       "tenantHelp": "Zarejestruj aplikację w Microsoft Entra (App registrations), zezwól na konta osobiste i służbowe, dodaj poniższy adres przekierowania jako platformę Web, utwórz sekret klienta i wklej oba tutaj.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Zgodny z OpenAI",
       "description": "Opisy usług, podsumowania historii, odczyt dokumentów i asystent, obsługiwane przez dowolny serwer mówiący API czatu OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM i podobne. Twoje dane zostają na Twoim sprzęcie.",
       "description": "Opisy usług, podsumowania historii, odczyt dokumentów i asystent, obsługiwane przez dowolny serwer mówiący API czatu OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM i podobne. Twoje dane zostają na Twoim sprzęcie.",
       "fields": {
       "fields": {
         "baseUrl": "Adres bazowy",
         "baseUrl": "Adres bazowy",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Mowa na tekst",
       "description": "Dyktowanie w zleceniu zapisywane przez model mowy zamiast przeglądarki: znacznie dokładniej, w każdym języku i ze znajomością słownictwa warsztatowego. Działa niezależnie od Twojego dostawcy AI, więc możesz go zostawić bez zmian. Używa Twojego własnego klucza OpenAI albo własnego serwera obsługującego API transkrypcji OpenAI. Nagranie jest wysyłane do zapisania i nie jest przechowywane.",
       "description": "Dyktowanie w zleceniu zapisywane przez model mowy zamiast przeglądarki: znacznie dokładniej, w każdym języku i ze znajomością słownictwa warsztatowego. Działa niezależnie od Twojego dostawcy AI, więc możesz go zostawić bez zmian. Używa Twojego własnego klucza OpenAI albo własnego serwera obsługującego API transkrypcji OpenAI. Nagranie jest wysyłane do zapisania i nie jest przechowywane.",
       "fields": {
       "fields": {
         "apiKey": "Klucz API",
         "apiKey": "Klucz API",
@@ -653,6 +747,108 @@
         "modeAi": "Zawsze model mowy",
         "modeAi": "Zawsze model mowy",
         "modeChoice": "Każdy wybiera sam: model mowy lub przeglądarka"
         "modeChoice": "Każdy wybiera sam: model mowy lub przeglądarka"
       }
       }
+    },
+    "s3": {
+      "name": "Zasobnik w chmurze",
+      "description": "Szyfrowane kopie zapasowe tego warsztatu do własnego bucketu w Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage lub innej przestrzeni zgodnej z S3, według wybranego harmonogramu. Po pierwszym uruchomieniu wysyłane są tylko zmiany, więc kopie co godzinę pozostają tanie.",
+      "fields": {
+        "accessKeyId": "Identyfikator klucza dostępu",
+        "accessKeyIdHelp": "Klucz od dostawcy przestrzeni z uprawnieniami do listowania, odczytu, zapisu i usuwania obiektów w buckecie. Utwórz osobny klucz tylko dla Torqvoice, aby można go było unieważnić niezależnie.",
+        "secretAccessKey": "Tajny klucz dostępu",
+        "password": "Hasło kopii zapasowych",
+        "passwordHelp": "Każda kopia zapasowa jest szyfrowana tym hasłem. Wymyśl je lub naciśnij Wygeneruj i przechowuj kopię poza tym serwerem: bez niego nikt nie otworzy tych kopii zapasowych, nawet Torqvoice. Aby ponownie połączyć przestrzeń, w której są już kopie zapasowe Torqvoice, wpisz hasło, którym zostały utworzone.",
+        "endpoint": "Własny punkt końcowy",
+        "endpointHelp": "Tylko dla opcji Inna przestrzeń zgodna z S3: adres S3 własnej przestrzeni, na przykład serwera MinIO, jako nazwa hosta, w razie potrzeby z portem."
+      },
+      "settings": {
+        "provider": "Dostawca",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Inna przestrzeń zgodna z S3",
+        "region": "Region",
+        "regionHelp": "Gdzie znajduje się bucket. Wybierz go z listy dostawcy; w przypadku innej przestrzeni wpisz wartość, jakiej oczekuje, o ile jej potrzebuje. Nieużywany przez Cloudflare R2.",
+        "accountId": "Identyfikator konta Cloudflare",
+        "accountIdHelp": "32-znakowy identyfikator widoczny na stronie przeglądu R2 w panelu Cloudflare.",
+        "bucket": "Bucket",
+        "bucketHelp": "Lista bucketów pojawia się zaraz po wpisaniu klucza. Jeśli klucz ma dostęp do jednego bucketu, ale nie może wyświetlić ich listy, wpisz jego nazwę.",
+        "prefix": "Folder w buckecie",
+        "prefixHelp": "Kopie zapasowe są przechowywane w tym folderze. Użyj innego folderu dla każdego warsztatu, który korzysta z tego samego bucketu.",
+        "frequency": "Jak często",
+        "frequencyHourly": "Co godzinę",
+        "frequencyEvery4h": "Co 4 godziny",
+        "frequencyDaily": "Codziennie",
+        "frequencyWeekly": "Co tydzień",
+        "frequencyMonthly": "Co miesiąc",
+        "time": "Godzina",
+        "timeHelp": "Według zegara warsztatu, w formacie HH:MM. Używana przez kopie codzienne, cotygodniowe i comiesięczne.",
+        "weekday": "Dzień tygodnia",
+        "sunday": "Niedziela",
+        "monday": "Poniedziałek",
+        "tuesday": "Wtorek",
+        "wednesday": "Środa",
+        "thursday": "Czwartek",
+        "friday": "Piątek",
+        "saturday": "Sobota",
+        "monthDay": "Dzień miesiąca",
+        "monthDayLast": "Ostatni dzień",
+        "contents": "Zawartość",
+        "contentsHelp": "Klucze tajne to te, które ten warsztat wpisał do Torqvoice: klucze płatności, AI i webhooków. Każda kopia zapasowa i tak jest szyfrowana hasłem kopii zapasowych. Kopia zapasowa zawiera dane warsztatu i, jeśli tak wybrano, jego pliki. Nie ma w niej kont użytkowników ani haseł: na nowym serwerze logujesz się, zapraszasz zespół i przywracasz dane do tego warsztatu.",
+        "contentsData": "Tylko dane",
+        "contentsDataAndSecrets": "Dane i klucze tajne",
+        "includeFiles": "Dołącz zdjęcia i pliki",
+        "includeFilesHelp": "Każdy plik jest wysyłany raz i używany ponownie przez kolejne kopie, więc po pierwszym uruchomieniu kosztuje to niewiele.",
+        "keepHourly": "Zachowaj kopie godzinowe",
+        "keepHourlyHelp": "Ile najnowszych kopii godzinowych zachować. Ma znaczenie tylko przy harmonogramie co godzinę i co 4 godziny.",
+        "keepDaily": "Zachowaj kopie dzienne",
+        "keepWeekly": "Zachowaj kopie tygodniowe",
+        "keepMonthly": "Zachowaj kopie miesięczne",
+        "keepMonthlyHelp": "Starsze kopie są usuwane po każdym uruchomieniu, a najnowsze każdego rodzaju zostają. Ustaw liczbę na 0, aby nie zachowywać żadnej kopii danego rodzaju."
+      }
+    },
+    "local-folder": {
+      "name": "Folder lokalny",
+      "description": "Szyfrowane kopie zapasowe tego warsztatu do folderu na tym serwerze: wolumenu kopii zapasowych z pliku compose lub zamontowanego tam dysku sieciowego. Kopia na tym samym dysku co baza danych sama w sobie nie jest bezpieczna; połącz ją z przestrzenią w innym miejscu.",
+      "fields": {
+        "password": "Hasło kopii zapasowych",
+        "passwordHelp": "Każda kopia zapasowa jest szyfrowana tym hasłem. Wymyśl je lub naciśnij Wygeneruj i przechowuj kopię poza tym serwerem: bez niego nikt nie otworzy tych kopii zapasowych, nawet Torqvoice. Aby ponownie połączyć przestrzeń, w której są już kopie zapasowe Torqvoice, wpisz hasło, którym zostały utworzone."
+      },
+      "settings": {
+        "folder": "Folder",
+        "folderHelp": "Folder w katalogu głównym kopii zapasowych serwera, w którym są kopie zapasowe tego warsztatu. Pozostaw puste, aby użyć folderu nazwanego od tego warsztatu. Na nowym serwerze wpisz nazwę folderu używanego przez wcześniejszą instalację, aby odzyskać jej kopie zapasowe.",
+        "frequency": "Jak często",
+        "frequencyHourly": "Co godzinę",
+        "frequencyEvery4h": "Co 4 godziny",
+        "frequencyDaily": "Codziennie",
+        "frequencyWeekly": "Co tydzień",
+        "frequencyMonthly": "Co miesiąc",
+        "time": "Godzina",
+        "timeHelp": "Według zegara warsztatu, w formacie HH:MM. Używana przez kopie codzienne, cotygodniowe i comiesięczne.",
+        "weekday": "Dzień tygodnia",
+        "sunday": "Niedziela",
+        "monday": "Poniedziałek",
+        "tuesday": "Wtorek",
+        "wednesday": "Środa",
+        "thursday": "Czwartek",
+        "friday": "Piątek",
+        "saturday": "Sobota",
+        "monthDay": "Dzień miesiąca",
+        "monthDayLast": "Ostatni dzień",
+        "contents": "Zawartość",
+        "contentsHelp": "Klucze tajne to te, które ten warsztat wpisał do Torqvoice: klucze płatności, AI i webhooków. Każda kopia zapasowa i tak jest szyfrowana hasłem kopii zapasowych. Kopia zapasowa zawiera dane warsztatu i, jeśli tak wybrano, jego pliki. Nie ma w niej kont użytkowników ani haseł: na nowym serwerze logujesz się, zapraszasz zespół i przywracasz dane do tego warsztatu.",
+        "contentsData": "Tylko dane",
+        "contentsDataAndSecrets": "Dane i klucze tajne",
+        "includeFiles": "Dołącz zdjęcia i pliki",
+        "includeFilesHelp": "Każdy plik jest wysyłany raz i używany ponownie przez kolejne kopie, więc po pierwszym uruchomieniu kosztuje to niewiele.",
+        "keepHourly": "Zachowaj kopie godzinowe",
+        "keepHourlyHelp": "Ile najnowszych kopii godzinowych zachować. Ma znaczenie tylko przy harmonogramie co godzinę i co 4 godziny.",
+        "keepDaily": "Zachowaj kopie dzienne",
+        "keepWeekly": "Zachowaj kopie tygodniowe",
+        "keepMonthly": "Zachowaj kopie miesięczne",
+        "keepMonthlyHelp": "Starsze kopie są usuwane po każdym uruchomieniu, a najnowsze każdego rodzaju zostają. Ustaw liczbę na 0, aby nie zachowywać żadnej kopii danego rodzaju."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Odświeżanie danych o akcjach serwisowych",
       "refresh": "Odświeżanie danych o akcjach serwisowych",
       "vehicle": "Sprawdzenie akcji serwisowych dla nowego pojazdu"
       "vehicle": "Sprawdzenie akcji serwisowych dla nowego pojazdu"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "miejsce docelowe kopii zapasowych",
+    "failedTitle": "Kopia zapasowa nie powiodła się",
+    "failedMessage": "Kopia zapasowa do {name} nie została ukończona: {reason}",
+    "failedSubject": "Kopia zapasowa do {name} nie powiodła się",
+    "failedEmailIntro": "Zaplanowana kopia zapasowa Twojego warsztatu do {name} nie została ukończona.",
+    "openToRetry": "Otwórz Ustawienia, Integracje, {name}, aby zobaczyć szczegóły uruchomienia i spróbować ponownie.",
+    "overdueTitle": "Zaległa kopia zapasowa",
+    "overdueSince": "Od {time} nie ukończono żadnej kopii zapasowej do {name}.",
+    "overdueNever": "Nie ukończono jeszcze żadnej kopii zapasowej do {name}.",
+    "overdueSubject": "Kopia zapasowa do {name} jest zaległa",
+    "openToSee": "Otwórz Ustawienia, Integracje, {name}, aby zobaczyć, co poszło nie tak.",
+    "platformName": "Kopie zapasowe Torqvoice",
+    "restoredTitle": "Warsztat przywrócony",
+    "restoredSubject": "Twój warsztat został przywrócony z kopii zapasowej",
+    "restoredIntro": "{name}: warsztat został przywrócony do punktu przywracania {snapshot} ({time}). Wszystkiego, co wprowadzono po tym punkcie, nie ma już w warsztacie.",
+    "restoredByMember": "O to przywrócenie poprosił członek Twojego warsztatu.",
+    "restoredBySupport": "Zespół pomocy Torqvoice zrobił to na Twoją prośbę. Podany powód: {reason}",
+    "noReason": "nie podano",
+    "restoredUndo": "Najpierw utworzono kopię bezpieczeństwa warsztatu w stanie sprzed przywrócenia, która jest przechowywana przez 90 dni. Skontaktuj się z zespołem pomocy, aby do niej wrócić."
   }
   }
 }
 }

+ 161 - 1
messages/pl/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.",
     "reauthTooManyAttempts": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.",
     "reauthSignInAgainAccount": "Zaloguj się ponownie, zanim usuniesz konto.",
     "reauthSignInAgainAccount": "Zaloguj się ponownie, zanim usuniesz konto.",
     "reauthSignInAgainWorkshop": "Zaloguj się ponownie, zanim usuniesz warsztat.",
     "reauthSignInAgainWorkshop": "Zaloguj się ponownie, zanim usuniesz warsztat.",
+    "reauthSignInAgainRestore": "Zaloguj się ponownie, zanim przywrócisz warsztat.",
     "deleteAccountMoved": "Chcesz usunąć swoje konto?",
     "deleteAccountMoved": "Chcesz usunąć swoje konto?",
     "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych.",
     "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych.",
     "signatureTitle": "Podpis",
     "signatureTitle": "Podpis",
@@ -1399,6 +1400,11 @@
     "importComplete": "Import zakończony",
     "importComplete": "Import zakończony",
     "importCompleteMessage": "Wszystkie dane zostaly przywrocone z pliku kopii zapasowej.",
     "importCompleteMessage": "Wszystkie dane zostaly przywrocone z pliku kopii zapasowej.",
     "importFailed": "Import nie powiódł się",
     "importFailed": "Import nie powiódł się",
+    "restoreRefused": {
+      "busy": "Na tym serwerze trwa już tworzenie kopii zapasowej lub przywracanie. Spróbuj ponownie, gdy się zakończy.",
+      "ownerOnly": "W Torqvoice Cloud kopię zapasową może przywrócić tylko właściciel warsztatu.",
+      "foreignIds": "Plik odwołuje się do rekordów należących do innego warsztatu, więc niczego nie przywrócono."
+    },
     "importFromOther": "Importuj z innych uslug",
     "importFromOther": "Importuj z innych uslug",
     "importFromOtherDescription": "Migruj dane z innej platformy. Wybierz usluge, aby rozpocząć.",
     "importFromOtherDescription": "Migruj dane z innej platformy. Wybierz usluge, aby rozpocząć.",
     "lubelogTitle": "Importuj z LubeLog",
     "lubelogTitle": "Importuj z LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Kopia zapasowa Torqvoice",
     "sourceBackup": "Kopia zapasowa Torqvoice",
     "sourceBackupDesc": "Plik .zip lub .json pobrany z tej strony. Przywrócenie zastępuje wszystko w tym warsztacie.",
     "sourceBackupDesc": "Plik .zip lub .json pobrany z tej strony. Przywrócenie zastępuje wszystko w tym warsztacie.",
     "restore": "Przywróć",
     "restore": "Przywróć",
-    "restoreTitle": "Przywróć kopię zapasową"
+    "restoreTitle": "Przywróć kopię zapasową",
+    "includeSecrets": "Dołącz klucze tajne",
+    "includeSecretsHelp": "Klucze tajne, takie jak klucze płatności i AI, są pomijane, chyba że zaznaczysz tę opcję. Przywracanie zachowuje wtedy klucze, które ta instalacja już ma.",
+    "platformBackups": {
+      "title": "Kopie zapasowe Torqvoice",
+      "description": "Torqvoice co godzinę tworzy kopię zapasową tego warsztatu i przechowuje punkty przywracania przez 30 dni, a potem cotygodniowe i comiesięczne.",
+      "runNow": "Utwórz kopię teraz",
+      "started": "Rozpoczęto tworzenie kopii zapasowej",
+      "lastGood": "Ostatnia udana kopia zapasowa",
+      "never": "Brak kopii zapasowej",
+      "nextRun": "Następne uruchomienie",
+      "notScheduled": "Nie zaplanowano",
+      "repositorySize": "Przechowywane przez Torqvoice",
+      "running": "W toku",
+      "restoring": "Przywracanie",
+      "queued": "W kolejce",
+      "queuedAhead": "{count, plural, one {W kolejce, przed nią # zadanie} few {W kolejce, przed nią # zadania} many {W kolejce, przed nią # zadań} other {W kolejce, przed nią # zadania}}",
+      "runningHint": "Trwa tworzenie kopii zapasowej. Ta karta odświeża się sama, dopóki się nie zakończy.",
+      "restoringHint": "Trwa przywracanie: dane warsztatu są zastępowane punktem przywracania. Ta strona odświeża się sama, dopóki się nie zakończy. Do tego czasu nie pracuj w warsztacie.",
+      "paused": "Twój plan nie obejmuje już kopii zapasowych Torqvoice. Uruchomienia zostały wstrzymane; punkty przywracania są przechowywane do {date}, a potem zostaną usunięte.",
+      "upsell": "Dostępne w planie Pro: cogodzinne kopie zapasowe wykonywane przez Torqvoice z punktami przywracania, do których możesz wrócić.",
+      "planLink": "Zobacz plany",
+      "pointsTitle": "Punkty przywracania",
+      "pointsEmpty": "Pierwsza kopia zapasowa nie została jeszcze utworzona.",
+      "showAll": "Pokaż wszystkie ({count})",
+      "showFewer": "Pokaż mniej",
+      "needsOwner": "Tylko właściciel warsztatu może przywracać.",
+      "sizeNote": "Każdy punkt przywracania jest kompletny sam w sobie; jego rozmiar to wszystko, co obejmuje. Repozytorium przechowuje każdy fragment tylko raz, a kolejne uruchomienia dodają tylko to, co się zmieniło, dlatego „Przechowywane przez Torqvoice” pozostaje znacznie poniżej sumy rozmiarów.",
+      "recentTitle": "Ostatnie uruchomienia",
+      "noRuns": "Nie utworzono jeszcze żadnej kopii zapasowej.",
+      "notStarted": "Nie rozpoczęto",
+      "supportReason": "Przywrócone przez zespół pomocy Torqvoice: {reason}",
+      "columns": {
+        "when": "Kiedy",
+        "kind": "Rodzaj",
+        "size": "Rozmiar",
+        "status": "Status",
+        "trigger": "Uruchomione przez",
+        "duration": "Czas trwania"
+      },
+      "kind": {
+        "scheduled": "Zaplanowana",
+        "manual": "Ręczna",
+        "safety": "Kopia bezpieczeństwa",
+        "safetyHint": "Utworzona tuż przed przywróceniem, aby można je było cofnąć"
+      },
+      "trigger": {
+        "scheduled": "Harmonogram",
+        "manual": "Ręcznie",
+        "restore": "Przywracanie"
+      },
+      "status": {
+        "queued": "W kolejce",
+        "running": "W toku",
+        "done": "Gotowe",
+        "failed": "Nieudane",
+        "skipped": "Pominięte"
+      },
+      "explore": "Przeglądaj",
+      "restore": {
+        "action": "Przywróć",
+        "dialogTitle": "Przywrócić warsztat do stanu z {time}?",
+        "warning": "Punkt przywracania z {time} <bold>zastępuje wszystko w tym warsztacie</bold>: klientów, pojazdy, zlecenia, faktury, ustawienia, zdjęcia i pliki. Wszystko, co dodano od tego czasu, przepadnie.",
+        "safetyNote": "Najpierw tworzona jest kopia bezpieczeństwa warsztatu w obecnym stanie, przechowywana przez 90 dni, dzięki czemu przywrócenie można cofnąć z poziomu punktów przywracania.",
+        "untouched": "Członkowie, konta, subskrypcja i integracje pozostają nienaruszone. Klucze tajne, których nie ma w kopii, zachowują obecne wartości.",
+        "confirmPrompt": "Wpisz nazwę warsztatu, <bold>{name}</bold>, aby potwierdzić.",
+        "whileRunning": "Przywracanie działa w tle, a przy wielu zdjęciach trwa kilka minut. Strona odświeża się sama, dopóki się nie zakończy.",
+        "cancel": "Anuluj",
+        "confirm": "Przywróć",
+        "started": "Rozpoczęto przywracanie"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Wróć do kopii zapasowych",
+      "title": "Punkt przywracania z {date}",
+      "titleShort": "Punkt przywracania {id}",
+      "readOnly": "Tylko do odczytu: nic tutaj nie zmienia warsztatu. Widok zamyka się po 15 minutach bezczynności.",
+      "readOnlyBadge": "Tylko do odczytu",
+      "opening": "Otwieranie punktu przywracania",
+      "openingHint": "Kopia zapasowa jest odszyfrowywana tutaj, na serwerze, i przechowywana w pamięci przez kwadrans. W warsztacie nic się nie zmienia.",
+      "openingSteps": {
+        "connecting": "Łączenie z repozytorium kopii zapasowych",
+        "locating": "Szukanie pliku danych w migawce",
+        "pulling": "Pobieranie i odszyfrowywanie danych",
+        "reading": "Odczytywanie rekordów",
+        "indexing": "Indeksowanie klientów, pojazdów i zleceń"
+      },
+      "openingReady": "Gotowe",
+      "openFailed": "Nie udało się otworzyć punktu przywracania",
+      "tryAgain": "Spróbuj ponownie",
+      "ownerOnly": "Zawartość punktu przywracania może przeglądać tylko właściciel warsztatu lub administrator.",
+      "closed": "Punkt przywracania został zamknięty; otwórz go ponownie",
+      "openAgain": "Otwórz ponownie",
+      "datasets": {
+        "customers": "Klienci",
+        "vehicles": "Pojazdy",
+        "serviceRecords": "Zlecenia",
+        "quotes": "Wyceny",
+        "inventoryParts": "Części"
+      },
+      "searchPlaceholder": "Szukaj po nazwie, numerze, rejestracji, VIN, e-mailu lub telefonie",
+      "loading": "Ładowanie…",
+      "loadFailed": "Nie udało się wczytać tej listy",
+      "empty": "W punkcie przywracania nie ma niczego tego rodzaju.",
+      "noMatches": "Brak wyników dla „{query}”.",
+      "showing": "Pokazano {shown} z {total}",
+      "loadMore": "Pokaż więcej",
+      "clearSearch": "Wyczyść wyszukiwanie",
+      "record": {
+        "loading": "Wczytywanie rekordu…",
+        "loadFailed": "Nie udało się wczytać rekordu",
+        "notFound": "Tego rekordu nie ma w punkcie przywracania.",
+        "fields": "Szczegóły",
+        "jumpTo": "Przejdź do",
+        "previous": "Poprzedni rekord",
+        "next": "Następny rekord",
+        "position": "{index} z {total}",
+        "noColumns": "W tych wierszach nie ma nic do pokazania.",
+        "yes": "Tak",
+        "no": "Nie"
+      },
+      "sections": {
+        "concerns": "Zgłoszenia klienta",
+        "partItems": "Części",
+        "laborItems": "Robocizna",
+        "attachments": "Zdjęcia i dokumenty",
+        "payments": "Płatności",
+        "statusReports": "Raporty statusu",
+        "timeEntries": "Wpisy czasu",
+        "notes": "Notatki",
+        "fuelLogs": "Tankowania",
+        "reminders": "Przypomnienia",
+        "serviceRequests": "Zlecenia serwisowe",
+        "inspectionStatus": "Stan przeglądu",
+        "findings": "Uwagi",
+        "conditionMarks": "Oznaczenia stanu",
+        "aiMessages": "Wiadomości AI",
+        "recurringInvoices": "Faktury cykliczne",
+        "serviceRecords": "Zlecenia",
+        "movements": "Ruchy magazynowe",
+        "gallery": "Galeria"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Zaplanowane kopie zapasowe",
+      "description": "Automatyczne kopie zapasowe do własnej przestrzeni dyskowej, według wybranego harmonogramu.",
+      "empty": "Nie połączono jeszcze żadnego miejsca docelowego kopii zapasowych.",
+      "setUp": "Skonfiguruj miejsce docelowe kopii zapasowych",
+      "lastGood": "Ostatnia udana kopia zapasowa: {when}",
+      "noGoodBackup": "Brak udanej kopii zapasowej",
+      "nextRun": "następne uruchomienie: {when}",
+      "notScheduled": "nie zaplanowano",
+      "lastError": "Ostatni błąd: {error}",
+      "extraCopy": "Dodatkowa kopia we własnej przestrzeni dyskowej, według wybranego harmonogramu, oprócz kopii zapasowych tworzonych przez Torqvoice."
+    }
   },
   },
   "license": {
   "license": {
     "title": "Licencja white-label",
     "title": "Licencja white-label",

+ 7 - 2
messages/pt-BR/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Tipo de marca adicionado",
     "settings_markType_create": "Tipo de marca adicionado",
     "settings_markType_update": "Tipo de marca alterado",
     "settings_markType_update": "Tipo de marca alterado",
     "settings_markType_remove": "Tipo de marca removido",
     "settings_markType_remove": "Tipo de marca removido",
-    "settings_markType_reorder": "Tipos de marca reordenados"
+    "settings_markType_reorder": "Tipos de marca reordenados",
+    "integration_backup_run": "Backup iniciado",
+    "integration_backup_restore": "Backup restaurado"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Removeu uma marca do mapa de estado",
     "condition_mark_remove": "Removeu uma marca do mapa de estado",
     "condition_mark_carry": "Confirmou {count, plural, one {# marca anterior como ainda presente} other {# marcas anteriores como ainda presentes}} no mapa de estado",
     "condition_mark_carry": "Confirmou {count, plural, one {# marca anterior como ainda presente} other {# marcas anteriores como ainda presentes}} no mapa de estado",
     "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
     "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
-    "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura"
+    "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura",
+    "backup_run_started": "Iniciou um backup para {name}",
+    "backup_restore_started": "Iniciou a restauração do backup {snapshot} de {name}",
+    "backup_restored": "Restaurou o backup {snapshot} de {name}"
   }
   }
 }
 }

+ 219 - 2
messages/pt-BR/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Faturas para a contabilidade",
       "invoices": "Faturas para a contabilidade",
       "customers": "Clientes para a contabilidade",
       "customers": "Clientes para a contabilidade",
       "payments": "Pagamentos nos dois sentidos"
       "payments": "Pagamentos nos dois sentidos"
+    },
+    "backup": {
+      "target": "Destino de backup"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Todos os veículos com placa foram verificados recentemente, então ainda não há nada para consultar."
       "nothingDue": "Todos os veículos com placa foram verificados recentemente, então ainda não há nada para consultar."
     },
     },
     "supersedes": "Conectar a {next} desconecta a {current}. Só uma pode estar ativa por vez, então {current} deixa de ser usada assim que {next} entrar no ar.",
     "supersedes": "Conectar a {next} desconecta a {current}. Só uma pode estar ativa por vez, então {current} deixa de ser usada assim que {next} entrar no ar.",
+    "platformHandlesBackups": "O Torqvoice já faz backup desta oficina a cada hora e mantém pontos de restauração por 30 dias. Conecte um bucket se você também quiser uma cópia sua, que possa ser lida sem o Torqvoice.",
+    "platformHandlesBackupsLink": "Ver seus pontos de restauração",
     "batchRetried": "{count, plural, =0 {Nada para tentar de novo} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
     "batchRetried": "{count, plural, =0 {Nada para tentar de novo} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
-    "advanced": "Avançado"
+    "advanced": "Avançado",
+    "generate": "Gerar",
+    "show": "Mostrar",
+    "hide": "Ocultar",
+    "generatedPasswordWarning": "Guarde uma cópia desta senha em um lugar seguro, fora deste servidor. Sem ela ninguém consegue abrir estes backups, nem mesmo o Torqvoice.",
+    "backup": {
+      "title": "Backups",
+      "description": "Quando este destino recebeu o último backup bem-sucedido, quando será o próximo e as execuções mais recentes.",
+      "lastGood": "Último backup bem-sucedido",
+      "added": "{size} adicionados",
+      "never": "Nenhum ainda",
+      "nextRun": "Próxima execução",
+      "notScheduled": "Não agendado",
+      "repositorySize": "Armazenado no destino",
+      "runNow": "Fazer backup agora",
+      "running": "Fazendo backup",
+      "runningHint": "Um backup está em andamento. Esta página se atualiza sozinha até terminar.",
+      "started": "Backup iniciado",
+      "recentTitle": "Execuções recentes",
+      "noRuns": "Nenhum backup foi feito ainda.",
+      "addedNote": "A primeira execução envia tudo. Cada uma depois dela adiciona só as partes que mudaram desde então, por isso é pequena. Cada backup continua completo e pode ser restaurado ou explorado sozinho.",
+      "notStarted": "Não iniciado",
+      "filesNewCount": "{count, plural, one {# arquivo novo} other {# arquivos novos}}",
+      "columns": {
+        "status": "Status",
+        "started": "Início",
+        "trigger": "Iniciado por",
+        "duration": "Duração",
+        "filesNew": "Arquivos novos",
+        "bytesAdded": "Adicionado"
+      },
+      "trigger": {
+        "scheduled": "Agendamento",
+        "manual": "Manual",
+        "restore": "Restauração"
+      },
+      "status": {
+        "queued": "Na fila",
+        "running": "Em andamento",
+        "done": "Concluído",
+        "failed": "Falhou",
+        "skipped": "Ignorado"
+      },
+      "restoring": "Restaurando",
+      "restoringHint": "Uma restauração está em andamento: os dados da oficina estão sendo substituídos pelo backup. Esta página se atualiza sozinha até terminar. Não trabalhe na oficina até lá.",
+      "explore": "Explorar",
+      "restore": {
+        "showAll": "Mostrar todos os backups",
+        "refresh": "Atualizar",
+        "loading": "Lendo os backups no destino",
+        "loadFailed": "Não foi possível listar os backups",
+        "empty": "Ainda não há backups neste destino.",
+        "needsOwner": "Só o proprietário da oficina pode restaurar um backup aqui.",
+        "needsAdmin": "Só um proprietário ou administrador pode restaurar um backup.",
+        "filesIncluded": "Fotos e arquivos incluídos",
+        "filesNotIncluded": "Fotos e arquivos não incluídos",
+        "earlierInstall": "Instalação anterior",
+        "action": "Restaurar",
+        "dialogTitle": "Restaurar o backup de {time}?",
+        "warning": "A restauração <bold>substitui tudo o que esta oficina tem agora</bold>: clientes, veículos, ordens de serviço, faturas, configurações e, quando o backup os inclui, fotos e arquivos. Tudo o que foi adicionado depois que o backup foi feito é perdido. Isso não pode ser desfeito.",
+        "secretsNote": "As chaves secretas que o backup deixou de fora mantêm os valores que esta instalação tem agora. Contas de usuário e senhas não são alteradas.",
+        "confirmPrompt": "Digite o nome da oficina, <bold>{name}</bold>, para confirmar.",
+        "whileRunning": "A restauração roda em segundo plano e leva alguns minutos quando há muitas fotos. A página se atualiza sozinha até terminar.",
+        "cancel": "Cancelar",
+        "confirm": "Restaurar este backup",
+        "started": "Restauração iniciada"
+      }
+    },
+    "lockedBackups": "Backups agendados e criptografados da sua oficina para um armazenamento seu.",
+    "searchOrType": "Pesquisar ou digitar um nome",
+    "useTyped": "Usar \"{value}\"",
+    "listNeedsKeys": "Informe as chaves acima para ver a lista",
+    "listEmpty": "A lista está vazia. Digite um nome.",
+    "testing": "Testando a conexão",
+    "testOk": "A conexão funciona: um objeto de teste foi gravado e excluído em seguida.",
+    "testFailed": "O teste falhou",
+    "testSteps": {
+      "settings": "Verifique as configurações do provedor",
+      "list": "A chave não consegue listar o bucket",
+      "write": "A chave não consegue gravar no bucket",
+      "delete": "A chave não consegue excluir do bucket",
+      "folder": "Não foi possível criar ou abrir a pasta"
+    },
+    "testHint": "Informe as chaves e o bucket para testá-los antes de conectar. O teste grava um objeto pequeno e o exclui."
   },
   },
   "activity": {
   "activity": {
     "title": "Atividade",
     "title": "Atividade",
@@ -199,7 +287,10 @@
     "invalid": "A resposta do provedor estava incompleta. Tente novamente.",
     "invalid": "A resposta do provedor estava incompleta. Tente novamente.",
     "state": "Este login expirou ou pertence a outra sessão. Comece de novo.",
     "state": "Este login expirou ou pertence a outra sessão. Comece de novo.",
     "exchange": "O provedor recusou o login. Verifique as credenciais do aplicativo e tente novamente.",
     "exchange": "O provedor recusou o login. Verifique as credenciais do aplicativo e tente novamente.",
-    "identify": "Conectado, mas não foi possível ler a conta. Tente reconectar."
+    "identify": "Conectado, mas não foi possível ler a conta. Tente reconectar.",
+    "backupRunning": "Já há um backup em andamento.",
+    "restoreOwnerOnly": "No Torqvoice Cloud, só o proprietário da oficina pode restaurar um backup.",
+    "restoreBusy": "Já há um backup ou uma restauração em andamento neste servidor. Tente de novo quando terminar."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Entrar na videochamada",
     "join": "Entrar na videochamada",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Calendário do Microsoft 365",
       "description": "Coloque as ordens de serviço agendadas em um calendário do Outlook, adicione um link do Microsoft Teams e veja os horários ocupados do calendário no Torqvoice.",
       "description": "Coloque as ordens de serviço agendadas em um calendário do Outlook, adicione um link do Microsoft Teams e veja os horários ocupados do calendário no Torqvoice.",
       "tenantHelp": "Registre um aplicativo no Microsoft Entra (Registros de aplicativo), permita contas pessoais e corporativas, adicione o URI de redirecionamento abaixo como plataforma Web, crie um segredo do cliente e cole os dois aqui.",
       "tenantHelp": "Registre um aplicativo no Microsoft Entra (Registros de aplicativo), permita contas pessoais e corporativas, adicione o URI de redirecionamento abaixo como plataforma Web, crie um segredo do cliente e cole os dois aqui.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Compatível com OpenAI",
       "description": "Descrições de serviço, resumos de histórico, leitura de documentos e o assistente, respondidos por qualquer servidor que fale a API de chat da OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e afins. Seus dados ficam no seu próprio hardware.",
       "description": "Descrições de serviço, resumos de histórico, leitura de documentos e o assistente, respondidos por qualquer servidor que fale a API de chat da OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e afins. Seus dados ficam no seu próprio hardware.",
       "fields": {
       "fields": {
         "baseUrl": "URL base",
         "baseUrl": "URL base",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Voz para texto",
       "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É separado do seu provedor de IA, então você pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor próprio que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
       "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É separado do seu provedor de IA, então você pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor próprio que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
       "fields": {
       "fields": {
         "apiKey": "Chave de API",
         "apiKey": "Chave de API",
@@ -653,6 +747,108 @@
         "modeAi": "Sempre o modelo de voz",
         "modeAi": "Sempre o modelo de voz",
         "modeChoice": "Cada pessoa escolhe: modelo de voz ou navegador"
         "modeChoice": "Cada pessoa escolhe: modelo de voz ou navegador"
       }
       }
+    },
+    "s3": {
+      "name": "Bucket de armazenamento na nuvem",
+      "description": "Backups criptografados desta oficina para um bucket seu no Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage ou qualquer outro armazenamento compatível com S3, na frequência que você escolher. Depois da primeira execução, só o que mudou é enviado, então backups de hora em hora continuam baratos.",
+      "fields": {
+        "accessKeyId": "ID da chave de acesso",
+        "accessKeyIdHelp": "Uma chave do seu provedor de armazenamento com permissão para listar, ler, gravar e excluir objetos no bucket. Crie uma chave só para o Torqvoice, para que ela possa ser revogada separadamente.",
+        "secretAccessKey": "Chave de acesso secreta",
+        "password": "Senha dos backups",
+        "passwordHelp": "Todo backup é criptografado com esta senha. Invente uma ou clique em Gerar, e guarde uma cópia fora deste servidor: sem ela ninguém consegue abrir estes backups, nem mesmo o Torqvoice. Para reconectar um armazenamento que já tem backups do Torqvoice, digite a senha com que eles foram feitos.",
+        "endpoint": "Endpoint personalizado",
+        "endpointHelp": "Só para a opção Outro armazenamento compatível com S3: o endereço S3 do seu próprio armazenamento, como um servidor MinIO, como nome de host, com porta se necessário."
+      },
+      "settings": {
+        "provider": "Provedor",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Outro armazenamento compatível com S3",
+        "region": "Região",
+        "regionHelp": "Onde fica o bucket. Escolha-a na lista do provedor; em outro armazenamento, digite a que ele pedir, se pedir alguma. Não é usada pelo Cloudflare R2.",
+        "accountId": "ID da conta Cloudflare",
+        "accountIdHelp": "O ID de 32 caracteres mostrado na página de visão geral do R2 no painel da Cloudflare.",
+        "bucket": "Bucket",
+        "bucketHelp": "Os buckets são listados com a chave assim que ela é informada. Se a chave pode usar um bucket, mas não listá-los, digite o nome.",
+        "prefix": "Pasta no bucket",
+        "prefixHelp": "Os backups ficam nesta pasta. Use uma pasta diferente para cada oficina que compartilha o bucket.",
+        "frequency": "Frequência",
+        "frequencyHourly": "A cada hora",
+        "frequencyEvery4h": "A cada 4 horas",
+        "frequencyDaily": "Todos os dias",
+        "frequencyWeekly": "Todas as semanas",
+        "frequencyMonthly": "Todos os meses",
+        "time": "Horário",
+        "timeHelp": "No relógio da oficina, no formato HH:MM. Usado pelos backups diários, semanais e mensais.",
+        "weekday": "Dia da semana",
+        "sunday": "Domingo",
+        "monday": "Segunda-feira",
+        "tuesday": "Terça-feira",
+        "wednesday": "Quarta-feira",
+        "thursday": "Quinta-feira",
+        "friday": "Sexta-feira",
+        "saturday": "Sábado",
+        "monthDay": "Dia do mês",
+        "monthDayLast": "Último dia",
+        "contents": "Conteúdo",
+        "contentsHelp": "Chaves secretas são as que esta oficina digitou no Torqvoice: chaves de pagamento, de IA e de webhook. De qualquer forma, todo backup é criptografado com a senha dos backups. Um backup contém os registros da oficina e, se você escolher, os arquivos dela. Contas de usuário e senhas não ficam nele: em um servidor novo, você entra, convida a equipe e restaura os dados nessa oficina.",
+        "contentsData": "Somente dados",
+        "contentsDataAndSecrets": "Dados e chaves secretas",
+        "includeFiles": "Incluir fotos e arquivos",
+        "includeFilesHelp": "Cada arquivo é enviado uma vez e reaproveitado pelos backups seguintes, então isso custa pouco depois da primeira execução.",
+        "keepHourly": "Manter backups por hora",
+        "keepHourlyHelp": "Quantos dos backups por hora mais recentes manter. Só importa nos agendamentos a cada hora e a cada 4 horas.",
+        "keepDaily": "Manter backups diários",
+        "keepWeekly": "Manter backups semanais",
+        "keepMonthly": "Manter backups mensais",
+        "keepMonthlyHelp": "Backups mais antigos são removidos após cada execução, mantendo os mais recentes de cada tipo. Defina um número como 0 para não manter nenhum daquele tipo."
+      }
+    },
+    "local-folder": {
+      "name": "Pasta local",
+      "description": "Backups criptografados desta oficina para uma pasta neste servidor: o volume de backups no arquivo compose ou uma unidade de rede montada nele. Uma cópia no mesmo disco do banco de dados não é segura sozinha; combine-a com um armazenamento em outro lugar.",
+      "fields": {
+        "password": "Senha dos backups",
+        "passwordHelp": "Todo backup é criptografado com esta senha. Invente uma ou clique em Gerar, e guarde uma cópia fora deste servidor: sem ela ninguém consegue abrir estes backups, nem mesmo o Torqvoice. Para reconectar um armazenamento que já tem backups do Torqvoice, digite a senha com que eles foram feitos."
+      },
+      "settings": {
+        "folder": "Pasta",
+        "folderHelp": "A pasta, dentro da raiz de backups do servidor, que guarda os backups desta oficina. Deixe em branco para usar uma pasta com o nome desta oficina. Em um servidor novo, digite o nome da pasta usada por uma instalação anterior para recuperar os backups dela.",
+        "frequency": "Frequência",
+        "frequencyHourly": "A cada hora",
+        "frequencyEvery4h": "A cada 4 horas",
+        "frequencyDaily": "Todos os dias",
+        "frequencyWeekly": "Todas as semanas",
+        "frequencyMonthly": "Todos os meses",
+        "time": "Horário",
+        "timeHelp": "No relógio da oficina, no formato HH:MM. Usado pelos backups diários, semanais e mensais.",
+        "weekday": "Dia da semana",
+        "sunday": "Domingo",
+        "monday": "Segunda-feira",
+        "tuesday": "Terça-feira",
+        "wednesday": "Quarta-feira",
+        "thursday": "Quinta-feira",
+        "friday": "Sexta-feira",
+        "saturday": "Sábado",
+        "monthDay": "Dia do mês",
+        "monthDayLast": "Último dia",
+        "contents": "Conteúdo",
+        "contentsHelp": "Chaves secretas são as que esta oficina digitou no Torqvoice: chaves de pagamento, de IA e de webhook. De qualquer forma, todo backup é criptografado com a senha dos backups. Um backup contém os registros da oficina e, se você escolher, os arquivos dela. Contas de usuário e senhas não ficam nele: em um servidor novo, você entra, convida a equipe e restaura os dados nessa oficina.",
+        "contentsData": "Somente dados",
+        "contentsDataAndSecrets": "Dados e chaves secretas",
+        "includeFiles": "Incluir fotos e arquivos",
+        "includeFilesHelp": "Cada arquivo é enviado uma vez e reaproveitado pelos backups seguintes, então isso custa pouco depois da primeira execução.",
+        "keepHourly": "Manter backups por hora",
+        "keepHourlyHelp": "Quantos dos backups por hora mais recentes manter. Só importa nos agendamentos a cada hora e a cada 4 horas.",
+        "keepDaily": "Manter backups diários",
+        "keepWeekly": "Manter backups semanais",
+        "keepMonthly": "Manter backups mensais",
+        "keepMonthlyHelp": "Backups mais antigos são removidos após cada execução, mantendo os mais recentes de cada tipo. Defina um número como 0 para não manter nenhum daquele tipo."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Atualização dos dados de recall",
       "refresh": "Atualização dos dados de recall",
       "vehicle": "Verificação de recall para um veículo novo"
       "vehicle": "Verificação de recall para um veículo novo"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "destino de backup",
+    "failedTitle": "Falha no backup",
+    "failedMessage": "O backup para {name} não terminou: {reason}",
+    "failedSubject": "Falha no backup para {name}",
+    "failedEmailIntro": "O backup agendado da sua oficina para {name} não terminou.",
+    "openToRetry": "Abra Configurações, Integrações, {name} para ver a execução e tentar novamente.",
+    "overdueTitle": "Backup atrasado",
+    "overdueSince": "Nenhum backup para {name} terminou desde {time}.",
+    "overdueNever": "Nenhum backup para {name} terminou ainda.",
+    "overdueSubject": "O backup para {name} está atrasado",
+    "openToSee": "Abra Configurações, Integrações, {name} para ver o que deu errado.",
+    "platformName": "Backups do Torqvoice",
+    "restoredTitle": "Oficina restaurada",
+    "restoredSubject": "Sua oficina foi restaurada a partir de um backup",
+    "restoredIntro": "{name}: a oficina voltou ao ponto de restauração {snapshot} em {time}. Tudo o que foi registrado depois desse ponto não está mais na oficina.",
+    "restoredByMember": "Um membro da sua oficina pediu esta restauração.",
+    "restoredBySupport": "O suporte da Torqvoice fez isso a seu pedido. Motivo informado: {reason}",
+    "noReason": "nenhum informado",
+    "restoredUndo": "Antes, foi feita uma cópia de proteção da oficina como ela estava logo antes da restauração, que fica guardada por 90 dias. Fale com o suporte para voltar a ela."
   }
   }
 }
 }

+ 161 - 1
messages/pt-BR/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Muitas tentativas. Aguarde alguns minutos e tente novamente.",
     "reauthTooManyAttempts": "Muitas tentativas. Aguarde alguns minutos e tente novamente.",
     "reauthSignInAgainAccount": "Entre novamente antes de excluir sua conta.",
     "reauthSignInAgainAccount": "Entre novamente antes de excluir sua conta.",
     "reauthSignInAgainWorkshop": "Entre novamente antes de excluir a oficina.",
     "reauthSignInAgainWorkshop": "Entre novamente antes de excluir a oficina.",
+    "reauthSignInAgainRestore": "Entre novamente antes de restaurar a oficina.",
     "deleteAccountMoved": "Quer excluir sua conta?",
     "deleteAccountMoved": "Quer excluir sua conta?",
     "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados.",
     "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados.",
     "signatureTitle": "Assinatura",
     "signatureTitle": "Assinatura",
@@ -1399,6 +1400,11 @@
     "importComplete": "Importação concluída",
     "importComplete": "Importação concluída",
     "importCompleteMessage": "Todos os dados foram restaurados do arquivo de backup.",
     "importCompleteMessage": "Todos os dados foram restaurados do arquivo de backup.",
     "importFailed": "Importação falhou",
     "importFailed": "Importação falhou",
+    "restoreRefused": {
+      "busy": "Já há um backup ou uma restauração em andamento neste servidor. Tente de novo quando terminar.",
+      "ownerOnly": "No Torqvoice Cloud, só o proprietário da oficina pode restaurar um backup.",
+      "foreignIds": "O arquivo se refere a registros que pertencem a outra oficina, então nada foi restaurado."
+    },
     "importFromOther": "Importar de outros serviços",
     "importFromOther": "Importar de outros serviços",
     "importFromOtherDescription": "Migre seus dados de outra plataforma. Selecione um serviço para começar.",
     "importFromOtherDescription": "Migre seus dados de outra plataforma. Selecione um serviço para começar.",
     "lubelogTitle": "Importar do LubeLog",
     "lubelogTitle": "Importar do LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Backup do Torqvoice",
     "sourceBackup": "Backup do Torqvoice",
     "sourceBackupDesc": "Um arquivo .zip ou .json baixado nesta página. Restaurar substitui tudo nesta oficina.",
     "sourceBackupDesc": "Um arquivo .zip ou .json baixado nesta página. Restaurar substitui tudo nesta oficina.",
     "restore": "Restaurar",
     "restore": "Restaurar",
-    "restoreTitle": "Restaurar um backup"
+    "restoreTitle": "Restaurar um backup",
+    "includeSecrets": "Incluir chaves secretas",
+    "includeSecretsHelp": "As chaves secretas, como as de pagamento e de IA, ficam de fora, a menos que você marque esta opção. Uma restauração mantém então as chaves que esta instalação já tem.",
+    "platformBackups": {
+      "title": "Backups do Torqvoice",
+      "description": "O Torqvoice faz backup desta oficina a cada hora e mantém pontos de restauração por 30 dias; depois, mantém pontos semanais e mensais.",
+      "runNow": "Fazer backup agora",
+      "started": "Backup iniciado",
+      "lastGood": "Último backup bem-sucedido",
+      "never": "Nenhum backup ainda",
+      "nextRun": "Próxima execução",
+      "notScheduled": "Não agendado",
+      "repositorySize": "Armazenado pelo Torqvoice",
+      "running": "Em andamento",
+      "restoring": "Restaurando",
+      "queued": "Na fila",
+      "queuedAhead": "{count, plural, one {Na fila, # à frente} other {Na fila, # à frente}}",
+      "runningHint": "Um backup está em andamento. Este cartão se atualiza sozinho até terminar.",
+      "restoringHint": "Uma restauração está em andamento: os dados da oficina estão sendo substituídos pelo ponto de restauração. Esta página se atualiza sozinha até terminar. Não trabalhe na oficina até lá.",
+      "paused": "Seu plano não inclui mais os backups do Torqvoice. As execuções pararam; os pontos de restauração ficam guardados até {date} e depois são excluídos.",
+      "upsell": "Disponível no Pro: backups de hora em hora feitos pelo Torqvoice, com pontos de restauração para os quais você pode voltar.",
+      "planLink": "Ver planos",
+      "pointsTitle": "Pontos de restauração",
+      "pointsEmpty": "O primeiro backup ainda não foi feito.",
+      "showAll": "Mostrar todos os {count}",
+      "showFewer": "Mostrar menos",
+      "needsOwner": "Só o proprietário da oficina pode restaurar.",
+      "sizeNote": "Cada ponto de restauração é completo por si só; seu tamanho é tudo o que ele abrange. O repositório guarda cada parte uma única vez e as execuções seguintes adicionam só o que mudou, por isso “Armazenado pelo Torqvoice” fica bem abaixo da soma dos tamanhos.",
+      "recentTitle": "Execuções recentes",
+      "noRuns": "Nenhum backup foi feito ainda.",
+      "notStarted": "Não iniciado",
+      "supportReason": "Restaurado pelo suporte da Torqvoice: {reason}",
+      "columns": {
+        "when": "Quando",
+        "kind": "Tipo",
+        "size": "Tamanho",
+        "status": "Status",
+        "trigger": "Iniciado por",
+        "duration": "Duração"
+      },
+      "kind": {
+        "scheduled": "Agendado",
+        "manual": "Manual",
+        "safety": "Cópia de proteção",
+        "safetyHint": "Feita logo antes de uma restauração, para que ela possa ser desfeita"
+      },
+      "trigger": {
+        "scheduled": "Agendado",
+        "manual": "Manual",
+        "restore": "Restauração"
+      },
+      "status": {
+        "queued": "Na fila",
+        "running": "Em andamento",
+        "done": "Concluído",
+        "failed": "Falhou",
+        "skipped": "Ignorado"
+      },
+      "explore": "Explorar",
+      "restore": {
+        "action": "Restaurar",
+        "dialogTitle": "Restaurar a oficina para o estado de {time}?",
+        "warning": "O ponto de restauração de {time} <bold>substitui tudo o que há nesta oficina</bold>: clientes, veículos, ordens de serviço, faturas, configurações, fotos e arquivos. Tudo o que foi adicionado desde então é perdido.",
+        "safetyNote": "Antes, é feita uma cópia de proteção da oficina como ela está agora, guardada por 90 dias, para que a restauração possa ser desfeita pelos pontos de restauração.",
+        "untouched": "Membros, contas, a assinatura e as integrações não são alterados. As chaves secretas que não estão no backup mantêm os valores atuais.",
+        "confirmPrompt": "Digite o nome da oficina, <bold>{name}</bold>, para confirmar.",
+        "whileRunning": "A restauração roda em segundo plano e leva alguns minutos quando há muitas fotos. A página se atualiza sozinha até terminar.",
+        "cancel": "Cancelar",
+        "confirm": "Restaurar",
+        "started": "Restauração iniciada"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Voltar aos backups",
+      "title": "Ponto de restauração de {date}",
+      "titleShort": "Ponto de restauração {id}",
+      "readOnly": "Somente leitura: nada aqui altera a oficina. A visualização fecha após 15 minutos sem atividade.",
+      "readOnlyBadge": "Somente leitura",
+      "opening": "Abrindo o ponto de restauração",
+      "openingHint": "O backup é descriptografado aqui, no servidor, e mantido na memória por quinze minutos. Nada muda na oficina.",
+      "openingSteps": {
+        "connecting": "Conectando ao repositório de backup",
+        "locating": "Localizando o arquivo de dados no snapshot",
+        "pulling": "Baixando e descriptografando os dados",
+        "reading": "Lendo os registros",
+        "indexing": "Indexando clientes, veículos e ordens de serviço"
+      },
+      "openingReady": "Pronto",
+      "openFailed": "Não foi possível abrir o ponto de restauração",
+      "tryAgain": "Tentar novamente",
+      "ownerOnly": "Só o proprietário da oficina ou um administrador pode ver o conteúdo de um ponto de restauração.",
+      "closed": "O ponto de restauração foi fechado; abra-o novamente",
+      "openAgain": "Abrir novamente",
+      "datasets": {
+        "customers": "Clientes",
+        "vehicles": "Veículos",
+        "serviceRecords": "Ordens de serviço",
+        "quotes": "Orçamentos",
+        "inventoryParts": "Peças"
+      },
+      "searchPlaceholder": "Pesquisar por nome, número, placa, chassi (VIN), e-mail ou telefone",
+      "loading": "Carregando…",
+      "loadFailed": "Não foi possível carregar esta lista",
+      "empty": "Não há nada desse tipo no ponto de restauração.",
+      "noMatches": "Nada corresponde a \"{query}\".",
+      "showing": "{shown} de {total} exibidos",
+      "loadMore": "Mostrar mais",
+      "clearSearch": "Limpar busca",
+      "record": {
+        "loading": "Carregando o registro…",
+        "loadFailed": "Não foi possível carregar o registro",
+        "notFound": "Este registro não está no ponto de restauração.",
+        "fields": "Detalhes",
+        "jumpTo": "Ir para",
+        "previous": "Registro anterior",
+        "next": "Próximo registro",
+        "position": "{index} de {total}",
+        "noColumns": "Nada para mostrar nestas linhas.",
+        "yes": "Sim",
+        "no": "Não"
+      },
+      "sections": {
+        "concerns": "Relatos do cliente",
+        "partItems": "Peças",
+        "laborItems": "Mão de obra",
+        "attachments": "Fotos e documentos",
+        "payments": "Pagamentos",
+        "statusReports": "Relatórios de status",
+        "timeEntries": "Registros de tempo",
+        "notes": "Notas",
+        "fuelLogs": "Registros de abastecimento",
+        "reminders": "Lembretes",
+        "serviceRequests": "Solicitações de serviço",
+        "inspectionStatus": "Status da inspeção",
+        "findings": "Apontamentos",
+        "conditionMarks": "Marcas de estado",
+        "aiMessages": "Mensagens de IA",
+        "recurringInvoices": "Faturas recorrentes",
+        "serviceRecords": "Ordens de serviço",
+        "movements": "Movimentações de estoque",
+        "gallery": "Galeria"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Backups agendados",
+      "description": "Backups automáticos para um armazenamento seu, na frequência que você escolher.",
+      "empty": "Nenhum destino de backup conectado ainda.",
+      "setUp": "Configurar um destino de backup",
+      "lastGood": "Último backup bem-sucedido: {when}",
+      "noGoodBackup": "Nenhum backup bem-sucedido ainda",
+      "nextRun": "próxima execução: {when}",
+      "notScheduled": "não agendado",
+      "lastError": "Último erro: {error}",
+      "extraCopy": "Uma cópia extra em um armazenamento seu, na frequência que você escolher, além dos backups que o Torqvoice faz."
+    }
   },
   },
   "license": {
   "license": {
     "title": "Licença marca branca",
     "title": "Licença marca branca",

+ 7 - 2
messages/pt-PT/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Tipo de marca adicionado",
     "settings_markType_create": "Tipo de marca adicionado",
     "settings_markType_update": "Tipo de marca alterado",
     "settings_markType_update": "Tipo de marca alterado",
     "settings_markType_remove": "Tipo de marca removido",
     "settings_markType_remove": "Tipo de marca removido",
-    "settings_markType_reorder": "Tipos de marca reordenados"
+    "settings_markType_reorder": "Tipos de marca reordenados",
+    "integration_backup_run": "Cópia de segurança iniciada",
+    "integration_backup_restore": "Cópia de segurança reposta"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Removeu uma marca do mapa de estado",
     "condition_mark_remove": "Removeu uma marca do mapa de estado",
     "condition_mark_carry": "Confirmou {count, plural, one {# marca anterior como ainda presente} other {# marcas anteriores como ainda presentes}} no mapa de estado",
     "condition_mark_carry": "Confirmou {count, plural, one {# marca anterior como ainda presente} other {# marcas anteriores como ainda presentes}} no mapa de estado",
     "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
     "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
-    "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura"
+    "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura",
+    "backup_run_started": "Iniciou uma cópia de segurança para {name}",
+    "backup_restore_started": "Iniciou a reposição da cópia de segurança {snapshot} a partir de {name}",
+    "backup_restored": "Repôs a cópia de segurança {snapshot} a partir de {name}"
   }
   }
 }
 }

+ 219 - 2
messages/pt-PT/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Faturas para a contabilidade",
       "invoices": "Faturas para a contabilidade",
       "customers": "Clientes para a contabilidade",
       "customers": "Clientes para a contabilidade",
       "payments": "Pagamentos nos dois sentidos"
       "payments": "Pagamentos nos dois sentidos"
+    },
+    "backup": {
+      "target": "Destino das cópias de segurança"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Todas as viaturas com matrícula foram verificadas recentemente, por isso ainda não há nada para consultar."
       "nothingDue": "Todas as viaturas com matrícula foram verificadas recentemente, por isso ainda não há nada para consultar."
     },
     },
     "supersedes": "Ligar {next} desliga {current}. Só pode estar uma ativa de cada vez, por isso {current} deixa de ser utilizada assim que {next} entrar em funcionamento.",
     "supersedes": "Ligar {next} desliga {current}. Só pode estar uma ativa de cada vez, por isso {current} deixa de ser utilizada assim que {next} entrar em funcionamento.",
+    "platformHandlesBackups": "O Torqvoice já faz uma cópia de segurança desta oficina a cada hora e guarda pontos de restauro durante 30 dias. Ligue um bucket se também quiser uma cópia sua, legível sem o Torqvoice.",
+    "platformHandlesBackupsLink": "Ver os seus pontos de reposição",
     "batchRetried": "{count, plural, =0 {Nada para tentar novamente} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
     "batchRetried": "{count, plural, =0 {Nada para tentar novamente} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
-    "advanced": "Avançado"
+    "advanced": "Avançado",
+    "generate": "Gerar",
+    "show": "Mostrar",
+    "hide": "Ocultar",
+    "generatedPasswordWarning": "Guarde uma cópia desta palavra-passe num local seguro, fora deste servidor. Sem ela ninguém consegue abrir estas cópias de segurança, nem mesmo o Torqvoice.",
+    "backup": {
+      "title": "Cópias de segurança",
+      "description": "Quando este destino recebeu a última cópia de segurança bem-sucedida, quando será a próxima e as execuções mais recentes.",
+      "lastGood": "Última cópia bem-sucedida",
+      "added": "{size} adicionados",
+      "never": "Nenhuma ainda",
+      "nextRun": "Próxima execução",
+      "notScheduled": "Não agendada",
+      "repositorySize": "Guardado no destino",
+      "runNow": "Fazer cópia agora",
+      "running": "A fazer cópia",
+      "runningHint": "Está a decorrer uma cópia de segurança. Esta página atualiza-se sozinha até terminar.",
+      "started": "Cópia de segurança iniciada",
+      "recentTitle": "Execuções recentes",
+      "noRuns": "Ainda não foi feita nenhuma cópia de segurança.",
+      "addedNote": "A primeira execução envia tudo. Cada uma a seguir acrescenta só as partes que mudaram desde então, por isso é pequena. Cada cópia continua completa e pode ser restaurada ou explorada por si só.",
+      "notStarted": "Não iniciada",
+      "filesNewCount": "{count, plural, one {# ficheiro novo} other {# ficheiros novos}}",
+      "columns": {
+        "status": "Estado",
+        "started": "Início",
+        "trigger": "Iniciada por",
+        "duration": "Duração",
+        "filesNew": "Ficheiros novos",
+        "bytesAdded": "Adicionado"
+      },
+      "trigger": {
+        "scheduled": "Agendamento",
+        "manual": "Manual",
+        "restore": "Reposição"
+      },
+      "status": {
+        "queued": "Na fila",
+        "running": "Em curso",
+        "done": "Concluída",
+        "failed": "Falhou",
+        "skipped": "Ignorada"
+      },
+      "restoring": "A repor",
+      "restoringHint": "Está a decorrer uma reposição: os dados da oficina estão a ser substituídos pela cópia de segurança. Esta página atualiza-se sozinha até terminar. Não trabalhe na oficina até lá.",
+      "explore": "Explorar",
+      "restore": {
+        "showAll": "Mostrar todas as cópias de segurança",
+        "refresh": "Atualizar",
+        "loading": "A ler as cópias de segurança no destino",
+        "loadFailed": "Não foi possível listar as cópias de segurança",
+        "empty": "Ainda não há cópias de segurança neste destino.",
+        "needsOwner": "Só o proprietário da oficina pode repor aqui uma cópia de segurança.",
+        "needsAdmin": "Só um proprietário ou administrador pode repor uma cópia de segurança.",
+        "filesIncluded": "Fotografias e ficheiros incluídos",
+        "filesNotIncluded": "Fotografias e ficheiros não incluídos",
+        "earlierInstall": "Instalação anterior",
+        "action": "Repor",
+        "dialogTitle": "Repor a cópia de segurança de {time}?",
+        "warning": "Repor <bold>substitui tudo o que esta oficina tem agora</bold>: clientes, viaturas, ordens de reparação, faturas, definições e, quando a cópia os inclui, fotografias e ficheiros. Tudo o que foi acrescentado depois de a cópia ser feita perde-se. Não é possível anular.",
+        "secretsNote": "As chaves secretas que a cópia deixou de fora mantêm os valores que esta instalação tem agora. As contas de utilizador e as palavras-passe não são alteradas.",
+        "confirmPrompt": "Escreva o nome da oficina, <bold>{name}</bold>, para confirmar.",
+        "whileRunning": "A reposição decorre em segundo plano e demora alguns minutos quando há muitas fotografias. A página atualiza-se sozinha até terminar.",
+        "cancel": "Cancelar",
+        "confirm": "Repor esta cópia de segurança",
+        "started": "Reposição iniciada"
+      }
+    },
+    "lockedBackups": "Cópias de segurança agendadas e encriptadas da sua oficina para um armazenamento seu.",
+    "searchOrType": "Pesquisar ou escrever um nome",
+    "useTyped": "Usar \"{value}\"",
+    "listNeedsKeys": "Introduza as chaves acima para ver a lista",
+    "listEmpty": "A lista está vazia. Escreva um nome.",
+    "testing": "A testar a ligação",
+    "testOk": "A ligação funciona: foi escrito um objeto de teste, que foi depois eliminado.",
+    "testFailed": "O teste falhou",
+    "testSteps": {
+      "settings": "Verifique as definições do fornecedor",
+      "list": "A chave não consegue listar o bucket",
+      "write": "A chave não consegue escrever no bucket",
+      "delete": "A chave não consegue eliminar do bucket",
+      "folder": "Não foi possível criar ou abrir a pasta"
+    },
+    "testHint": "Introduza as chaves e o bucket para os testar antes de ligar. O teste escreve um objeto pequeno e elimina-o."
   },
   },
   "activity": {
   "activity": {
     "title": "Atividade",
     "title": "Atividade",
@@ -199,7 +287,10 @@
     "invalid": "A resposta do fornecedor estava incompleta. Tente novamente.",
     "invalid": "A resposta do fornecedor estava incompleta. Tente novamente.",
     "state": "Este início de sessão expirou ou pertence a outra sessão. Comece de novo.",
     "state": "Este início de sessão expirou ou pertence a outra sessão. Comece de novo.",
     "exchange": "O fornecedor recusou o início de sessão. Verifique as credenciais da aplicação e tente novamente.",
     "exchange": "O fornecedor recusou o início de sessão. Verifique as credenciais da aplicação e tente novamente.",
-    "identify": "Ligado, mas não foi possível ler a conta. Tente voltar a ligar."
+    "identify": "Ligado, mas não foi possível ler a conta. Tente voltar a ligar.",
+    "backupRunning": "Já está a decorrer uma cópia de segurança.",
+    "restoreOwnerOnly": "No Torqvoice Cloud, só o proprietário da oficina pode repor uma cópia de segurança.",
+    "restoreBusy": "Já está a decorrer uma cópia de segurança ou uma reposição neste servidor. Tente novamente quando terminar."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Entrar na videochamada",
     "join": "Entrar na videochamada",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Calendário do Microsoft 365",
       "description": "Coloque as ordens de serviço marcadas num calendário do Outlook, adicione um link do Microsoft Teams e veja os horários ocupados do calendário no Torqvoice.",
       "description": "Coloque as ordens de serviço marcadas num calendário do Outlook, adicione um link do Microsoft Teams e veja os horários ocupados do calendário no Torqvoice.",
       "tenantHelp": "Registe uma aplicação no Microsoft Entra (Registos de aplicações), permita contas pessoais e empresariais, adicione o URI de redirecionamento abaixo como plataforma Web, crie um segredo do cliente e cole ambos aqui.",
       "tenantHelp": "Registe uma aplicação no Microsoft Entra (Registos de aplicações), permita contas pessoais e empresariais, adicione o URI de redirecionamento abaixo como plataforma Web, crie um segredo do cliente e cole ambos aqui.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Compatível com OpenAI",
       "description": "Descrições de serviço, resumos de histórico, leitura de documentos e o assistente, respondidos por qualquer servidor que fale a API de chat da OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e semelhantes. Os seus dados ficam no seu próprio hardware.",
       "description": "Descrições de serviço, resumos de histórico, leitura de documentos e o assistente, respondidos por qualquer servidor que fale a API de chat da OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM e semelhantes. Os seus dados ficam no seu próprio hardware.",
       "fields": {
       "fields": {
         "baseUrl": "URL base",
         "baseUrl": "URL base",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Voz para texto",
       "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É independente do seu fornecedor de IA, por isso pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor seu que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
       "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É independente do seu fornecedor de IA, por isso pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor seu que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
       "fields": {
       "fields": {
         "apiKey": "Chave de API",
         "apiKey": "Chave de API",
@@ -653,6 +747,108 @@
         "modeAi": "Sempre o modelo de voz",
         "modeAi": "Sempre o modelo de voz",
         "modeChoice": "Cada pessoa escolhe: modelo de voz ou navegador"
         "modeChoice": "Cada pessoa escolhe: modelo de voz ou navegador"
       }
       }
+    },
+    "s3": {
+      "name": "Bucket de armazenamento na nuvem",
+      "description": "Cópias de segurança encriptadas desta oficina para um bucket seu no Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage ou em qualquer outro armazenamento compatível com S3, com a frequência que escolher. Depois da primeira execução, só é enviado o que mudou, por isso as cópias de hora a hora continuam baratas.",
+      "fields": {
+        "accessKeyId": "ID da chave de acesso",
+        "accessKeyIdHelp": "Uma chave do seu fornecedor de armazenamento com permissão para listar, ler, escrever e eliminar objetos no bucket. Crie uma chave só para o Torqvoice, para que possa ser revogada separadamente.",
+        "secretAccessKey": "Chave de acesso secreta",
+        "password": "Palavra-passe das cópias de segurança",
+        "passwordHelp": "Cada cópia de segurança é encriptada com esta palavra-passe. Invente uma ou carregue em Gerar, e guarde uma cópia fora deste servidor: sem ela ninguém consegue abrir estas cópias, nem mesmo o Torqvoice. Para voltar a ligar um armazenamento que já contém cópias de segurança do Torqvoice, introduza a palavra-passe com que foram feitas.",
+        "endpoint": "Endpoint personalizado",
+        "endpointHelp": "Só para a opção Outro armazenamento compatível com S3: o endereço S3 do seu próprio armazenamento, como um servidor MinIO, como nome de anfitrião, com porta se necessário."
+      },
+      "settings": {
+        "provider": "Fornecedor",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Outro armazenamento compatível com S3",
+        "region": "Região",
+        "regionHelp": "Onde está o bucket. Escolha-a na lista do fornecedor; noutro armazenamento, escreva a que este pedir, se pedir alguma. Não é usada pelo Cloudflare R2.",
+        "accountId": "ID da conta Cloudflare",
+        "accountIdHelp": "O ID de 32 caracteres apresentado na página de visão geral do R2 no painel da Cloudflare.",
+        "bucket": "Bucket",
+        "bucketHelp": "Os buckets são listados com a chave assim que esta é introduzida. Se a chave puder usar um bucket, mas não os listar, escreva o nome.",
+        "prefix": "Pasta no bucket",
+        "prefixHelp": "As cópias de segurança ficam nesta pasta. Use uma pasta diferente para cada oficina que partilha o bucket.",
+        "frequency": "Frequência",
+        "frequencyHourly": "De hora a hora",
+        "frequencyEvery4h": "De 4 em 4 horas",
+        "frequencyDaily": "Todos os dias",
+        "frequencyWeekly": "Todas as semanas",
+        "frequencyMonthly": "Todos os meses",
+        "time": "Hora do dia",
+        "timeHelp": "No relógio da oficina, no formato HH:MM. Usada pelas cópias diárias, semanais e mensais.",
+        "weekday": "Dia da semana",
+        "sunday": "Domingo",
+        "monday": "Segunda-feira",
+        "tuesday": "Terça-feira",
+        "wednesday": "Quarta-feira",
+        "thursday": "Quinta-feira",
+        "friday": "Sexta-feira",
+        "saturday": "Sábado",
+        "monthDay": "Dia do mês",
+        "monthDayLast": "Último dia",
+        "contents": "Conteúdo",
+        "contentsHelp": "As chaves secretas são as que esta oficina introduziu no Torqvoice: chaves de pagamentos, de IA e de webhooks. Em qualquer caso, cada cópia é encriptada com a palavra-passe das cópias de segurança. Uma cópia contém os registos da oficina e, se o escolher, os respetivos ficheiros. As contas de utilizador e as palavras-passe não estão incluídas: num servidor novo, inicia sessão, convida a equipa e repõe os dados nessa oficina.",
+        "contentsData": "Apenas dados",
+        "contentsDataAndSecrets": "Dados e chaves secretas",
+        "includeFiles": "Incluir fotografias e ficheiros",
+        "includeFilesHelp": "Cada ficheiro é enviado uma vez e reutilizado pelas cópias seguintes, por isso custa pouco depois da primeira execução.",
+        "keepHourly": "Manter cópias horárias",
+        "keepHourlyHelp": "Quantas das cópias horárias mais recentes manter. Só conta nos agendamentos de hora a hora e de 4 em 4 horas.",
+        "keepDaily": "Manter cópias diárias",
+        "keepWeekly": "Manter cópias semanais",
+        "keepMonthly": "Manter cópias mensais",
+        "keepMonthlyHelp": "As cópias mais antigas são removidas após cada execução, mantendo as mais recentes de cada tipo. Defina um número como 0 para não manter nenhuma desse tipo."
+      }
+    },
+    "local-folder": {
+      "name": "Pasta local",
+      "description": "Cópias de segurança encriptadas desta oficina para uma pasta neste servidor: o volume de cópias de segurança no ficheiro compose ou uma unidade de rede montada aí. Uma cópia no mesmo disco da base de dados não é segura por si só; combine-a com um armazenamento noutro local.",
+      "fields": {
+        "password": "Palavra-passe das cópias de segurança",
+        "passwordHelp": "Cada cópia de segurança é encriptada com esta palavra-passe. Invente uma ou carregue em Gerar, e guarde uma cópia fora deste servidor: sem ela ninguém consegue abrir estas cópias, nem mesmo o Torqvoice. Para voltar a ligar um armazenamento que já contém cópias de segurança do Torqvoice, introduza a palavra-passe com que foram feitas."
+      },
+      "settings": {
+        "folder": "Pasta",
+        "folderHelp": "A pasta, dentro da raiz de cópias de segurança do servidor, que guarda as cópias desta oficina. Deixe em branco para usar uma pasta com o nome desta oficina. Num servidor novo, escreva o nome da pasta usada por uma instalação anterior para recuperar as respetivas cópias.",
+        "frequency": "Frequência",
+        "frequencyHourly": "De hora a hora",
+        "frequencyEvery4h": "De 4 em 4 horas",
+        "frequencyDaily": "Todos os dias",
+        "frequencyWeekly": "Todas as semanas",
+        "frequencyMonthly": "Todos os meses",
+        "time": "Hora do dia",
+        "timeHelp": "No relógio da oficina, no formato HH:MM. Usada pelas cópias diárias, semanais e mensais.",
+        "weekday": "Dia da semana",
+        "sunday": "Domingo",
+        "monday": "Segunda-feira",
+        "tuesday": "Terça-feira",
+        "wednesday": "Quarta-feira",
+        "thursday": "Quinta-feira",
+        "friday": "Sexta-feira",
+        "saturday": "Sábado",
+        "monthDay": "Dia do mês",
+        "monthDayLast": "Último dia",
+        "contents": "Conteúdo",
+        "contentsHelp": "As chaves secretas são as que esta oficina introduziu no Torqvoice: chaves de pagamentos, de IA e de webhooks. Em qualquer caso, cada cópia é encriptada com a palavra-passe das cópias de segurança. Uma cópia contém os registos da oficina e, se o escolher, os respetivos ficheiros. As contas de utilizador e as palavras-passe não estão incluídas: num servidor novo, inicia sessão, convida a equipa e repõe os dados nessa oficina.",
+        "contentsData": "Apenas dados",
+        "contentsDataAndSecrets": "Dados e chaves secretas",
+        "includeFiles": "Incluir fotografias e ficheiros",
+        "includeFilesHelp": "Cada ficheiro é enviado uma vez e reutilizado pelas cópias seguintes, por isso custa pouco depois da primeira execução.",
+        "keepHourly": "Manter cópias horárias",
+        "keepHourlyHelp": "Quantas das cópias horárias mais recentes manter. Só conta nos agendamentos de hora a hora e de 4 em 4 horas.",
+        "keepDaily": "Manter cópias diárias",
+        "keepWeekly": "Manter cópias semanais",
+        "keepMonthly": "Manter cópias mensais",
+        "keepMonthlyHelp": "As cópias mais antigas são removidas após cada execução, mantendo as mais recentes de cada tipo. Defina um número como 0 para não manter nenhuma desse tipo."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Atualização dos dados de recall",
       "refresh": "Atualização dos dados de recall",
       "vehicle": "Verificação de recall para uma viatura nova"
       "vehicle": "Verificação de recall para uma viatura nova"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "destino das cópias de segurança",
+    "failedTitle": "A cópia de segurança falhou",
+    "failedMessage": "A cópia de segurança para {name} não terminou: {reason}",
+    "failedSubject": "A cópia de segurança para {name} falhou",
+    "failedEmailIntro": "A cópia de segurança agendada da sua oficina para {name} não terminou.",
+    "openToRetry": "Abra Definições, Integrações, {name} para ver a execução e tentar novamente.",
+    "overdueTitle": "Cópia de segurança em atraso",
+    "overdueSince": "Nenhuma cópia de segurança para {name} terminou desde {time}.",
+    "overdueNever": "Ainda não terminou nenhuma cópia de segurança para {name}.",
+    "overdueSubject": "A cópia de segurança para {name} está em atraso",
+    "openToSee": "Abra Definições, Integrações, {name} para ver o que correu mal.",
+    "platformName": "Cópias de segurança do Torqvoice",
+    "restoredTitle": "Oficina reposta",
+    "restoredSubject": "A sua oficina foi reposta a partir de uma cópia de segurança",
+    "restoredIntro": "{name}: a oficina foi reposta no ponto de restauro {snapshot} em {time}. Tudo o que foi introduzido depois desse ponto já não está na oficina.",
+    "restoredByMember": "Um membro da sua oficina pediu esta reposição.",
+    "restoredBySupport": "O suporte do Torqvoice fez isto a seu pedido. Motivo indicado: {reason}",
+    "noReason": "nenhum indicado",
+    "restoredUndo": "Antes, foi feita uma cópia de proteção da oficina tal como estava imediatamente antes da reposição, que é guardada durante 90 dias. Contacte o suporte para voltar a ela."
   }
   }
 }
 }

+ 161 - 1
messages/pt-PT/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Demasiadas tentativas. Aguarde alguns minutos e tente novamente.",
     "reauthTooManyAttempts": "Demasiadas tentativas. Aguarde alguns minutos e tente novamente.",
     "reauthSignInAgainAccount": "Inicie sessão novamente antes de eliminar a sua conta.",
     "reauthSignInAgainAccount": "Inicie sessão novamente antes de eliminar a sua conta.",
     "reauthSignInAgainWorkshop": "Inicie sessão novamente antes de eliminar a oficina.",
     "reauthSignInAgainWorkshop": "Inicie sessão novamente antes de eliminar a oficina.",
+    "reauthSignInAgainRestore": "Inicie sessão novamente antes de repor a oficina.",
     "deleteAccountMoved": "Quer eliminar a sua conta?",
     "deleteAccountMoved": "Quer eliminar a sua conta?",
     "deleteAccountMovedLink": "Pode fazê-lo nas definições de Dados.",
     "deleteAccountMovedLink": "Pode fazê-lo nas definições de Dados.",
     "signatureTitle": "Assinatura",
     "signatureTitle": "Assinatura",
@@ -1399,6 +1400,11 @@
     "importComplete": "Importação concluída",
     "importComplete": "Importação concluída",
     "importCompleteMessage": "Todos os dados foram repostos a partir do ficheiro de cópia de segurança.",
     "importCompleteMessage": "Todos os dados foram repostos a partir do ficheiro de cópia de segurança.",
     "importFailed": "A importação falhou",
     "importFailed": "A importação falhou",
+    "restoreRefused": {
+      "busy": "Já está a decorrer uma cópia de segurança ou uma reposição neste servidor. Tente novamente quando terminar.",
+      "ownerOnly": "No Torqvoice Cloud, só o proprietário da oficina pode repor uma cópia de segurança.",
+      "foreignIds": "O ficheiro refere registos que pertencem a outra oficina, por isso nada foi reposto."
+    },
     "importFromOther": "Importar de outros serviços",
     "importFromOther": "Importar de outros serviços",
     "importFromOtherDescription": "Migre os seus dados de outra plataforma. Selecione um serviço para começar.",
     "importFromOtherDescription": "Migre os seus dados de outra plataforma. Selecione um serviço para começar.",
     "lubelogTitle": "Importar do LubeLog",
     "lubelogTitle": "Importar do LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Cópia de segurança do Torqvoice",
     "sourceBackup": "Cópia de segurança do Torqvoice",
     "sourceBackupDesc": "Um ficheiro .zip ou .json transferido nesta página. Repor substitui tudo nesta oficina.",
     "sourceBackupDesc": "Um ficheiro .zip ou .json transferido nesta página. Repor substitui tudo nesta oficina.",
     "restore": "Repor",
     "restore": "Repor",
-    "restoreTitle": "Repor uma cópia de segurança"
+    "restoreTitle": "Repor uma cópia de segurança",
+    "includeSecrets": "Incluir chaves secretas",
+    "includeSecretsHelp": "As chaves secretas, como as de pagamentos e de IA, ficam de fora, a menos que assinale esta opção. Uma reposição mantém então as chaves que esta instalação já tem.",
+    "platformBackups": {
+      "title": "Cópias de segurança do Torqvoice",
+      "description": "O Torqvoice faz uma cópia de segurança desta oficina a cada hora e guarda pontos de restauro durante 30 dias e, depois, pontos semanais e mensais.",
+      "runNow": "Fazer cópia agora",
+      "started": "Cópia de segurança iniciada",
+      "lastGood": "Última cópia bem-sucedida",
+      "never": "Ainda sem cópias",
+      "nextRun": "Próxima execução",
+      "notScheduled": "Não agendada",
+      "repositorySize": "Guardado pelo Torqvoice",
+      "running": "Em curso",
+      "restoring": "A repor",
+      "queued": "Na fila",
+      "queuedAhead": "{count, plural, one {Na fila, # à frente} other {Na fila, # à frente}}",
+      "runningHint": "Está a decorrer uma cópia de segurança. Este cartão atualiza-se sozinho até terminar.",
+      "restoringHint": "Está a decorrer uma reposição: os dados da oficina estão a ser substituídos pelo ponto de restauro. Esta página atualiza-se sozinha até terminar. Não trabalhe na oficina até lá.",
+      "paused": "O seu plano já não inclui as cópias de segurança do Torqvoice. As execuções pararam; os pontos de restauro são guardados até {date} e depois eliminados.",
+      "upsell": "Disponível no Pro: cópias de segurança de hora a hora feitas pelo Torqvoice, com pontos de restauro aos quais pode voltar.",
+      "planLink": "Ver planos",
+      "pointsTitle": "Pontos de restauro",
+      "pointsEmpty": "A primeira cópia de segurança ainda não foi feita.",
+      "showAll": "Mostrar todos os {count}",
+      "showFewer": "Mostrar menos",
+      "needsOwner": "Só o proprietário da oficina pode repor.",
+      "sizeNote": "Cada ponto de restauro está completo por si só; o seu tamanho é tudo o que abrange. O repositório guarda cada parte uma única vez e as execuções seguintes acrescentam só o que mudou, por isso «Guardado pelo Torqvoice» fica muito abaixo da soma dos tamanhos.",
+      "recentTitle": "Execuções recentes",
+      "noRuns": "Ainda não foi feita nenhuma cópia de segurança.",
+      "notStarted": "Não iniciada",
+      "supportReason": "Reposta pelo suporte do Torqvoice: {reason}",
+      "columns": {
+        "when": "Quando",
+        "kind": "Tipo",
+        "size": "Tamanho",
+        "status": "Estado",
+        "trigger": "Iniciada por",
+        "duration": "Duração"
+      },
+      "kind": {
+        "scheduled": "Agendada",
+        "manual": "Manual",
+        "safety": "Cópia de proteção",
+        "safetyHint": "Feita imediatamente antes de uma reposição, para que possa ser anulada"
+      },
+      "trigger": {
+        "scheduled": "Agendada",
+        "manual": "Manual",
+        "restore": "Reposição"
+      },
+      "status": {
+        "queued": "Na fila",
+        "running": "Em curso",
+        "done": "Concluída",
+        "failed": "Falhou",
+        "skipped": "Ignorada"
+      },
+      "explore": "Explorar",
+      "restore": {
+        "action": "Repor",
+        "dialogTitle": "Repor a oficina no estado de {time}?",
+        "warning": "O ponto de restauro de {time} <bold>substitui tudo o que existe nesta oficina</bold>: clientes, viaturas, ordens de reparação, faturas, definições, fotografias e ficheiros. Tudo o que foi acrescentado desde então perde-se.",
+        "safetyNote": "Antes, é feita uma cópia de proteção da oficina tal como está agora, guardada durante 90 dias, para que a reposição possa ser anulada a partir dos pontos de restauro.",
+        "untouched": "Os membros, as contas, a subscrição e as integrações não são alterados. As chaves secretas que não estão na cópia mantêm os valores atuais.",
+        "confirmPrompt": "Escreva o nome da oficina, <bold>{name}</bold>, para confirmar.",
+        "whileRunning": "A reposição decorre em segundo plano e demora alguns minutos quando há muitas fotografias. A página atualiza-se sozinha até terminar.",
+        "cancel": "Cancelar",
+        "confirm": "Repor",
+        "started": "Reposição iniciada"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Voltar às cópias de segurança",
+      "title": "Ponto de restauro de {date}",
+      "titleShort": "Ponto de restauro {id}",
+      "readOnly": "Só de leitura: nada aqui altera a oficina. A vista fecha-se após 15 minutos sem atividade.",
+      "readOnlyBadge": "Apenas leitura",
+      "opening": "A abrir o ponto de restauro",
+      "openingHint": "A cópia de segurança é desencriptada aqui, no servidor, e mantida em memória durante um quarto de hora. Nada muda na oficina.",
+      "openingSteps": {
+        "connecting": "A ligar ao repositório de cópias de segurança",
+        "locating": "A procurar o ficheiro de dados no snapshot",
+        "pulling": "A obter e desencriptar os dados",
+        "reading": "A ler os registos",
+        "indexing": "A indexar clientes, veículos e ordens de trabalho"
+      },
+      "openingReady": "Pronto",
+      "openFailed": "Não foi possível abrir o ponto de restauro",
+      "tryAgain": "Tentar novamente",
+      "ownerOnly": "Só o proprietário da oficina ou um administrador pode ver o conteúdo de um ponto de restauro.",
+      "closed": "O ponto de restauro foi fechado; abra-o novamente",
+      "openAgain": "Abrir novamente",
+      "datasets": {
+        "customers": "Clientes",
+        "vehicles": "Viaturas",
+        "serviceRecords": "Ordens de serviço",
+        "quotes": "Orçamentos",
+        "inventoryParts": "Peças"
+      },
+      "searchPlaceholder": "Pesquisar por nome, número, matrícula, número de quadro (VIN), e-mail ou telefone",
+      "loading": "A carregar…",
+      "loadFailed": "Não foi possível carregar esta lista",
+      "empty": "Não há nada deste tipo no ponto de restauro.",
+      "noMatches": "Nada corresponde a \"{query}\".",
+      "showing": "{shown} de {total} mostrados",
+      "loadMore": "Mostrar mais",
+      "clearSearch": "Limpar pesquisa",
+      "record": {
+        "loading": "A carregar o registo…",
+        "loadFailed": "Não foi possível carregar o registo",
+        "notFound": "Este registo não está no ponto de restauro.",
+        "fields": "Detalhes",
+        "jumpTo": "Ir para",
+        "previous": "Registo anterior",
+        "next": "Registo seguinte",
+        "position": "{index} de {total}",
+        "noColumns": "Nada a mostrar nestas linhas.",
+        "yes": "Sim",
+        "no": "Não"
+      },
+      "sections": {
+        "concerns": "Queixas do cliente",
+        "partItems": "Peças",
+        "laborItems": "Mão de obra",
+        "attachments": "Fotografias e documentos",
+        "payments": "Pagamentos",
+        "statusReports": "Relatórios de estado",
+        "timeEntries": "Registos de tempo",
+        "notes": "Notas",
+        "fuelLogs": "Registos de abastecimento",
+        "reminders": "Lembretes",
+        "serviceRequests": "Pedidos de serviço",
+        "inspectionStatus": "Estado da inspeção",
+        "findings": "Observações",
+        "conditionMarks": "Marcas de estado",
+        "aiMessages": "Mensagens de IA",
+        "recurringInvoices": "Faturas recorrentes",
+        "serviceRecords": "Ordens de serviço",
+        "movements": "Movimentos de stock",
+        "gallery": "Galeria"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Cópias de segurança agendadas",
+      "description": "Cópias de segurança automáticas para um armazenamento seu, com a frequência que escolher.",
+      "empty": "Ainda não há nenhum destino de cópias de segurança ligado.",
+      "setUp": "Configurar um destino de cópias de segurança",
+      "lastGood": "Última cópia bem-sucedida: {when}",
+      "noGoodBackup": "Ainda sem cópias bem-sucedidas",
+      "nextRun": "próxima execução: {when}",
+      "notScheduled": "não agendada",
+      "lastError": "Último erro: {error}",
+      "extraCopy": "Uma cópia adicional num armazenamento seu, com a frequência que escolher, além das cópias de segurança que o Torqvoice faz."
+    }
   },
   },
   "license": {
   "license": {
     "title": "Licença de marca branca",
     "title": "Licença de marca branca",

+ 7 - 2
messages/ru/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "Добавлен тип отметки",
     "settings_markType_create": "Добавлен тип отметки",
     "settings_markType_update": "Изменён тип отметки",
     "settings_markType_update": "Изменён тип отметки",
     "settings_markType_remove": "Удалён тип отметки",
     "settings_markType_remove": "Удалён тип отметки",
-    "settings_markType_reorder": "Изменён порядок типов отметок"
+    "settings_markType_reorder": "Изменён порядок типов отметок",
+    "integration_backup_run": "Запущено резервное копирование",
+    "integration_backup_restore": "Восстановлена резервная копия"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Удалил(а) отметку с карты состояния",
     "condition_mark_remove": "Удалил(а) отметку с карты состояния",
     "condition_mark_carry": "Подтвердил(а), что {count, plural, one {# прежняя отметка} few {# прежние отметки} many {# прежних отметок} other {# прежней отметки}} на карте состояния всё ещё на месте",
     "condition_mark_carry": "Подтвердил(а), что {count, plural, one {# прежняя отметка} few {# прежние отметки} many {# прежних отметок} other {# прежней отметки}} на карте состояния всё ещё на месте",
     "condition_map_on_invoice_on": "Добавил(а) карту состояния в счёт",
     "condition_map_on_invoice_on": "Добавил(а) карту состояния в счёт",
-    "condition_map_on_invoice_off": "Убрал(а) карту состояния из счёта"
+    "condition_map_on_invoice_off": "Убрал(а) карту состояния из счёта",
+    "backup_run_started": "Запустил(а) резервное копирование в {name}",
+    "backup_restore_started": "Запустил(а) восстановление резервной копии {snapshot} из {name}",
+    "backup_restored": "Восстановил(а) резервную копию {snapshot} из {name}"
   }
   }
 }
 }

+ 219 - 2
messages/ru/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Счета в бухгалтерию",
       "invoices": "Счета в бухгалтерию",
       "customers": "Клиенты в бухгалтерию",
       "customers": "Клиенты в бухгалтерию",
       "payments": "Платежи в обе стороны"
       "payments": "Платежи в обе стороны"
+    },
+    "backup": {
+      "target": "Место назначения резервных копий"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Все автомобили с номером недавно проверялись, проверять пока нечего."
       "nothingDue": "Все автомобили с номером недавно проверялись, проверять пока нечего."
     },
     },
     "supersedes": "Подключение {next} отключит {current}. Одновременно работает только один, поэтому {current} перестанет использоваться, как только заработает {next}.",
     "supersedes": "Подключение {next} отключит {current}. Одновременно работает только один, поэтому {current} перестанет использоваться, как только заработает {next}.",
+    "platformHandlesBackups": "Torqvoice уже каждый час создаёт резервную копию этой мастерской и хранит точки восстановления 30 дней. Подключите бакет, если вам нужна ещё и собственная копия, которую можно прочитать без Torqvoice.",
+    "platformHandlesBackupsLink": "Посмотреть точки восстановления",
     "batchRetried": "{count, plural, =0 {Нечего повторять} one {# задача снова в очереди} few {# задачи снова в очереди} many {# задач снова в очереди} other {# задачи снова в очереди}}",
     "batchRetried": "{count, plural, =0 {Нечего повторять} one {# задача снова в очереди} few {# задачи снова в очереди} many {# задач снова в очереди} other {# задачи снова в очереди}}",
-    "advanced": "Дополнительно"
+    "advanced": "Дополнительно",
+    "generate": "Сгенерировать",
+    "show": "Показать",
+    "hide": "Скрыть",
+    "generatedPasswordWarning": "Сохраните копию этого пароля в надёжном месте, не на этом сервере. Без него никто не сможет открыть эти резервные копии, даже Torqvoice.",
+    "backup": {
+      "title": "Резервные копии",
+      "description": "Когда в это место назначения в последний раз попала успешная резервная копия, когда запустится следующая, и последние запуски.",
+      "lastGood": "Последняя успешная копия",
+      "added": "Добавлено {size}",
+      "never": "Пока нет",
+      "nextRun": "Следующий запуск",
+      "notScheduled": "Не запланировано",
+      "repositorySize": "Хранится в месте назначения",
+      "runNow": "Создать копию сейчас",
+      "running": "Создаётся копия",
+      "runningHint": "Идёт резервное копирование. Страница обновляется сама, пока оно не завершится.",
+      "started": "Резервное копирование запущено",
+      "recentTitle": "Последние запуски",
+      "noRuns": "Резервные копии ещё не создавались.",
+      "addedNote": "Первый запуск загружает всё. Каждый следующий добавляет только изменившиеся с тех пор фрагменты, поэтому он небольшой. Каждая копия при этом полна и может быть восстановлена или просмотрена отдельно.",
+      "notStarted": "Не запущено",
+      "filesNewCount": "{count, plural, one {# новый файл} few {# новых файла} many {# новых файлов} other {# нового файла}}",
+      "columns": {
+        "status": "Статус",
+        "started": "Начало",
+        "trigger": "Кто запустил",
+        "duration": "Длительность",
+        "filesNew": "Новые файлы",
+        "bytesAdded": "Добавлено"
+      },
+      "trigger": {
+        "scheduled": "По расписанию",
+        "manual": "Вручную",
+        "restore": "Восстановление"
+      },
+      "status": {
+        "queued": "В очереди",
+        "running": "Выполняется",
+        "done": "Готово",
+        "failed": "Ошибка",
+        "skipped": "Пропущено"
+      },
+      "restoring": "Идёт восстановление",
+      "restoringHint": "Идёт восстановление: данные мастерской заменяются резервной копией. Страница обновляется сама, пока оно не завершится. До тех пор не работайте в мастерской.",
+      "explore": "Просмотреть",
+      "restore": {
+        "showAll": "Показать все резервные копии",
+        "refresh": "Обновить",
+        "loading": "Чтение резервных копий в месте назначения",
+        "loadFailed": "Не удалось получить список резервных копий",
+        "empty": "В этом месте назначения пока нет резервных копий.",
+        "needsOwner": "Восстановить резервную копию здесь может только владелец мастерской.",
+        "needsAdmin": "Восстановить резервную копию может только владелец или администратор.",
+        "filesIncluded": "Фото и файлы включены",
+        "filesNotIncluded": "Фото и файлы не включены",
+        "earlierInstall": "Прежняя установка",
+        "action": "Восстановить",
+        "dialogTitle": "Восстановить резервную копию от {time}?",
+        "warning": "Восстановление <bold>заменяет всё, что сейчас есть в этой мастерской</bold>: клиентов, автомобили, заказ-наряды, счета, настройки и, если они есть в резервной копии, фото и файлы. Всё, что добавлено после создания резервной копии, пропадёт. Это действие нельзя отменить.",
+        "secretsNote": "Секретные ключи, не включённые в резервную копию, сохраняют значения, которые сейчас есть в этой установке. Учётные записи пользователей и пароли не затрагиваются.",
+        "confirmPrompt": "Для подтверждения введите название мастерской, <bold>{name}</bold>.",
+        "whileRunning": "Восстановление идёт в фоне и занимает несколько минут, если фотографий много. Страница обновляется сама, пока оно не завершится.",
+        "cancel": "Отмена",
+        "confirm": "Восстановить эту резервную копию",
+        "started": "Восстановление запущено"
+      }
+    },
+    "lockedBackups": "Зашифрованные резервные копии вашей мастерской по расписанию в ваше собственное хранилище.",
+    "searchOrType": "Найдите или введите имя",
+    "useTyped": "Использовать «{value}»",
+    "listNeedsKeys": "Введите ключи выше, чтобы увидеть список",
+    "listEmpty": "Список пуст. Введите имя вручную.",
+    "testing": "Проверка подключения",
+    "testOk": "Подключение работает: тестовый объект был записан и снова удалён.",
+    "testFailed": "Проверка не прошла",
+    "testSteps": {
+      "settings": "Проверьте настройки поставщика",
+      "list": "Ключ не может просматривать список объектов в бакете",
+      "write": "Ключ не может записывать в бакет",
+      "delete": "Ключ не может удалять из бакета",
+      "folder": "Не удаётся создать или открыть папку"
+    },
+    "testHint": "Заполните ключи и бакет, чтобы проверить их до подключения. Проверка записывает один небольшой объект и удаляет его."
   },
   },
   "activity": {
   "activity": {
     "title": "Активность",
     "title": "Активность",
@@ -199,7 +287,10 @@
     "invalid": "Ответ провайдера был неполным. Попробуйте ещё раз.",
     "invalid": "Ответ провайдера был неполным. Попробуйте ещё раз.",
     "state": "Срок этого входа истёк, или он относится к другой сессии. Начните заново.",
     "state": "Срок этого входа истёк, или он относится к другой сессии. Начните заново.",
     "exchange": "Провайдер отклонил вход. Проверьте данные приложения и попробуйте ещё раз.",
     "exchange": "Провайдер отклонил вход. Проверьте данные приложения и попробуйте ещё раз.",
-    "identify": "Подключено, но не удалось прочитать данные аккаунта. Попробуйте переподключиться."
+    "identify": "Подключено, но не удалось прочитать данные аккаунта. Попробуйте переподключиться.",
+    "backupRunning": "Резервное копирование уже выполняется.",
+    "restoreOwnerOnly": "В Torqvoice Cloud восстановить резервную копию может только владелец мастерской.",
+    "restoreBusy": "На этом сервере уже выполняется резервное копирование или восстановление. Попробуйте ещё раз, когда оно завершится."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Присоединиться к видеозвонку",
     "join": "Присоединиться к видеозвонку",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Календарь Microsoft 365",
       "description": "Добавляйте запланированные заказ-наряды в календарь Outlook, прикрепляйте ссылку Microsoft Teams и видите занятое время календаря в Torqvoice.",
       "description": "Добавляйте запланированные заказ-наряды в календарь Outlook, прикрепляйте ссылку Microsoft Teams и видите занятое время календаря в Torqvoice.",
       "tenantHelp": "Зарегистрируйте приложение в Microsoft Entra (App registrations), разрешите личные и рабочие аккаунты, добавьте указанный ниже URI перенаправления как платформу Web, создайте секрет клиента и вставьте сюда оба значения.",
       "tenantHelp": "Зарегистрируйте приложение в Microsoft Entra (App registrations), разрешите личные и рабочие аккаунты, добавьте указанный ниже URI перенаправления как платформу Web, создайте секрет клиента и вставьте сюда оба значения.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "Совместимый с OpenAI",
       "description": "Описания работ, сводки истории, распознавание документов и ассистент, которые отвечает любой сервер с chat API OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM и подобные. Ваши данные остаются на вашем оборудовании.",
       "description": "Описания работ, сводки истории, распознавание документов и ассистент, которые отвечает любой сервер с chat API OpenAI: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM и подобные. Ваши данные остаются на вашем оборудовании.",
       "fields": {
       "fields": {
         "baseUrl": "Базовый URL",
         "baseUrl": "Базовый URL",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Речь в текст",
       "description": "Диктовка в заказ-наряде, которую записывает речевая модель, а не браузер: гораздо точнее, на любом языке и со словами мастерской. Работает отдельно от вашего провайдера ИИ, так что его можно оставить как есть. Использует ваш собственный ключ OpenAI или ваш собственный сервер с API распознавания речи в стиле OpenAI. Запись отправляется на расшифровку и не сохраняется.",
       "description": "Диктовка в заказ-наряде, которую записывает речевая модель, а не браузер: гораздо точнее, на любом языке и со словами мастерской. Работает отдельно от вашего провайдера ИИ, так что его можно оставить как есть. Использует ваш собственный ключ OpenAI или ваш собственный сервер с API распознавания речи в стиле OpenAI. Запись отправляется на расшифровку и не сохраняется.",
       "fields": {
       "fields": {
         "apiKey": "API-ключ",
         "apiKey": "API-ключ",
@@ -653,6 +747,108 @@
         "modeAi": "Всегда речевая модель",
         "modeAi": "Всегда речевая модель",
         "modeChoice": "Каждый выбирает сам: речевая модель или браузер"
         "modeChoice": "Каждый выбирает сам: речевая модель или браузер"
       }
       }
+    },
+    "s3": {
+      "name": "Облачный бакет",
+      "description": "Зашифрованные резервные копии этой мастерской в ваш собственный бакет в Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage или любом другом S3-совместимом хранилище по выбранному вами расписанию. После первого запуска загружаются только изменения, поэтому ежечасные копии остаются дешёвыми.",
+      "fields": {
+        "accessKeyId": "Идентификатор ключа доступа",
+        "accessKeyIdHelp": "Ключ вашего поставщика хранилища с правом просматривать список, читать, записывать и удалять объекты в бакете. Создайте отдельный ключ только для Torqvoice, чтобы его можно было отозвать независимо.",
+        "secretAccessKey": "Секретный ключ доступа",
+        "password": "Пароль резервных копий",
+        "passwordHelp": "Каждая резервная копия шифруется этим паролем. Придумайте его или нажмите «Сгенерировать» и сохраните копию не на этом сервере: без него никто не сможет открыть эти резервные копии, даже Torqvoice. Чтобы снова подключить хранилище, в котором уже есть резервные копии Torqvoice, введите пароль, с которым они были созданы.",
+        "endpoint": "Свой адрес (endpoint)",
+        "endpointHelp": "Только для варианта «Другое S3-совместимое хранилище»: S3-адрес вашего собственного хранилища, например сервера MinIO, в виде имени хоста, при необходимости с портом."
+      },
+      "settings": {
+        "provider": "Поставщик",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Другое S3-совместимое хранилище",
+        "region": "Регион",
+        "regionHelp": "Где находится бакет. Выберите его из списка поставщика; для другого хранилища введите значение, которое оно ожидает, если оно его требует. Cloudflare R2 его не использует.",
+        "accountId": "Идентификатор аккаунта Cloudflare",
+        "accountIdHelp": "32-символьный идентификатор на странице обзора R2 в панели управления Cloudflare.",
+        "bucket": "Бакет",
+        "bucketHelp": "Список бакетов появляется, как только введён ключ. Если ключу разрешено работать с одним бакетом, но не получать их список, введите его имя.",
+        "prefix": "Папка в бакете",
+        "prefixHelp": "Резервные копии хранятся в этой папке. Для каждой мастерской, которая использует тот же бакет, укажите отдельную папку.",
+        "frequency": "Как часто",
+        "frequencyHourly": "Каждый час",
+        "frequencyEvery4h": "Каждые 4 часа",
+        "frequencyDaily": "Каждый день",
+        "frequencyWeekly": "Каждую неделю",
+        "frequencyMonthly": "Каждый месяц",
+        "time": "Время",
+        "timeHelp": "По часам мастерской, в формате HH:MM. Используется для ежедневных, еженедельных и ежемесячных копий.",
+        "weekday": "День недели",
+        "sunday": "Воскресенье",
+        "monday": "Понедельник",
+        "tuesday": "Вторник",
+        "wednesday": "Среда",
+        "thursday": "Четверг",
+        "friday": "Пятница",
+        "saturday": "Суббота",
+        "monthDay": "День месяца",
+        "monthDayLast": "Последний день",
+        "contents": "Содержимое",
+        "contentsHelp": "К секретным ключам относятся ключи, которые эта мастерская ввела в Torqvoice: ключи платежей, ИИ и вебхуков. В любом случае каждая резервная копия шифруется паролем резервных копий. Резервная копия содержит записи мастерской и, если выбрано, её файлы. Учётных записей пользователей и паролей в ней нет: на новом сервере вы входите, приглашаете команду и восстанавливаете данные в эту мастерскую.",
+        "contentsData": "Только данные",
+        "contentsDataAndSecrets": "Данные и секретные ключи",
+        "includeFiles": "Включить фото и файлы",
+        "includeFilesHelp": "Каждый файл загружается один раз и повторно используется последующими копиями, поэтому после первого запуска это стоит недорого.",
+        "keepHourly": "Хранить ежечасные копии",
+        "keepHourlyHelp": "Сколько последних ежечасных копий хранить. Имеет значение только для расписаний «каждый час» и «каждые 4 часа».",
+        "keepDaily": "Хранить ежедневные копии",
+        "keepWeekly": "Хранить еженедельные копии",
+        "keepMonthly": "Хранить ежемесячные копии",
+        "keepMonthlyHelp": "После каждого запуска старые копии удаляются, а самые новые копии каждого вида остаются. Укажите 0, чтобы не хранить копии этого вида."
+      }
+    },
+    "local-folder": {
+      "name": "Локальная папка",
+      "description": "Зашифрованные резервные копии этой мастерской в папку на этом сервере: том для резервных копий из файла compose или подключённый туда сетевой диск. Копия на том же диске, что и база данных, сама по себе не защищена; используйте её вместе с хранилищем в другом месте.",
+      "fields": {
+        "password": "Пароль резервных копий",
+        "passwordHelp": "Каждая резервная копия шифруется этим паролем. Придумайте его или нажмите «Сгенерировать» и сохраните копию не на этом сервере: без него никто не сможет открыть эти резервные копии, даже Torqvoice. Чтобы снова подключить хранилище, в котором уже есть резервные копии Torqvoice, введите пароль, с которым они были созданы."
+      },
+      "settings": {
+        "folder": "Папка",
+        "folderHelp": "Папка в корневом каталоге резервных копий сервера, где хранятся резервные копии этой мастерской. Оставьте пустым, чтобы использовать папку с именем этой мастерской. На новом сервере введите имя папки, которую использовала прежняя установка, чтобы вернуть её резервные копии.",
+        "frequency": "Как часто",
+        "frequencyHourly": "Каждый час",
+        "frequencyEvery4h": "Каждые 4 часа",
+        "frequencyDaily": "Каждый день",
+        "frequencyWeekly": "Каждую неделю",
+        "frequencyMonthly": "Каждый месяц",
+        "time": "Время",
+        "timeHelp": "По часам мастерской, в формате HH:MM. Используется для ежедневных, еженедельных и ежемесячных копий.",
+        "weekday": "День недели",
+        "sunday": "Воскресенье",
+        "monday": "Понедельник",
+        "tuesday": "Вторник",
+        "wednesday": "Среда",
+        "thursday": "Четверг",
+        "friday": "Пятница",
+        "saturday": "Суббота",
+        "monthDay": "День месяца",
+        "monthDayLast": "Последний день",
+        "contents": "Содержимое",
+        "contentsHelp": "К секретным ключам относятся ключи, которые эта мастерская ввела в Torqvoice: ключи платежей, ИИ и вебхуков. В любом случае каждая резервная копия шифруется паролем резервных копий. Резервная копия содержит записи мастерской и, если выбрано, её файлы. Учётных записей пользователей и паролей в ней нет: на новом сервере вы входите, приглашаете команду и восстанавливаете данные в эту мастерскую.",
+        "contentsData": "Только данные",
+        "contentsDataAndSecrets": "Данные и секретные ключи",
+        "includeFiles": "Включить фото и файлы",
+        "includeFilesHelp": "Каждый файл загружается один раз и повторно используется последующими копиями, поэтому после первого запуска это стоит недорого.",
+        "keepHourly": "Хранить ежечасные копии",
+        "keepHourlyHelp": "Сколько последних ежечасных копий хранить. Имеет значение только для расписаний «каждый час» и «каждые 4 часа».",
+        "keepDaily": "Хранить ежедневные копии",
+        "keepWeekly": "Хранить еженедельные копии",
+        "keepMonthly": "Хранить ежемесячные копии",
+        "keepMonthlyHelp": "После каждого запуска старые копии удаляются, а самые новые копии каждого вида остаются. Укажите 0, чтобы не хранить копии этого вида."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Обновление данных об отзывных кампаниях",
       "refresh": "Обновление данных об отзывных кампаниях",
       "vehicle": "Проверка отзывных кампаний для нового автомобиля"
       "vehicle": "Проверка отзывных кампаний для нового автомобиля"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "место назначения резервных копий",
+    "failedTitle": "Резервное копирование не удалось",
+    "failedMessage": "Резервное копирование в {name} не завершилось: {reason}",
+    "failedSubject": "Резервное копирование в {name} не удалось",
+    "failedEmailIntro": "Плановое резервное копирование вашей мастерской в {name} не завершилось.",
+    "openToRetry": "Откройте Настройки, Интеграции, {name}, чтобы посмотреть запуск и попробовать снова.",
+    "overdueTitle": "Резервное копирование просрочено",
+    "overdueSince": "С {time} ни одно резервное копирование в {name} не завершилось.",
+    "overdueNever": "Ни одно резервное копирование в {name} ещё не завершилось.",
+    "overdueSubject": "Резервное копирование в {name} просрочено",
+    "openToSee": "Откройте Настройки, Интеграции, {name}, чтобы узнать, что пошло не так.",
+    "platformName": "Резервные копии от Torqvoice",
+    "restoredTitle": "Мастерская восстановлена",
+    "restoredSubject": "Ваша мастерская восстановлена из резервной копии",
+    "restoredIntro": "{name}: {time} мастерская была возвращена к точке восстановления {snapshot}. Всего, что было внесено после этой точки, в мастерской больше нет.",
+    "restoredByMember": "Это восстановление запросил участник вашей мастерской.",
+    "restoredBySupport": "Служба поддержки Torqvoice сделала это по вашей просьбе. Указанная причина: {reason}",
+    "noReason": "не указана",
+    "restoredUndo": "Перед этим была создана страховочная копия мастерской в том виде, в каком она была непосредственно перед восстановлением; она хранится 90 дней. Чтобы вернуться к ней, обратитесь в службу поддержки."
   }
   }
 }
 }

+ 161 - 1
messages/ru/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Слишком много попыток. Подождите несколько минут и попробуйте снова.",
     "reauthTooManyAttempts": "Слишком много попыток. Подождите несколько минут и попробуйте снова.",
     "reauthSignInAgainAccount": "Войдите снова, прежде чем удалять аккаунт.",
     "reauthSignInAgainAccount": "Войдите снова, прежде чем удалять аккаунт.",
     "reauthSignInAgainWorkshop": "Войдите снова, прежде чем удалять мастерскую.",
     "reauthSignInAgainWorkshop": "Войдите снова, прежде чем удалять мастерскую.",
+    "reauthSignInAgainRestore": "Войдите снова, прежде чем восстанавливать мастерскую.",
     "deleteAccountMoved": "Хотите удалить свою учётную запись?",
     "deleteAccountMoved": "Хотите удалить свою учётную запись?",
     "deleteAccountMovedLink": "Это можно сделать в настройках данных.",
     "deleteAccountMovedLink": "Это можно сделать в настройках данных.",
     "signatureTitle": "Подпись",
     "signatureTitle": "Подпись",
@@ -1399,6 +1400,11 @@
     "importComplete": "Импорт завершён",
     "importComplete": "Импорт завершён",
     "importCompleteMessage": "Все данные были восстановлены из файла резервной копии.",
     "importCompleteMessage": "Все данные были восстановлены из файла резервной копии.",
     "importFailed": "Ошибка импорта",
     "importFailed": "Ошибка импорта",
+    "restoreRefused": {
+      "busy": "На этом сервере уже выполняется резервное копирование или восстановление. Попробуйте ещё раз, когда оно завершится.",
+      "ownerOnly": "В Torqvoice Cloud восстановить резервную копию может только владелец мастерской.",
+      "foreignIds": "Файл ссылается на записи, которые принадлежат другой мастерской, поэтому ничего не восстановлено."
+    },
     "importFromOther": "Импорт из других сервисов",
     "importFromOther": "Импорт из других сервисов",
     "importFromOtherDescription": "Перенесите данные с другой платформы. Выберите сервис для начала.",
     "importFromOtherDescription": "Перенесите данные с другой платформы. Выберите сервис для начала.",
     "lubelogTitle": "Импорт из LubeLog",
     "lubelogTitle": "Импорт из LubeLog",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Резервная копия Torqvoice",
     "sourceBackup": "Резервная копия Torqvoice",
     "sourceBackupDesc": "Файл .zip или .json, скачанный с этой страницы. Восстановление заменяет всё в этой мастерской.",
     "sourceBackupDesc": "Файл .zip или .json, скачанный с этой страницы. Восстановление заменяет всё в этой мастерской.",
     "restore": "Восстановить",
     "restore": "Восстановить",
-    "restoreTitle": "Восстановить резервную копию"
+    "restoreTitle": "Восстановить резервную копию",
+    "includeSecrets": "Включить секретные ключи",
+    "includeSecretsHelp": "Секретные ключи, например ключи платежей и ИИ, не включаются, если не отметить этот пункт. Тогда при восстановлении сохраняются ключи, которые уже есть в этой установке.",
+    "platformBackups": {
+      "title": "Резервные копии от Torqvoice",
+      "description": "Torqvoice каждый час создаёт резервную копию этой мастерской и хранит точки восстановления 30 дней, а затем еженедельные и ежемесячные.",
+      "runNow": "Создать копию сейчас",
+      "started": "Резервное копирование запущено",
+      "lastGood": "Последняя успешная копия",
+      "never": "Копий пока нет",
+      "nextRun": "Следующий запуск",
+      "notScheduled": "Не запланировано",
+      "repositorySize": "Хранится у Torqvoice",
+      "running": "Выполняется",
+      "restoring": "Идёт восстановление",
+      "queued": "В очереди",
+      "queuedAhead": "{count, plural, one {В очереди, перед ней # задача} few {В очереди, перед ней # задачи} many {В очереди, перед ней # задач} other {В очереди, перед ней # задачи}}",
+      "runningHint": "Идёт резервное копирование. Карточка обновляется сама, пока оно не завершится.",
+      "restoringHint": "Идёт восстановление: данные мастерской заменяются точкой восстановления. Страница обновляется сама, пока оно не завершится. До тех пор не работайте в мастерской.",
+      "paused": "Ваш тариф больше не включает резервные копии от Torqvoice. Запуски остановлены; точки восстановления хранятся до {date}, а затем удаляются.",
+      "upsell": "Доступно в тарифе Pro: ежечасные резервные копии от Torqvoice с точками восстановления, к которым можно вернуться.",
+      "planLink": "Посмотреть тарифы",
+      "pointsTitle": "Точки восстановления",
+      "pointsEmpty": "Первая резервная копия ещё не создавалась.",
+      "showAll": "Показать все ({count})",
+      "showFewer": "Показать меньше",
+      "needsOwner": "Восстановить может только владелец мастерской.",
+      "sizeNote": "Каждая точка восстановления полна сама по себе; её размер — всё, что она охватывает. Хранилище держит каждый фрагмент один раз, а последующие запуски добавляют только изменившееся, поэтому «Хранится у Torqvoice» остаётся намного меньше суммы размеров.",
+      "recentTitle": "Последние запуски",
+      "noRuns": "Резервные копии ещё не создавались.",
+      "notStarted": "Не запущено",
+      "supportReason": "Восстановлено службой поддержки Torqvoice: {reason}",
+      "columns": {
+        "when": "Когда",
+        "kind": "Тип",
+        "size": "Размер",
+        "status": "Статус",
+        "trigger": "Кто запустил",
+        "duration": "Длительность"
+      },
+      "kind": {
+        "scheduled": "По расписанию",
+        "manual": "Вручную",
+        "safety": "Страховочная копия",
+        "safetyHint": "Создана непосредственно перед восстановлением, чтобы его можно было отменить"
+      },
+      "trigger": {
+        "scheduled": "По расписанию",
+        "manual": "Вручную",
+        "restore": "Восстановление"
+      },
+      "status": {
+        "queued": "В очереди",
+        "running": "Выполняется",
+        "done": "Готово",
+        "failed": "Ошибка",
+        "skipped": "Пропущено"
+      },
+      "explore": "Просмотреть",
+      "restore": {
+        "action": "Восстановить",
+        "dialogTitle": "Вернуть мастерскую к состоянию на {time}?",
+        "warning": "Точка восстановления от {time} <bold>заменяет всё, что есть в этой мастерской</bold>: клиентов, автомобили, заказ-наряды, счета, настройки, фото и файлы. Всё, что добавлено с тех пор, пропадёт.",
+        "safetyNote": "Сначала создаётся страховочная копия мастерской в её текущем виде, которая хранится 90 дней, поэтому восстановление можно отменить из точек восстановления.",
+        "untouched": "Участники, учётные записи, подписка и интеграции не затрагиваются. Секретные ключи, которых нет в резервной копии, сохраняют текущие значения.",
+        "confirmPrompt": "Для подтверждения введите название мастерской, <bold>{name}</bold>.",
+        "whileRunning": "Восстановление идёт в фоне и занимает несколько минут, если фотографий много. Страница обновляется сама, пока оно не завершится.",
+        "cancel": "Отмена",
+        "confirm": "Восстановить",
+        "started": "Восстановление запущено"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Назад к резервным копиям",
+      "title": "Точка восстановления от {date}",
+      "titleShort": "Точка восстановления {id}",
+      "readOnly": "Только для чтения: здесь ничего не меняет мастерскую. Просмотр закрывается после 15 минут бездействия.",
+      "readOnlyBadge": "Только чтение",
+      "opening": "Открываем точку восстановления",
+      "openingHint": "Резервная копия расшифровывается здесь, на сервере, и четверть часа хранится в памяти. В мастерской ничего не меняется.",
+      "openingSteps": {
+        "connecting": "Подключение к хранилищу резервных копий",
+        "locating": "Поиск файла данных в снимке",
+        "pulling": "Загрузка и расшифровка данных",
+        "reading": "Чтение записей",
+        "indexing": "Индексация клиентов, автомобилей и заказ-нарядов"
+      },
+      "openingReady": "Готово",
+      "openFailed": "Не удалось открыть точку восстановления",
+      "tryAgain": "Повторить",
+      "ownerOnly": "Заглянуть в точку восстановления может только владелец мастерской или администратор.",
+      "closed": "Точка восстановления закрыта; откройте её снова",
+      "openAgain": "Открыть снова",
+      "datasets": {
+        "customers": "Клиенты",
+        "vehicles": "Транспортные средства",
+        "serviceRecords": "Заказ-наряды",
+        "quotes": "Предложения",
+        "inventoryParts": "Запчасти"
+      },
+      "searchPlaceholder": "Поиск по имени, номеру, госномеру, VIN, электронной почте или телефону",
+      "loading": "Загрузка…",
+      "loadFailed": "Не удалось загрузить этот список",
+      "empty": "В точке восстановления нет ничего такого.",
+      "noMatches": "Ничего не найдено по запросу «{query}».",
+      "showing": "Показано {shown} из {total}",
+      "loadMore": "Показать ещё",
+      "clearSearch": "Очистить поиск",
+      "record": {
+        "loading": "Загрузка записи…",
+        "loadFailed": "Не удалось загрузить запись",
+        "notFound": "Этой записи нет в точке восстановления.",
+        "fields": "Подробности",
+        "jumpTo": "Перейти к",
+        "previous": "Предыдущая запись",
+        "next": "Следующая запись",
+        "position": "{index} из {total}",
+        "noColumns": "В этих строках нечего показать.",
+        "yes": "Да",
+        "no": "Нет"
+      },
+      "sections": {
+        "concerns": "Обращения клиента",
+        "partItems": "Запчасти",
+        "laborItems": "Работы",
+        "attachments": "Фото и документы",
+        "payments": "Платежи",
+        "statusReports": "Отчёты о состоянии",
+        "timeEntries": "Записи времени",
+        "notes": "Заметки",
+        "fuelLogs": "Заправки",
+        "reminders": "Напоминания",
+        "serviceRequests": "Заявки на обслуживание",
+        "inspectionStatus": "Статус техосмотра",
+        "findings": "Замечания",
+        "conditionMarks": "Отметки состояния",
+        "aiMessages": "Сообщения ИИ",
+        "recurringInvoices": "Периодические счета",
+        "serviceRecords": "Заказ-наряды",
+        "movements": "Движения по складу",
+        "gallery": "Галерея"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Резервное копирование по расписанию",
+      "description": "Автоматические резервные копии в ваше собственное хранилище по выбранному вами расписанию.",
+      "empty": "Место назначения для резервных копий ещё не подключено.",
+      "setUp": "Настроить место назначения для резервных копий",
+      "lastGood": "Последняя успешная копия: {when}",
+      "noGoodBackup": "Успешных копий пока нет",
+      "nextRun": "следующий запуск: {when}",
+      "notScheduled": "не запланировано",
+      "lastError": "Последняя ошибка: {error}",
+      "extraCopy": "Дополнительная копия в вашем собственном хранилище по выбранному вами расписанию, в дополнение к резервным копиям, которые создаёт Torqvoice."
+    }
   },
   },
   "license": {
   "license": {
     "title": "White-Label лицензия",
     "title": "White-Label лицензия",

+ 7 - 2
messages/tr/audit.json

@@ -193,7 +193,9 @@
     "settings_markType_create": "İşaret türü eklendi",
     "settings_markType_create": "İşaret türü eklendi",
     "settings_markType_update": "İşaret türü değiştirildi",
     "settings_markType_update": "İşaret türü değiştirildi",
     "settings_markType_remove": "İşaret türü kaldırıldı",
     "settings_markType_remove": "İşaret türü kaldırıldı",
-    "settings_markType_reorder": "İşaret türleri yeniden sıralandı"
+    "settings_markType_reorder": "İşaret türleri yeniden sıralandı",
+    "integration_backup_run": "Yedekleme başlatıldı",
+    "integration_backup_restore": "Yedek geri yüklendi"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -346,6 +348,9 @@
     "condition_mark_remove": "Durum haritasından işaret kaldırdı",
     "condition_mark_remove": "Durum haritasından işaret kaldırdı",
     "condition_mark_carry": "Durum haritasındaki {count, plural, one {# önceki işaretin} other {# önceki işaretin}} hâlâ durduğunu onayladı",
     "condition_mark_carry": "Durum haritasındaki {count, plural, one {# önceki işaretin} other {# önceki işaretin}} hâlâ durduğunu onayladı",
     "condition_map_on_invoice_on": "Durum haritasını faturaya ekledi",
     "condition_map_on_invoice_on": "Durum haritasını faturaya ekledi",
-    "condition_map_on_invoice_off": "Durum haritasını faturadan kaldırdı"
+    "condition_map_on_invoice_off": "Durum haritasını faturadan kaldırdı",
+    "backup_run_started": "{name} hedefine yedekleme başlattı",
+    "backup_restore_started": "{name} hedefinden {snapshot} yedeğini geri yüklemeye başladı",
+    "backup_restored": "{name} hedefinden {snapshot} yedeğini geri yükledi"
   }
   }
 }
 }

+ 219 - 2
messages/tr/integrations.json

@@ -70,6 +70,9 @@
       "invoices": "Faturalar muhasebeye",
       "invoices": "Faturalar muhasebeye",
       "customers": "Müşteriler muhasebeye",
       "customers": "Müşteriler muhasebeye",
       "payments": "Ödemeler iki yönlü"
       "payments": "Ödemeler iki yönlü"
+    },
+    "backup": {
+      "target": "Yedekleme hedefi"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -161,8 +164,93 @@
       "nothingDue": "Plakası olan tüm araçlar yakın zamanda kontrol edildi, şimdilik sorgulanacak bir şey yok."
       "nothingDue": "Plakası olan tüm araçlar yakın zamanda kontrol edildi, şimdilik sorgulanacak bir şey yok."
     },
     },
     "supersedes": "{next} bağlandığında {current} bağlantısı kesilir. Aynı anda yalnızca biri geçerli olabilir; {next} devreye girdiği anda {current} kullanılmayı bırakır.",
     "supersedes": "{next} bağlandığında {current} bağlantısı kesilir. Aynı anda yalnızca biri geçerli olabilir; {next} devreye girdiği anda {current} kullanılmayı bırakır.",
+    "platformHandlesBackups": "Torqvoice bu atölyeyi zaten her saat yedekliyor ve geri yükleme noktalarını 30 gün saklıyor. Torqvoice olmadan da okunabilen, size ait bir kopya da istiyorsanız bir bucket bağlayın.",
+    "platformHandlesBackupsLink": "Geri yükleme noktalarını görün",
     "batchRetried": "{count, plural, =0 {Yeniden denenecek bir şey yok} one {# iş yeniden kuyruğa alındı} other {# iş yeniden kuyruğa alındı}}",
     "batchRetried": "{count, plural, =0 {Yeniden denenecek bir şey yok} one {# iş yeniden kuyruğa alındı} other {# iş yeniden kuyruğa alındı}}",
-    "advanced": "Gelişmiş"
+    "advanced": "Gelişmiş",
+    "generate": "Oluştur",
+    "show": "Göster",
+    "hide": "Gizle",
+    "generatedPasswordWarning": "Bu parolanın bir kopyasını bu sunucudan uzakta, güvenli bir yerde saklayın. Parola olmadan bu yedekleri kimse açamaz, Torqvoice bile.",
+    "backup": {
+      "title": "Yedekler",
+      "description": "Bu hedefe son başarılı yedeğin ne zaman alındığı, bir sonrakinin ne zaman çalışacağı ve son çalıştırmalar.",
+      "lastGood": "Son başarılı yedek",
+      "added": "{size} eklendi",
+      "never": "Henüz yok",
+      "nextRun": "Sonraki çalıştırma",
+      "notScheduled": "Planlanmadı",
+      "repositorySize": "Hedefte depolanan",
+      "runNow": "Şimdi yedekle",
+      "running": "Yedekleniyor",
+      "runningHint": "Bir yedekleme çalışıyor. Bu sayfa, işlem bitene kadar kendiliğinden güncellenir.",
+      "started": "Yedekleme başlatıldı",
+      "recentTitle": "Son çalıştırmalar",
+      "noRuns": "Henüz hiç yedekleme çalışmadı.",
+      "addedNote": "İlk çalıştırma her şeyi yükler. Sonraki her çalıştırma yalnızca o zamandan beri değişen parçaları ekler, bu yüzden küçüktür. Yine de her yedek eksiksizdir ve tek başına geri yüklenebilir ya da incelenebilir.",
+      "notStarted": "Başlamadı",
+      "filesNewCount": "{count, plural, one {# yeni dosya} other {# yeni dosya}}",
+      "columns": {
+        "status": "Durum",
+        "started": "Başlangıç",
+        "trigger": "Başlatan",
+        "duration": "Süre",
+        "filesNew": "Yeni dosyalar",
+        "bytesAdded": "Eklenen"
+      },
+      "trigger": {
+        "scheduled": "Zamanlama",
+        "manual": "Elle",
+        "restore": "Geri yükleme"
+      },
+      "status": {
+        "queued": "Kuyrukta",
+        "running": "Çalışıyor",
+        "done": "Tamamlandı",
+        "failed": "Başarısız",
+        "skipped": "Atlandı"
+      },
+      "restoring": "Geri yükleniyor",
+      "restoringHint": "Bir geri yükleme çalışıyor: atölyenin verileri yedektekilerle değiştiriliyor. Bu sayfa, işlem bitene kadar kendiliğinden güncellenir. O zamana kadar atölyede çalışmayın.",
+      "explore": "İncele",
+      "restore": {
+        "showAll": "Tüm yedekleri göster",
+        "refresh": "Yenile",
+        "loading": "Hedefteki yedekler okunuyor",
+        "loadFailed": "Yedekler listelenemedi",
+        "empty": "Bu hedefte henüz yedek yok.",
+        "needsOwner": "Burada bir yedeği yalnızca atölyenin sahibi geri yükleyebilir.",
+        "needsAdmin": "Bir yedeği yalnızca bir sahip veya yönetici geri yükleyebilir.",
+        "filesIncluded": "Fotoğraflar ve dosyalar dahil",
+        "filesNotIncluded": "Fotoğraflar ve dosyalar dahil değil",
+        "earlierInstall": "Önceki kurulum",
+        "action": "Geri yükle",
+        "dialogTitle": "{time} tarihli yedek geri yüklensin mi?",
+        "warning": "Geri yükleme <bold>bu atölyede şu anda bulunan her şeyin yerini alır</bold>: müşteriler, araçlar, iş emirleri, faturalar, ayarlar ve yedek içeriyorsa fotoğraflar ile dosyalar. Yedek alındıktan sonra eklenen her şey kaybolur. Bu işlem geri alınamaz.",
+        "secretsNote": "Yedeğin dışarıda bıraktığı gizli anahtarlar, bu kurulumda şu anda bulunan değerlerini korur. Kullanıcı hesaplarına ve parolalara dokunulmaz.",
+        "confirmPrompt": "Onaylamak için atölyenin adını yazın: <bold>{name}</bold>.",
+        "whileRunning": "Geri yükleme arka planda çalışır ve çok sayıda fotoğraf olduğunda birkaç dakika sürer. Sayfa, işlem bitene kadar kendiliğinden güncellenir.",
+        "cancel": "İptal",
+        "confirm": "Bu yedeği geri yükle",
+        "started": "Geri yükleme başlatıldı"
+      }
+    },
+    "lockedBackups": "Atölyenizin size ait depolamaya zamanlanmış, şifreli yedekleri.",
+    "searchOrType": "Ara veya bir ad yaz",
+    "useTyped": "\"{value}\" değerini kullan",
+    "listNeedsKeys": "Listeyi görmek için önce yukarıdaki anahtarları girin",
+    "listEmpty": "Hiçbir şey listelenmedi. Bunun yerine adı yazın.",
+    "testing": "Bağlantı test ediliyor",
+    "testOk": "Bağlantı çalışıyor: bir test nesnesi yazıldı ve yeniden silindi.",
+    "testFailed": "Test başarısız oldu",
+    "testSteps": {
+      "settings": "Sağlayıcı ayarlarını kontrol edin",
+      "list": "Anahtar bucket'ı listeleyemiyor",
+      "write": "Anahtar bucket'a yazamıyor",
+      "delete": "Anahtar bucket'tan silemiyor",
+      "folder": "Klasör oluşturulamıyor veya açılamıyor"
+    },
+    "testHint": "Bağlanmadan önce test etmek için anahtarları ve bucket'ı doldurun. Test küçük bir nesne yazar ve onu siler."
   },
   },
   "activity": {
   "activity": {
     "title": "Aktivite",
     "title": "Aktivite",
@@ -199,7 +287,10 @@
     "invalid": "Sağlayıcının yanıtı eksikti. Yeniden deneyin.",
     "invalid": "Sağlayıcının yanıtı eksikti. Yeniden deneyin.",
     "state": "Bu oturum açma işleminin süresi doldu veya başka bir oturuma ait. Yeniden başlayın.",
     "state": "Bu oturum açma işleminin süresi doldu veya başka bir oturuma ait. Yeniden başlayın.",
     "exchange": "Sağlayıcı oturum açmayı reddetti. Uygulama kimlik bilgilerini kontrol edip yeniden deneyin.",
     "exchange": "Sağlayıcı oturum açmayı reddetti. Uygulama kimlik bilgilerini kontrol edip yeniden deneyin.",
-    "identify": "Bağlandı, ancak hesap okunamadı. Yeniden bağlanmayı deneyin."
+    "identify": "Bağlandı, ancak hesap okunamadı. Yeniden bağlanmayı deneyin.",
+    "backupRunning": "Zaten çalışan bir yedekleme var.",
+    "restoreOwnerOnly": "Torqvoice Cloud'da bir yedeği yalnızca atölyenin sahibi geri yükleyebilir.",
+    "restoreBusy": "Bu sunucuda zaten bir yedekleme veya geri yükleme çalışıyor. İşlem bitince yeniden deneyin."
   },
   },
   "meeting": {
   "meeting": {
     "join": "Görüntülü görüşmeye katıl",
     "join": "Görüntülü görüşmeye katıl",
@@ -230,6 +321,7 @@
       }
       }
     },
     },
     "microsoft-365": {
     "microsoft-365": {
+      "name": "Microsoft 365 Takvimi",
       "description": "Planlanmış iş emirlerini bir Outlook takvimine ekleyin, Microsoft Teams bağlantısı ekleyin ve takvimin meşgul zamanlarını Torqvoice içinde görün.",
       "description": "Planlanmış iş emirlerini bir Outlook takvimine ekleyin, Microsoft Teams bağlantısı ekleyin ve takvimin meşgul zamanlarını Torqvoice içinde görün.",
       "tenantHelp": "Microsoft Entra'da (App registrations) bir uygulama kaydedin, kişisel ve iş hesaplarına izin verin, aşağıdaki yönlendirme URI'sini Web platformu olarak ekleyin, bir istemci gizli anahtarı oluşturun ve her ikisini buraya yapıştırın.",
       "tenantHelp": "Microsoft Entra'da (App registrations) bir uygulama kaydedin, kişisel ve iş hesaplarına izin verin, aşağıdaki yönlendirme URI'sini Web platformu olarak ekleyin, bir istemci gizli anahtarı oluşturun ve her ikisini buraya yapıştırın.",
       "fields": {
       "fields": {
@@ -499,6 +591,7 @@
       }
       }
     },
     },
     "openai-compatible": {
     "openai-compatible": {
+      "name": "OpenAI uyumlu",
       "description": "Servis açıklamaları, geçmiş özetleri, belge okuma ve asistan, OpenAI sohbet API'sini konuşan herhangi bir sunucu tarafından yanıtlanır: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM ve benzerleri. Verileriniz kendi donanımınızda kalır.",
       "description": "Servis açıklamaları, geçmiş özetleri, belge okuma ve asistan, OpenAI sohbet API'sini konuşan herhangi bir sunucu tarafından yanıtlanır: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM ve benzerleri. Verileriniz kendi donanımınızda kalır.",
       "fields": {
       "fields": {
         "baseUrl": "Temel URL",
         "baseUrl": "Temel URL",
@@ -638,6 +731,7 @@
       }
       }
     },
     },
     "speech-to-text": {
     "speech-to-text": {
+      "name": "Konuşmadan metne",
       "description": "İş emrindeki dikte, tarayıcı yerine bir konuşma modeli tarafından yazıya dökülür: çok daha doğrudur, her dilde çalışır ve atölye terimlerini bilir. Yapay zekâ sağlayıcınızdan ayrıdır, o yüzden onu olduğu gibi bırakabilirsiniz. Kendi OpenAI anahtarınızı ya da OpenAI'nin transkripsiyon API'siyle çalışan kendi sunucunuzu kullanır. Kayıt yazıya dökülmek üzere gönderilir ve saklanmaz.",
       "description": "İş emrindeki dikte, tarayıcı yerine bir konuşma modeli tarafından yazıya dökülür: çok daha doğrudur, her dilde çalışır ve atölye terimlerini bilir. Yapay zekâ sağlayıcınızdan ayrıdır, o yüzden onu olduğu gibi bırakabilirsiniz. Kendi OpenAI anahtarınızı ya da OpenAI'nin transkripsiyon API'siyle çalışan kendi sunucunuzu kullanır. Kayıt yazıya dökülmek üzere gönderilir ve saklanmaz.",
       "fields": {
       "fields": {
         "apiKey": "API anahtarı",
         "apiKey": "API anahtarı",
@@ -653,6 +747,108 @@
         "modeAi": "Her zaman konuşma modeli",
         "modeAi": "Her zaman konuşma modeli",
         "modeChoice": "Herkes kendisi seçer: konuşma modeli veya tarayıcı"
         "modeChoice": "Herkes kendisi seçer: konuşma modeli veya tarayıcı"
       }
       }
+    },
+    "s3": {
+      "name": "Bulut depolama bucket'ı",
+      "description": "Bu atölyenin Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage veya başka bir S3 uyumlu depolamadaki size ait bir bucket'a, seçtiğiniz zamanlamayla şifreli yedekleri. İlk çalıştırmadan sonra yalnızca değişenler yüklenir, bu yüzden saatlik yedekler ucuz kalır.",
+      "fields": {
+        "accessKeyId": "Erişim anahtarı kimliği",
+        "accessKeyIdHelp": "Depolama sağlayıcınızdan, bucket'taki nesneleri listeleme, okuma, yazma ve silme izni olan bir anahtar. Tek başına iptal edilebilmesi için yalnızca Torqvoice'a özel bir anahtar oluşturun.",
+        "secretAccessKey": "Gizli erişim anahtarı",
+        "password": "Yedekleme parolası",
+        "passwordHelp": "Her yedek bu parolayla şifrelenir. Bir parola belirleyin ya da Oluştur'a basın ve bir kopyasını bu sunucudan uzakta saklayın: parola olmadan bu yedekleri kimse açamaz, Torqvoice bile. Zaten Torqvoice yedekleri bulunan bir depolamayı yeniden bağlamak için yedeklerin oluşturulduğu parolayı girin.",
+        "endpoint": "Özel uç nokta",
+        "endpointHelp": "Yalnızca Diğer S3 uyumlu depolama seçeneği için: kendi depolamanızın S3 adresi, örneğin bir MinIO sunucusu, ana bilgisayar adı olarak ve gerekirse bağlantı noktasıyla."
+      },
+      "settings": {
+        "provider": "Sağlayıcı",
+        "providerAws": "Amazon S3",
+        "providerR2": "Cloudflare R2",
+        "providerB2": "Backblaze B2",
+        "providerWasabi": "Wasabi",
+        "providerHetzner": "Hetzner Object Storage",
+        "providerOther": "Diğer S3 uyumlu depolama",
+        "region": "Bölge",
+        "regionHelp": "Bucket'ın bulunduğu yer. Sağlayıcının listesinden seçin; başka bir depolama için, bir değer bekliyorsa onu yazın. Cloudflare R2 tarafından kullanılmaz.",
+        "accountId": "Cloudflare hesap kimliği",
+        "accountIdHelp": "Cloudflare panonuzun R2 genel bakış sayfasında gösterilen 32 karakterlik kimlik.",
+        "bucket": "Bucket",
+        "bucketHelp": "Anahtar girilir girilmez anahtarın görebildiği bucket'lar listelenir. Anahtar bir bucket'ı kullanabiliyor ama bucket'ları listeleyemiyorsa adını yazın.",
+        "prefix": "Bucket içindeki klasör",
+        "prefixHelp": "Yedekler bu klasörün altında tutulur. Bucket'ı paylaşan her atölye için ayrı bir klasör kullanın.",
+        "frequency": "Sıklık",
+        "frequencyHourly": "Her saat",
+        "frequencyEvery4h": "4 saatte bir",
+        "frequencyDaily": "Her gün",
+        "frequencyWeekly": "Her hafta",
+        "frequencyMonthly": "Her ay",
+        "time": "Saat",
+        "timeHelp": "Atölye saatine göre, HH:MM biçiminde. Günlük, haftalık ve aylık yedeklerde kullanılır.",
+        "weekday": "Haftanın günü",
+        "sunday": "Pazar",
+        "monday": "Pazartesi",
+        "tuesday": "Salı",
+        "wednesday": "Çarşamba",
+        "thursday": "Perşembe",
+        "friday": "Cuma",
+        "saturday": "Cumartesi",
+        "monthDay": "Ayın günü",
+        "monthDayLast": "Son gün",
+        "contents": "İçerik",
+        "contentsHelp": "Gizli anahtarlar, bu atölyenin Torqvoice'a girdiği anahtarlardır: ödeme, yapay zeka ve webhook anahtarları. Her yedek her durumda yedekleme parolasıyla şifrelenir. Bir yedek, atölyenin kayıtlarını ve seçildiğinde dosyalarını içerir. Kullanıcı hesapları ve parolalar yedekte yer almaz: yeni bir sunucuda oturum açar, ekibi davet eder ve verileri o atölyeye geri yüklersiniz.",
+        "contentsData": "Yalnızca veriler",
+        "contentsDataAndSecrets": "Veriler ve gizli anahtarlar",
+        "includeFiles": "Fotoğrafları ve dosyaları dahil et",
+        "includeFilesHelp": "Her dosya bir kez yüklenir ve sonraki yedeklerde yeniden kullanılır, bu yüzden ilk çalıştırmadan sonra maliyeti azdır.",
+        "keepHourly": "Saatlik yedekleri sakla",
+        "keepHourlyHelp": "En yeni saatlik yedeklerden kaç tanesinin saklanacağı. Yalnızca her saat ve 4 saatte bir zamanlamalarında önemlidir.",
+        "keepDaily": "Günlük yedekleri sakla",
+        "keepWeekly": "Haftalık yedekleri sakla",
+        "keepMonthly": "Aylık yedekleri sakla",
+        "keepMonthlyHelp": "Her çalıştırmadan sonra eski yedekler silinir ve her türün en yenileri saklanır. O türden hiç saklamamak için sayıyı 0 yapın."
+      }
+    },
+    "local-folder": {
+      "name": "Yerel klasör",
+      "description": "Bu atölyenin, bu sunucudaki bir klasöre şifreli yedekleri: compose dosyasındaki yedek birimi veya oraya bağlanmış bir ağ sürücüsü. Veritabanıyla aynı diskteki bir kopya tek başına güvenli değildir; başka bir yerdeki depolamayla birlikte kullanın.",
+      "fields": {
+        "password": "Yedekleme parolası",
+        "passwordHelp": "Her yedek bu parolayla şifrelenir. Bir parola belirleyin ya da Oluştur'a basın ve bir kopyasını bu sunucudan uzakta saklayın: parola olmadan bu yedekleri kimse açamaz, Torqvoice bile. Zaten Torqvoice yedekleri bulunan bir depolamayı yeniden bağlamak için yedeklerin oluşturulduğu parolayı girin."
+      },
+      "settings": {
+        "folder": "Klasör",
+        "folderHelp": "Sunucunun yedek kök dizini altında bu atölyenin yedeklerini tutan klasör. Bu atölyenin adını taşıyan bir klasör kullanmak için boş bırakın. Yeni bir sunucuda, yedekleri geri almak için önceki kurulumun kullandığı klasör adını yazın.",
+        "frequency": "Sıklık",
+        "frequencyHourly": "Her saat",
+        "frequencyEvery4h": "4 saatte bir",
+        "frequencyDaily": "Her gün",
+        "frequencyWeekly": "Her hafta",
+        "frequencyMonthly": "Her ay",
+        "time": "Saat",
+        "timeHelp": "Atölye saatine göre, HH:MM biçiminde. Günlük, haftalık ve aylık yedeklerde kullanılır.",
+        "weekday": "Haftanın günü",
+        "sunday": "Pazar",
+        "monday": "Pazartesi",
+        "tuesday": "Salı",
+        "wednesday": "Çarşamba",
+        "thursday": "Perşembe",
+        "friday": "Cuma",
+        "saturday": "Cumartesi",
+        "monthDay": "Ayın günü",
+        "monthDayLast": "Son gün",
+        "contents": "İçerik",
+        "contentsHelp": "Gizli anahtarlar, bu atölyenin Torqvoice'a girdiği anahtarlardır: ödeme, yapay zeka ve webhook anahtarları. Her yedek her durumda yedekleme parolasıyla şifrelenir. Bir yedek, atölyenin kayıtlarını ve seçildiğinde dosyalarını içerir. Kullanıcı hesapları ve parolalar yedekte yer almaz: yeni bir sunucuda oturum açar, ekibi davet eder ve verileri o atölyeye geri yüklersiniz.",
+        "contentsData": "Yalnızca veriler",
+        "contentsDataAndSecrets": "Veriler ve gizli anahtarlar",
+        "includeFiles": "Fotoğrafları ve dosyaları dahil et",
+        "includeFilesHelp": "Her dosya bir kez yüklenir ve sonraki yedeklerde yeniden kullanılır, bu yüzden ilk çalıştırmadan sonra maliyeti azdır.",
+        "keepHourly": "Saatlik yedekleri sakla",
+        "keepHourlyHelp": "En yeni saatlik yedeklerden kaç tanesinin saklanacağı. Yalnızca her saat ve 4 saatte bir zamanlamalarında önemlidir.",
+        "keepDaily": "Günlük yedekleri sakla",
+        "keepWeekly": "Haftalık yedekleri sakla",
+        "keepMonthly": "Aylık yedekleri sakla",
+        "keepMonthlyHelp": "Her çalıştırmadan sonra eski yedekler silinir ve her türün en yenileri saklanır. O türden hiç saklamamak için sayıyı 0 yapın."
+      }
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {
@@ -676,5 +872,26 @@
       "refresh": "Geri çağırma verisi yenileme",
       "refresh": "Geri çağırma verisi yenileme",
       "vehicle": "Yeni araç için geri çağırma kontrolü"
       "vehicle": "Yeni araç için geri çağırma kontrolü"
     }
     }
+  },
+  "backupAlerts": {
+    "destination": "yedekleme hedefi",
+    "failedTitle": "Yedekleme başarısız oldu",
+    "failedMessage": "{name} için yedekleme tamamlanmadı: {reason}",
+    "failedSubject": "{name} için yedekleme başarısız oldu",
+    "failedEmailIntro": "Atölyenizin {name} için zamanlanmış yedeklemesi tamamlanmadı.",
+    "openToRetry": "Çalıştırmayı görmek ve yeniden denemek için Ayarlar, Entegrasyonlar, {name} bölümünü açın.",
+    "overdueTitle": "Yedekleme gecikti",
+    "overdueSince": "{time} tarihinden bu yana {name} için hiçbir yedekleme tamamlanmadı.",
+    "overdueNever": "{name} için henüz hiçbir yedekleme tamamlanmadı.",
+    "overdueSubject": "{name} için yedekleme gecikti",
+    "openToSee": "Neyin ters gittiğini görmek için Ayarlar, Entegrasyonlar, {name} bölümünü açın.",
+    "platformName": "Torqvoice yedekleri",
+    "restoredTitle": "Atölye geri yüklendi",
+    "restoredSubject": "Atölyeniz bir yedekten geri yüklendi",
+    "restoredIntro": "{name}: atölye {time} tarihinde {snapshot} geri yükleme noktasına döndürüldü. O noktadan sonra girilen her şey artık atölyede yok.",
+    "restoredByMember": "Bu geri yüklemeyi atölyenizin bir üyesi istedi.",
+    "restoredBySupport": "Bunu Torqvoice destek ekibi sizin isteğiniz üzerine yaptı. Belirtilen neden: {reason}",
+    "noReason": "belirtilmedi",
+    "restoredUndo": "Önce atölyenin geri yüklemeden hemen önceki hâlinin bir güvenlik kopyası alındı ve 90 gün saklanıyor. Ona geri dönmek için destek ekibiyle iletişime geçin."
   }
   }
 }
 }

+ 161 - 1
messages/tr/settings.json

@@ -355,6 +355,7 @@
     "reauthTooManyAttempts": "Çok fazla deneme. Birkaç dakika bekleyip tekrar deneyin.",
     "reauthTooManyAttempts": "Çok fazla deneme. Birkaç dakika bekleyip tekrar deneyin.",
     "reauthSignInAgainAccount": "Hesabınızı silmeden önce yeniden giriş yapın.",
     "reauthSignInAgainAccount": "Hesabınızı silmeden önce yeniden giriş yapın.",
     "reauthSignInAgainWorkshop": "Atölyeyi silmeden önce yeniden giriş yapın.",
     "reauthSignInAgainWorkshop": "Atölyeyi silmeden önce yeniden giriş yapın.",
+    "reauthSignInAgainRestore": "Atölyeyi geri yüklemeden önce yeniden giriş yapın.",
     "deleteAccountMoved": "Hesabınızı silmek mi istiyorsunuz?",
     "deleteAccountMoved": "Hesabınızı silmek mi istiyorsunuz?",
     "deleteAccountMovedLink": "Bunu veri ayarlarından yapabilirsiniz.",
     "deleteAccountMovedLink": "Bunu veri ayarlarından yapabilirsiniz.",
     "signatureTitle": "İmza",
     "signatureTitle": "İmza",
@@ -1399,6 +1400,11 @@
     "importComplete": "İçe Aktarma Tamamlandı",
     "importComplete": "İçe Aktarma Tamamlandı",
     "importCompleteMessage": "Tüm veriler yedek dosyasından geri yüklendi.",
     "importCompleteMessage": "Tüm veriler yedek dosyasından geri yüklendi.",
     "importFailed": "İçe Aktarma Başarısız",
     "importFailed": "İçe Aktarma Başarısız",
+    "restoreRefused": {
+      "busy": "Bu sunucuda zaten bir yedekleme veya geri yükleme çalışıyor. İşlem bitince yeniden deneyin.",
+      "ownerOnly": "Torqvoice Cloud'da bir yedeği yalnızca atölyenin sahibi geri yükleyebilir.",
+      "foreignIds": "Dosya başka bir atölyeye ait kayıtlara atıfta bulunuyor, bu yüzden hiçbir şey geri yüklenmedi."
+    },
     "importFromOther": "Diğer Hizmetlerden İçe Aktar",
     "importFromOther": "Diğer Hizmetlerden İçe Aktar",
     "importFromOtherDescription": "Verilerinizi başka bir platformdan taşıyın. Başlamak için bir hizmet seçin.",
     "importFromOtherDescription": "Verilerinizi başka bir platformdan taşıyın. Başlamak için bir hizmet seçin.",
     "lubelogTitle": "LubeLog'dan İçe Aktar",
     "lubelogTitle": "LubeLog'dan İçe Aktar",
@@ -1450,7 +1456,161 @@
     "sourceBackup": "Torqvoice yedeği",
     "sourceBackup": "Torqvoice yedeği",
     "sourceBackupDesc": "Bu sayfadan indirilmiş bir .zip veya .json dosyası. Geri yükleme bu atölyedeki her şeyi değiştirir.",
     "sourceBackupDesc": "Bu sayfadan indirilmiş bir .zip veya .json dosyası. Geri yükleme bu atölyedeki her şeyi değiştirir.",
     "restore": "Geri yükle",
     "restore": "Geri yükle",
-    "restoreTitle": "Bir yedeği geri yükle"
+    "restoreTitle": "Bir yedeği geri yükle",
+    "includeSecrets": "Gizli anahtarları dahil et",
+    "includeSecretsHelp": "Ödeme ve yapay zeka anahtarları gibi gizli anahtarlar, bunu işaretlemediğiniz sürece dışarıda bırakılır. Geri yükleme bu durumda bu kurulumda zaten bulunan anahtarları korur.",
+    "platformBackups": {
+      "title": "Torqvoice yedekleri",
+      "description": "Torqvoice bu atölyeyi her saat yedekler ve geri yükleme noktalarını 30 gün, ardından haftalık ve aylık olanları saklar.",
+      "runNow": "Şimdi yedekle",
+      "started": "Yedekleme başlatıldı",
+      "lastGood": "Son başarılı yedek",
+      "never": "Henüz yedek yok",
+      "nextRun": "Sonraki çalıştırma",
+      "notScheduled": "Planlanmadı",
+      "repositorySize": "Torqvoice tarafından depolanan",
+      "running": "Çalışıyor",
+      "restoring": "Geri yükleniyor",
+      "queued": "Kuyrukta",
+      "queuedAhead": "{count, plural, one {Kuyrukta, önünde # iş var} other {Kuyrukta, önünde # iş var}}",
+      "runningHint": "Bir yedekleme çalışıyor. Bu kart, işlem bitene kadar kendiliğinden güncellenir.",
+      "restoringHint": "Bir geri yükleme çalışıyor: atölyenin verileri geri yükleme noktasındakilerle değiştiriliyor. Bu sayfa, işlem bitene kadar kendiliğinden güncellenir. O zamana kadar atölyede çalışmayın.",
+      "paused": "Planınız artık Torqvoice yedeklerini içermiyor. Çalıştırmalar durduruldu; geri yükleme noktaları {date} tarihine kadar saklanır ve ardından silinir.",
+      "upsell": "Pro'da mevcut: Torqvoice tarafından alınan saatlik yedekler ve geri dönebileceğiniz geri yükleme noktaları.",
+      "planLink": "Planları görün",
+      "pointsTitle": "Geri yükleme noktaları",
+      "pointsEmpty": "İlk yedekleme henüz çalışmadı.",
+      "showAll": "Tümünü göster ({count})",
+      "showFewer": "Daha az göster",
+      "needsOwner": "Yalnızca atölyenin sahibi geri yükleyebilir.",
+      "sizeNote": "Her geri yükleme noktası kendi başına eksiksizdir; boyutu kapsadığı her şeydir. Depo her parçayı yalnızca bir kez saklar ve sonraki çalıştırmalar yalnızca değişenleri ekler; bu yüzden “Torqvoice tarafından depolanan” boyutların toplamının çok altında kalır.",
+      "recentTitle": "Son çalıştırmalar",
+      "noRuns": "Henüz hiç yedekleme çalışmadı.",
+      "notStarted": "Başlamadı",
+      "supportReason": "Torqvoice destek ekibi tarafından geri yüklendi: {reason}",
+      "columns": {
+        "when": "Zaman",
+        "kind": "Tür",
+        "size": "Boyut",
+        "status": "Durum",
+        "trigger": "Başlatan",
+        "duration": "Süre"
+      },
+      "kind": {
+        "scheduled": "Zamanlanmış",
+        "manual": "Elle",
+        "safety": "Güvenlik kopyası",
+        "safetyHint": "Geri alınabilmesi için bir geri yüklemeden hemen önce alındı"
+      },
+      "trigger": {
+        "scheduled": "Zamanlanmış",
+        "manual": "Elle",
+        "restore": "Geri yükleme"
+      },
+      "status": {
+        "queued": "Kuyrukta",
+        "running": "Çalışıyor",
+        "done": "Tamamlandı",
+        "failed": "Başarısız",
+        "skipped": "Atlandı"
+      },
+      "explore": "İncele",
+      "restore": {
+        "action": "Geri yükle",
+        "dialogTitle": "Atölye {time} tarihindeki hâline geri yüklensin mi?",
+        "warning": "{time} tarihli geri yükleme noktası <bold>bu atölyedeki her şeyin yerini alır</bold>: müşteriler, araçlar, iş emirleri, faturalar, ayarlar, fotoğraflar ve dosyalar. O zamandan beri eklenen her şey kaybolur.",
+        "safetyNote": "Önce atölyenin şu anki hâlinin bir güvenlik kopyası alınır ve 90 gün saklanır; böylece geri yükleme, geri yükleme noktalarından geri alınabilir.",
+        "untouched": "Üyelere, hesaplara, aboneliğe ve entegrasyonlara dokunulmaz. Yedekte bulunmayan gizli anahtarlar mevcut değerlerini korur.",
+        "confirmPrompt": "Onaylamak için atölyenin adını yazın: <bold>{name}</bold>.",
+        "whileRunning": "Geri yükleme arka planda çalışır ve çok sayıda fotoğraf olduğunda birkaç dakika sürer. Sayfa, işlem bitene kadar kendiliğinden güncellenir.",
+        "cancel": "İptal",
+        "confirm": "Geri yükle",
+        "started": "Geri yükleme başlatıldı"
+      }
+    },
+    "restoreExplorer": {
+      "back": "Yedeklere dön",
+      "title": "{date} tarihli geri yükleme noktası",
+      "titleShort": "Geri yükleme noktası {id}",
+      "readOnly": "Salt okunur: buradaki hiçbir şey atölyeyi değiştirmez. Görünüm 15 dakika işlem yapılmazsa kapanır.",
+      "readOnlyBadge": "Salt okunur",
+      "opening": "Geri yükleme noktası açılıyor",
+      "openingHint": "Yedek burada, sunucuda çözülür ve çeyrek saat bellekte tutulur. Atölyede hiçbir şey değişmez.",
+      "openingSteps": {
+        "connecting": "Yedek deposuna bağlanılıyor",
+        "locating": "Anlık görüntüde veri dosyası aranıyor",
+        "pulling": "Veriler alınıyor ve şifresi çözülüyor",
+        "reading": "Kayıtlar okunuyor",
+        "indexing": "Müşteriler, araçlar ve iş emirleri dizinleniyor"
+      },
+      "openingReady": "Hazır",
+      "openFailed": "Geri yükleme noktası açılamadı",
+      "tryAgain": "Tekrar dene",
+      "ownerOnly": "Bir geri yükleme noktasının içine yalnızca atölyenin sahibi veya bir yönetici bakabilir.",
+      "closed": "Geri yükleme noktası kapatıldı; yeniden açın",
+      "openAgain": "Yeniden aç",
+      "datasets": {
+        "customers": "Müşteriler",
+        "vehicles": "Araçlar",
+        "serviceRecords": "İş emirleri",
+        "quotes": "Teklifler",
+        "inventoryParts": "Parçalar"
+      },
+      "searchPlaceholder": "Ad, numara, plaka, VIN, e-posta veya telefona göre arayın",
+      "loading": "Yükleniyor…",
+      "loadFailed": "Bu liste yüklenemedi",
+      "empty": "Geri yükleme noktasında bu türde bir şey yok.",
+      "noMatches": "\"{query}\" ile eşleşen bir şey yok.",
+      "showing": "{total} kayıttan {shown} tanesi gösteriliyor",
+      "loadMore": "Daha fazla göster",
+      "clearSearch": "Aramayı temizle",
+      "record": {
+        "loading": "Kayıt yükleniyor…",
+        "loadFailed": "Kayıt yüklenemedi",
+        "notFound": "Bu kayıt geri yükleme noktasında yok.",
+        "fields": "Ayrıntılar",
+        "jumpTo": "Şuraya git",
+        "previous": "Önceki kayıt",
+        "next": "Sonraki kayıt",
+        "position": "{index} / {total}",
+        "noColumns": "Bu satırlarda gösterilecek bir şey yok.",
+        "yes": "Evet",
+        "no": "Hayır"
+      },
+      "sections": {
+        "concerns": "Müşteri bildirimleri",
+        "partItems": "Parçalar",
+        "laborItems": "İşçilik",
+        "attachments": "Fotoğraflar ve belgeler",
+        "payments": "Ödemeler",
+        "statusReports": "Durum raporları",
+        "timeEntries": "Zaman kayıtları",
+        "notes": "Notlar",
+        "fuelLogs": "Yakıt kayıtları",
+        "reminders": "Hatırlatıcılar",
+        "serviceRequests": "Servis talepleri",
+        "inspectionStatus": "Muayene durumu",
+        "findings": "Bulgular",
+        "conditionMarks": "Durum işaretleri",
+        "aiMessages": "Yapay zekâ mesajları",
+        "recurringInvoices": "Tekrarlayan faturalar",
+        "serviceRecords": "İş emirleri",
+        "movements": "Stok hareketleri",
+        "gallery": "Galeri"
+      }
+    },
+    "scheduledBackups": {
+      "title": "Zamanlanmış yedeklemeler",
+      "description": "Size ait depolamaya, seçtiğiniz zamanlamayla otomatik yedeklemeler.",
+      "empty": "Henüz bağlı bir yedekleme hedefi yok.",
+      "setUp": "Bir yedekleme hedefi kurun",
+      "lastGood": "Son başarılı yedek: {when}",
+      "noGoodBackup": "Henüz başarılı yedek yok",
+      "nextRun": "sonraki çalıştırma: {when}",
+      "notScheduled": "planlanmadı",
+      "lastError": "Son hata: {error}",
+      "extraCopy": "Torqvoice'un aldığı yedeklere ek olarak, size ait depolamada, seçtiğiniz zamanlamayla alınan ek bir kopya."
+    }
   },
   },
   "license": {
   "license": {
     "title": "Beyaz Etiket Lisansı",
     "title": "Beyaz Etiket Lisansı",

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 341 - 56
package-lock.json


+ 8 - 1
package.json

@@ -15,6 +15,7 @@
     "prepare": "next-ws patch --yes && node scripts/patch-next-ws-first-upgrade.mjs",
     "prepare": "next-ws patch --yes && node scripts/patch-next-ws-first-upgrade.mjs",
     "test": "vitest run",
     "test": "vitest run",
     "test:coverage": "vitest --coverage",
     "test:coverage": "vitest --coverage",
+    "test:db": "vitest run --config vitest.db.config.ts",
     "test:e2e": "playwright test",
     "test:e2e": "playwright test",
     "test:e2e:ui": "playwright test --ui",
     "test:e2e:ui": "playwright test --ui",
     "seed:demo": "npx tsx prisma/seed_dummy_data.ts"
     "seed:demo": "npx tsx prisma/seed_dummy_data.ts"
@@ -37,9 +38,11 @@
     "@tiptap/pm": "^3.19.0",
     "@tiptap/pm": "^3.19.0",
     "@tiptap/react": "^3.19.0",
     "@tiptap/react": "^3.19.0",
     "@tiptap/starter-kit": "^3.19.0",
     "@tiptap/starter-kit": "^3.19.0",
+    "@types/archiver": "^8.0.0",
     "@types/qrcode": "^1.5.6",
     "@types/qrcode": "^1.5.6",
     "@types/react-grid-layout": "^1.3.6",
     "@types/react-grid-layout": "^1.3.6",
     "@zumer/snapdom": "^2.23.1",
     "@zumer/snapdom": "^2.23.1",
+    "archiver": "^8.0.0",
     "axios": "^1.20.0",
     "axios": "^1.20.0",
     "better-auth": "1.6.29",
     "better-auth": "1.6.29",
     "bson": "^7.2.0",
     "bson": "^7.2.0",
@@ -53,7 +56,6 @@
     "form-data": "^4.0.6",
     "form-data": "^4.0.6",
     "html5-qrcode": "^2.3.8",
     "html5-qrcode": "^2.3.8",
     "iconv-lite": "^0.7.3",
     "iconv-lite": "^0.7.3",
-    "jszip": "^3.10.1",
     "lucide-react": "^0.563.0",
     "lucide-react": "^0.563.0",
     "mailgun.js": "^12.7.1",
     "mailgun.js": "^12.7.1",
     "next": "16.3.6",
     "next": "16.3.6",
@@ -83,10 +85,12 @@
     "resend": "^6.14.0",
     "resend": "^6.14.0",
     "sharp": "^0.35.5",
     "sharp": "^0.35.5",
     "sonner": "^2.0.7",
     "sonner": "^2.0.7",
+    "stream-json": "^1.8.0",
     "stripe": "^20.3.1",
     "stripe": "^20.3.1",
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
     "vaul": "^1.1.2",
     "vaul": "^1.1.2",
     "ws": "^8.21.0",
     "ws": "^8.21.0",
+    "yauzl": "^3.4.0",
     "zod": "^4.3.6",
     "zod": "^4.3.6",
     "zustand": "^5.0.11"
     "zustand": "^5.0.11"
   },
   },
@@ -106,9 +110,12 @@
     "@types/react": "^19",
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "@types/react-dom": "^19",
     "@types/smtp-server": "^3.5.13",
     "@types/smtp-server": "^3.5.13",
+    "@types/stream-json": "^1.7.8",
     "@types/ws": "^8.18.1",
     "@types/ws": "^8.18.1",
+    "@types/yauzl": "^3.4.0",
     "@vitest/coverage-v8": "^4.1.11",
     "@vitest/coverage-v8": "^4.1.11",
     "jsdom": "^30.0.1",
     "jsdom": "^30.0.1",
+    "jszip": "^3.10.1",
     "mailparser": "^3.9.33",
     "mailparser": "^3.9.33",
     "shadcn": "^4.16.2",
     "shadcn": "^4.16.2",
     "smtp-server": "^3.19.16",
     "smtp-server": "^3.19.16",

+ 38 - 0
prisma/migrations/20261029090000_backup_runs/migration.sql

@@ -0,0 +1,38 @@
+-- One row per backup run of a workshop to a destination it connected under
+-- Settings -> Integrations: scheduled or manual, the restic snapshot it made
+-- and how it ended. A new table with no data to move.
+
+-- CreateTable
+CREATE TABLE "backup_runs" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "connectionId" TEXT NOT NULL,
+    "trigger" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'queued',
+    "startedAt" TIMESTAMP(3),
+    "finishedAt" TIMESTAMP(3),
+    "snapshotId" TEXT,
+    "filesNew" INTEGER,
+    "bytesAdded" BIGINT,
+    "totalBytes" BIGINT,
+    "error" TEXT,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "backup_runs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "backup_runs_connectionId_createdAt_idx" ON "backup_runs"("connectionId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "backup_runs_organizationId_createdAt_idx" ON "backup_runs"("organizationId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "backup_runs_status_idx" ON "backup_runs"("status");
+
+-- AddForeignKey
+ALTER TABLE "backup_runs" ADD CONSTRAINT "backup_runs_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "backup_runs" ADD CONSTRAINT "backup_runs_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "integration_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+

+ 46 - 0
prisma/migrations/20261030120000_platform_backups/migration.sql

@@ -0,0 +1,46 @@
+-- Backups Torqvoice runs for a workshop on the cloud instance (one restic
+-- repository per workshop in a bucket of ours), beside the destinations a
+-- workshop connects itself. A run row now says which of the two it belongs
+-- to, and a platform run has no connection. Existing rows are destination
+-- runs and keep their connection.
+
+-- AlterTable
+ALTER TABLE "backup_runs" ALTER COLUMN "connectionId" DROP NOT NULL;
+ALTER TABLE "backup_runs" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'destination';
+ALTER TABLE "backup_runs" ADD COLUMN "safetySnapshotId" TEXT;
+ALTER TABLE "backup_runs" ADD COLUMN "requestedBy" TEXT;
+ALTER TABLE "backup_runs" ADD COLUMN "reason" TEXT;
+
+-- DropIndex
+DROP INDEX IF EXISTS "backup_runs_organizationId_createdAt_idx";
+DROP INDEX IF EXISTS "backup_runs_status_idx";
+
+-- CreateIndex
+CREATE INDEX "backup_runs_organizationId_kind_createdAt_idx" ON "backup_runs"("organizationId", "kind", "createdAt");
+CREATE INDEX "backup_runs_status_createdAt_idx" ON "backup_runs"("status", "createdAt");
+
+-- CreateTable
+CREATE TABLE "platform_backups" (
+    "organizationId" TEXT NOT NULL,
+    "lastScheduledAt" TIMESTAMP(3),
+    "lastGoodAt" TIMESTAMP(3),
+    "lastPruneAt" TIMESTAMP(3),
+    "lastCheckAt" TIMESTAMP(3),
+    "retryAt" TIMESTAMP(3),
+    "pausedAt" TIMESTAMP(3),
+    "purgedAt" TIMESTAMP(3),
+    "repositorySize" BIGINT,
+    "snapshots" JSONB NOT NULL DEFAULT '[]',
+    "lastFailureMailAt" TIMESTAMP(3),
+    "lastOverdueAlertAt" TIMESTAMP(3),
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "platform_backups_pkey" PRIMARY KEY ("organizationId")
+);
+
+-- CreateIndex
+CREATE INDEX "platform_backups_pausedAt_idx" ON "platform_backups"("pausedAt");
+
+-- AddForeignKey
+ALTER TABLE "platform_backups" ADD CONSTRAINT "platform_backups_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 74 - 0
prisma/schema/integrations.prisma

@@ -31,6 +31,7 @@ model IntegrationConnection {
   jobs           IntegrationJob[]
   jobs           IntegrationJob[]
   logs           IntegrationLog[]
   logs           IntegrationLog[]
   externalEvents ExternalCalendarEvent[]
   externalEvents ExternalCalendarEvent[]
+  backupRuns     BackupRun[]
 
 
   @@unique([organizationId, connectorId])
   @@unique([organizationId, connectorId])
   @@index([organizationId])
   @@index([organizationId])
@@ -156,3 +157,76 @@ model VehicleSafetyReport {
   @@index([fetchedAt])
   @@index([fetchedAt])
   @@map("vehicle_safety_reports")
   @@map("vehicle_safety_reports")
 }
 }
+
+/// One backup of one workshop to one destination: a scheduled or manual
+/// restic run, what it produced and how it ended. Kept so the connection page
+/// can show the last good backup and the cron can tell when one is overdue.
+model BackupRun {
+  id               String                 @id @default(cuid())
+  organizationId   String
+  organization     Organization           @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  /// destination: the workshop's own bucket or folder (connectionId set).
+  /// platform: the repository Torqvoice keeps for the workshop on the cloud
+  /// instance (no connection).
+  kind             String                 @default("destination")
+  connectionId     String?
+  connection       IntegrationConnection? @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+  /// scheduled | manual | restore
+  trigger          String
+  /// queued | running | done | failed | skipped
+  status           String                 @default("queued")
+  startedAt        DateTime?
+  finishedAt       DateTime?
+  /// restic snapshot id: the one a backup made, or the one a restore put back.
+  snapshotId       String?
+  /// The snapshot of the workshop as it was taken right before a restore
+  /// replaced it, so a restore can itself be undone.
+  safetySnapshotId String?
+  /// Who asked: a member's user id, or null for a run the schedule or
+  /// support (through torqvoice.com) started.
+  requestedBy      String?
+  /// Why support restored, as typed on torqvoice.com; shown to the workshop.
+  reason           String?
+  filesNew         Int?
+  bytesAdded       BigInt?
+  totalBytes       BigInt?
+  error            String?
+  createdAt        DateTime               @default(now())
+
+  @@index([connectionId, createdAt])
+  @@index([organizationId, kind, createdAt])
+  @@index([status, createdAt])
+  @@map("backup_runs")
+}
+
+/// The backups Torqvoice runs for a workshop on the cloud instance: one
+/// restic repository per workshop in a bucket of ours, hourly, Pro and up.
+/// One row per workshop that has ever been in the cycle; the run rows carry
+/// the history, this row carries where the schedule stands.
+model PlatformBackup {
+  organizationId     String       @id
+  organization       Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  lastScheduledAt    DateTime?
+  lastGoodAt         DateTime?
+  lastPruneAt        DateTime?
+  lastCheckAt        DateTime?
+  /// One retry half an hour after a failed or skipped run.
+  retryAt            DateTime?
+  /// Set while the workshop's plan no longer includes platform backups.
+  /// Runs stop; the repository is kept for a grace period, then forgotten.
+  pausedAt           DateTime?
+  /// The repository's snapshots were forgotten after the grace period.
+  purgedAt           DateTime?
+  repositorySize     BigInt?
+  /// The restore points in the repository after the last run, newest first:
+  /// [{ id, shortId, time, bytes, trigger, preRestore }]. What the Backups
+  /// card and torqvoice.com list, so neither has to open the repository.
+  snapshots          Json         @default("[]")
+  lastFailureMailAt  DateTime?
+  lastOverdueAlertAt DateTime?
+  createdAt          DateTime     @default(now())
+  updatedAt          DateTime     @updatedAt
+
+  @@index([pausedAt])
+  @@map("platform_backups")
+}

+ 2 - 0
prisma/schema/organization.prisma

@@ -92,6 +92,8 @@ model Organization {
   documentDesigns             DocumentDesign[]
   documentDesigns             DocumentDesign[]
   documentDesignSnapshots     DocumentDesignSnapshot[]
   documentDesignSnapshots     DocumentDesignSnapshot[]
   documentAssetSnapshots      DocumentAssetSnapshot[]
   documentAssetSnapshots      DocumentAssetSnapshot[]
+  backupRuns                  BackupRun[]
+  platformBackup              PlatformBackup?
 
 
   @@map("organizations")
   @@map("organizations")
 }
 }

+ 7 - 0
public/images/integrations/local-folder.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Local folder">
+  <title>Local folder</title>
+  <rect width="48" height="48" rx="11" fill="#475569"/>
+  <path d="M11 16.5a2.5 2.5 0 0 1 2.5-2.5h7.2l3 3H34.5a2.5 2.5 0 0 1 2.5 2.5v12a2.5 2.5 0 0 1-2.5 2.5h-21A2.5 2.5 0 0 1 11 31.5v-15z" fill="#ffffff" opacity="0.95"/>
+  <path d="M11 21h26v10.5a2.5 2.5 0 0 1-2.5 2.5h-21A2.5 2.5 0 0 1 11 31.5V21z" fill="#cbd5e1"/>
+  <rect x="26" y="26" width="8" height="5" rx="1" fill="#475569"/>
+</svg>

+ 8 - 0
public/images/integrations/s3.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="S3-compatible storage">
+  <title>S3-compatible storage</title>
+  <rect width="48" height="48" rx="11" fill="#0f766e"/>
+  <path d="M13 14.5c0-2.2 4.9-4 11-4s11 1.8 11 4-4.9 4-11 4-11-1.8-11-4z" fill="#ffffff" opacity="0.95"/>
+  <path d="M13.6 17.5l2.1 14.6c.2 1.6 3.9 3.4 8.3 3.4s8.1-1.8 8.3-3.4l2.1-14.6c-2.3 1.5-6.1 2.3-10.4 2.3s-8.1-.8-10.4-2.3z" fill="#ffffff" opacity="0.8"/>
+  <path d="M16.5 23.2c2 1 4.6 1.5 7.5 1.5s5.5-.5 7.5-1.5" stroke="#0f766e" stroke-width="1.6" fill="none" stroke-linecap="round"/>
+  <path d="M17.3 28.4c1.8.9 4.2 1.4 6.7 1.4s4.9-.5 6.7-1.4" stroke="#0f766e" stroke-width="1.6" fill="none" stroke-linecap="round"/>
+</svg>

+ 33 - 0
scripts/generate-tenancy-schema.ts

@@ -0,0 +1,33 @@
+/**
+ * Writes src/lib/backup/tenancy-schema.ts from the Prisma schema. Run after a
+ * schema change: `npx tsx scripts/generate-tenancy-schema.ts`. The test in
+ * src/__tests__/lib/backup/tenancy-schema.test.ts fails until it is run.
+ */
+import { execFileSync } from 'node:child_process'
+import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import { parseSchemaGraph } from '../src/lib/backup/schema-graph'
+
+const root = process.cwd()
+const dir = path.join(root, 'prisma', 'schema')
+const schema = readdirSync(dir)
+  .filter((name) => name.endsWith('.prisma'))
+  .sort()
+  .map((name) => readFileSync(path.join(dir, name), 'utf-8'))
+  .join('\n')
+
+const graph = parseSchemaGraph(schema)
+const source = `// Generated from prisma/schema by scripts/generate-tenancy-schema.ts. Do not edit.
+import type { SchemaGraph } from './schema-graph'
+
+/**
+ * Which models carry an organisation, which columns are foreign keys and how
+ * a child model reaches its workshop. What the restore's tenancy guard reads.
+ */
+export const TENANCY_SCHEMA: SchemaGraph = ${JSON.stringify(graph, null, 2)}
+`
+const target = path.join(root, 'src/lib/backup/tenancy-schema.ts')
+writeFileSync(target, source)
+// Written the way the repository formats TypeScript, so `npm run check` is quiet.
+execFileSync('npx', ['biome', 'format', '--write', target], { stdio: 'inherit' })
+console.log(`tenancy-schema.ts: ${Object.keys(graph).length} models`)

+ 137 - 0
src/__tests__/api/backup-explore-file-route.test.ts

@@ -0,0 +1,137 @@
+/**
+ * @vitest-environment node
+ *
+ * One upload out of an open restore point: who may ask, what a refusal
+ * looks like, and how the file is served (inline for what a browser shows,
+ * a download for the rest, never cached).
+ */
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/get-auth-context', () => ({ getAuthContext: vi.fn() }))
+vi.mock('@/lib/backup/explore', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/backup/explore')>()
+  return {
+    RestorePointClosedError: actual.RestorePointClosedError,
+    extractRestorePointFile: vi.fn(),
+  }
+})
+
+import { NextRequest } from 'next/server'
+import { GET } from '@/app/api/protected/backup/explore/[snapshotId]/file/route'
+import { extractRestorePointFile, RestorePointClosedError } from '@/lib/backup/explore'
+import { getAuthContext } from '@/lib/get-auth-context'
+
+const mockAuth = vi.mocked(getAuthContext)
+const mockExtract = vi.mocked(extractRestorePointFile)
+
+const SNAP = '28891c8f448a3c36'
+const dir = mkdtempSync(path.join(os.tmpdir(), 'explore-file-route-'))
+const photo = path.join(dir, 'photo.jpg')
+writeFileSync(photo, Buffer.alloc(1234, 7))
+afterAll(() => rmSync(dir, { recursive: true, force: true }))
+
+function call(query: Record<string, string>, snapshotId = SNAP) {
+  const url = new URL(`http://app.test/api/protected/backup/explore/${snapshotId}/file`)
+  for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v)
+  return GET(new NextRequest(url), { params: Promise.resolve({ snapshotId }) })
+}
+
+const admin = { organizationId: 'org1', userId: 'u1', isAdmin: true }
+
+beforeEach(() => {
+  mockAuth.mockReset()
+  mockExtract.mockReset()
+  mockAuth.mockResolvedValue(admin as never)
+  mockExtract.mockResolvedValue({ file: photo, bytes: 1234 })
+})
+
+describe('GET /api/protected/backup/explore/[snapshotId]/file', () => {
+  it('is 401 without a session', async () => {
+    mockAuth.mockResolvedValue(null as never)
+    const res = await call({ category: 'services', name: 'photo.jpg' })
+    expect(res.status).toBe(401)
+    expect(mockExtract).not.toHaveBeenCalled()
+  })
+
+  it('is 403 for someone who is not an owner or administrator', async () => {
+    mockAuth.mockResolvedValue({ ...admin, isAdmin: false } as never)
+    const res = await call({ category: 'services', name: 'photo.jpg' })
+    expect(res.status).toBe(403)
+    expect(mockExtract).not.toHaveBeenCalled()
+  })
+
+  it('is 400 for a snapshot id that is not a restic id', async () => {
+    for (const bad of ['short', 'ABCDEF1234', '..%2F..%2Fetc', 'zzzzzzzzzz']) {
+      const res = await call({ category: 'services', name: 'photo.jpg' }, bad)
+      expect(res.status).toBe(400)
+      expect(await res.json()).toEqual({ error: 'Invalid snapshot id' })
+    }
+    expect(mockExtract).not.toHaveBeenCalled()
+  })
+
+  it('asks the explorer with the session’s organisation, never one from the request', async () => {
+    await call({ category: 'services', name: 'photo.jpg', organizationId: 'org2' })
+    expect(mockExtract).toHaveBeenCalledWith('org1', SNAP, 'services', 'photo.jpg')
+  })
+
+  it('is 409 restorePointClosed when the restore point has to be opened again', async () => {
+    mockExtract.mockRejectedValue(new RestorePointClosedError())
+    const res = await call({ category: 'services', name: 'photo.jpg' })
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ error: 'restorePointClosed' })
+  })
+
+  it('is 404 when the file is not in the restore point', async () => {
+    mockExtract.mockRejectedValue(new Error('That file is not in this restore point'))
+    const res = await call({ category: 'services', name: 'photo.jpg' })
+    expect(res.status).toBe(404)
+    expect(await res.json()).toEqual({ error: 'That file is not in this restore point' })
+  })
+
+  it('is 400 for any other refusal', async () => {
+    mockExtract.mockRejectedValue(new Error('Invalid file name'))
+    const res = await call({ category: 'services', name: '../x' })
+    expect(res.status).toBe(400)
+    expect(await res.json()).toEqual({ error: 'Invalid file name' })
+  })
+
+  it('streams a photo inline, never cached', async () => {
+    const res = await call({ category: 'services', name: 'photo.jpg' })
+    expect(res.status).toBe(200)
+    expect(res.headers.get('content-type')).toBe('image/jpeg')
+    expect(res.headers.get('content-length')).toBe('1234')
+    expect(res.headers.get('content-disposition')).toBe('inline; filename="photo.jpg"')
+    expect(res.headers.get('cache-control')).toBe('private, no-store')
+    expect(res.headers.get('x-content-type-options')).toBe('nosniff')
+    const body = Buffer.from(await res.arrayBuffer())
+    expect(body.length).toBe(1234)
+    expect(body.every((b) => b === 7)).toBe(true)
+  })
+
+  it('matches the extension without regard to case', async () => {
+    const res = await call({ category: 'services', name: 'SCAN.PDF' })
+    expect(res.headers.get('content-type')).toBe('application/pdf')
+    expect(res.headers.get('content-disposition')).toMatch(/^inline;/)
+    await res.arrayBuffer()
+  })
+
+  it('serves anything else as a download', async () => {
+    for (const name of ['export.zip', 'drawing.svg', 'page.html', 'README']) {
+      const res = await call({ category: 'services', name })
+      expect(res.status).toBe(200)
+      expect(res.headers.get('content-type')).toBe('application/octet-stream')
+      expect(res.headers.get('content-disposition')).toBe(`attachment; filename="${name}"`)
+      expect(res.headers.get('cache-control')).toBe('private, no-store')
+      await res.arrayBuffer()
+    }
+  })
+
+  it('sanitises the file name in Content-Disposition', async () => {
+    const res = await call({ category: 'services', name: 'a "b";c\r\nd.zip' })
+    expect(res.headers.get('content-disposition')).toBe('attachment; filename="a__b__c__d.zip"')
+    await res.arrayBuffer()
+  })
+})

+ 9 - 0
src/__tests__/api/email-webhook-ses.test.ts

@@ -41,6 +41,7 @@ vi.mock('@aws-sdk/client-s3', () => ({
 }))
 }))
 
 
 import { POST } from '@/app/api/webhooks/email/ses/route'
 import { POST } from '@/app/api/webhooks/email/ses/route'
+import { RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
 import { snsStringToSign, type SnsEnvelope } from '@/lib/sns-signature'
 import { snsStringToSign, type SnsEnvelope } from '@/lib/sns-signature'
 
 
 const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
 const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
@@ -192,6 +193,14 @@ describe('POST /api/webhooks/email/ses', () => {
     expect((await post(envelope())).status).toBe(500)
     expect((await post(envelope())).status).toBe(500)
   })
   })
 
 
+  it('answers 503 with Retry-After while the workshop is being restored', async () => {
+    receive.mockResolvedValue({ outcome: 'retry', reason: 'workshop is being restored' })
+    const response = await post(envelope())
+    expect(response.status).toBe(503)
+    expect(response.headers.get('Retry-After')).toBe('300')
+    expect(await response.json()).toEqual({ error: RESTORE_IN_PROGRESS_MESSAGE })
+  })
+
   it('ignores a notification that is not a stored mail', async () => {
   it('ignores a notification that is not a stored mail', async () => {
     const bounce = envelope({ Message: JSON.stringify({ notificationType: 'Bounce' }) })
     const bounce = envelope({ Message: JSON.stringify({ notificationType: 'Bounce' }) })
     expect((await post(bounce)).status).toBe(200)
     expect((await post(bounce)).status).toBe(200)

+ 224 - 0
src/__tests__/app/api/restore-freeze-public-routes.test.ts

@@ -0,0 +1,224 @@
+/**
+ * @vitest-environment node
+ *
+ * The public routes that write for a workshop without anyone signed in:
+ * while that workshop is being restored they answer 409 and write nothing,
+ * so a phone's photo or a customer's answer is not lost under the restore.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({ frozen: new Set<string>() }))
+
+vi.mock('@/lib/backup/freeze', async (importOriginal) => {
+  const real = await importOriginal<typeof import('@/lib/backup/freeze')>()
+  return {
+    ...real,
+    isRestoring: (organizationId: string) => state.frozen.has(organizationId),
+    assertNotRestoring: (organizationId: string) => {
+      if (state.frozen.has(organizationId)) throw new real.RestoreInProgressError()
+    },
+  }
+})
+vi.mock('@/lib/db', () => ({
+  db: {
+    inspection: { findFirst: vi.fn() },
+    inspectionItem: { findFirst: vi.fn(), update: vi.fn() },
+    inspectionAttachment: { count: vi.fn(), create: vi.fn() },
+    inspectionQuoteRequest: { findFirst: vi.fn(), create: vi.fn(), delete: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
+    quote: { findFirst: vi.fn(), update: vi.fn() },
+    auditLog: { count: vi.fn() },
+  },
+}))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+// The actions' signed-in halves are not under test here.
+vi.mock('@/lib/with-auth', () => ({ withAuth: vi.fn() }))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/lib/notify', () => ({ notify: vi.fn() }))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn() }))
+vi.mock('@/lib/image-upload.server', () => ({ compressPhoto: vi.fn() }))
+vi.mock('@/lib/files/manager', () => ({ discardUnsavedUpload: vi.fn(async () => undefined) }))
+vi.mock('@/lib/upload-root', () => ({ uploadsRoot: () => '/tmp/uploads' }))
+vi.mock('node:fs/promises', () => {
+  const fs = { mkdir: vi.fn(), writeFile: vi.fn() }
+  return { ...fs, default: fs }
+})
+
+import { writeFile } from 'node:fs/promises'
+import { db } from '@/lib/db'
+import { RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
+import { createInspectionHandoffToken, createPhotoHandoffToken } from '@/lib/photo-handoff'
+import { POST as inspectionHandoffPost } from '@/app/api/public/inspection-handoff/[token]/route'
+import { POST as photoHandoffPost } from '@/app/api/public/photo-handoff/[token]/route'
+import {
+  DELETE as quoteRequestDelete,
+  POST as quoteRequestPost,
+} from '@/app/api/public/forms/inspection-quote-request/route'
+import { POST as quoteResponsePost } from '@/app/api/public/forms/quote-response/route'
+import { POST as trackViewPost } from '@/app/api/public/track-view/route'
+
+const ORG = 'org-frozen'
+
+function upload(url: string, token: string, post: typeof inspectionHandoffPost) {
+  const body = new FormData()
+  body.append(
+    'file',
+    new File([new Uint8Array([0xff, 0xd8, 0xff])], 'a.jpg', { type: 'image/jpeg' })
+  )
+  return post(new Request(url, { method: 'POST', body }), {
+    params: Promise.resolve({ token }),
+  })
+}
+
+function json(url: string, body: unknown, method = 'POST') {
+  return new Request(url, {
+    method,
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  process.env.BETTER_AUTH_SECRET = 'test-secret-for-freeze-public-routes'
+  state.frozen.clear()
+  state.frozen.add(ORG)
+})
+
+describe('phone handoff routes during a restore', () => {
+  it('inspection handoff answers 409 with the restore message and writes nothing', async () => {
+    const { token } = createInspectionHandoffToken({
+      organizationId: ORG,
+      inspectionId: 'insp-1',
+      userId: 'user-1',
+    })
+    const res = await upload(
+      `http://app.test/api/public/inspection-handoff/${token}`,
+      token,
+      inspectionHandoffPost
+    )
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ code: 'restoring', error: RESTORE_IN_PROGRESS_MESSAGE })
+    expect(db.inspection.findFirst).not.toHaveBeenCalled()
+    expect(db.inspectionAttachment.create).not.toHaveBeenCalled()
+    expect(writeFile).not.toHaveBeenCalled()
+  })
+
+  it('inspection handoff goes on for a workshop that is not frozen', async () => {
+    state.frozen.clear()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
+    const { token } = createInspectionHandoffToken({
+      organizationId: ORG,
+      inspectionId: 'insp-1',
+      userId: 'user-1',
+    })
+    const res = await upload(
+      `http://app.test/api/public/inspection-handoff/${token}`,
+      token,
+      inspectionHandoffPost
+    )
+    // Past the freeze check: the (missing) inspection is what answers.
+    expect(res.status).toBe(404)
+    expect(db.inspection.findFirst).toHaveBeenCalledTimes(1)
+  })
+
+  it('work order photo handoff answers 409 and writes nothing', async () => {
+    const { token } = createPhotoHandoffToken({
+      organizationId: ORG,
+      serviceRecordId: 'job-1',
+      concernId: null,
+      purpose: 'photos',
+      userId: 'user-1',
+    })
+    const res = await upload(
+      `http://app.test/api/public/photo-handoff/${token}`,
+      token,
+      photoHandoffPost
+    )
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ code: 'restoring', error: RESTORE_IN_PROGRESS_MESSAGE })
+    expect(db.serviceRecord.findFirst).not.toHaveBeenCalled()
+    expect(writeFile).not.toHaveBeenCalled()
+  })
+})
+
+describe('customer forms during a restore', () => {
+  it('a quote request from an inspection answers 409 and creates nothing', async () => {
+    vi.mocked(db.inspection.findFirst).mockResolvedValue({
+      id: 'insp-1',
+      organizationId: ORG,
+      vehicle: null,
+      items: [],
+    } as never)
+    const res = await quoteRequestPost(
+      json('http://app.test/api/public/forms/inspection-quote-request', {
+        inspectionId: 'insp-1',
+        publicToken: 'tok-1',
+        selectedItemIds: ['item-1'],
+      })
+    )
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ success: false, error: RESTORE_IN_PROGRESS_MESSAGE })
+    expect(db.inspectionQuoteRequest.create).not.toHaveBeenCalled()
+  })
+
+  it('cancelling a quote request answers 409 and deletes nothing', async () => {
+    vi.mocked(db.inspection.findFirst).mockResolvedValue({
+      id: 'insp-1',
+      organizationId: ORG,
+    } as never)
+    vi.mocked(db.inspectionQuoteRequest.findFirst).mockResolvedValue({ id: 'req-1' } as never)
+    const res = await quoteRequestDelete(
+      json(
+        'http://app.test/api/public/forms/inspection-quote-request',
+        { inspectionId: 'insp-1', publicToken: 'tok-1' },
+        'DELETE'
+      )
+    )
+    expect(res.status).toBe(409)
+    expect(db.inspectionQuoteRequest.delete).not.toHaveBeenCalled()
+  })
+
+  it('answering a shared quote answers 409 and changes nothing', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      id: 'quote-1',
+      organizationId: ORG,
+      status: 'sent',
+      customer: null,
+    } as never)
+    const res = await quoteResponsePost(
+      json('http://app.test/api/public/forms/quote-response', {
+        quoteId: 'quote-1',
+        publicToken: 'tok-1',
+        action: 'accepted',
+      })
+    )
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ success: false, error: RESTORE_IN_PROGRESS_MESSAGE })
+    expect(db.quote.update).not.toHaveBeenCalled()
+  })
+
+  it('a view stamp is skipped for a frozen workshop and written for another', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      id: 'job-1',
+      organizationId: ORG,
+    } as never)
+    const frozenRes = await trackViewPost(
+      json('http://app.test/api/public/track-view', { type: 'invoice', token: 'tok-1' }) as never
+    )
+    expect(frozenRes.status).toBe(409)
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      id: 'q-1',
+      organizationId: 'org-live',
+    } as never)
+    vi.mocked(db.quote.update).mockResolvedValue({} as never)
+    const liveRes = await trackViewPost(
+      json('http://app.test/api/public/track-view', { type: 'quote', token: 'tok-2' }) as never
+    )
+    expect(liveRes.status).toBe(200)
+    expect(db.quote.update).toHaveBeenCalledTimes(1)
+  })
+})

+ 138 - 0
src/__tests__/app/api/restore-freeze-routes.test.ts

@@ -0,0 +1,138 @@
+/**
+ * @vitest-environment node
+ *
+ * While a workshop is being restored, the API routes that write rows or
+ * files for it answer 409 and write nothing. Server actions get the same
+ * answer from withAuth; these routes authenticate on their own.
+ */
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+  uploads: '',
+  frozen: new Set<string>(),
+}))
+
+vi.mock('@/lib/backup/freeze', () => ({
+  isRestoring: (organizationId: string) => state.frozen.has(organizationId),
+  RESTORE_IN_PROGRESS_MESSAGE: 'A restore of this workshop is in progress.',
+}))
+vi.mock('@/lib/get-auth-context', () => ({ getAuthContext: vi.fn() }))
+vi.mock('@/lib/upload-root', () => ({
+  uploadsRoot: () => state.uploads,
+  uploadsRoots: () => [state.uploads],
+}))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/demo', () => ({ isDemoMode: false }))
+vi.mock('@/lib/db', () => ({ db: {} }))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: vi.fn(async () => 'UTC') }))
+vi.mock('@/features/integrations/Lib/backup', () => ({
+  canRestoreBackups: vi.fn(async () => true),
+}))
+vi.mock('@/lib/backup/engine', () => ({ backupLockHolder: vi.fn(() => null) }))
+vi.mock('@/lib/backup/tenancy', () => ({
+  RestoreRefusedError: class RestoreRefusedError extends Error {},
+  restoreLockHolder: vi.fn(() => null),
+}))
+vi.mock('@/lib/backup/import', () => ({
+  assertSupportedBackup: vi.fn(),
+  restoreBackup: vi.fn(async () => ({ redactedSettings: [] })),
+  zipFileSource: vi.fn(),
+}))
+vi.mock('@/lib/backup/zip-file', () => ({
+  openZip: vi.fn(),
+  readEntryJson: vi.fn(),
+  readJsonFile: vi.fn(),
+  extractEntry: vi.fn(),
+  spoolUpload: vi.fn(),
+}))
+
+import { NextRequest } from 'next/server'
+import { POST as uploadPost } from '@/app/api/protected/upload/route'
+import { POST as importPost } from '@/app/api/protected/backup/import/route'
+import { POST as lubelogPost } from '@/app/api/protected/backup/import-lubelog/route'
+import { POST as invoiceNinjaPost } from '@/app/api/protected/backup/import-invoice-ninja/route'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { restoreBackup } from '@/lib/backup/import'
+import { spoolUpload } from '@/lib/backup/zip-file'
+
+const ORG = 'org-frozen'
+
+state.uploads = fs.mkdtempSync(path.join(os.tmpdir(), 'tv-freeze-routes-'))
+afterAll(() => fs.rmSync(state.uploads, { recursive: true, force: true }))
+
+function imageUpload() {
+  const form = new FormData()
+  form.set('file', new File([new Uint8Array(64)], 'car.png', { type: 'image/png' }))
+  return new NextRequest('http://app.test/api/protected/upload', { method: 'POST', body: form })
+}
+
+function backupUpload(url: string) {
+  return new NextRequest(url, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ version: 1, data: {} }),
+  })
+}
+
+beforeEach(() => {
+  state.frozen.clear()
+  fs.rmSync(path.join(state.uploads, ORG), { recursive: true, force: true })
+  vi.mocked(getAuthContext).mockReset()
+  vi.mocked(getAuthContext).mockResolvedValue({
+    organizationId: ORG,
+    userId: 'user-1',
+    role: 'owner',
+    isAdmin: true,
+    isSuperAdmin: false,
+  } as never)
+  vi.mocked(restoreBackup).mockClear()
+  vi.mocked(spoolUpload).mockClear()
+})
+
+describe('upload route during a restore', () => {
+  it('answers 409 and writes no file while the workshop is frozen', async () => {
+    state.frozen.add(ORG)
+    const res = await uploadPost(imageUpload())
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ error: 'A restore of this workshop is in progress.' })
+    expect(fs.existsSync(path.join(state.uploads, ORG))).toBe(false)
+  })
+
+  it('still stores the file for a workshop that is not frozen', async () => {
+    state.frozen.add('org-other')
+    const res = await uploadPost(imageUpload())
+    expect(res.status).toBe(200)
+    expect(fs.readdirSync(path.join(state.uploads, ORG, 'vehicles'))).toHaveLength(1)
+  })
+})
+
+describe('backup import route during a restore', () => {
+  it('answers 409 without reading the upload or restoring anything', async () => {
+    state.frozen.add(ORG)
+    const res = await importPost(backupUpload('http://app.test/api/protected/backup/import'))
+    expect(res.status).toBe(409)
+    expect(await res.json()).toEqual({ error: 'A restore of this workshop is in progress.' })
+    expect(restoreBackup).not.toHaveBeenCalled()
+  })
+
+  it('restores when the workshop is not frozen', async () => {
+    const res = await importPost(backupUpload('http://app.test/api/protected/backup/import'))
+    expect(res.status).toBe(200)
+    expect(restoreBackup).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('third-party imports during a restore', () => {
+  it.each([
+    ['LubeLogger', lubelogPost, 'import-lubelog'],
+    ['Invoice Ninja', invoiceNinjaPost, 'import-invoice-ninja'],
+  ])('%s answers 409 before the upload is spooled', async (_name, post, slug) => {
+    state.frozen.add(ORG)
+    const res = await post(backupUpload(`http://app.test/api/protected/backup/${slug}`))
+    expect(res.status).toBe(409)
+    expect(spoolUpload).not.toHaveBeenCalled()
+  })
+})

+ 195 - 0
src/__tests__/app/api/webhooks-freeze.test.ts

@@ -0,0 +1,195 @@
+/**
+ * @vitest-environment node
+ *
+ * While a workshop is being restored, the vendor webhooks write nothing for
+ * it: a payment or a message filed now would be lost when the restore
+ * replaces the workshop's rows. They answer 503 with Retry-After, so the
+ * vendor delivers the event again once the restore is done.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { db, payments, stripe, recordVendorPayment, messaging, whatsapp, notify } = vi.hoisted(
+  () => ({
+    db: {
+      serviceRecord: { findUnique: vi.fn() },
+      payment: { findFirst: vi.fn() },
+      customer: { findFirst: vi.fn() },
+      smsMessage: { create: vi.fn() },
+    },
+    payments: { paymentProviderFor: vi.fn() },
+    stripe: { retrieve: vi.fn() },
+    recordVendorPayment: vi.fn(),
+    messaging: { organizationForWebhookSecret: vi.fn(), channelSetup: vi.fn() },
+    whatsapp: {
+      getWhatsappWebhookContext: vi.fn(),
+      markWhatsappWebhookSeen: vi.fn(),
+      applyWhatsappStatus: vi.fn(),
+      recordInboundWhatsapp: vi.fn(),
+    },
+    notify: vi.fn(),
+  })
+)
+
+vi.mock('@/lib/backup/freeze', () => ({
+  isRestoring: vi.fn(() => true),
+  RESTORE_IN_PROGRESS_MESSAGE: 'A restore of this workshop is in progress.',
+}))
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@/features/integrations/Lib/payments', () => payments)
+vi.mock('@/lib/payment-providers/vendor-hosts', () => ({
+  stripeClient: () => ({ checkout: { sessions: { retrieve: stripe.retrieve } } }),
+}))
+vi.mock('@/lib/payment-providers/record-payment', () => ({ recordVendorPayment }))
+vi.mock('@/features/integrations/Lib/messaging', () => messaging)
+vi.mock('@/lib/whatsapp', () => whatsapp)
+vi.mock('@/lib/notify', () => ({ notify }))
+
+import { POST as stripePost } from '@/app/api/webhooks/stripe/route'
+import { POST as vippsPost } from '@/app/api/webhooks/vipps/route'
+import { POST as paypalPost } from '@/app/api/webhooks/paypal/route'
+import { POST as twilioPost } from '@/app/api/webhooks/sms/twilio/route'
+import { POST as vonagePost } from '@/app/api/webhooks/sms/vonage/route'
+import { POST as telnyxPost } from '@/app/api/webhooks/sms/telnyx/route'
+import { POST as whatsappPost } from '@/app/api/webhooks/whatsapp/[provider]/[organizationId]/route'
+import { isRestoring } from '@/lib/backup/freeze'
+
+const ORG = 'org_frozen'
+
+function json(url: string, body: unknown) {
+  return new Request(url, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+async function expectRestoring(response: Response) {
+  expect(response.status).toBe(503)
+  expect(response.headers.get('Retry-After')).toBe('300')
+  expect(await response.json()).toEqual({ error: 'A restore of this workshop is in progress.' })
+}
+
+const verifyPayment = vi.fn()
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  db.serviceRecord.findUnique.mockResolvedValue({ id: 'sr_1', organizationId: ORG })
+  db.payment.findFirst.mockResolvedValue(null)
+  db.customer.findFirst.mockResolvedValue({ id: 'cust_1', name: 'Kari' })
+  db.smsMessage.create.mockResolvedValue({ id: 'sms_1' })
+  verifyPayment.mockResolvedValue({
+    paid: true,
+    amount: 100,
+    serviceRecordId: 'sr_1',
+    organizationId: ORG,
+  })
+  payments.paymentProviderFor.mockResolvedValue({
+    setup: { credentials: { secretKey: 'sk_test' } },
+    provider: { verifyPayment },
+  })
+  stripe.retrieve.mockResolvedValue({
+    id: 'cs_1',
+    metadata: { orgId: ORG, serviceRecordId: 'sr_1' },
+    payment_status: 'paid',
+    amount_total: 10000,
+    currency: 'usd',
+  })
+  messaging.organizationForWebhookSecret.mockResolvedValue(ORG)
+  messaging.channelSetup.mockResolvedValue(null)
+})
+
+describe('payment webhooks during a restore', () => {
+  it('Stripe answers 503 and books nothing', async () => {
+    const response = await stripePost(
+      json('http://app.test/api/webhooks/stripe', {
+        type: 'checkout.session.completed',
+        data: { object: { id: 'cs_1', metadata: { orgId: ORG, serviceRecordId: 'sr_1' } } },
+      })
+    )
+    await expectRestoring(response)
+    expect(isRestoring).toHaveBeenCalledWith(ORG)
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('Vipps answers 503 before verifying or booking the payment', async () => {
+    const response = await vippsPost(
+      json('http://app.test/api/webhooks/vipps', {
+        reference: 'inv-sr_1-1',
+        metadata: { orgId: ORG, serviceRecordId: 'sr_1' },
+      })
+    )
+    await expectRestoring(response)
+    expect(verifyPayment).not.toHaveBeenCalled()
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('PayPal answers 503 before capturing or booking the order', async () => {
+    const response = await paypalPost(
+      json('http://app.test/api/webhooks/paypal', {
+        event_type: 'PAYMENT.CAPTURE.COMPLETED',
+        resource: { id: 'order_1', custom_id: `sr_1:${ORG}` },
+      })
+    )
+    await expectRestoring(response)
+    expect(verifyPayment).not.toHaveBeenCalled()
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+})
+
+describe('SMS webhooks during a restore', () => {
+  it('Twilio answers 503 and files no message', async () => {
+    const response = await twilioPost(
+      new Request('http://app.test/api/webhooks/sms/twilio?org_secret=s', {
+        method: 'POST',
+        headers: { 'content-type': 'application/x-www-form-urlencoded' },
+        body: new URLSearchParams({ From: '+4799999999', To: '+4711111111', Body: 'Hi' }),
+      })
+    )
+    await expectRestoring(response)
+    expect(db.customer.findFirst).not.toHaveBeenCalled()
+    expect(db.smsMessage.create).not.toHaveBeenCalled()
+  })
+
+  it('Vonage answers 503 and files no message', async () => {
+    const response = await vonagePost(
+      json('http://app.test/api/webhooks/sms/vonage?org_secret=s', {
+        msisdn: '4799999999',
+        to: '4711111111',
+        text: 'Hi',
+      })
+    )
+    await expectRestoring(response)
+    expect(db.smsMessage.create).not.toHaveBeenCalled()
+  })
+
+  it('Telnyx answers 503 and files no message', async () => {
+    const response = await telnyxPost(
+      json('http://app.test/api/webhooks/sms/telnyx?org_secret=s', {
+        data: {
+          event_type: 'message.received',
+          payload: { from: { phone_number: '+4799999999' }, to: '+4711111111', text: 'Hi' },
+        },
+      })
+    )
+    await expectRestoring(response)
+    expect(db.smsMessage.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('WhatsApp webhook during a restore', () => {
+  it('answers 503 and reads or files nothing', async () => {
+    const receive = vi.fn()
+    whatsapp.getWhatsappWebhookContext.mockResolvedValue({
+      adapter: { id: 'meta', receive, acknowledge: () => new Response(null, { status: 200 }) },
+      context: {},
+    })
+    const response = await whatsappPost(
+      json(`http://app.test/api/webhooks/whatsapp/meta/${ORG}`, { entry: [] }),
+      { params: Promise.resolve({ provider: 'meta', organizationId: ORG }) }
+    )
+    await expectRestoring(response)
+    expect(receive).not.toHaveBeenCalled()
+    expect(whatsapp.recordInboundWhatsapp).not.toHaveBeenCalled()
+    expect(whatsapp.markWhatsappWebhookSeen).not.toHaveBeenCalled()
+  })
+})

+ 534 - 0
src/__tests__/db/restore-tenancy.db.test.ts

@@ -0,0 +1,534 @@
+/**
+ * The decisive proof that a restore never touches another workshop, on a
+ * real PostgreSQL (`npm run test:db` with E2E_DATABASE_URL set).
+ *
+ * Two workshops, A and B, are seeded with the full spread of records. Every
+ * row of every table is dumped, and the restore core is run into A three
+ * times: with A's own backup, with a crafted file whose foreign keys point
+ * at B's rows, and with a crafted file that reuses B's row ids as its own.
+ * After each, B's rows are compared with the dump taken before, row by row
+ * over every table, not a hand-picked list. The comparison is what proves
+ * it; the guard code is only how it is achieved.
+ */
+import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { PassThrough } from 'node:stream'
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
+import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
+import { TENANCY_SCHEMA } from '@/lib/backup/tenancy-schema'
+
+const url = process.env.E2E_DATABASE_URL ?? ''
+const databaseName = url.split('/').pop()?.split('?')[0]?.toLowerCase() ?? ''
+if (url && !/test|e2e/.test(databaseName)) {
+  throw new Error(
+    `Refusing to use database "${databaseName}": the suite empties it, so its name must say test or e2e.`
+  )
+}
+
+// Set before the app's modules load, so the client connects to the test
+// database and files land in a folder of this run's own.
+process.env.DATABASE_URL = url
+const dataRoot = mkdtempSync(path.join(tmpdir(), 'restore-tenancy-'))
+process.env.DATA_ROOT = dataRoot
+
+type Row = Record<string, unknown>
+type Dump = Map<string, Row[]>
+
+/** `@@map` names: the table each model is stored in. */
+function tableOfModel(): Map<string, string> {
+  const out = new Map<string, string>()
+  for (const block of readPrismaSchema().matchAll(/\nmodel\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) {
+    const map = /@@map\("([^"]+)"\)/.exec(block[2])
+    out.set(block[1], map ? map[1] : block[1])
+  }
+  return out
+}
+
+const TABLE_OF = tableOfModel()
+const MODEL_OF = new Map([...TABLE_OF].map(([model, table]) => [table, model]))
+
+let db: typeof import('@/lib/db')['db']
+let importCore: typeof import('@/lib/backup/import')
+let buildData: typeof import('@/lib/backup/build-data')
+let tenancy: typeof import('@/lib/backup/tenancy')
+let tableNames: string[] = []
+
+async function listTables(): Promise<string[]> {
+  const rows = await db.$queryRawUnsafe<{ table_name: string }[]>(
+    `select table_name from information_schema.tables
+     where table_schema = 'public' and table_type = 'BASE TABLE' order by table_name`
+  )
+  return rows.map((r) => r.table_name).filter((t) => t !== '_prisma_migrations')
+}
+
+async function emptyDatabase(): Promise<void> {
+  await db.$executeRawUnsafe(`truncate table ${tableNames.map((t) => `"${t}"`).join(', ')} cascade`)
+}
+
+/** Every row of every table, as JSON text sorted within each table. */
+async function dumpAll(): Promise<Dump> {
+  const out: Dump = new Map()
+  for (const table of tableNames) {
+    const rows = await db.$queryRawUnsafe<{ row: string }[]>(
+      `select row_to_json(t)::text as row from "${table}" t`
+    )
+    out.set(
+      table,
+      rows
+        .map((r) => r.row)
+        .sort()
+        .map((text) => JSON.parse(text) as Row)
+    )
+  }
+  return out
+}
+
+/** Rows by table and id, for following a child row to its parent. */
+function index(dump: Dump): Map<string, Map<string, Row>> {
+  const out = new Map<string, Map<string, Row>>()
+  for (const [table, rows] of dump) {
+    out.set(table, new Map(rows.map((r) => [String(r.id), r])))
+  }
+  return out
+}
+
+/**
+ * Whose row this is: its organisation column, or its parent's, following
+ * the relation paths the tenancy schema knows. Null for a global row such
+ * as a user.
+ */
+function organizationOf(
+  table: string,
+  row: Row,
+  byId: Map<string, Map<string, Row>>,
+  depth = 0
+): string | null {
+  const model = MODEL_OF.get(table)
+  const known = model ? TENANCY_SCHEMA[model] : undefined
+  if (!known || depth > 6) return null
+  if (known.organizationId)
+    return typeof row.organizationId === 'string' ? row.organizationId : null
+  for (const [first] of known.ownerPaths) {
+    const column = Object.entries(known.foreignKeys).find(([, fk]) => fk.relation === first)
+    if (!column) continue
+    const id = row[column[0]]
+    const parentTable = TABLE_OF.get(column[1].target)
+    if (typeof id !== 'string' || !parentTable) continue
+    const parent = byId.get(parentTable)?.get(id)
+    if (!parent) continue
+    const owner = organizationOf(parentTable, parent, byId, depth + 1)
+    if (owner) return owner
+  }
+  return null
+}
+
+/** The rows of one workshop, table by table, as the dump holds them. */
+function rowsOf(dump: Dump, organizationId: string): Dump {
+  const byId = index(dump)
+  const out: Dump = new Map()
+  for (const [table, rows] of dump) {
+    const mine = rows.filter((r) => organizationOf(table, r, byId) === organizationId)
+    if (mine.length) out.set(table, mine)
+  }
+  return out
+}
+
+function idsOf(rows: Dump): Set<string> {
+  const out = new Set<string>()
+  for (const list of rows.values())
+    for (const r of list) if (typeof r.id === 'string') out.add(r.id)
+  return out
+}
+
+function countRows(rows: Dump): number {
+  let n = 0
+  for (const list of rows.values()) n += list.length
+  return n
+}
+
+/** Every string anywhere in a row that is one of the given ids. */
+function referencesTo(rows: Dump, ids: Set<string>): string[] {
+  const hits: string[] = []
+  for (const [table, list] of rows) {
+    for (const row of list) {
+      for (const [column, value] of Object.entries(row)) {
+        if (typeof value === 'string' && ids.has(value) && column !== 'id') {
+          hits.push(`${table}.${column} -> ${value}`)
+        }
+      }
+    }
+  }
+  return hits
+}
+
+interface Seeded {
+  organizationId: string
+  userId: string
+  customerId: string
+  vehicleId: string
+  technicianId: string
+  workBayId: string
+  inventoryPartId: string
+  templateId: string
+  locationId: string
+}
+
+/** A workshop with a bit of everything a backup carries. */
+async function seedWorkshop(tag: string): Promise<Seeded> {
+  const user = await db.user.create({
+    data: { name: `Owner ${tag}`, email: `${tag}@restore.test` },
+  })
+  const org = await db.organization.create({ data: { name: `Workshop ${tag}` } })
+  const organizationId = org.id
+  const userId = user.id
+  await db.organizationMember.create({ data: { userId, organizationId, role: 'owner' } })
+
+  const customer = await db.customer.create({
+    data: { name: `Kari ${tag}`, email: `kari-${tag}@example.com`, userId, organizationId },
+  })
+  const technician = await db.technician.create({ data: { name: `Ola ${tag}`, organizationId } })
+  const bay = await db.workBay.create({ data: { name: `Bay ${tag}`, organizationId } })
+  const part = await db.inventoryPart.create({
+    data: {
+      name: `Brake pads ${tag}`,
+      userId,
+      organizationId,
+      movements: { create: [{ delta: 4, quantityAfter: 4, reason: 'seed', organizationId }] },
+    },
+  })
+  const vehicle = await db.vehicle.create({
+    data: {
+      make: 'Volvo',
+      model: 'V70',
+      year: 2015,
+      userId,
+      organizationId,
+      customerId: customer.id,
+      notes: { create: [{ title: 'Note', content: `Rattle ${tag}` }] },
+      reminders: { create: [{ title: `Service ${tag}`, organizationId }] },
+    },
+  })
+
+  const uploads = path.join(dataRoot, 'uploads', organizationId, 'services')
+  mkdirSync(uploads, { recursive: true })
+  writeFileSync(path.join(uploads, `scan-${tag}.pdf`), `pdf of ${tag}`)
+
+  const job = await db.serviceRecord.create({
+    data: {
+      title: `Brakes ${tag}`,
+      organizationId,
+      vehicleId: vehicle.id,
+      customerId: customer.id,
+      technicianId: technician.id,
+      workBayId: bay.id,
+      invoiceNumber: `INV-${tag}-1`,
+      partItems: { create: [{ name: 'Pads', inventoryPartId: part.id }] },
+      laborItems: { create: [{ description: 'Fit pads' }] },
+      payments: { create: [{ amount: 100 }] },
+      attachments: {
+        create: [
+          {
+            fileName: `scan-${tag}.pdf`,
+            fileUrl: `/api/protected/files/${organizationId}/services/scan-${tag}.pdf`,
+            fileType: 'application/pdf',
+            fileSize: 12,
+          },
+        ],
+      },
+    },
+  })
+  await db.timeEntry.create({
+    data: { technicianId: technician.id, serviceRecordId: job.id, organizationId },
+  })
+  await db.serviceRecord.create({
+    data: { title: `Counter sale ${tag}`, organizationId, customerId: customer.id },
+  })
+  await db.quote.create({
+    data: {
+      title: `Tyres ${tag}`,
+      userId,
+      organizationId,
+      vehicleId: vehicle.id,
+      customerId: customer.id,
+      partItems: { create: [{ name: 'Tyre' }] },
+      laborItems: { create: [{ description: 'Fit tyres' }] },
+    },
+  })
+  const template = await db.inspectionTemplate.create({
+    data: {
+      name: `Default ${tag}`,
+      organizationId,
+      sections: { create: [{ name: 'Brakes', items: { create: [{ name: 'Pads' }] } }] },
+    },
+  })
+  await db.inspection.create({
+    data: {
+      vehicleId: vehicle.id,
+      templateId: template.id,
+      organizationId,
+      technicianId: technician.id,
+      items: { create: [{ name: 'Pads', section: 'Brakes' }] },
+    },
+  })
+  const warehouse = await db.tireWarehouse.create({
+    data: {
+      name: `Hotel ${tag}`,
+      organizationId,
+      userId,
+      locations: { create: [{ code: 'A1', organizationId }] },
+    },
+  })
+  const location = await db.tireLocation.findFirstOrThrow({ where: { warehouseId: warehouse.id } })
+  await db.tireSet.create({
+    data: {
+      organizationId,
+      userId,
+      vehicleId: vehicle.id,
+      customerId: customer.id,
+      locationId: location.id,
+      measurements: { create: [{}] },
+    },
+  })
+  await db.smsMessage.create({
+    data: {
+      direction: 'outbound',
+      fromNumber: '+4700000000',
+      toNumber: '+4711111111',
+      body: `Ready ${tag}`,
+      organizationId,
+      customerId: customer.id,
+    },
+  })
+  await db.emailMessage.create({
+    data: {
+      direction: 'outbound',
+      address: `kari-${tag}@example.com`,
+      body: `Hello ${tag}`,
+      organizationId,
+      customerId: customer.id,
+    },
+  })
+  await db.appSetting.createMany({
+    data: [
+      { key: 'workshop.name', value: `Workshop ${tag}`, userId, organizationId },
+      { key: 'workshop.phone', value: `+47 ${tag}`, userId, organizationId },
+    ],
+  })
+  await db.workOrderStatus.create({
+    data: { name: `Waiting ${tag}`, stage: 'pending', organizationId },
+  })
+  await db.notification.create({
+    data: {
+      type: 'seed',
+      title: 'Seeded',
+      message: tag,
+      entityType: 'Vehicle',
+      entityId: vehicle.id,
+      entityUrl: `/vehicles/${vehicle.id}`,
+      organizationId,
+    },
+  })
+  await db.webhook.create({
+    data: {
+      name: `Hook ${tag}`,
+      url: 'https://example.test/hook',
+      secret: `whsec_${tag}`,
+      events: '["*"]',
+      organizationId,
+      createdById: userId,
+    },
+  })
+  await db.role.create({
+    data: {
+      name: `Mechanic ${tag}`,
+      organizationId,
+      permissions: { create: [{ action: 'read', subject: 'vehicles' }] },
+    },
+  })
+  await db.laborPreset.create({
+    data: {
+      name: `Service ${tag}`,
+      userId,
+      organizationId,
+      items: { create: [{ description: 'Oil change' }] },
+    },
+  })
+  await db.documentDesign.create({
+    data: {
+      organizationId,
+      documentType: 'invoice',
+      name: `Design ${tag}`,
+      layout: {},
+      template: {},
+    },
+  })
+  await db.auditLog.create({ data: { action: 'seed', organizationId, userId } })
+
+  return {
+    organizationId,
+    userId,
+    customerId: customer.id,
+    vehicleId: vehicle.id,
+    technicianId: technician.id,
+    workBayId: bay.id,
+    inventoryPartId: part.id,
+    templateId: template.id,
+    locationId: location.id,
+  }
+}
+
+/** A's backup, the way the engine stages it for restic: data.json and the uploads folder. */
+async function exportOf(organizationId: string): Promise<{ data: Row; dir: string }> {
+  const chunks: string[] = []
+  const out = new PassThrough()
+  out.on('data', (chunk: Buffer | string) => chunks.push(chunk.toString()))
+  await buildData.writeBackupData(
+    organizationId,
+    {
+      settings: true,
+      customers: true,
+      vehicles: true,
+      quotes: true,
+      inventory: true,
+      customFields: true,
+      files: false,
+      technicians: true,
+      inspections: true,
+      auditLogs: true,
+      smsMessages: true,
+      scheduledMessages: true,
+      notifications: true,
+      tireHotel: true,
+      workshopConfig: true,
+      includeSecrets: true,
+    },
+    out
+  )
+  out.end()
+  const text = chunks.join('')
+  const dir = mkdtempSync(path.join(dataRoot, 'restore-'))
+  mkdirSync(path.join(dir, 'stage'))
+  writeFileSync(path.join(dir, 'stage', 'data.json'), text)
+  cpSync(path.join(dataRoot, 'uploads', organizationId), path.join(dir, 'files'), {
+    recursive: true,
+  })
+  return { data: JSON.parse(text) as Row, dir }
+}
+
+const describeDb = url ? describe : describe.skip
+
+describeDb('a restore never touches another workshop', () => {
+  let a: Seeded
+  let b: Seeded
+  let before: Dump
+  let bBefore: Dump
+  let bIds: Set<string>
+
+  beforeAll(async () => {
+    ;({ db } = await import('@/lib/db'))
+    importCore = await import('@/lib/backup/import')
+    buildData = await import('@/lib/backup/build-data')
+    tenancy = await import('@/lib/backup/tenancy')
+    tableNames = await listTables()
+    expect(tableNames.length).toBeGreaterThan(50)
+  })
+
+  beforeEach(async () => {
+    await emptyDatabase()
+    rmSync(path.join(dataRoot, 'uploads'), { recursive: true, force: true })
+    a = await seedWorkshop('a')
+    b = await seedWorkshop('b')
+    before = await dumpAll()
+    bBefore = rowsOf(before, b.organizationId)
+    bIds = idsOf(bBefore)
+    // The seed has to be worth comparing.
+    expect(countRows(bBefore)).toBeGreaterThan(30)
+    expect(bBefore.size).toBeGreaterThan(25)
+  })
+
+  afterAll(async () => {
+    if (db) await db.$disconnect()
+    rmSync(dataRoot, { recursive: true, force: true })
+  })
+
+  it("restores A's own backup and leaves B byte for byte as it was", async () => {
+    const { dir } = await exportOf(a.organizationId)
+    const aBefore = rowsOf(before, a.organizationId)
+
+    const result = await importCore.restoreFromDisk(
+      { organizationId: a.organizationId, userId: a.userId },
+      { dataFile: path.join(dir, 'stage', 'data.json'), filesDir: path.join(dir, 'files') }
+    )
+
+    expect(result.redactedSettings).toEqual([])
+    const after = await dumpAll()
+    expect(rowsOf(after, b.organizationId)).toEqual(bBefore)
+    // A came back whole, with the ids it had, and points at nothing of B's.
+    const aAfter = rowsOf(after, a.organizationId)
+    expect(idsOf(aAfter)).toEqual(idsOf(aBefore))
+    expect(referencesTo(aAfter, bIds)).toEqual([])
+    // Nothing belongs to anyone else, and no row was left ownerless.
+    const byId = index(after)
+    for (const [table, rows] of after) {
+      for (const row of rows) {
+        const owner = organizationOf(table, row, byId)
+        expect(
+          owner === null || owner === a.organizationId || owner === b.organizationId,
+          `${table} ${row.id} belongs to ${owner}`
+        ).toBe(true)
+      }
+    }
+  })
+
+  it('clears every foreign key a crafted file points at B with, and leaves B as it was', async () => {
+    const { data } = await exportOf(a.organizationId)
+    const file = data.data as Record<string, Row[]>
+    file.vehicles[0].customerId = b.customerId
+    const job = (file.vehicles[0].serviceRecords as Row[])[0]
+    job.workBayId = b.workBayId
+    job.technicianId = b.technicianId
+    ;(job.partItems as Row[])[0].inventoryPartId = b.inventoryPartId
+    ;(job.timeEntries as Row[])[0].technicianId = b.technicianId
+    file.counterSales[0].customerId = b.customerId
+    file.quotes[0].vehicleId = b.vehicleId
+    file.inspections[0].templateId = b.templateId
+    file.tireSets[0].locationId = b.locationId
+    file.smsMessages[0].customerId = b.customerId
+
+    await importCore.restoreBackup(
+      { organizationId: a.organizationId, userId: a.userId },
+      data as unknown as import('@/lib/backup/import').BackupData,
+      null
+    )
+
+    const after = await dumpAll()
+    expect(rowsOf(after, b.organizationId)).toEqual(bBefore)
+    const aAfter = rowsOf(after, a.organizationId)
+    expect(referencesTo(aAfter, bIds)).toEqual([])
+    // The links were cleared rather than the rows lost, except the ones
+    // that cannot exist without their link.
+    expect(aAfter.get(TABLE_OF.get('Vehicle') ?? '')).toHaveLength(1)
+    expect(aAfter.get(TABLE_OF.get('Quote') ?? '')).toHaveLength(1)
+    expect(aAfter.get(TABLE_OF.get('Inspection') ?? '')).toBeUndefined()
+  })
+
+  it("refuses a file that reuses B's ids before anything is written", async () => {
+    const { data } = await exportOf(a.organizationId)
+    const file = data.data as Record<string, Row[]>
+    file.customers[0].id = b.customerId
+    file.vehicles[0].customerId = b.customerId
+
+    const error = await importCore
+      .restoreBackup(
+        { organizationId: a.organizationId, userId: a.userId },
+        data as unknown as import('@/lib/backup/import').BackupData,
+        null
+      )
+      .catch((e: unknown) => e)
+
+    expect(error).toBeInstanceOf(tenancy.RestoreRefusedError)
+    expect((error as { code: string }).code).toBe('foreign_ids')
+    expect(await dumpAll()).toEqual(before)
+  })
+})

+ 1 - 4
src/__tests__/features/backup/inspection-restore-fields.test.ts

@@ -15,10 +15,7 @@ import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
  */
  */
 
 
 const ROOT = process.cwd()
 const ROOT = process.cwd()
-const importSource = fs.readFileSync(
-  path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
-  'utf-8'
-)
+const importSource = fs.readFileSync(path.join(ROOT, 'src/lib/backup/import.ts'), 'utf-8')
 const schema = readPrismaSchema(ROOT)
 const schema = readPrismaSchema(ROOT)
 
 
 function scalarColumns(model: string): string[] {
 function scalarColumns(model: string): string[] {

+ 1 - 4
src/__tests__/features/backup/part-restore-fields.test.ts

@@ -16,10 +16,7 @@ import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
  */
  */
 
 
 const ROOT = process.cwd()
 const ROOT = process.cwd()
-const importSource = fs.readFileSync(
-  path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
-  'utf-8'
-)
+const importSource = fs.readFileSync(path.join(ROOT, 'src/lib/backup/import.ts'), 'utf-8')
 const schema = readPrismaSchema(ROOT)
 const schema = readPrismaSchema(ROOT)
 
 
 /** Scalar (persisted, non-relation) columns of a model, from the schema. */
 /** Scalar (persisted, non-relation) columns of a model, from the schema. */

+ 1 - 4
src/__tests__/features/backup/restore-overrides.test.ts

@@ -25,10 +25,7 @@ import { describe, expect, it } from 'vitest'
 import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
 import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
 
 
 const ROOT = process.cwd()
 const ROOT = process.cwd()
-const source = fs.readFileSync(
-  path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
-  'utf-8'
-)
+const source = fs.readFileSync(path.join(ROOT, 'src/lib/backup/import.ts'), 'utf-8')
 const schema = readPrismaSchema(ROOT)
 const schema = readPrismaSchema(ROOT)
 
 
 /** The arguments of one call, split at top level so nested objects survive. */
 /** The arguments of one call, split at top level so nested objects survive. */

+ 1 - 4
src/__tests__/features/backup/vehicle-restore-fields.test.ts

@@ -11,10 +11,7 @@ import { describe, expect, it } from 'vitest'
  * restored workshop lost them without a word.
  * restored workshop lost them without a word.
  */
  */
 
 
-const importSource = fs.readFileSync(
-  path.join(process.cwd(), 'src/app/api/protected/backup/import/route.ts'),
-  'utf-8'
-)
+const importSource = fs.readFileSync(path.join(process.cwd(), 'src/lib/backup/import.ts'), 'utf-8')
 
 
 function vehicleRestoreMap(): string {
 function vehicleRestoreMap(): string {
   const start = importSource.indexOf('tx.vehicle.create(')
   const start = importSource.indexOf('tx.vehicle.create(')

+ 31 - 0
src/__tests__/features/inspection-reminders/booking-actions.test.ts

@@ -67,6 +67,10 @@ const { cancelBooking, confirmBooking, getBookingPage } = await import(
   '@/features/inspection-reminders/Actions/bookingActions'
   '@/features/inspection-reminders/Actions/bookingActions'
 )
 )
 
 
+const { freezeOrganization, unfreezeOrganization, RESTORE_IN_PROGRESS_MESSAGE } = await import(
+  '@/lib/backup/freeze'
+)
+
 const TOKEN = 'abcdefghijklmnopqrstuvwx'
 const TOKEN = 'abcdefghijklmnopqrstuvwx'
 const future = new Date(Date.now() + 3 * 86_400_000)
 const future = new Date(Date.now() + 3 * 86_400_000)
 function send(over: Record<string, unknown> = {}) {
 function send(over: Record<string, unknown> = {}) {
@@ -200,4 +204,31 @@ describe('booking link', () => {
       expect.objectContaining({ type: 'online_booking_cancelled' })
       expect.objectContaining({ type: 'online_booking_cancelled' })
     )
     )
   })
   })
+
+  it('books and cancels nothing while the workshop is being restored', async () => {
+    freezeOrganization('org-a')
+    try {
+      inspectionReminderSend.findUnique.mockResolvedValue(send())
+      await expect(confirmBooking({ token: TOKEN, start: future.toISOString() })).rejects.toThrow(
+        RESTORE_IN_PROGRESS_MESSAGE
+      )
+      expect(serviceRecord.create).not.toHaveBeenCalled()
+      expect(serviceRequest.create).not.toHaveBeenCalled()
+
+      inspectionReminderSend.findUnique.mockResolvedValue(
+        send({ bookedAt: new Date(), bookedServiceRecordId: 'rec-1' })
+      )
+      serviceRecord.findUnique.mockResolvedValue({
+        startDateTime: future,
+        endDateTime: new Date(future.getTime() + 3_600_000),
+        status: 'scheduled',
+      })
+      await expect(cancelBooking(TOKEN)).rejects.toThrow(RESTORE_IN_PROGRESS_MESSAGE)
+      expect(serviceRecord.deleteMany).not.toHaveBeenCalled()
+      expect(inspectionReminderSend.update).not.toHaveBeenCalled()
+      expect(notify).not.toHaveBeenCalled()
+    } finally {
+      unfreezeOrganization('org-a')
+    }
+  })
 })
 })

+ 253 - 0
src/__tests__/features/integrations/backup-actions.test.ts

@@ -0,0 +1,253 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+type Row = Record<string, any>
+
+const h = vi.hoisted(() => ({
+  connections: [] as Row[],
+  features: { backups: true, integrations: true } as Record<string, boolean>,
+  manifests: {} as Record<string, Row | undefined>,
+}))
+
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (fn: (ctx: { organizationId: string; userId: string }) => Promise<unknown>) => {
+    try {
+      return { success: true, data: await fn({ organizationId: 'org1', userId: 'user1' }) }
+    } catch (error) {
+      return { success: false, error: (error as Error).message, name: (error as Error).name }
+    }
+  },
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/demo', () => ({ demoGuard: vi.fn() }))
+
+vi.mock('@/lib/db', () => {
+  const db: Record<string, any> = {
+    integrationConnection: {
+      findUnique: vi.fn(
+        async ({ where }: { where: { organizationId_connectorId: Row } }) =>
+          h.connections.find(
+            (c) =>
+              c.organizationId === where.organizationId_connectorId.organizationId &&
+              c.connectorId === where.organizationId_connectorId.connectorId
+          ) ?? null
+      ),
+      findMany: vi.fn(async ({ where }: { where: Row }) =>
+        h.connections.filter(
+          (c) => c.organizationId === where.organizationId && where.status.in.includes(c.status)
+        )
+      ),
+    },
+  }
+  // The queue's transaction: the advisory lock is a no-op here, and the
+  // transaction client is the same fake.
+  db.$transaction = async (fn: (tx: Record<string, any>) => unknown) => fn(db)
+  db.$executeRaw = vi.fn(async () => 0)
+  return { db }
+})
+
+vi.mock('@/integrations/registry', () => ({
+  getManifest: (id: string) => h.manifests[id],
+  listManifests: () => Object.values(h.manifests).filter(Boolean),
+}))
+
+vi.mock('@/lib/features', async () => {
+  class FeatureGatedError extends Error {
+    feature: string
+    constructor(feature: string, message?: string) {
+      super(message)
+      this.name = 'FeatureGatedError'
+      this.feature = feature
+    }
+  }
+  return {
+    FeatureGatedError,
+    getFeatures: vi.fn(async () => h.features),
+    isCloudMode: () => false,
+  }
+})
+
+// The names and sentences come out as their keys here; what is under test
+// is who gets told, not the wording.
+vi.mock('next-intl/server', () => ({
+  getTranslations: async () => Object.assign((key: string) => key, { has: () => false }),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: vi.fn(async () => 'UTC') }))
+
+vi.mock('@/lib/backup/engine', () => {
+  class BackupBusyError extends Error {}
+  return {
+    BackupBusyError,
+    queueBackupRun: vi.fn(async () => ({ runId: 'run1' })),
+    // A first backup can take an hour: the drain never settles here.
+    drainBackupQueue: vi.fn(() => new Promise(() => undefined)),
+  }
+})
+
+import { listBackupDestinations, runBackupNow } from '@/features/integrations/Actions/backupActions'
+import { BackupBusyError, drainBackupQueue, queueBackupRun } from '@/lib/backup/engine'
+
+const S3 = { id: 's3', name: 'S3 storage', capabilities: ['backup.target'], plan: 'backups' }
+const LOCAL = {
+  id: 'local-folder',
+  name: 'Local folder',
+  capabilities: ['backup.target'],
+  plan: 'backups',
+}
+const CALENDAR = { id: 'google-calendar', name: 'Google Calendar', capabilities: ['calendar.sync'] }
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  h.features = { backups: true, integrations: true }
+  h.manifests = { s3: S3, 'local-folder': LOCAL, 'google-calendar': CALENDAR }
+  h.connections = [
+    {
+      id: 'conn-s3',
+      organizationId: 'org1',
+      connectorId: 's3',
+      status: 'active',
+      settings: { frequency: 'daily', time: '02:00' },
+      state: {
+        'backup.lastScheduledAt': '2026-10-29T02:00:30.000Z',
+        'backup.lastGoodAt': '2026-10-29T02:04:00.000Z',
+      },
+      lastError: null,
+    },
+  ]
+})
+
+describe('runBackupNow', () => {
+  it('refuses a connector that is not a backup destination', async () => {
+    h.connections.push({
+      id: 'cal',
+      organizationId: 'org1',
+      connectorId: 'google-calendar',
+      status: 'active',
+    })
+    const result = await runBackupNow('google-calendar')
+    expect(result).toMatchObject({ success: false, error: expect.stringMatching(/not a backup/) })
+    expect(queueBackupRun).not.toHaveBeenCalled()
+  })
+
+  it('refuses an unknown connector', async () => {
+    const result = await runBackupNow('nope')
+    expect(result.success).toBe(false)
+    expect(queueBackupRun).not.toHaveBeenCalled()
+  })
+
+  it('refuses when the plan lacks backups', async () => {
+    h.features = { backups: false, integrations: true }
+    const result = (await runBackupNow('s3')) as Row
+    expect(result.success).toBe(false)
+    expect(result.name).toBe('FeatureGatedError')
+    expect(queueBackupRun).not.toHaveBeenCalled()
+  })
+
+  it('refuses when the destination is not connected', async () => {
+    const result = await runBackupNow('local-folder')
+    expect(result).toMatchObject({ success: false, error: 'Connect the destination first' })
+
+    h.connections.push({
+      id: 'conn-local',
+      organizationId: 'org1',
+      connectorId: 'local-folder',
+      status: 'disconnected',
+    })
+    expect(await runBackupNow('local-folder')).toMatchObject({ success: false })
+    expect(queueBackupRun).not.toHaveBeenCalled()
+  })
+
+  it('accepts a destination in error, so the workshop can try again', async () => {
+    h.connections[0].status = 'error'
+    expect(await runBackupNow('s3')).toMatchObject({ success: true, data: { runId: 'run1' } })
+  })
+
+  it('queues the run in the member\'s name and returns its id without waiting for it', async () => {
+    // The drain never settles; the action must still return.
+    const result = await runBackupNow('s3')
+    expect(result).toEqual({ success: true, data: { runId: 'run1' } })
+    expect(queueBackupRun).toHaveBeenCalledWith(
+      { kind: 'destination', connectionId: 'conn-s3' },
+      'manual',
+      { requestedBy: 'user1' }
+    )
+    expect(drainBackupQueue).toHaveBeenCalledTimes(1)
+    expect(vi.mocked(queueBackupRun).mock.invocationCallOrder[0]).toBeLessThan(
+      vi.mocked(drainBackupQueue).mock.invocationCallOrder[0]
+    )
+  })
+
+  it('says a backup is already running when one is queued', async () => {
+    vi.mocked(queueBackupRun).mockRejectedValueOnce(new BackupBusyError())
+    expect(await runBackupNow('s3')).toMatchObject({
+      success: false,
+      error: 'A backup is already running.',
+    })
+    expect(drainBackupQueue).not.toHaveBeenCalled()
+  })
+})
+
+describe('listBackupDestinations', () => {
+  it('returns only backup connectors, with the next run time', async () => {
+    h.connections.push(
+      {
+        id: 'cal',
+        organizationId: 'org1',
+        connectorId: 'google-calendar',
+        status: 'active',
+        settings: {},
+        state: {},
+        lastError: null,
+      },
+      {
+        id: 'conn-local',
+        organizationId: 'org1',
+        connectorId: 'local-folder',
+        status: 'error',
+        settings: { frequency: 'weekly', time: '03:00', weekday: '1' },
+        state: {},
+        lastError: 'Storage refused the request',
+      },
+      {
+        id: 'other-org',
+        organizationId: 'org2',
+        connectorId: 's3',
+        status: 'active',
+        settings: {},
+        state: {},
+        lastError: null,
+      }
+    )
+
+    const result = await listBackupDestinations()
+    expect(result.success).toBe(true)
+    const list = (result as { data: Row[] }).data
+    expect(list.map((d) => d.connectorId)).toEqual(['s3', 'local-folder'])
+
+    expect(list[0]).toEqual({
+      connectorId: 's3',
+      name: 'S3 storage',
+      status: 'active',
+      lastGood: '2026-10-29T02:04:00.000Z',
+      nextRunAt: '2026-10-30T02:00:00.000Z',
+      lastError: null,
+    })
+    expect(list[1]).toMatchObject({
+      connectorId: 'local-folder',
+      name: 'Local folder',
+      status: 'error',
+      lastGood: null,
+      lastError: 'Storage refused the request',
+    })
+    // Weekly on Monday at 03:00 UTC, after now.
+    const next = new Date(list[1].nextRunAt)
+    expect(next.getTime()).toBeGreaterThan(Date.now())
+    expect(next.getUTCDay()).toBe(1)
+    expect(next.toISOString().slice(11, 16)).toBe('03:00')
+  })
+
+  it('returns an empty list when nothing is connected', async () => {
+    h.connections = []
+    expect(await listBackupDestinations()).toEqual({ success: true, data: [] })
+  })
+})

+ 5 - 0
src/__tests__/features/integrations/engine-size-lookup.test.ts

@@ -7,6 +7,11 @@ vi.mock('@/features/integrations/Lib/connections', () => ({
     server: { manifest: { id: 'checkcardetails', name: 'Check Car Details' }, lookupVehicle },
     server: { manifest: { id: 'checkcardetails', name: 'Check Car Details' }, lookupVehicle },
   })),
   })),
 }))
 }))
+// The names and sentences come out as their keys here; what is under test
+// is who gets told, not the wording.
+vi.mock('next-intl/server', () => ({
+  getTranslations: async () => Object.assign((key: string) => key, { has: () => false }),
+}))
 vi.mock('@/lib/db', () => ({
 vi.mock('@/lib/db', () => ({
   db: { appSetting: { findUnique: vi.fn() } },
   db: { appSetting: { findUnique: vi.fn() } },
 }))
 }))

+ 218 - 0
src/__tests__/features/integrations/explore-source.test.ts

@@ -0,0 +1,218 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Which repository the restore point explorer may read from: the platform
+ * one while the plan has it (or during the grace period after a downgrade),
+ * or a backup destination the workshop connected and its plan allows.
+ */
+
+type Row = Record<string, any>
+
+const h = vi.hoisted(() => ({
+  config: { bucket: 'b' } as Row | null,
+  features: { platformBackups: true, integrations: true } as Record<string, boolean>,
+  platformRow: null as Row | null,
+  connections: [] as Row[],
+  manifests: {} as Record<string, Row | undefined>,
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    platformBackup: {
+      findUnique: vi.fn(async () => h.platformRow),
+    },
+    integrationConnection: {
+      findUnique: vi.fn(
+        async ({ where }: { where: { organizationId_connectorId: Row } }) =>
+          h.connections.find(
+            (c) =>
+              c.organizationId === where.organizationId_connectorId.organizationId &&
+              c.connectorId === where.organizationId_connectorId.connectorId
+          ) ?? null
+      ),
+    },
+  },
+}))
+
+vi.mock('@/integrations/registry', () => ({
+  getManifest: (id: string) => h.manifests[id],
+  listManifests: () => Object.values(h.manifests).filter(Boolean),
+}))
+
+vi.mock('@/lib/features', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/features')>()
+  return {
+    ...actual,
+    getFeatures: vi.fn(async () => h.features),
+    isCloudMode: () => true,
+  }
+})
+
+vi.mock('@/lib/backup/platform', () => ({
+  platformBackupConfig: () => h.config,
+  platformBackupAllowed: (features: { platformBackups: boolean }) => features.platformBackups,
+}))
+
+import { resolveExploreTarget } from '@/features/integrations/Lib/explore-source'
+import { db } from '@/lib/db'
+import { FeatureGatedError, getFeatures } from '@/lib/features'
+
+const ORG = 'org1'
+
+beforeEach(() => {
+  h.config = { bucket: 'b' }
+  h.features = { platformBackups: true, integrations: true, ai: false }
+  h.platformRow = null
+  h.connections = []
+  h.manifests = {
+    s3: { id: 's3', name: 'Cloud storage bucket', capabilities: ['backup.target'] },
+    'local-folder': {
+      id: 'local-folder',
+      name: 'Local folder',
+      capabilities: ['backup.target'],
+      plan: 'platformBackups',
+    },
+    fiken: { id: 'fiken', name: 'Fiken', capabilities: ['accounting'] },
+  }
+  vi.mocked(db.platformBackup.findUnique).mockClear()
+})
+
+describe('resolveExploreTarget: platform', () => {
+  it('is the workshop’s own platform repository when the plan has it', async () => {
+    await expect(resolveExploreTarget(ORG, { kind: 'platform' })).resolves.toEqual({
+      kind: 'platform',
+      organizationId: ORG,
+    })
+    expect(getFeatures).toHaveBeenCalledWith(ORG)
+    expect(db.platformBackup.findUnique).not.toHaveBeenCalled()
+  })
+
+  it('refuses when platform backups are not set up here', async () => {
+    h.config = null
+    await expect(resolveExploreTarget(ORG, { kind: 'platform' })).rejects.toThrow(
+      'Backups by Torqvoice are not available here'
+    )
+  })
+
+  it('refuses when the plan does not have it and nothing is paused', async () => {
+    h.features.platformBackups = false
+    const err = await resolveExploreTarget(ORG, { kind: 'platform' }).catch((e) => e)
+    expect(err).toBeInstanceOf(FeatureGatedError)
+    expect(err.feature).toBe('platformBackups')
+    expect(db.platformBackup.findUnique).toHaveBeenCalledWith({
+      where: { organizationId: ORG },
+      select: { pausedAt: true, purgedAt: true },
+    })
+  })
+
+  it('refuses when the plan does not have it and the row was never paused', async () => {
+    h.features.platformBackups = false
+    h.platformRow = { pausedAt: null, purgedAt: null }
+    await expect(resolveExploreTarget(ORG, { kind: 'platform' })).rejects.toBeInstanceOf(
+      FeatureGatedError
+    )
+  })
+
+  it('allows looking during the grace period after a downgrade', async () => {
+    h.features.platformBackups = false
+    h.platformRow = { pausedAt: new Date('2026-10-20T00:00:00Z'), purgedAt: null }
+    await expect(resolveExploreTarget(ORG, { kind: 'platform' })).resolves.toEqual({
+      kind: 'platform',
+      organizationId: ORG,
+    })
+  })
+
+  it('refuses once the repository was purged', async () => {
+    h.features.platformBackups = false
+    h.platformRow = {
+      pausedAt: new Date('2026-09-01T00:00:00Z'),
+      purgedAt: new Date('2026-10-01T00:00:00Z'),
+    }
+    await expect(resolveExploreTarget(ORG, { kind: 'platform' })).rejects.toBeInstanceOf(
+      FeatureGatedError
+    )
+  })
+})
+
+describe('resolveExploreTarget: destination', () => {
+  function connect(connectorId: string, status: string, organizationId = ORG) {
+    h.connections.push({
+      id: `conn-${connectorId}-${organizationId}`,
+      organizationId,
+      connectorId,
+      status,
+    })
+  }
+
+  it('refuses a connector that is not a backup destination, or that does not exist', async () => {
+    connect('fiken', 'active')
+    await expect(
+      resolveExploreTarget(ORG, { kind: 'destination', connectorId: 'fiken' })
+    ).rejects.toThrow('This integration is not a backup destination')
+    await expect(
+      resolveExploreTarget(ORG, { kind: 'destination', connectorId: 'nope' })
+    ).rejects.toThrow('This integration is not a backup destination')
+  })
+
+  it('refuses a destination the plan does not allow', async () => {
+    connect('s3', 'active')
+    h.features.integrations = false
+    const err = await resolveExploreTarget(ORG, { kind: 'destination', connectorId: 's3' }).catch(
+      (e) => e
+    )
+    expect(err).toBeInstanceOf(FeatureGatedError)
+    expect(err.feature).toBe('integrations')
+
+    // A connector gated by its own plan feature names that feature.
+    connect('local-folder', 'active')
+    h.features.integrations = true
+    h.features.platformBackups = false
+    const own = await resolveExploreTarget(ORG, {
+      kind: 'destination',
+      connectorId: 'local-folder',
+    }).catch((e) => e)
+    expect(own).toBeInstanceOf(FeatureGatedError)
+    expect(own.feature).toBe('platformBackups')
+  })
+
+  it('refuses a destination that is not connected, or not active', async () => {
+    await expect(
+      resolveExploreTarget(ORG, { kind: 'destination', connectorId: 's3' })
+    ).rejects.toThrow('Connect the destination first')
+
+    // Another workshop's connection is not this one's.
+    connect('s3', 'active', 'org2')
+    await expect(
+      resolveExploreTarget(ORG, { kind: 'destination', connectorId: 's3' })
+    ).rejects.toThrow('Connect the destination first')
+
+    for (const status of ['disconnected', 'pending', 'disabled']) {
+      h.connections = [{ id: 'c', organizationId: ORG, connectorId: 's3', status }]
+      await expect(
+        resolveExploreTarget(ORG, { kind: 'destination', connectorId: 's3' })
+      ).rejects.toThrow('Connect the destination first')
+    }
+  })
+
+  it('is the workshop’s own connection when it is active or in error', async () => {
+    connect('s3', 'active')
+    await expect(
+      resolveExploreTarget(ORG, { kind: 'destination', connectorId: 's3' })
+    ).resolves.toEqual({ kind: 'destination', connectionId: 'conn-s3-org1' })
+
+    h.connections = [{ id: 'conn-err', organizationId: ORG, connectorId: 's3', status: 'error' }]
+    await expect(
+      resolveExploreTarget(ORG, { kind: 'destination', connectorId: 's3' })
+    ).resolves.toEqual({ kind: 'destination', connectionId: 'conn-err' })
+  })
+
+  it('does not need the platform repository', async () => {
+    h.config = null
+    h.features.platformBackups = false
+    connect('s3', 'active')
+    await expect(
+      resolveExploreTarget(ORG, { kind: 'destination', connectorId: 's3' })
+    ).resolves.toEqual({ kind: 'destination', connectionId: 'conn-s3-org1' })
+  })
+})

+ 100 - 0
src/__tests__/features/integrations/local-folder.test.ts

@@ -0,0 +1,100 @@
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ConnectorContext } from '@/features/integrations/Lib/types'
+import { connector, localRepositoryPath } from '@/integrations/local-folder/server'
+
+/**
+ * The local folder destination keeps one repository folder per workshop
+ * under the backup root. The Folder setting names it, so a new install can
+ * be pointed at the folder an earlier install wrote; nothing typed there can
+ * reach a folder outside the root.
+ */
+const ROOT = path.resolve('/srv/torqvoice-backups')
+const ORG = 'cm0org1abc'
+
+describe('local folder repository path', () => {
+  const previous = process.env.BACKUP_LOCAL_ROOT
+
+  beforeEach(() => {
+    process.env.BACKUP_LOCAL_ROOT = ROOT
+  })
+
+  afterEach(() => {
+    if (previous === undefined) delete process.env.BACKUP_LOCAL_ROOT
+    else process.env.BACKUP_LOCAL_ROOT = previous
+  })
+
+  it('is named after the workshop by default', () => {
+    expect(localRepositoryPath(ORG)).toBe(path.join(ROOT, ORG))
+    expect(localRepositoryPath(ORG, undefined)).toBe(path.join(ROOT, ORG))
+    expect(localRepositoryPath(ORG, 42)).toBe(path.join(ROOT, ORG))
+  })
+
+  it('uses the folder name the person typed, trimmed', () => {
+    expect(localRepositoryPath(ORG, 'cm0oldinstall9')).toBe(path.join(ROOT, 'cm0oldinstall9'))
+    expect(localRepositoryPath(ORG, '  main-shop_2024.v1  ')).toBe(
+      path.join(ROOT, 'main-shop_2024.v1')
+    )
+  })
+
+  it('falls back to the workshop when the name is empty after trimming', () => {
+    expect(localRepositoryPath(ORG, '')).toBe(path.join(ROOT, ORG))
+    expect(localRepositoryPath(ORG, '   ')).toBe(path.join(ROOT, ORG))
+  })
+
+  it('refuses a name that is a path or climbs out of the root', () => {
+    const message = 'The folder name may only hold letters, digits, dots, dashes and underscores'
+    for (const name of ['.', '..', 'a/b', '/abs', '../other', 'a\\b', '.hidden', 'x'.repeat(101)]) {
+      expect(() => localRepositoryPath(ORG, name), name).toThrow(message)
+    }
+  })
+
+  it('works under a root given as / or with .. in it', () => {
+    process.env.BACKUP_LOCAL_ROOT = '/'
+    expect(localRepositoryPath(ORG, 'shop')).toBe(path.join('/', 'shop'))
+    process.env.BACKUP_LOCAL_ROOT = `${ROOT}/../other`
+    expect(localRepositoryPath(ORG, 'shop')).toBe(path.join(ROOT, '..', 'other', 'shop'))
+  })
+
+  it('refuses a folder that would resolve outside the root, or onto the root itself', () => {
+    // No name the pattern lets through can do this, so the join is bent to
+    // prove the second guard holds on its own.
+    const join = vi.spyOn(path, 'join')
+    try {
+      join.mockReturnValueOnce('/etc')
+      expect(() => localRepositoryPath(ORG, 'shop')).toThrow(
+        'The folder must be inside the backup root'
+      )
+      join.mockReturnValueOnce(ROOT)
+      expect(() => localRepositoryPath(ORG, 'shop')).toThrow(
+        'The folder must be inside the backup root'
+      )
+      join.mockReturnValueOnce(`${ROOT}-sibling/shop`)
+      expect(() => localRepositoryPath(ORG, 'shop')).toThrow(
+        'The folder must be inside the backup root'
+      )
+    } finally {
+      join.mockRestore()
+    }
+  })
+
+  it('is what the connector backs up to and checks, from the saved setting', async () => {
+    const ctx = {
+      connection: { organizationId: ORG, settings: { folder: 'cm0oldinstall9' } },
+    } as unknown as ConnectorContext
+    await expect(connector.backupTarget?.repository(ctx)).resolves.toEqual({
+      repository: path.join(ROOT, 'cm0oldinstall9'),
+      env: {},
+    })
+    await expect(connector.identify?.(ctx)).resolves.toMatchObject({
+      id: path.join(ROOT, 'cm0oldinstall9'),
+    })
+    await expect(
+      connector.previewTest?.({ organizationId: ORG, credentials: {}, settings: { folder: '..' } })
+    ).resolves.toMatchObject({ ok: false, step: 'folder' })
+  })
+
+  it('still refuses an unexpected workshop id', () => {
+    expect(() => localRepositoryPath('../evil', 'shop')).toThrow('Unexpected organisation id')
+  })
+})

+ 74 - 0
src/__tests__/features/integrations/run-due-jobs-freeze.test.ts

@@ -0,0 +1,74 @@
+// @vitest-environment node
+/**
+ * The job runner leaves a workshop being restored out of its batch, so that
+ * workshop's queued jobs cannot fill every batch and hold up everyone else's.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    integrationJob: { findMany: vi.fn(), updateMany: vi.fn(), findUnique: vi.fn() },
+  },
+}))
+vi.mock('@/integrations/registry', () => ({ getManifest: vi.fn() }))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn() }))
+vi.mock('@/features/integrations/Lib/connections', () => ({
+  loadConnection: vi.fn(),
+  setConnectionStatus: vi.fn(),
+  writeLog: vi.fn(),
+}))
+
+import { db } from '@/lib/db'
+import { freezeOrganization, unfreezeOrganization } from '@/lib/backup/freeze'
+import { runDueJobs } from '@/features/integrations/Lib/jobs'
+
+beforeEach(() => {
+  vi.mocked(db.integrationJob.findMany).mockReset()
+  vi.mocked(db.integrationJob.updateMany).mockReset()
+  // No job can be claimed, so runJob stops right after the claim.
+  vi.mocked(db.integrationJob.updateMany).mockResolvedValue({ count: 0 } as never)
+})
+
+afterEach(() => {
+  unfreezeOrganization('org-restoring')
+})
+
+describe('runDueJobs', () => {
+  it('asks for every workshop when none is frozen', async () => {
+    vi.mocked(db.integrationJob.findMany).mockResolvedValue([] as never)
+    await runDueJobs()
+    const where = vi.mocked(db.integrationJob.findMany).mock.calls[0][0]?.where
+    expect(where).toMatchObject({ status: 'queued' })
+    expect(where).not.toHaveProperty('organizationId')
+  })
+
+  it('leaves a frozen workshop out of the query', async () => {
+    freezeOrganization('org-restoring')
+    vi.mocked(db.integrationJob.findMany).mockResolvedValue([] as never)
+    await runDueJobs(10)
+    const args = vi.mocked(db.integrationJob.findMany).mock.calls[0][0]
+    expect(args?.where).toMatchObject({
+      status: 'queued',
+      organizationId: { notIn: ['org-restoring'] },
+    })
+    expect(args?.take).toBe(10)
+  })
+
+  it('still skips a job whose workshop froze after the query', async () => {
+    vi.mocked(db.integrationJob.findMany).mockImplementation((async () => {
+      freezeOrganization('org-restoring')
+      return [
+        { id: 'job-frozen', organizationId: 'org-restoring' },
+        { id: 'job-live', organizationId: 'org-live' },
+      ]
+    }) as never)
+
+    await runDueJobs()
+
+    // Only the live workshop's job is claimed.
+    const claimed = vi
+      .mocked(db.integrationJob.updateMany)
+      .mock.calls.map((c) => (c[0] as { where: { id: string } }).where.id)
+    expect(claimed).toEqual(['job-live'])
+  })
+})

+ 167 - 0
src/__tests__/features/integrations/s3-backup.test.ts

@@ -0,0 +1,167 @@
+import { describe, expect, it } from 'vitest'
+import { endpointHost, repositoryFor, S3SettingsError } from '@/integrations/s3/server'
+import { describeRepository, sanitizeRepository } from '@/lib/backup/target'
+
+/**
+ * The address restic is pointed at is built from typed settings. These tests
+ * prove nothing typed can carry a host, a path or a scheme of its own into
+ * the endpoint, and that the one typed address only exists self-hosted.
+ */
+const creds = { accessKeyId: 'AKIA', secretAccessKey: 'shh' }
+
+describe('s3 repository address', () => {
+  it('builds the vendor endpoints from fixed patterns', () => {
+    expect(
+      repositoryFor(
+        { provider: 'aws', region: 'eu-central-1', bucket: 'shop', prefix: 'tv' },
+        creds,
+        true
+      )
+    ).toEqual({
+      repository: 's3:https://s3.eu-central-1.amazonaws.com/shop/tv',
+      env: { AWS_ACCESS_KEY_ID: 'AKIA', AWS_SECRET_ACCESS_KEY: 'shh' },
+      options: { 's3.region': 'eu-central-1' },
+    })
+    expect(
+      repositoryFor({ provider: 'r2', accountId: 'a'.repeat(32), bucket: 'shop' }, creds, true)
+        .repository
+    ).toBe(`s3:https://${'a'.repeat(32)}.r2.cloudflarestorage.com/shop/torqvoice`)
+    expect(endpointHost('b2', { region: 'us-west-004' }, undefined, true).host).toBe(
+      's3.us-west-004.backblazeb2.com'
+    )
+    expect(endpointHost('wasabi', { region: 'eu-central-1' }, undefined, true).host).toBe(
+      's3.eu-central-1.wasabisys.com'
+    )
+    expect(endpointHost('hetzner', { region: 'fsn1' }, undefined, true).host).toBe(
+      'fsn1.your-objectstorage.com'
+    )
+  })
+
+  it('takes the bucket names AWS allows that are safe in a path: a dot, two dashes in a row', () => {
+    for (const bucket of ['shop.other', 'shop--x']) {
+      const repo = repositoryFor(
+        { provider: 'aws', region: 'eu-west-1', bucket, prefix: 'tv' },
+        creds,
+        false
+      )
+      expect(repo.repository).toBe(`s3:https://s3.eu-west-1.amazonaws.com/${bucket}/tv`)
+    }
+  })
+
+  it('refuses a region, account id, bucket or prefix that could change the host or path', () => {
+    const bad = (settings: Record<string, unknown>, field: string) => {
+      try {
+        repositoryFor(
+          { provider: 'aws', region: 'eu-west-1', bucket: 'shop', ...settings },
+          creds,
+          true
+        )
+      } catch (err) {
+        expect(err).toBeInstanceOf(S3SettingsError)
+        expect((err as S3SettingsError).field).toBe(field)
+        return
+      }
+      throw new Error('expected a refusal')
+    }
+    bad({ region: 'eu-west-1.evil.com' }, 'region')
+    bad({ region: 'eu-west-1/..' }, 'region')
+    bad({ region: 'EU west' }, 'region')
+    bad({ region: '' }, 'region')
+    bad({ bucket: 'a' }, 'bucket')
+    bad({ bucket: 'shop/other' }, 'bucket')
+    bad({ bucket: 'shop..other' }, 'bucket')
+    bad({ bucket: 'shop_x' }, 'bucket')
+    bad({ prefix: '../other' }, 'prefix')
+    bad({ prefix: 'a//b' }, 'prefix')
+    bad({ prefix: '/abs' }, 'prefix')
+    bad({ prefix: 'x?y' }, 'prefix')
+    bad({ provider: 'r2', accountId: 'not-hex' }, 'accountId')
+    bad({ provider: 'nope' }, 'provider')
+  })
+
+  it('never takes a custom endpoint on the cloud instance', () => {
+    expect(() =>
+      repositoryFor(
+        { provider: 'other', bucket: 'shop' },
+        { ...creds, endpoint: 'https://minio.internal:9000' },
+        true
+      )
+    ).toThrow(S3SettingsError)
+  })
+
+  it('takes a plain host, with a port, as the self-hosted custom endpoint', () => {
+    const repo = repositoryFor(
+      { provider: 'other', bucket: 'shop', region: 'us-east-1' },
+      { ...creds, endpoint: 'https://minio.internal:9000' },
+      false
+    )
+    expect(repo.repository).toBe('s3:https://minio.internal:9000/shop/torqvoice')
+    expect(
+      repositoryFor(
+        { provider: 'other', bucket: 'shop' },
+        { ...creds, endpoint: 'http://192.168.1.5:9000' },
+        false
+      ).repository
+    ).toBe('s3:http://192.168.1.5:9000/shop/torqvoice')
+    for (const endpoint of [
+      'https://user:pw@minio.internal',
+      'https://minio.internal/path',
+      'https://minio.internal/?x=1',
+      'ftp://minio.internal',
+      'not a url at all',
+      '',
+    ]) {
+      expect(() =>
+        repositoryFor({ provider: 'other', bucket: 'shop' }, { ...creds, endpoint }, false)
+      ).toThrow(S3SettingsError)
+    }
+  })
+
+  it('requires both keys', () => {
+    expect(() =>
+      repositoryFor(
+        { provider: 'aws', region: 'eu-west-1', bucket: 'shop' },
+        { accessKeyId: 'x' },
+        true
+      )
+    ).toThrow(S3SettingsError)
+  })
+})
+
+describe('sanitizeRepository', () => {
+  it('drops the env the runner owns and malformed names', () => {
+    const out = sanitizeRepository({
+      repository: ' s3:https://h/b/p ',
+      env: {
+        AWS_ACCESS_KEY_ID: 'a',
+        RESTIC_PASSWORD: 'theirs',
+        RESTIC_REPOSITORY: 'elsewhere',
+        PATH: '/evil',
+        HOME: '/evil',
+        'bad name': 'x',
+        lower: 'x',
+      },
+      options: { 's3.region': 'eu', 'bad option!': 'x' },
+    })
+    expect(out).toEqual({
+      repository: 's3:https://h/b/p',
+      env: { AWS_ACCESS_KEY_ID: 'a' },
+      options: { 's3.region': 'eu' },
+    })
+  })
+
+  it('refuses an empty repository', () => {
+    expect(() => sanitizeRepository({ repository: ' ', env: {} })).toThrow()
+  })
+})
+
+describe('describeRepository', () => {
+  it('strips anything secret from an s3 address and leaves a path alone', () => {
+    expect(
+      describeRepository({ repository: 's3:https://u:p@host/bucket/prefix?x=1', env: {} })
+    ).toBe('s3:https://host/bucket/prefix')
+    expect(describeRepository({ repository: '/app/data/backups/org1', env: {} })).toBe(
+      '/app/data/backups/org1'
+    )
+  })
+})

+ 15 - 0
src/__tests__/features/messaging/receive-ses-mail.test.ts

@@ -32,7 +32,9 @@ const { db, notify, store } = vi.hoisted(() => ({
 }))
 }))
 vi.mock('@/lib/db', () => ({ db }))
 vi.mock('@/lib/db', () => ({ db }))
 vi.mock('@/lib/notify', () => ({ notify }))
 vi.mock('@/lib/notify', () => ({ notify }))
+vi.mock('@/lib/backup/freeze', () => ({ isRestoring: vi.fn(() => false) }))
 
 
+import { isRestoring } from '@/lib/backup/freeze'
 import { type ReceiveOutcome, receiveSesMail } from '@/features/messaging/Lib/inboundMail.server'
 import { type ReceiveOutcome, receiveSesMail } from '@/features/messaging/Lib/inboundMail.server'
 import type { SesReceivedMail } from '@/features/messaging/Lib/inboundMail'
 import type { SesReceivedMail } from '@/features/messaging/Lib/inboundMail'
 
 
@@ -111,6 +113,7 @@ function deps(buffer: Buffer, size?: number) {
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.clearAllMocks()
   vi.clearAllMocks()
+  vi.mocked(isRestoring).mockReturnValue(false)
   db.organization.findUnique.mockResolvedValue({ id: 'org_1', name: 'EF Moto' })
   db.organization.findUnique.mockResolvedValue({ id: 'org_1', name: 'EF Moto' })
   db.emailMessage.findUnique.mockResolvedValue(null)
   db.emailMessage.findUnique.mockResolvedValue(null)
   db.emailMessage.findMany.mockResolvedValue([
   db.emailMessage.findMany.mockResolvedValue([
@@ -278,4 +281,16 @@ describe('receiveSesMail', () => {
     expect(data.body).toBe('Thanks!')
     expect(data.body).toBe('Thanks!')
     expect(data.attachments).toBeUndefined()
     expect(data.attachments).toBeUndefined()
   })
   })
+
+  it('asks for a retry, reading and filing nothing, while the workshop is being restored', async () => {
+    vi.mocked(isRestoring).mockReturnValue(true)
+    const d = deps(raw({}))
+    const result = await receiveSesMail(mail, d)
+    expect(result).toEqual({ outcome: 'retry', reason: 'workshop is being restored' })
+    expect(isRestoring).toHaveBeenCalledWith('org_1')
+    expect(d.fetchObject).not.toHaveBeenCalled()
+    expect(store).not.toHaveBeenCalled()
+    expect(db.emailMessage.create).not.toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+  })
 })
 })

+ 63 - 0
src/__tests__/features/messaging/scheduled-message-timezone.test.ts

@@ -32,6 +32,14 @@ vi.mock('@/lib/workshop-timezone', () => ({
   },
   },
 }))
 }))
 
 
+// Workshops being restored, read at call time like the zone lookups above.
+const frozen = new Set<string>()
+vi.mock('@/lib/backup/freeze', () => ({
+  isRestoring: (organizationId: string) => frozen.has(organizationId),
+  frozenOrganizationIds: () => [...frozen],
+  RESTORE_IN_PROGRESS_MESSAGE: 'A restore of this workshop is in progress.',
+}))
+
 vi.mock('@/lib/db', () => ({
 vi.mock('@/lib/db', () => ({
   db: {
   db: {
     user: { findUnique: vi.fn() },
     user: { findUnique: vi.fn() },
@@ -41,6 +49,7 @@ vi.mock('@/lib/db', () => ({
 }))
 }))
 
 
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { notify } from '@/lib/notify'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { nextSendAt } from '@/features/scheduled-messages/Lib/dispatchScheduledMessage'
 import { nextSendAt } from '@/features/scheduled-messages/Lib/dispatchScheduledMessage'
 import { processDueMessages } from '@/lib/cron/scheduled-messages'
 import { processDueMessages } from '@/lib/cron/scheduled-messages'
@@ -65,6 +74,7 @@ function setupAuth(organizationId: string) {
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
   zoneLookups.length = 0
   zoneLookups.length = 0
+  frozen.clear()
 })
 })
 
 
 describe('nextSendAt', () => {
 describe('nextSendAt', () => {
@@ -112,6 +122,59 @@ describe('processDueMessages', () => {
     expect(updates[1].sendAt).toEqual(new Date('2026-10-25T17:00:00.000Z'))
     expect(updates[1].sendAt).toEqual(new Date('2026-10-25T17:00:00.000Z'))
     expect(zoneLookups).toEqual(['org-oslo'])
     expect(zoneLookups).toEqual(['org-oslo'])
   })
   })
+
+  it('leaves the messages of a workshop being restored untouched and sends the rest', async () => {
+    const base = {
+      channel: 'in_app',
+      subject: 'Reminder',
+      body: 'Time for a service',
+      recipient: null,
+      customerId: null,
+      vehicleId: null,
+      frequency: 'once',
+      endDate: null,
+      runCount: 0,
+      sendAt: new Date('2026-10-24T16:00:00Z'),
+    }
+    frozen.add('org-restoring')
+    vi.mocked(db.scheduledMessage.findMany).mockResolvedValue([
+      { ...base, id: 'msg-frozen', organizationId: 'org-restoring' },
+      // A day late as well: a frozen workshop's message is not marked missed either
+      {
+        ...base,
+        id: 'msg-frozen-stale',
+        organizationId: 'org-restoring',
+        sendAt: new Date('2026-10-22T16:00:00Z'),
+      },
+      { ...base, id: 'msg-live', organizationId: 'org-oslo' },
+    ] as any)
+    vi.mocked(db.scheduledMessage.update).mockResolvedValue({} as any)
+
+    const sent = await processDueMessages(new Date('2026-10-24T16:00:30Z'))
+
+    expect(sent).toBe(1)
+    const updated = vi.mocked(db.scheduledMessage.update).mock.calls.map((c) => c[0].where.id)
+    expect(updated).toEqual(['msg-live'])
+    expect(vi.mocked(notify)).toHaveBeenCalledTimes(1)
+    expect(vi.mocked(notify).mock.calls[0][0]).toMatchObject({ organizationId: 'org-oslo' })
+    expect(zoneLookups).toEqual(['org-oslo'])
+  })
+
+  it("keeps a frozen workshop's messages out of the batch, so they cannot fill it", async () => {
+    vi.mocked(db.scheduledMessage.findMany).mockResolvedValue([] as any)
+
+    await processDueMessages(new Date('2026-10-24T16:00:30Z'))
+    expect(vi.mocked(db.scheduledMessage.findMany).mock.calls[0][0]?.where).not.toHaveProperty(
+      'organizationId'
+    )
+
+    frozen.add('org-restoring')
+    await processDueMessages(new Date('2026-10-24T16:00:30Z'))
+    expect(vi.mocked(db.scheduledMessage.findMany).mock.calls[1][0]?.where).toMatchObject({
+      status: 'scheduled',
+      organizationId: { notIn: ['org-restoring'] },
+    })
+  })
 })
 })
 
 
 describe('getScheduledMessagesInRange', () => {
 describe('getScheduledMessagesInRange', () => {

+ 37 - 1
src/__tests__/features/public/quoteRequestActions.test.ts

@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 
 
 vi.mock('@/lib/db', () => ({
 vi.mock('@/lib/db', () => ({
   db: {
   db: {
@@ -23,6 +23,11 @@ import {
 } from '@/features/inspections/Actions/quoteRequestActions'
 } from '@/features/inspections/Actions/quoteRequestActions'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { notify } from '@/lib/notify'
 import { notify } from '@/lib/notify'
+import {
+  RestoreInProgressError,
+  freezeOrganization,
+  unfreezeOrganization,
+} from '@/lib/backup/freeze'
 
 
 const mockFindInspection = vi.mocked(db.inspection.findFirst)
 const mockFindInspection = vi.mocked(db.inspection.findFirst)
 const mockFindRequest = vi.mocked(db.inspectionQuoteRequest.findFirst)
 const mockFindRequest = vi.mocked(db.inspectionQuoteRequest.findFirst)
@@ -49,6 +54,37 @@ beforeEach(() => {
   mockDeleteRequest.mockResolvedValue({} as any)
   mockDeleteRequest.mockResolvedValue({} as any)
 })
 })
 
 
+afterEach(() => {
+  unfreezeOrganization('org-1')
+})
+
+describe('while the workshop is being restored', () => {
+  it('createQuoteRequest refuses and writes nothing', async () => {
+    freezeOrganization('org-1')
+    mockFindInspection.mockResolvedValue(INSPECTION as any)
+    mockFindRequest.mockResolvedValue(null)
+    await expect(
+      createQuoteRequest({
+        inspectionId: 'insp-1',
+        publicToken: 'pub-tok-1',
+        selectedItemIds: ['item-1'],
+      })
+    ).rejects.toBeInstanceOf(RestoreInProgressError)
+    expect(mockCreateRequest).not.toHaveBeenCalled()
+    expect(mockNotify).not.toHaveBeenCalled()
+  })
+
+  it('cancelQuoteRequest refuses and deletes nothing', async () => {
+    freezeOrganization('org-1')
+    mockFindInspection.mockResolvedValue(INSPECTION as any)
+    mockFindRequest.mockResolvedValue(PENDING_REQUEST as any)
+    await expect(
+      cancelQuoteRequest({ inspectionId: 'insp-1', publicToken: 'pub-tok-1' })
+    ).rejects.toBeInstanceOf(RestoreInProgressError)
+    expect(mockDeleteRequest).not.toHaveBeenCalled()
+  })
+})
+
 describe('createQuoteRequest', () => {
 describe('createQuoteRequest', () => {
   it('throws ZodError when required fields are missing', async () => {
   it('throws ZodError when required fields are missing', async () => {
     await expect(createQuoteRequest({})).rejects.toThrow()
     await expect(createQuoteRequest({})).rejects.toThrow()

+ 32 - 1
src/__tests__/features/reminder-alerts.test.ts

@@ -2,7 +2,7 @@
  * Due-reminder notifications: per-reminder channel choice (bell / email),
  * Due-reminder notifications: per-reminder channel choice (bell / email),
  * notify-once semantics, and re-arming when a reminder is rescheduled.
  * notify-once semantics, and re-arming when a reminder is rescheduled.
  */
  */
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 
 
 vi.mock('@/lib/cached-session', () => ({
 vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedSession: vi.fn(),
@@ -39,6 +39,7 @@ import { notify } from '@/lib/notify'
 import { sendOrgMail } from '@/lib/email'
 import { sendOrgMail } from '@/lib/email'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { processDueReminders } from '@/lib/cron/reminder-alerts'
 import { processDueReminders } from '@/lib/cron/reminder-alerts'
+import { freezeOrganization, unfreezeOrganization } from '@/lib/backup/freeze'
 import { createReminder, updateReminder } from '@/features/vehicles/Actions/reminderActions'
 import { createReminder, updateReminder } from '@/features/vehicles/Actions/reminderActions'
 
 
 const ORG_A = 'org-a'
 const ORG_A = 'org-a'
@@ -68,7 +69,37 @@ beforeEach(() => {
   vi.mocked(db.reminder.updateMany).mockResolvedValue({ count: 1 } as never)
   vi.mocked(db.reminder.updateMany).mockResolvedValue({ count: 1 } as never)
 })
 })
 
 
+afterEach(() => {
+  unfreezeOrganization(ORG_A)
+})
+
 describe('processDueReminders', () => {
 describe('processDueReminders', () => {
+  it("keeps a frozen workshop's reminders out of the batch, but not reminders without a workshop", async () => {
+    vi.mocked(db.reminder.findMany).mockResolvedValue([])
+    await processDueReminders(new Date('2026-08-13T12:00:00Z'))
+    expect(vi.mocked(db.reminder.findMany).mock.calls[0][0]?.where).not.toHaveProperty('AND')
+
+    freezeOrganization(ORG_A)
+    await processDueReminders(new Date('2026-08-13T12:00:00Z'))
+    expect(vi.mocked(db.reminder.findMany).mock.calls[1][0]?.where).toMatchObject({
+      isCompleted: false,
+      AND: [{ OR: [{ organizationId: null }, { organizationId: { notIn: [ORG_A] } }] }],
+    })
+  })
+
+  it('skips a reminder whose workshop froze after the query', async () => {
+    vi.mocked(db.reminder.findMany).mockImplementation((async () => {
+      freezeOrganization(ORG_A)
+      return [dueReminder()]
+    }) as never)
+
+    const count = await processDueReminders(new Date('2026-08-13T12:00:00Z'))
+
+    expect(count).toBe(0)
+    expect(vi.mocked(notify)).not.toHaveBeenCalled()
+    expect(vi.mocked(db.reminder.updateMany)).not.toHaveBeenCalled()
+  })
+
   it('only picks up uncompleted, unnotified reminders with a channel enabled', async () => {
   it('only picks up uncompleted, unnotified reminders with a channel enabled', async () => {
     vi.mocked(db.reminder.findMany).mockResolvedValue([])
     vi.mocked(db.reminder.findMany).mockResolvedValue([])
     await processDueReminders(new Date('2026-08-13T12:00:00Z'))
     await processDueReminders(new Date('2026-08-13T12:00:00Z'))

+ 3 - 3
src/__tests__/features/service-concerns.test.ts

@@ -60,16 +60,16 @@ describe('service concerns', () => {
     expect(entity, 'ServiceConcern must be in the backup manifest').toBeDefined()
     expect(entity, 'ServiceConcern must be in the backup manifest').toBeDefined()
     expect(entity?.nestedUnder).toBe('ServiceRecord')
     expect(entity?.nestedUnder).toBe('ServiceRecord')
 
 
-    const exportRoute = read('src/app/api/protected/backup/export/route.ts')
+    const exportRoute = read('src/lib/backup/build-data.ts')
     // Both paths: vehicle-linked jobs and counter sales.
     // Both paths: vehicle-linked jobs and counter sales.
     expect(exportRoute.match(/concerns: true/g)?.length).toBe(2)
     expect(exportRoute.match(/concerns: true/g)?.length).toBe(2)
 
 
-    const importRoute = read('src/app/api/protected/backup/import/route.ts')
+    const importRoute = read('src/lib/backup/import.ts')
     expect(importRoute).toContain('serviceConcern.createMany')
     expect(importRoute).toContain('serviceConcern.createMany')
   })
   })
 
 
   it('restores concerns before the findings that point at them', () => {
   it('restores concerns before the findings that point at them', () => {
-    const importRoute = read('src/app/api/protected/backup/import/route.ts')
+    const importRoute = read('src/lib/backup/import.ts')
     const concerns = importRoute.indexOf('serviceConcern.createMany')
     const concerns = importRoute.indexOf('serviceConcern.createMany')
     const findings = importRoute.indexOf('vehicleFinding.createMany')
     const findings = importRoute.indexOf('vehicleFinding.createMany')
     expect(concerns).toBeGreaterThan(-1)
     expect(concerns).toBeGreaterThan(-1)

+ 60 - 0
src/__tests__/features/status-reports/submit-feedback-freeze.test.ts

@@ -0,0 +1,60 @@
+// @vitest-environment node
+/**
+ * A customer's reply on a shared status report waits for a restore of the
+ * workshop, like every other write: it would be lost under the restore.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: { statusReport: { findUnique: vi.fn(), update: vi.fn() } },
+}))
+vi.mock('@/lib/notify', () => ({ notify: vi.fn() }))
+
+import { db } from '@/lib/db'
+import { notify } from '@/lib/notify'
+import {
+  RestoreInProgressError,
+  freezeOrganization,
+  unfreezeOrganization,
+} from '@/lib/backup/freeze'
+import { submitStatusReportFeedback } from '@/features/status-reports/Actions/submitFeedback'
+
+const REPORT = {
+  id: 'rep-1',
+  organizationId: 'org-1',
+  expiresAt: null,
+  customerFeedback: null,
+  serviceRecord: null,
+  inspection: null,
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(db.statusReport.findUnique).mockResolvedValue(REPORT as never)
+  vi.mocked(db.statusReport.update).mockResolvedValue({} as never)
+})
+
+afterEach(() => {
+  unfreezeOrganization('org-1')
+})
+
+describe('submitStatusReportFeedback', () => {
+  it('refuses while the workshop is being restored and writes nothing', async () => {
+    freezeOrganization('org-1')
+    await expect(
+      submitStatusReportFeedback({ token: 'tok-1', feedback: 'Looks good' })
+    ).rejects.toBeInstanceOf(RestoreInProgressError)
+    expect(db.statusReport.update).not.toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+  })
+
+  it('saves the feedback when the workshop is not frozen', async () => {
+    freezeOrganization('org-other')
+    try {
+      await submitStatusReportFeedback({ token: 'tok-1', feedback: 'Looks good' })
+      expect(db.statusReport.update).toHaveBeenCalledTimes(1)
+    } finally {
+      unfreezeOrganization('org-other')
+    }
+  })
+})

+ 23 - 0
src/__tests__/features/telegram/webhook-start.test.ts

@@ -27,8 +27,13 @@ vi.mock('@/lib/telegram', async (importOriginal) => ({
   sendTelegramMessage,
   sendTelegramMessage,
 }))
 }))
 vi.mock('@/lib/notify', () => ({ notify }))
 vi.mock('@/lib/notify', () => ({ notify }))
+vi.mock('@/lib/backup/freeze', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/backup/freeze')>()),
+  isRestoring: vi.fn(() => false),
+}))
 
 
 import { POST } from '@/app/api/webhooks/telegram/[organizationId]/route'
 import { POST } from '@/app/api/webhooks/telegram/[organizationId]/route'
+import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
 import { BARE_START_REPLY } from '@/lib/telegram'
 import { BARE_START_REPLY } from '@/lib/telegram'
 
 
 const ORG = 'org_1'
 const ORG = 'org_1'
@@ -51,6 +56,7 @@ const params = Promise.resolve({ organizationId: ORG })
 describe('the Telegram webhook', () => {
 describe('the Telegram webhook', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks()
     vi.clearAllMocks()
+    vi.mocked(isRestoring).mockReturnValue(false)
     db.telegramMessage.create.mockResolvedValue({ id: 'msg_1' })
     db.telegramMessage.create.mockResolvedValue({ id: 'msg_1' })
     db.telegramMessage.updateMany.mockResolvedValue({ count: 0 })
     db.telegramMessage.updateMany.mockResolvedValue({ count: 0 })
   })
   })
@@ -143,4 +149,21 @@ describe('the Telegram webhook', () => {
     expect(response.status).toBe(200)
     expect(response.status).toBe(200)
     expect(db.telegramMessage.create).not.toHaveBeenCalled()
     expect(db.telegramMessage.create).not.toHaveBeenCalled()
   })
   })
+
+  it('asks Telegram to come back later while the workshop is being restored', async () => {
+    vi.mocked(isRestoring).mockReturnValue(true)
+    db.customer.findFirst.mockResolvedValue({ id: 'cust_42', name: 'Jane Cooper' })
+
+    for (const text of ['/start cust_42', 'Is the car ready?']) {
+      const response = await POST(update(text), { params })
+      expect(response.status).toBe(503)
+      expect(response.headers.get('Retry-After')).toBe('300')
+      expect(await response.json()).toEqual({ error: RESTORE_IN_PROGRESS_MESSAGE })
+    }
+    expect(isRestoring).toHaveBeenCalledWith(ORG)
+    expect(db.customer.update).not.toHaveBeenCalled()
+    expect(db.telegramMessage.updateMany).not.toHaveBeenCalled()
+    expect(db.telegramMessage.create).not.toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+  })
 })
 })

+ 3 - 9
src/__tests__/features/tire-hotel/backup-coverage.test.ts

@@ -14,14 +14,8 @@ import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
  */
  */
 
 
 const ROOT = process.cwd()
 const ROOT = process.cwd()
-const exportSource = fs.readFileSync(
-  path.join(ROOT, 'src/app/api/protected/backup/export/route.ts'),
-  'utf-8'
-)
-const importSource = fs.readFileSync(
-  path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
-  'utf-8'
-)
+const exportSource = fs.readFileSync(path.join(ROOT, 'src/lib/backup/build-data.ts'), 'utf-8')
+const importSource = fs.readFileSync(path.join(ROOT, 'src/lib/backup/import.ts'), 'utf-8')
 const schema = readPrismaSchema(ROOT)
 const schema = readPrismaSchema(ROOT)
 
 
 /** Every tire hotel model the schema declares. */
 /** Every tire hotel model the schema declares. */
@@ -94,7 +88,7 @@ describe('import', () => {
   it('reads back exactly what the export writes', () => {
   it('reads back exactly what the export writes', () => {
     // The two sides agree on the key names or the data lands nowhere.
     // The two sides agree on the key names or the data lands nowhere.
     for (const key of ['tireWarehouses', 'tireSets']) {
     for (const key of ['tireWarehouses', 'tireSets']) {
-      expect(exportSource, `export never sets data.${key}`).toContain(`data.${key} =`)
+      expect(exportSource, `export never writes ${key}`).toContain(`key: '${key}'`)
       expect(importSource, `import never reads data.${key}`).toContain(`data.${key}`)
       expect(importSource, `import never reads data.${key}`).toContain(`data.${key}`)
     }
     }
   })
   })

+ 1 - 0
src/__tests__/i18n/audit-summaries.test.ts

@@ -128,6 +128,7 @@ describe('audit summaries', () => {
       technician: 'Kari',
       technician: 'Kari',
       job: 'Brakes',
       job: 'Brakes',
       minutes: 45,
       minutes: 45,
+      snapshot: '28891c8f',
     }
     }
     for (const locale of LOCALES) {
     for (const locale of LOCALES) {
       const messages = summary(locale)
       const messages = summary(locale)

+ 2 - 8
src/__tests__/lib/backup-file-urls.test.ts

@@ -187,19 +187,13 @@ describe('withIssuedDataFileUrls', () => {
   })
   })
 
 
   it('is what the import route restores issued data through', () => {
   it('is what the import route restores issued data through', () => {
-    const route = fs.readFileSync(
-      path.join(process.cwd(), 'src/app/api/protected/backup/import/route.ts'),
-      'utf8'
-    )
+    const route = fs.readFileSync(path.join(process.cwd(), 'src/lib/backup/import.ts'), 'utf8')
     expect(route).toMatch(/issuedData:[\s\S]{0,200}?withIssuedDataFileUrls\(sr\.issuedData/)
     expect(route).toMatch(/issuedData:[\s\S]{0,200}?withIssuedDataFileUrls\(sr\.issuedData/)
   })
   })
 })
 })
 
 
 describe('the import route rewrites every restored file column', () => {
 describe('the import route rewrites every restored file column', () => {
-  const source = fs.readFileSync(
-    path.join(process.cwd(), 'src/app/api/protected/backup/import/route.ts'),
-    'utf-8'
-  )
+  const source = fs.readFileSync(path.join(process.cwd(), 'src/lib/backup/import.ts'), 'utf-8')
 
 
   /** Where each column is restored, and what proves it is rewritten there. */
   /** Where each column is restored, and what proves it is rewritten there. */
   const RESTORED: Record<string, RegExp> = {
   const RESTORED: Record<string, RegExp> = {

+ 3 - 3
src/__tests__/lib/backup-manifest.test.ts

@@ -178,8 +178,8 @@ describe('backup manifest', () => {
 })
 })
 
 
 describe('the routes implement the manifest', () => {
 describe('the routes implement the manifest', () => {
-  const exportRoute = fs.readFileSync('src/app/api/protected/backup/export/route.ts', 'utf-8')
-  const importRoute = fs.readFileSync('src/app/api/protected/backup/import/route.ts', 'utf-8')
+  const exportRoute = fs.readFileSync('src/lib/backup/build-data.ts', 'utf-8')
+  const importRoute = fs.readFileSync('src/lib/backup/import.ts', 'utf-8')
 
 
   it('restores every nested entity on the way back in', () => {
   it('restores every nested entity on the way back in', () => {
     // The two tests below only cover top-level keys, because a nested entity
     // The two tests below only cover top-level keys, because a nested entity
@@ -227,7 +227,7 @@ describe('the routes implement the manifest', () => {
 
 
   it('writes every top-level key on the way out', () => {
   it('writes every top-level key on the way out', () => {
     const missing = topLevelEntities()
     const missing = topLevelEntities()
-      .filter((entity) => !exportRoute.includes(`data.${entity.key} =`))
+      .filter((entity) => !exportRoute.includes(`key: '${entity.key}'`))
       .map((entity) => entity.key)
       .map((entity) => entity.key)
 
 
     expect(
     expect(

+ 248 - 0
src/__tests__/lib/backup/build-data.test.ts

@@ -0,0 +1,248 @@
+/**
+ * @vitest-environment node
+ */
+import { Writable } from 'node:stream'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+  calls: [] as { model: string; args: Record<string, any> }[],
+  /** Pages each model returns in turn; empty once used up. */
+  pages: {} as Record<string, unknown[][]>,
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: new Proxy(
+    {},
+    {
+      get: (_target, model: string) => ({
+        findMany: async (args: Record<string, any>) => {
+          state.calls.push({ model, args })
+          return state.pages[model]?.shift() ?? []
+        },
+      }),
+    }
+  ),
+}))
+
+import {
+  BACKUP_DATASET_KEYS,
+  type BuildDataOptions,
+  DEFAULT_OPTIONS,
+  writeBackupData,
+} from '@/lib/backup/build-data'
+import { REDACTED } from '@/lib/backup/secrets'
+
+const ALL: BuildDataOptions = { ...DEFAULT_OPTIONS, includeSecrets: false }
+const NONE = Object.fromEntries(
+  Object.keys(ALL).map((k) => [k, false])
+) as unknown as BuildDataOptions
+
+/** Collects the output, with a tiny buffer so the writer has to wait for drain. */
+function sink() {
+  let text = ''
+  const out = new Writable({
+    highWaterMark: 8,
+    write(chunk, _enc, cb) {
+      text += chunk.toString()
+      setImmediate(cb)
+    },
+  })
+  return { out, text: () => text }
+}
+
+async function run(options: BuildDataOptions) {
+  const { out, text } = sink()
+  const result = await writeBackupData('org-1', options, out)
+  await new Promise<void>((resolve) => out.end(resolve))
+  return { result, json: JSON.parse(text()), raw: text() }
+}
+
+beforeEach(() => {
+  state.calls = []
+  state.pages = {}
+})
+
+describe('organisation scope', () => {
+  it('filters every dataset query by the organisation', async () => {
+    const { result } = await run(ALL)
+
+    expect(result.datasets).toEqual([...BACKUP_DATASET_KEYS])
+    expect(state.calls.length).toBe(BACKUP_DATASET_KEYS.length)
+    for (const call of state.calls) {
+      expect(call.args.where?.organizationId, call.model).toBe('org-1')
+      expect(call.args.orderBy, call.model).toEqual({ id: 'asc' })
+      expect(call.args.take, call.model).toBeGreaterThan(0)
+    }
+  })
+
+  it('refuses to run without an organisation', async () => {
+    const { out } = sink()
+    await expect(writeBackupData('', ALL, out)).rejects.toThrow()
+    expect(state.calls).toEqual([])
+  })
+
+  it('emits the dataset names the import reads, in order', async () => {
+    const { json } = await run(ALL)
+    expect(Object.keys(json.data)).toEqual([
+      'settings',
+      'documentDesigns',
+      'emailTemplates',
+      'customers',
+      'documentDesignSnapshots',
+      'documentAssetSnapshots',
+      'customFieldDefinitions',
+      'inventoryParts',
+      'vehicles',
+      'orgReminders',
+      'counterSales',
+      'quotes',
+      'technicians',
+      'workBays',
+      'inspectionTemplates',
+      'inspections',
+      'tireWarehouses',
+      'tireSets',
+      'workOrderStatuses',
+      'conditionMarkTypes',
+      'laborPresets',
+      'webhooks',
+      'reportSchedules',
+      'roles',
+      'auditLogs',
+      'smsMessages',
+      'whatsappMessages',
+      'telegramMessages',
+      'emailMessages',
+      'scheduledMessages',
+      'inspectionReminderCampaigns',
+      'inspectionReminderSends',
+      'notifications',
+    ])
+  })
+})
+
+describe('JSON shape', () => {
+  it('writes the file the import reads', async () => {
+    const createdAt = new Date('2026-10-01T08:00:00.000Z')
+    state.pages.customer = [[{ id: 'c1', name: 'Ada', createdAt, organizationId: 'org-1' }]]
+    state.pages.documentAssetSnapshot = [
+      [{ id: 'a1', organizationId: 'org-1', data: new Uint8Array([1, 2, 3]) }],
+    ]
+
+    const { json, raw, result } = await run({
+      ...NONE,
+      customers: true,
+      vehicles: true,
+    })
+
+    expect(raw.startsWith('{"version":2,"exportedAt":"')).toBe(true)
+    expect(raw).not.toContain('\n')
+    expect(json).toEqual({
+      version: 2,
+      exportedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/),
+      contents: 'data',
+      data: {
+        customers: [
+          { id: 'c1', name: 'Ada', createdAt: '2026-10-01T08:00:00.000Z', organizationId: 'org-1' },
+        ],
+        documentDesignSnapshots: [],
+        documentAssetSnapshots: [{ id: 'a1', organizationId: 'org-1', data: 'AQID' }],
+        vehicles: [],
+        orgReminders: [],
+        counterSales: [],
+      },
+    })
+    expect(result.rows).toBe(2)
+  })
+
+  it('says in the header whether the secrets are in it, before the data', async () => {
+    const dataOnly = await run(NONE)
+    expect(dataOnly.json.contents).toBe('data')
+    expect(dataOnly.raw).toMatch(/^\{"version":2,"exportedAt":"[^"]+","contents":"data","data":\{/)
+
+    const full = await run({ ...NONE, includeSecrets: true })
+    expect(full.json.contents).toBe('dataAndSecrets')
+    expect(full.raw).toMatch(
+      /^\{"version":2,"exportedAt":"[^"]+","contents":"dataAndSecrets","data":\{/
+    )
+  })
+
+  it('marks a "Data only" file even when there was no secret to leave out', async () => {
+    state.pages.appSetting = [[{ id: 's1', key: 'workshop.name', value: 'Shop' }]]
+    const { json } = await run({ ...NONE, settings: true })
+    expect(json.data.settings).toEqual([{ id: 's1', key: 'workshop.name', value: 'Shop' }])
+    expect(json.contents).toBe('data')
+  })
+
+  it('writes an empty data object when nothing is selected', async () => {
+    const { json } = await run(NONE)
+    expect(json.data).toEqual({})
+    expect(state.calls).toEqual([])
+  })
+
+  it('keeps the nested trees the export always carried', async () => {
+    await run({ ...NONE, vehicles: true, tireHotel: true })
+    const vehicle = state.calls.find((c) => c.model === 'vehicle')?.args
+    expect(vehicle?.include.serviceRecords.include).toMatchObject({
+      concerns: true,
+      timeEntries: true,
+      statusReports: true,
+    })
+    const reminders = state.calls.find((c) => c.model === 'reminder')?.args
+    expect(reminders?.where).toEqual({ organizationId: 'org-1', vehicleId: null })
+    const tireSet = state.calls.find((c) => c.model === 'tireSet')?.args
+    expect(tireSet?.include.measurements).toEqual({ include: { images: true } })
+  })
+})
+
+describe('paging', () => {
+  it('reads by id cursor until a short page', async () => {
+    const full = Array.from({ length: 500 }, (_, i) => ({
+      id: `n${String(i).padStart(4, '0')}`,
+      organizationId: 'org-1',
+    }))
+    state.pages.notification = [full, [{ id: 'n9999', organizationId: 'org-1' }]]
+
+    const { json, result } = await run({ ...NONE, notifications: true })
+
+    const calls = state.calls.filter((c) => c.model === 'notification')
+    expect(calls).toHaveLength(2)
+    expect(calls[0].args.cursor).toBeUndefined()
+    expect(calls[1].args).toMatchObject({ cursor: { id: 'n0499' }, skip: 1, take: 500 })
+    expect(json.data.notifications).toHaveLength(501)
+    expect(result.rows).toBe(501)
+  })
+})
+
+describe('secrets', () => {
+  const settings = [
+    { id: 's1', key: 'payment.stripe.secretKey', value: 'sk_live_x', organizationId: 'org-1' },
+    { id: 's2', key: 'workshop.phone', value: '+47 123', organizationId: 'org-1' },
+  ]
+  const webhooks = [{ id: 'w1', url: 'https://x.test', secret: 'whsec_x', organizationId: 'org-1' }]
+
+  it('redacts secret settings and webhook secrets by default', async () => {
+    state.pages.appSetting = [settings]
+    state.pages.webhook = [webhooks]
+    const { json, raw } = await run({ ...NONE, settings: true, workshopConfig: true })
+
+    expect(json.data.settings).toEqual([{ ...settings[0], value: REDACTED }, settings[1]])
+    expect(json.data.webhooks).toEqual([{ ...webhooks[0], secret: REDACTED }])
+    expect(raw).not.toContain('sk_live_x')
+    expect(raw).not.toContain('whsec_x')
+  })
+
+  it('writes them in full when asked', async () => {
+    state.pages.appSetting = [settings]
+    state.pages.webhook = [webhooks]
+    const { json } = await run({
+      ...NONE,
+      settings: true,
+      workshopConfig: true,
+      includeSecrets: true,
+    })
+
+    expect(json.data.settings).toEqual(settings)
+    expect(json.data.webhooks).toEqual(webhooks)
+  })
+})

+ 1426 - 0
src/__tests__/lib/backup/engine.test.ts

@@ -0,0 +1,1426 @@
+// @vitest-environment node
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readdirSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The engine end to end against a fake restic: a shell script that records
+ * each call (argv, env, the staged data file) and answers with restic 0.19.1
+ * JSON. The database, connection loader, data writer, mail and notifications
+ * are in-memory fakes.
+ */
+
+type Row = Record<string, any>
+
+const h = vi.hoisted(() => ({
+  runs: new Map<string, Row>(),
+  connections: new Map<string, Row>(),
+  members: [] as Row[],
+  /** PlatformBackup rows by organisation id. */
+  platform: new Map<string, Row>(),
+  cloud: false,
+  features: { platformBackups: false } as Record<string, boolean>,
+  seq: 0,
+  server: null as any,
+  password: 'correct horse battery',
+}))
+
+/** Prisma leaves a field alone when the value is undefined. */
+function merge(row: Row, data: Row): Row {
+  const out = { ...row }
+  for (const [k, v] of Object.entries(data)) if (v !== undefined) out[k] = v
+  return out
+}
+
+vi.mock('@/lib/db', () => {
+  const db: Record<string, any> = {
+    backupRun: {
+      create: vi.fn(async ({ data }: { data: Row }) => {
+        const id = `run${++h.seq}`
+        h.runs.set(id, {
+          id,
+          startedAt: null,
+          finishedAt: null,
+          snapshotId: null,
+          filesNew: null,
+          bytesAdded: null,
+          totalBytes: null,
+          error: null,
+          createdAt: new Date(),
+          ...data,
+        })
+        return { id }
+      }),
+      update: vi.fn(async ({ where, data }: { where: { id: string }; data: Row }) => {
+        const row = merge(h.runs.get(where.id) ?? {}, data)
+        h.runs.set(where.id, row)
+        return row
+      }),
+      findUnique: vi.fn(
+        async ({ where }: { where: { id: string } }) => h.runs.get(where.id) ?? null
+      ),
+      // Both reads: the busy check (organisation, kind, connection, open
+      // statuses) and the queue's next run (status 'queued', oldest first).
+      findFirst: vi.fn(async ({ where, orderBy }: { where: Row; orderBy?: Row }) => {
+        const statuses: string[] =
+          typeof where.status === 'string' ? [where.status] : where.status.in
+        const rows = [...h.runs.values()].filter(
+          (r) =>
+            statuses.includes(r.status) &&
+            (where.organizationId === undefined || r.organizationId === where.organizationId) &&
+            (where.kind === undefined || r.kind === where.kind) &&
+            (where.connectionId === undefined || r.connectionId === where.connectionId) &&
+            (where.trigger === undefined ||
+              (typeof where.trigger === 'string'
+                ? r.trigger === where.trigger
+                : where.trigger.in.includes(r.trigger)))
+        )
+        if (orderBy) rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
+        return rows[0] ?? null
+      }),
+    },
+    platformBackup: {
+      upsert: vi.fn(
+        async ({ where, create }: { where: { organizationId: string }; create: Row }) => {
+          const row = h.platform.get(where.organizationId) ?? {
+            lastPruneAt: null,
+            lastCheckAt: null,
+            ...create,
+          }
+          h.platform.set(where.organizationId, row)
+          return row
+        }
+      ),
+      update: vi.fn(async ({ where, data }: { where: { organizationId: string }; data: Row }) => {
+        const row = merge(h.platform.get(where.organizationId) ?? {}, data)
+        h.platform.set(where.organizationId, row)
+        return row
+      }),
+    },
+    integrationConnection: {
+      findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
+        const row = h.connections.get(where.id)
+        return row ? { ...row, state: { ...row.state } } : null
+      }),
+      update: vi.fn(async ({ where, data }: { where: { id: string }; data: Row }) => {
+        const row = merge(h.connections.get(where.id) ?? {}, data)
+        h.connections.set(where.id, row)
+        return row
+      }),
+    },
+    organizationMember: { findMany: vi.fn(async () => h.members) },
+  }
+  // The queue's transaction: the advisory lock is a no-op here, and the
+  // transaction client is the same fake.
+  db.$transaction = async (fn: (tx: Record<string, any>) => unknown) => fn(db)
+  db.$executeRaw = vi.fn(async () => 0)
+  return { db }
+})
+
+vi.mock('@/features/integrations/Lib/connections', () => ({
+  loadConnection: vi.fn(async (id: string) => {
+    const row = h.connections.get(id)
+    if (!row) throw new Error('Integration connection not found')
+    const ctx = {
+      connection: {
+        id,
+        organizationId: row.organizationId,
+        connectorId: row.connectorId,
+        settings: { ...row.settings },
+        state: { ...row.state },
+        externalAccountId: null,
+      },
+      credentials: { password: h.password },
+      log: vi.fn(async () => undefined),
+      async saveState(patch: Row) {
+        const current = h.connections.get(id) as Row
+        current.state = { ...current.state, ...patch }
+        ctx.connection.state = current.state
+      },
+    }
+    return { ctx, server: h.server, status: row.status }
+  }),
+  setConnectionStatus: vi.fn(async (id: string, status: string, error?: string | null) => {
+    const row = h.connections.get(id)
+    if (!row) return
+    row.status = status
+    if (error !== undefined) row.lastError = error
+    if (status === 'active') row.lastError = null
+  }),
+}))
+
+vi.mock('@/lib/backup/build-data', () => ({
+  writeBackupData: vi.fn(
+    async (organizationId: string, options: Row, out: NodeJS.WritableStream) => {
+      out.write(
+        JSON.stringify({ organizationId, includeSecrets: options.includeSecrets, at: h.seq })
+      )
+      return { datasets: ['customers'] }
+    }
+  ),
+}))
+
+vi.mock('@/i18n/organization-translator', () => ({
+  organizationTranslator: async () =>
+    Object.assign((key: string) => key, { has: () => false, locale: 'en' }),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
+vi.mock('@/lib/notify', () => ({ notify: vi.fn(async () => undefined) }))
+
+vi.mock('@/lib/email', () => ({
+  getOrgFromAddress: vi.fn(async () => 'Workshop <post@workshop.test>'),
+  sendOrgMail: vi.fn(async () => undefined),
+  getFromAddress: vi.fn(async () => 'Torqvoice <noreply@torqvoice.test>'),
+  sendMail: vi.fn(async () => undefined),
+}))
+
+vi.mock('@/integrations/registry', () => ({
+  getManifest: (id: string) =>
+    id === 'not-backup'
+      ? { id, name: 'Calendar', capabilities: ['calendar.sync'] }
+      : { id, name: 'Test storage', capabilities: ['backup.target'] },
+  listManifests: () => [],
+}))
+
+vi.mock('@/lib/features', () => ({
+  isCloudMode: () => h.cloud,
+  getFeatures: vi.fn(async () => h.features),
+}))
+
+import { writeBackupData } from '@/lib/backup/build-data'
+import {
+  BackupBusyError,
+  backupLockHolder,
+  drainBackupQueue,
+  openPlatform,
+  openTarget,
+  queueBackupRun,
+  runBackup,
+  stagingDirFor,
+  takeSafetySnapshot,
+} from '@/lib/backup/engine'
+import { platformRepositoryPassword } from '@/lib/backup/platform'
+import { resticSnapshots } from '@/lib/backup/restic'
+import { loadConnection, setConnectionStatus } from '@/features/integrations/Lib/connections'
+import { sendMail, sendOrgMail } from '@/lib/email'
+import { notify } from '@/lib/notify'
+import { connector as localFolder } from '@/integrations/local-folder/server'
+
+// Lines below are copied from restic 0.19.1 `--json` output (see restic.test.ts).
+const SNAPSHOT_ID = '28891c8f448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b'
+const SUMMARY_LINE = `{"message_type":"summary","files_new":2,"files_changed":0,"files_unmodified":0,"dirs_new":8,"dirs_changed":0,"dirs_unmodified":0,"data_blobs":2,"tree_blobs":9,"data_added":3851,"data_added_packed":2966,"total_files_processed":2,"total_bytes_processed":12,"total_duration":0.716071765,"backup_start":"2026-10-29T09:09:10.444930802+01:00","backup_end":"2026-10-29T09:09:11.161002555+01:00","snapshot_id":"${SNAPSHOT_ID}"}`
+const STATUS_LINE =
+  '{"message_type":"status","seconds_elapsed":1,"percent_done":0.684350835,"total_files":1,"total_bytes":400000000,"bytes_done":273740334,"current_files":["/data/blob"]}'
+const INIT_LINE = '{"message_type":"initialized","id":"7f2a","repository":"/repo"}'
+const STATS_LINE =
+  '{"total_size":6928,"total_uncompressed_size":11714,"compression_ratio":1.6908198614318706,"compression_progress":100,"compression_space_saving":40.85709407546525,"total_blob_count":29,"snapshots_count":3}'
+const FORGET_LINE =
+  '[{"tags":["org:org1"],"host":"torqvoice-org1","paths":null,"keep":[{"id":"9284","short_id":"92846caf"}],"remove":null,"reasons":[]}]'
+const CHECK_LINE =
+  '{"message_type":"summary","num_errors":0,"broken_packs":null,"suggest_repair_index":false,"suggest_prune":false}'
+const SAFETY_ID = '9d6cb16e448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b'
+const SNAPSHOTS_LINE = `[{"time":"2026-10-29T09:09:10.44+01:00","tree":"ab12","paths":["/tmp/torqvoice-backup-staging/org1"],"hostname":"torqvoice-org1","tags":["org:org1","scheduled","platform"],"id":"${SNAPSHOT_ID}","short_id":"28891c8f"},{"time":"2026-10-28T09:00:00+01:00","tree":"cd34","paths":["/tmp/torqvoice-backup-staging/org1"],"hostname":"torqvoice-org1","tags":["org:org1","pre-restore","platform"],"id":"${SAFETY_ID}","short_id":"9d6cb16e"}]`
+const BUCKET_GONE =
+  '{"message_type":"exit_error","code":1,"message":"Fatal: unable to save snapshot: bucket gone"}'
+
+const ORG = 'org1'
+const CONN = 'conn1'
+const DAY = 24 * 60 * 60 * 1000
+
+const root = mkdtempSync(path.join(tmpdir(), 'backup-engine-test-'))
+const rec = path.join(root, 'rec')
+const dataDir = path.join(root, 'data')
+const repoDir = path.join(root, 'repo')
+const uploadsDir = path.join(dataDir, 'uploads', ORG)
+const fake = path.join(root, 'restic.sh')
+const savedEnv = { ...process.env }
+
+function heredoc(line: string): string {
+  return `cat <<'EOF'\n${line}\nEOF`
+}
+
+writeFileSync(
+  fake,
+  `#!/bin/sh
+REC='${rec}'
+n=$(cat "$REC/count" 2>/dev/null || echo 0)
+n=$((n+1))
+echo "$n" > "$REC/count"
+printf '%s\\n' "$@" > "$REC/$n.argv"
+/usr/bin/env > "$REC/$n.env"
+sub=''
+skip=0
+for a in "$@"; do
+  if [ "$skip" = 1 ]; then skip=0; continue; fi
+  case "$a" in
+    --json) ;;
+    --repo|-o) skip=1 ;;
+    *) sub="$a"; break ;;
+  esac
+done
+if [ -f "$REC/fail-$sub" ]; then
+  cat "$REC/fail-$sub" >&2
+  exit 1
+fi
+case "$sub" in
+  init)
+    ${heredoc(INIT_LINE)}
+    ;;
+  backup)
+    after=0
+    for a in "$@"; do
+      if [ "$after" = 1 ] && [ -f "$a/data.json" ]; then cp "$a/data.json" "$REC/$n.data.json"; fi
+      if [ "$a" = "--" ]; then after=1; fi
+    done
+    ${heredoc(`${STATUS_LINE}\n${SUMMARY_LINE}`)}
+    if [ -f "$REC/partial-backup" ]; then
+      echo '{"message_type":"error","error":{"message":"open /data/x.jpg: permission denied"},"during":"archival","item":"/data/x.jpg"}' >&2
+      exit 3
+    fi
+    ;;
+  forget)
+    ${heredoc(FORGET_LINE)}
+    ;;
+  check)
+    ${heredoc(CHECK_LINE)}
+    ;;
+  stats)
+    ${heredoc(STATS_LINE)}
+    ;;
+  snapshots)
+    ${heredoc(SNAPSHOTS_LINE)}
+    ;;
+  *) ;;
+esac
+exit 0
+`
+)
+chmodSync(fake, 0o755)
+
+type Call = { sub: string; argv: string[]; env: Record<string, string>; data: string | null }
+
+function subcommand(argv: string[]): string {
+  for (let i = 0; i < argv.length; i++) {
+    if (argv[i] === '--json') continue
+    if (argv[i] === '--repo' || argv[i] === '-o') {
+      i++
+      continue
+    }
+    return argv[i]
+  }
+  return ''
+}
+
+/** Every restic call so far, in order. */
+function calls(): Call[] {
+  if (!existsSync(path.join(rec, 'count'))) return []
+  const n = Number(readFileSync(path.join(rec, 'count'), 'utf8').trim())
+  return Array.from({ length: n }, (_, i) => {
+    const argv = readFileSync(path.join(rec, `${i + 1}.argv`), 'utf8')
+      .split('\n')
+      .slice(0, -1)
+    const env: Record<string, string> = {}
+    for (const line of readFileSync(path.join(rec, `${i + 1}.env`), 'utf8').split('\n')) {
+      const eq = line.indexOf('=')
+      if (eq > 0) env[line.slice(0, eq)] = line.slice(eq + 1)
+    }
+    for (const key of ['PWD', 'OLDPWD', 'SHLVL', '_']) delete env[key]
+    const dataFile = path.join(rec, `${i + 1}.data.json`)
+    const data = existsSync(dataFile) ? readFileSync(dataFile, 'utf8') : null
+    return { sub: subcommand(argv), argv, env, data }
+  })
+}
+
+function callsOf(sub: string): Call[] {
+  return calls().filter((c) => c.sub === sub)
+}
+
+function addConnection(over: Row = {}): Row {
+  const row = {
+    id: CONN,
+    organizationId: ORG,
+    connectorId: 's3',
+    status: 'active',
+    lastError: null,
+    settings: { frequency: 'daily', contents: 'data', includeFiles: true },
+    state: {},
+    ...over,
+  }
+  h.connections.set(row.id, row)
+  return row
+}
+
+function addRun(over: Row = {}): string {
+  const id = `run${++h.seq}`
+  h.runs.set(id, {
+    id,
+    organizationId: ORG,
+    kind: 'destination',
+    connectionId: CONN,
+    trigger: 'manual',
+    status: 'queued',
+    startedAt: null,
+    finishedAt: null,
+    snapshotId: null,
+    filesNew: null,
+    bytesAdded: null,
+    totalBytes: null,
+    error: null,
+    createdAt: new Date(),
+    ...over,
+  })
+  return id
+}
+
+const PLATFORM_ENV = {
+  PLATFORM_BACKUP_S3_ENDPOINT: 'https://acc.r2.cloudflarestorage.com',
+  PLATFORM_BACKUP_S3_BUCKET: 'torqvoice-workshop-backups',
+  PLATFORM_BACKUP_S3_ACCESS_KEY: 'platform-key-id',
+  PLATFORM_BACKUP_S3_SECRET_KEY: 'platform-key-secret',
+  PLATFORM_BACKUP_MASTER_KEY: 'k7Qe2Vx9Lm4Rt8Zp1Ws6Yd3Hn5Jc0Bf7Gu2Ia9Oq',
+}
+
+/** The cloud instance with platform backups configured. */
+function setPlatformEnv(): void {
+  h.cloud = true
+  h.features = { platformBackups: true }
+  Object.assign(process.env, PLATFORM_ENV)
+}
+
+function failRestic(sub: string, stderr = BUCKET_GONE): void {
+  writeFileSync(path.join(rec, `fail-${sub}`), `${stderr}\n`)
+}
+
+/** Restic writes the next snapshot but exits 3: some files could not be read. */
+function partialBackup(): void {
+  writeFileSync(path.join(rec, 'partial-backup'), '')
+}
+
+function ago(ms: number): string {
+  return new Date(Date.now() - ms).toISOString()
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  h.runs.clear()
+  h.connections.clear()
+  h.platform.clear()
+  h.cloud = false
+  h.features = { platformBackups: false }
+  h.seq = 0
+  h.members = [
+    { user: { email: 'owner@workshop.test' } },
+    { user: { email: 'admin@workshop.test' } },
+  ]
+  h.server = {
+    backupTarget: {
+      repository: async () => ({
+        repository: repoDir,
+        env: { AWS_ACCESS_KEY_ID: 'theirs-id', AWS_SECRET_ACCESS_KEY: 'theirs-secret' },
+      }),
+    },
+  }
+  rmSync(rec, { recursive: true, force: true })
+  rmSync(dataDir, { recursive: true, force: true })
+  mkdirSync(rec, { recursive: true })
+  mkdirSync(uploadsDir, { recursive: true })
+  writeFileSync(path.join(uploadsDir, 'photo.jpg'), 'jpeg bytes')
+  process.env.DATA_ROOT = dataDir
+  process.env.RESTIC_BIN = fake
+  delete process.env.BACKUP_LOCAL_ROOT
+  ;(globalThis as Row).__torqvoiceBackupLock = null
+  ;(globalThis as Row).__torqvoiceBackupDraining = false
+})
+
+afterEach(() => {
+  for (const key of Object.keys(process.env)) {
+    if (!(key in savedEnv)) delete process.env[key]
+  }
+  Object.assign(process.env, savedEnv)
+})
+
+afterAll(() => {
+  rmSync(root, { recursive: true, force: true })
+})
+
+describe('queueBackupRun', () => {
+  it('creates a queued run for a backup destination', async () => {
+    addConnection()
+    const { runId } = await queueBackupRun({ kind: 'destination', connectionId: CONN }, 'manual', {
+      requestedBy: 'user1',
+    })
+    expect(h.runs.get(runId)).toMatchObject({
+      organizationId: ORG,
+      kind: 'destination',
+      connectionId: CONN,
+      trigger: 'manual',
+      status: 'queued',
+      requestedBy: 'user1',
+      reason: null,
+    })
+  })
+
+  it('still accepts a plain connection id', async () => {
+    addConnection()
+    const { runId } = await queueBackupRun(CONN, 'scheduled')
+    expect(h.runs.get(runId)).toMatchObject({
+      kind: 'destination',
+      connectionId: CONN,
+      requestedBy: null,
+    })
+  })
+
+  it('a run queued for one destination does not block another destination or the platform', async () => {
+    addConnection()
+    addConnection({ id: 'conn2' })
+    setPlatformEnv()
+    addRun({ status: 'running' })
+    await expect(
+      queueBackupRun({ kind: 'destination', connectionId: 'conn2' }, 'manual')
+    ).resolves.toHaveProperty('runId')
+    await expect(
+      queueBackupRun({ kind: 'platform', organizationId: ORG }, 'manual')
+    ).resolves.toHaveProperty('runId')
+    await expect(
+      queueBackupRun({ kind: 'platform', organizationId: ORG }, 'manual')
+    ).rejects.toBeInstanceOf(BackupBusyError)
+  })
+
+  it('makes a platform run with no connection, and refuses one when platform backups are off', async () => {
+    await expect(
+      queueBackupRun({ kind: 'platform', organizationId: ORG }, 'scheduled')
+    ).rejects.toThrow(/not configured/)
+    setPlatformEnv()
+    const { runId } = await queueBackupRun({ kind: 'platform', organizationId: ORG }, 'scheduled', {
+      reason: 'support check',
+    })
+    expect(h.runs.get(runId)).toMatchObject({
+      organizationId: ORG,
+      kind: 'platform',
+      connectionId: null,
+      trigger: 'scheduled',
+      status: 'queued',
+      reason: 'support check',
+    })
+  })
+
+  it.each(['queued', 'running'])('refuses while a run is %s', async (status) => {
+    addConnection()
+    addRun({ status })
+    await expect(queueBackupRun(CONN, 'manual')).rejects.toBeInstanceOf(BackupBusyError)
+    expect(h.runs.size).toBe(1)
+  })
+
+  it('queues again once the earlier run has finished', async () => {
+    addConnection()
+    addRun({ status: 'done' })
+    addRun({ status: 'failed' })
+    await expect(queueBackupRun(CONN, 'scheduled')).resolves.toHaveProperty('runId')
+  })
+
+  it('refuses a connector that is not a backup destination', async () => {
+    addConnection({ connectorId: 'not-backup' })
+    await expect(queueBackupRun(CONN, 'manual')).rejects.toThrow(/not a backup destination/)
+    expect(h.runs.size).toBe(0)
+  })
+
+  it('refuses a connection that does not exist', async () => {
+    await expect(queueBackupRun('nope', 'manual')).rejects.toThrow(/not found/)
+  })
+})
+
+describe('runBackup, happy path', () => {
+  it('records running, then done with the snapshot figures', async () => {
+    addConnection()
+    const runId = addRun()
+    const statuses: string[] = []
+    const { db } = await import('@/lib/db')
+    vi.mocked(db.backupRun.update).mockImplementation((async ({ where, data }: Row) => {
+      if (data.status) statuses.push(data.status)
+      const row = merge(h.runs.get(where.id) ?? {}, data)
+      h.runs.set(where.id, row)
+      return row
+    }) as never)
+
+    const view = await runBackup(runId)
+
+    expect(statuses).toEqual(['running', 'done'])
+    expect(view).toMatchObject({
+      id: runId,
+      status: 'done',
+      snapshotId: SNAPSHOT_ID,
+      filesNew: 2,
+      bytesAdded: 3851,
+      totalBytes: 6928,
+      error: null,
+    })
+    const row = h.runs.get(runId) as Row
+    expect(row.startedAt).toBeInstanceOf(Date)
+    expect(row.finishedAt).toBeInstanceOf(Date)
+    expect(row.bytesAdded).toBe(BigInt(3851))
+    expect(row.totalBytes).toBe(BigInt(6928))
+  })
+
+  it('backs up the staging folder and the uploads folder with the workshop host and tags', async () => {
+    addConnection()
+    await runBackup(addRun({ trigger: 'scheduled' }))
+
+    const [backup] = callsOf('backup')
+    const staging = stagingDirFor(ORG)
+    expect(staging).toBe(path.join(tmpdir(), 'torqvoice-backup-staging', ORG))
+    const tail = backup.argv.slice(backup.argv.indexOf('backup'))
+    expect(tail).toEqual([
+      'backup',
+      '--host',
+      `torqvoice-${ORG}`,
+      '--tag',
+      `org:${ORG}`,
+      '--tag',
+      'scheduled',
+      '--',
+      staging,
+      uploadsDir,
+    ])
+    expect(backup.argv.slice(0, 3)).toEqual(['--json', '--repo', repoDir])
+    // The data file was in place while restic read it.
+    expect(JSON.parse(backup.data ?? 'null')).toMatchObject({ organizationId: ORG })
+  })
+
+  it('backs up only the staging folder when files are left out', async () => {
+    addConnection({ settings: { frequency: 'daily', includeFiles: false } })
+    await runBackup(addRun())
+    const [backup] = callsOf('backup')
+    expect(backup.argv.slice(backup.argv.indexOf('--') + 1)).toEqual([stagingDirFor(ORG)])
+  })
+
+  it('backs up only the staging folder when the workshop has no uploads yet', async () => {
+    addConnection()
+    rmSync(uploadsDir, { recursive: true, force: true })
+    await runBackup(addRun())
+    const [backup] = callsOf('backup')
+    expect(backup.argv.slice(backup.argv.indexOf('--') + 1)).toEqual([stagingDirFor(ORG)])
+  })
+
+  it("runs init, unlock, backup, forget, the safety copies' forget, prune, check and stats in that order", async () => {
+    addConnection()
+    await runBackup(addRun())
+    expect(calls().map((c) => c.sub)).toEqual([
+      'init',
+      'unlock',
+      'backup',
+      'forget',
+      'forget',
+      'forget',
+      'check',
+      'stats',
+    ])
+    const [retention, safety, prune] = callsOf('forget')
+    // Retention after every run, keeping the safety copies, without a prune.
+    expect(retention.argv).toEqual(
+      expect.arrayContaining([
+        '--group-by',
+        'host',
+        '--host',
+        `torqvoice-${ORG}`,
+        '--tag',
+        `org:${ORG}`,
+        '--keep-tag',
+        'pre-restore',
+      ])
+    )
+    expect(retention.argv).not.toContain('--prune')
+    // The safety copies go after ninety days, whatever the policy says.
+    expect(safety.argv.slice(safety.argv.indexOf('forget'))).toEqual([
+      'forget',
+      '--group-by',
+      'host',
+      '--host',
+      `torqvoice-${ORG}`,
+      '--tag',
+      'pre-restore',
+      '--keep-within',
+      '90d',
+    ])
+    // The first run prunes: the same policy again, with --prune.
+    expect(prune.argv).toEqual(
+      expect.arrayContaining(['--keep-tag', 'pre-restore', '--prune', '--max-unused', '10%'])
+    )
+  })
+
+  it('creates the local repository folder before restic is asked to init it', async () => {
+    addConnection()
+    await runBackup(addRun())
+    expect(existsSync(repoDir)).toBe(true)
+  })
+
+  it('prunes and checks a repository that never had either', async () => {
+    addConnection()
+    await runBackup(addRun())
+    expect(callsOf('forget')[0].argv).not.toContain('--prune')
+    expect(callsOf('forget')[2].argv).toContain('--prune')
+    expect(callsOf('check')[0].argv).toContain('--read-data-subset=2%')
+    const state = h.connections.get(CONN)?.state
+    expect(typeof state['backup.lastPruneAt']).toBe('string')
+    expect(typeof state['backup.lastCheckAt']).toBe('string')
+  })
+
+  it('prunes only when the last prune is older than 7 days', async () => {
+    const recent = ago(6 * DAY)
+    addConnection({ state: { 'backup.lastPruneAt': recent, 'backup.lastCheckAt': ago(DAY) } })
+    await runBackup(addRun())
+    expect(callsOf('forget')).toHaveLength(2)
+    expect(callsOf('forget').some((c) => c.argv.includes('--prune'))).toBe(false)
+    expect(h.connections.get(CONN)?.state['backup.lastPruneAt']).toBe(recent)
+
+    rmSync(rec, { recursive: true, force: true })
+    mkdirSync(rec)
+    h.connections.get(CONN)!.state['backup.lastPruneAt'] = ago(8 * DAY)
+    await runBackup(addRun())
+    expect(callsOf('forget')).toHaveLength(3)
+    expect(callsOf('forget')[0].argv).not.toContain('--prune')
+    expect(callsOf('forget')[2].argv).toEqual(
+      expect.arrayContaining(['--prune', '--max-unused', '10%'])
+    )
+    const stamped = new Date(h.connections.get(CONN)?.state['backup.lastPruneAt']).getTime()
+    expect(Date.now() - stamped).toBeLessThan(60_000)
+  })
+
+  it('checks only when the last check is older than 30 days', async () => {
+    const recent = ago(29 * DAY)
+    addConnection({ state: { 'backup.lastPruneAt': ago(DAY), 'backup.lastCheckAt': recent } })
+    await runBackup(addRun())
+    expect(callsOf('check')).toHaveLength(0)
+    expect(h.connections.get(CONN)?.state['backup.lastCheckAt']).toBe(recent)
+
+    rmSync(rec, { recursive: true, force: true })
+    mkdirSync(rec)
+    h.connections.get(CONN)!.state['backup.lastCheckAt'] = ago(31 * DAY)
+    await runBackup(addRun())
+    expect(callsOf('check')).toHaveLength(1)
+    const stamped = new Date(h.connections.get(CONN)?.state['backup.lastCheckAt']).getTime()
+    expect(Date.now() - stamped).toBeLessThan(60_000)
+  })
+
+  it('a failed check does not fail the backup and is tried again next run', async () => {
+    addConnection({ state: { 'backup.lastPruneAt': ago(DAY) } })
+    failRestic(
+      'check',
+      '{"message_type":"exit_error","code":1,"message":"Fatal: repository contains errors"}'
+    )
+    const view = await runBackup(addRun())
+    expect(view.status).toBe('done')
+    expect(h.connections.get(CONN)?.state['backup.lastCheckAt']).toBeUndefined()
+  })
+
+  it('saves lastGoodAt and the repository size and sets the connection active', async () => {
+    addConnection({ status: 'error', lastError: 'old failure', state: { keep: 'me' } })
+    await runBackup(addRun())
+    const conn = h.connections.get(CONN) as Row
+    expect(conn.state.keep).toBe('me')
+    expect(Date.now() - new Date(conn.state['backup.lastGoodAt']).getTime()).toBeLessThan(60_000)
+    expect(conn.state['backup.repositorySize']).toBe(6928)
+    expect(setConnectionStatus).toHaveBeenCalledWith(CONN, 'active')
+    expect(conn.status).toBe('active')
+    expect(conn.lastError).toBeNull()
+  })
+
+  it('a failed stats call still finishes the run as done, without a size', async () => {
+    addConnection()
+    failRestic('stats')
+    const view = await runBackup(addRun())
+    expect(view.status).toBe('done')
+    expect(view.totalBytes).toBeNull()
+    expect(h.connections.get(CONN)?.state['backup.repositorySize']).toBeUndefined()
+  })
+
+  it('deletes the staged data file afterwards', async () => {
+    addConnection()
+    await runBackup(addRun())
+    expect(callsOf('backup')[0].data).not.toBeNull()
+    expect(existsSync(path.join(stagingDirFor(ORG), 'data.json'))).toBe(false)
+  })
+
+  it('releases the lock when done', async () => {
+    addConnection()
+    await runBackup(addRun())
+    expect(backupLockHolder()).toBeNull()
+  })
+
+  it('leaves a run that is no longer queued alone', async () => {
+    addConnection()
+    const runId = addRun({ status: 'done', snapshotId: 'abc' })
+    const view = await runBackup(runId)
+    expect(view).toMatchObject({ status: 'done', snapshotId: 'abc' })
+    expect(calls()).toHaveLength(0)
+  })
+})
+
+describe('runBackup, contents', () => {
+  it("passes includeSecrets true for 'dataAndSecrets'", async () => {
+    addConnection({ settings: { contents: 'dataAndSecrets' } })
+    await runBackup(addRun())
+    expect(writeBackupData).toHaveBeenCalledTimes(1)
+    const [org, options] = vi.mocked(writeBackupData).mock.calls[0]
+    expect(org).toBe(ORG)
+    expect(options).toMatchObject({ includeSecrets: true, files: false })
+  })
+
+  it("passes includeSecrets false for 'data'", async () => {
+    addConnection({ settings: { contents: 'data' } })
+    await runBackup(addRun())
+    expect(vi.mocked(writeBackupData).mock.calls[0][1]).toMatchObject({
+      includeSecrets: false,
+      files: false,
+    })
+  })
+})
+
+describe('runBackup, environment', () => {
+  it('gives restic nothing from process.env, only the destination env', async () => {
+    process.env.AWS_SECRET_ACCESS_KEY = 'ours'
+    process.env.AWS_ACCESS_KEY_ID = 'ours'
+    process.env.DATABASE_URL = 'postgres://ours'
+    process.env.RESTIC_PASSWORD = 'ours'
+    addConnection()
+    await runBackup(addRun())
+
+    const all = calls()
+    expect(all.length).toBeGreaterThan(0)
+    for (const call of all) {
+      expect(call.env.AWS_SECRET_ACCESS_KEY, call.sub).toBe('theirs-secret')
+      expect(call.env.AWS_ACCESS_KEY_ID, call.sub).toBe('theirs-id')
+      expect(call.env.RESTIC_PASSWORD, call.sub).toBe(h.password)
+      expect(call.env.DATABASE_URL, call.sub).toBeUndefined()
+      expect(call.env.DATA_ROOT, call.sub).toBeUndefined()
+      expect(call.env.RESTIC_BIN, call.sub).toBeUndefined()
+      expect(
+        Object.values(call.env).some((v) => v.includes('ours')),
+        call.sub
+      ).toBe(false)
+      expect(Object.keys(call.env).sort(), call.sub).toEqual(
+        [
+          'AWS_ACCESS_KEY_ID',
+          'AWS_SECRET_ACCESS_KEY',
+          'GOGC',
+          'HOME',
+          'PATH',
+          'RESTIC_CACHE_DIR',
+          'RESTIC_PASSWORD',
+          'RESTIC_REPOSITORY',
+          'TMPDIR',
+        ].sort()
+      )
+      expect(call.env.RESTIC_CACHE_DIR).toBe(path.join(dataDir, 'restic-cache', ORG))
+      expect(call.argv.join(' ')).not.toContain(h.password)
+    }
+  })
+})
+
+describe('runBackup, failure', () => {
+  it('marks the run failed, sets the connection to error, notifies and mails once', async () => {
+    addConnection()
+    failRestic('backup')
+    const runId = addRun()
+
+    const view = await runBackup(runId)
+
+    expect(view.status).toBe('failed')
+    expect(view.error).toMatch(/bucket gone/)
+    expect(view.snapshotId).toBeNull()
+    expect(h.runs.get(runId)?.finishedAt).toBeInstanceOf(Date)
+    expect(setConnectionStatus).toHaveBeenCalledWith(CONN, 'error', view.error)
+    expect(h.connections.get(CONN)?.status).toBe('error')
+    expect(notify).toHaveBeenCalledTimes(1)
+    expect(vi.mocked(notify).mock.calls[0][0]).toMatchObject({
+      organizationId: ORG,
+      type: 'backup_failed',
+      entityType: 'IntegrationConnection',
+      entityId: CONN,
+      entityUrl: '/settings/integrations/s3',
+    })
+    expect(sendOrgMail).toHaveBeenCalledTimes(2)
+    expect(
+      vi
+        .mocked(sendOrgMail)
+        .mock.calls.map((c) => (c[1] as Row).to)
+        .sort()
+    ).toEqual(['admin@workshop.test', 'owner@workshop.test'])
+    expect(typeof h.connections.get(CONN)?.state['backup.lastFailureMailAt']).toBe('string')
+    // No forget after a failed backup, and no good-backup stamp.
+    expect(callsOf('forget')).toHaveLength(0)
+    expect(h.connections.get(CONN)?.state['backup.lastGoodAt']).toBeUndefined()
+    expect(existsSync(path.join(stagingDirFor(ORG), 'data.json'))).toBe(false)
+    expect(backupLockHolder()).toBeNull()
+  })
+
+  it('a failed scheduled run books one retry 30 minutes later', async () => {
+    addConnection()
+    failRestic('backup')
+    const before = Date.now()
+    await runBackup(addRun({ trigger: 'scheduled' }))
+    const retryAt = new Date(h.connections.get(CONN)?.state['backup.retryAt']).getTime()
+    expect(retryAt).toBeGreaterThanOrEqual(before + 30 * 60 * 1000)
+    expect(retryAt).toBeLessThan(Date.now() + 30 * 60 * 1000 + 1000)
+  })
+
+  it('a failed manual run books no retry', async () => {
+    addConnection()
+    failRestic('backup')
+    await runBackup(addRun({ trigger: 'manual' }))
+    expect(h.connections.get(CONN)?.state['backup.retryAt']).toBeUndefined()
+  })
+
+  it('asks owners and admins only', async () => {
+    addConnection()
+    failRestic('backup')
+    await runBackup(addRun())
+    const { db } = await import('@/lib/db')
+    expect(vi.mocked(db.organizationMember.findMany).mock.calls[0][0]).toMatchObject({
+      where: { organizationId: ORG, role: { in: ['owner', 'admin'] } },
+    })
+  })
+
+  it('does not mail again within 24 hours, but still notifies', async () => {
+    addConnection()
+    failRestic('backup')
+    await runBackup(addRun())
+    await runBackup(addRun())
+    expect(notify).toHaveBeenCalledTimes(2)
+    expect(sendOrgMail).toHaveBeenCalledTimes(2)
+  })
+
+  it('mails again once the last failure mail is older than 24 hours', async () => {
+    addConnection({ state: { 'backup.lastFailureMailAt': ago(25 * 60 * 60 * 1000) } })
+    failRestic('backup')
+    await runBackup(addRun())
+    expect(sendOrgMail).toHaveBeenCalledTimes(2)
+  })
+
+  it('a backup restic could not read every file for fails the run, retries and reports it', async () => {
+    addConnection()
+    partialBackup()
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      const before = Date.now()
+      const runId = addRun({ trigger: 'scheduled' })
+
+      const view = await runBackup(runId)
+
+      expect(view.status).toBe('failed')
+      expect(view.error).toBe(
+        'Some files could not be read, so this backup is incomplete (restic snapshot 28891c8f). Check the server log and run the backup again.'
+      )
+      expect(view.snapshotId).toBeNull()
+      expect(callsOf('backup')).toHaveLength(1)
+      // Not a good backup: no retention, no stamp, the connection in error.
+      expect(callsOf('forget')).toHaveLength(0)
+      const conn = h.connections.get(CONN) as Row
+      expect(conn.state['backup.lastGoodAt']).toBeUndefined()
+      expect(setConnectionStatus).not.toHaveBeenCalledWith(CONN, 'active')
+      expect(conn.status).toBe('error')
+      expect(new Date(conn.state['backup.retryAt']).getTime()).toBeGreaterThanOrEqual(
+        before + 30 * 60 * 1000
+      )
+      expect(notify).toHaveBeenCalledTimes(1)
+      expect(sendOrgMail).toHaveBeenCalledTimes(2)
+      expect(backupLockHolder()).toBeNull()
+    } finally {
+      errors.mockRestore()
+    }
+  })
+
+  it('a partial platform backup records no good run and no restore points', async () => {
+    setPlatformEnv()
+    h.features = { platformBackups: true }
+    partialBackup()
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      const view = await runBackup(addRun({ kind: 'platform', connectionId: null }))
+      expect(view.status).toBe('failed')
+      expect(view.error).toMatch(/incomplete/)
+      expect(errors).toHaveBeenCalledWith(
+        expect.stringContaining('Backup is incomplete: some files could not be read'),
+        expect.objectContaining({ snapshot: SNAPSHOT_ID })
+      )
+      expect(callsOf('forget')).toHaveLength(0)
+      expect(callsOf('snapshots')).toHaveLength(0)
+      const row = h.platform.get(ORG) as Row | undefined
+      expect(row?.lastGoodAt ?? undefined).toBeUndefined()
+      expect(row?.snapshots ?? undefined).toBeUndefined()
+    } finally {
+      errors.mockRestore()
+    }
+  })
+
+  it('a failed forget fails the run', async () => {
+    addConnection()
+    failRestic('forget')
+    const view = await runBackup(addRun())
+    expect(view.status).toBe('failed')
+    expect(h.connections.get(CONN)?.state['backup.lastGoodAt']).toBeUndefined()
+  })
+
+  it('a destination without a backup target fails the run', async () => {
+    addConnection()
+    h.server = {}
+    const view = await runBackup(addRun())
+    expect(view.status).toBe('failed')
+    expect(view.error).toMatch(/not a backup destination/)
+    expect(calls()).toHaveLength(0)
+  })
+
+  it('a short password fails the run before restic runs', async () => {
+    addConnection()
+    h.password = 'short'
+    try {
+      const view = await runBackup(addRun())
+      expect(view.status).toBe('failed')
+      expect(view.error).toMatch(/at least 8 characters/)
+      expect(calls()).toHaveLength(0)
+    } finally {
+      h.password = 'correct horse battery'
+    }
+  })
+
+  // Documents expected behaviour. stagingDirFor() runs after the lock is
+  // taken but outside the try/finally that releases it (engine.ts:145), so
+  // an organisation id it refuses leaves the lock held for good and every
+  // later run on this server finishes as skipped.
+  it('an organisation id the staging folder refuses fails the run and frees the lock', async () => {
+    addConnection({ organizationId: 'org-1' })
+    const runId = addRun({ organizationId: 'org-1' })
+    await runBackup(runId).catch(() => undefined)
+    expect(backupLockHolder()).toBeNull()
+    expect(h.runs.get(runId)?.status).toBe('failed')
+  })
+})
+
+describe('runBackup, lock', () => {
+  it('a second run while one is going stays queued and runs once the first is done', async () => {
+    addConnection()
+    addConnection({ id: 'conn2' })
+    const first = addRun()
+    const second = addRun({ connectionId: 'conn2' })
+
+    const [a, b] = await Promise.all([runBackup(first), runBackup(second)])
+
+    expect(a.status).toBe('done')
+    expect(b.status).toBe('queued')
+    expect(b.error).toBeNull()
+    // The first run's finish drains the queue, which starts the second.
+    await vi.waitFor(() => expect(h.runs.get(second)?.status).toBe('done'))
+    expect(callsOf('backup')).toHaveLength(2)
+    expect(h.runs.get(second)?.startedAt.getTime()).toBeGreaterThanOrEqual(
+      h.runs.get(first)?.finishedAt.getTime()
+    )
+    await vi.waitFor(() => expect(backupLockHolder()).toBeNull())
+  })
+
+  it("a run that finds another holder's lock stays queued and the holder keeps the lock", async () => {
+    addConnection()
+    ;(globalThis as Row).__torqvoiceBackupLock = { runId: 'other', since: Date.now() }
+    const runId = addRun()
+    const view = await runBackup(runId)
+    expect(view.status).toBe('queued')
+    expect(h.runs.get(runId)?.status).toBe('queued')
+    expect(h.runs.get(runId)?.startedAt).toBeNull()
+    expect(calls()).toHaveLength(0)
+    expect(backupLockHolder()?.runId).toBe('other')
+  })
+})
+
+describe('mailAdmins', () => {
+  it("sends from the platform's address when the workshop's own vendor refuses", async () => {
+    const { mailAdmins } = await import('@/lib/backup/engine')
+    vi.mocked(sendOrgMail).mockRejectedValue(
+      new Error('Email provider smtp is not fully configured. Check its integration.')
+    )
+    try {
+      await mailAdmins(ORG, 'Backup restored', '<p>done</p>')
+    } finally {
+      vi.mocked(sendOrgMail).mockReset()
+      vi.mocked(sendOrgMail).mockResolvedValue(undefined)
+    }
+    expect(sendMail).toHaveBeenCalledTimes(2)
+    expect(
+      vi
+        .mocked(sendMail)
+        .mock.calls.map((c) => [c[0].to, c[0].from])
+        .sort()
+    ).toEqual([
+      ['admin@workshop.test', 'Torqvoice <noreply@torqvoice.test>'],
+      ['owner@workshop.test', 'Torqvoice <noreply@torqvoice.test>'],
+    ])
+  })
+
+  it("leaves it at the log when the platform's mail fails too", async () => {
+    const { mailAdmins } = await import('@/lib/backup/engine')
+    vi.mocked(sendOrgMail).mockRejectedValue(new Error('refused'))
+    vi.mocked(sendMail).mockRejectedValue(new Error('down'))
+    try {
+      await expect(mailAdmins(ORG, 'Backup restored', '<p>done</p>')).resolves.toBeUndefined()
+      expect(sendMail).toHaveBeenCalledTimes(2)
+    } finally {
+      vi.mocked(sendOrgMail).mockReset()
+      vi.mocked(sendOrgMail).mockResolvedValue(undefined)
+      vi.mocked(sendMail).mockReset()
+      vi.mocked(sendMail).mockResolvedValue(undefined)
+    }
+  })
+})
+
+describe('drainBackupQueue', () => {
+  it('starts restores and manual runs before scheduled ones, however old those are', async () => {
+    addConnection()
+    addConnection({ id: 'conn2' })
+    addConnection({ id: 'conn3' })
+    const started: string[] = []
+    const { db } = await import('@/lib/db')
+    vi.mocked(db.backupRun.update).mockImplementation((async ({ where, data }: Row) => {
+      if (data.status === 'running') started.push(where.id)
+      const row = merge(h.runs.get(where.id) ?? {}, data)
+      h.runs.set(where.id, row)
+      return row
+    }) as never)
+    const oldScheduled = addRun({
+      trigger: 'scheduled',
+      createdAt: new Date(Date.now() - 60_000),
+    })
+    const manual = addRun({
+      connectionId: 'conn2',
+      trigger: 'manual',
+      createdAt: new Date(Date.now() - 1000),
+    })
+    const newScheduled = addRun({
+      connectionId: 'conn3',
+      trigger: 'scheduled',
+      createdAt: new Date(Date.now() - 500),
+    })
+
+    expect(await drainBackupQueue()).toBe(3)
+
+    expect(started).toEqual([manual, oldScheduled, newScheduled])
+  })
+
+  it('works through the queued runs oldest first, one after the other', async () => {
+    addConnection()
+    addConnection({ id: 'conn2' })
+    const started: string[] = []
+    const { db } = await import('@/lib/db')
+    vi.mocked(db.backupRun.update).mockImplementation((async ({ where, data }: Row) => {
+      if (data.status === 'running') {
+        // Nothing else holds the lock or is running while this one starts.
+        expect([...h.runs.values()].filter((r) => r.status === 'running')).toEqual([])
+        started.push(where.id)
+      }
+      const row = merge(h.runs.get(where.id) ?? {}, data)
+      h.runs.set(where.id, row)
+      return row
+    }) as never)
+    // Added first, queued later: the order is by createdAt, not by insertion.
+    const newer = addRun({ createdAt: new Date(Date.now() - 1000) })
+    const older = addRun({ connectionId: 'conn2', createdAt: new Date(Date.now() - 60_000) })
+
+    expect(await drainBackupQueue()).toBe(2)
+
+    expect(started).toEqual([older, newer])
+    expect(h.runs.get(older)?.status).toBe('done')
+    expect(h.runs.get(newer)?.status).toBe('done')
+    expect(h.runs.get(newer)?.startedAt.getTime()).toBeGreaterThanOrEqual(
+      h.runs.get(older)?.finishedAt.getTime()
+    )
+    expect(callsOf('backup')).toHaveLength(2)
+    expect(backupLockHolder()).toBeNull()
+  })
+
+  it('returns 0 and starts nothing while another run holds the lock', async () => {
+    addConnection()
+    ;(globalThis as Row).__torqvoiceBackupLock = { runId: 'other', since: Date.now() }
+    const runId = addRun()
+    expect(await drainBackupQueue()).toBe(0)
+    expect(h.runs.get(runId)?.status).toBe('queued')
+    expect(calls()).toHaveLength(0)
+    expect(backupLockHolder()?.runId).toBe('other')
+  })
+
+  it('a second call while one is draining returns 0 at once', async () => {
+    addConnection()
+    const runId = addRun()
+    const draining = drainBackupQueue()
+    expect(await drainBackupQueue()).toBe(0)
+    expect(await draining).toBe(1)
+    expect(h.runs.get(runId)?.status).toBe('done')
+    expect(callsOf('backup')).toHaveLength(1)
+  })
+
+  it('returns 0 when nothing is queued', async () => {
+    addConnection()
+    addRun({ status: 'done' })
+    expect(await drainBackupQueue()).toBe(0)
+    expect(calls()).toHaveLength(0)
+  })
+})
+
+describe('openPlatform', () => {
+  it("opens the workshop's own repository in the platform bucket, with the platform keys only", async () => {
+    setPlatformEnv()
+    const target = await openPlatform(ORG)
+
+    expect(target.kind).toBe('platform')
+    expect(target.organizationId).toBe(ORG)
+    expect(target.connectionId).toBeNull()
+    expect(target.base.repository.repository).toBe(
+      `s3:https://acc.r2.cloudflarestorage.com/${PLATFORM_ENV.PLATFORM_BACKUP_S3_BUCKET}/workshops/${ORG}`
+    )
+    expect(target.base.repository.env).toEqual({
+      AWS_ACCESS_KEY_ID: PLATFORM_ENV.PLATFORM_BACKUP_S3_ACCESS_KEY,
+      AWS_SECRET_ACCESS_KEY: PLATFORM_ENV.PLATFORM_BACKUP_S3_SECRET_KEY,
+    })
+    expect(target.base.repository.options).toEqual({ 's3.region': 'auto' })
+    expect(target.base.password).toMatch(/^[0-9a-f]{64}$/)
+    expect(target.base.password).toBe(
+      platformRepositoryPassword(PLATFORM_ENV.PLATFORM_BACKUP_MASTER_KEY, ORG)
+    )
+    expect(target.includeFiles).toBe(true)
+    expect(target.includeSecrets).toBe(true)
+    expect(target.tags('scheduled')).toEqual([`org:${ORG}`, 'scheduled', 'platform'])
+    // The workshop's row is made on first use.
+    expect(h.platform.get(ORG)).toMatchObject({ organizationId: ORG })
+  })
+
+  it('gives every workshop a password of its own', async () => {
+    setPlatformEnv()
+    const a = await openPlatform('org1')
+    const b = await openPlatform('org2')
+    expect(a.base.password).not.toBe(b.base.password)
+    expect(a.base.repository.repository).not.toBe(b.base.repository.repository)
+    expect(b.base.repository.repository.endsWith('/workshops/org2')).toBe(true)
+  })
+
+  it('refuses when platform backups are not configured', async () => {
+    h.cloud = true
+    await expect(openPlatform(ORG)).rejects.toThrow(/not configured/)
+    expect(h.platform.size).toBe(0)
+  })
+})
+
+describe('runBackup, platform', () => {
+  it('backs up into the platform repository and records the restore points on the row', async () => {
+    setPlatformEnv()
+    const runId = addRun({ kind: 'platform', connectionId: null, trigger: 'scheduled' })
+
+    const view = await runBackup(runId)
+
+    expect(view.status, view.error ?? '').toBe('done')
+    expect(view.snapshotId).toBe(SNAPSHOT_ID)
+    expect(calls().map((c) => c.sub)).toEqual([
+      'init',
+      'unlock',
+      'backup',
+      'forget',
+      'forget',
+      'forget',
+      'check',
+      'stats',
+      'snapshots',
+    ])
+    const repository = `s3:https://acc.r2.cloudflarestorage.com/${PLATFORM_ENV.PLATFORM_BACKUP_S3_BUCKET}/workshops/${ORG}`
+    for (const call of calls()) {
+      expect(call.env.RESTIC_REPOSITORY, call.sub).toBe(repository)
+      expect(call.env.AWS_ACCESS_KEY_ID, call.sub).toBe(PLATFORM_ENV.PLATFORM_BACKUP_S3_ACCESS_KEY)
+      expect(call.env.RESTIC_PASSWORD, call.sub).toBe(
+        platformRepositoryPassword(PLATFORM_ENV.PLATFORM_BACKUP_MASTER_KEY, ORG)
+      )
+      expect(call.argv.join(' '), call.sub).toContain('-o s3.region=auto')
+    }
+    const [backup] = callsOf('backup')
+    expect(backup.argv).toEqual(
+      expect.arrayContaining(['--tag', `org:${ORG}`, '--tag', 'scheduled', '--tag', 'platform'])
+    )
+    // Secret keys are always in a platform backup.
+    expect(vi.mocked(writeBackupData).mock.calls[0][1]).toMatchObject({ includeSecrets: true })
+    // No connection is opened for a platform run.
+    expect(loadConnection).not.toHaveBeenCalled()
+
+    const { db } = await import('@/lib/db')
+    const update = vi
+      .mocked(db.platformBackup.update)
+      .mock.calls.map((c) => c[0] as Row)
+      .find((c) => Array.isArray(c.data.snapshots))
+    expect(update?.where).toEqual({ organizationId: ORG })
+    const row = h.platform.get(ORG) as Row
+    expect(row.snapshots).toEqual([
+      expect.objectContaining({ id: SNAPSHOT_ID, trigger: 'scheduled', preRestore: false }),
+      expect.objectContaining({ id: SAFETY_ID, trigger: null, preRestore: true }),
+    ])
+    expect(row.lastGoodAt).toBeInstanceOf(Date)
+    expect(row.lastPruneAt).toBeInstanceOf(Date)
+    expect(row.lastCheckAt).toBeInstanceOf(Date)
+    expect(row.repositorySize).toBe(BigInt(6928))
+    expect(row.retryAt).toBeNull()
+  })
+
+  it('a failed scheduled platform run books its retry on the platform row and tells nobody', async () => {
+    setPlatformEnv()
+    failRestic('backup')
+    const before = Date.now()
+    const view = await runBackup(
+      addRun({ kind: 'platform', connectionId: null, trigger: 'scheduled' })
+    )
+    expect(view.status).toBe('failed')
+    const retryAt = (h.platform.get(ORG) as Row).retryAt as Date
+    expect(retryAt.getTime()).toBeGreaterThanOrEqual(before + 30 * 60 * 1000)
+    expect(notify).not.toHaveBeenCalled()
+    expect(sendOrgMail).not.toHaveBeenCalled()
+  })
+})
+
+describe('takeSafetySnapshot', () => {
+  it('keeps the uploads folder in the safety copy even when the destination leaves files out', async () => {
+    addConnection({ settings: { frequency: 'daily', contents: 'data', includeFiles: false } })
+    const target = await openTarget({ kind: 'destination', connectionId: CONN })
+    expect(target.includeFiles).toBe(false)
+
+    const safety = await takeSafetySnapshot(ORG, target)
+
+    expect(safety).toEqual({ snapshotId: SNAPSHOT_ID, kind: 'destination' })
+    const [backup] = callsOf('backup')
+    expect(backup.argv).toEqual(expect.arrayContaining(['--tag', 'pre-restore']))
+    expect(backup.argv.slice(backup.argv.indexOf('--') + 1)).toEqual([
+      stagingDirFor(ORG),
+      uploadsDir,
+    ])
+    // A destination set to "Data only" still gets the secret keys in the
+    // safety copy, so undoing the restore gives today's keys back.
+    expect(target.includeSecrets).toBe(false)
+    expect(vi.mocked(writeBackupData).mock.calls[0][1]).toMatchObject({ includeSecrets: true })
+    // A destination keeps no restore point list of ours.
+    expect(callsOf('snapshots')).toEqual([])
+  })
+
+  it('lists the platform safety copy as a restore point right away', async () => {
+    setPlatformEnv()
+    const target = await openPlatform(ORG)
+
+    const safety = await takeSafetySnapshot(ORG, target)
+
+    expect(safety).toEqual({ snapshotId: SNAPSHOT_ID, kind: 'platform' })
+    expect(calls().map((c) => c.sub)).toEqual(['init', 'unlock', 'backup', 'snapshots'])
+    const [listing] = callsOf('snapshots')
+    expect(listing.argv).toEqual(
+      expect.arrayContaining(['--host', `torqvoice-${ORG}`, '--tag', `org:${ORG}`])
+    )
+    const { db } = await import('@/lib/db')
+    const update = vi
+      .mocked(db.platformBackup.update)
+      .mock.calls.map((c) => c[0] as Row)
+      .find((c) => Array.isArray(c.data.snapshots))
+    expect(update?.where).toEqual({ organizationId: ORG })
+    // Only the list changes: no good run is recorded for a safety copy.
+    expect(Object.keys(update?.data ?? {})).toEqual(['snapshots'])
+    const row = h.platform.get(ORG) as Row
+    expect(row.snapshots).toEqual(
+      expect.arrayContaining([expect.objectContaining({ id: SNAPSHOT_ID })])
+    )
+    expect(row.lastGoodAt).toBeUndefined()
+  })
+
+  it('refuses a safety copy restic could not read every file for, and records nothing', async () => {
+    addConnection()
+    partialBackup()
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      const target = await openTarget({ kind: 'destination', connectionId: CONN })
+      await expect(takeSafetySnapshot(ORG, target)).rejects.toThrow(
+        'The safety copy is incomplete: some files could not be read. Nothing was changed.'
+      )
+      expect(callsOf('backup')).toHaveLength(1)
+    } finally {
+      errors.mockRestore()
+    }
+  })
+
+  it('refuses a partial platform safety copy without listing it as a restore point', async () => {
+    setPlatformEnv()
+    partialBackup()
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      const target = await openPlatform(ORG)
+      await expect(takeSafetySnapshot(ORG, target)).rejects.toThrow(/incomplete/)
+      expect(callsOf('snapshots')).toHaveLength(0)
+      expect((h.platform.get(ORG) as Row | undefined)?.snapshots).toBeUndefined()
+    } finally {
+      errors.mockRestore()
+    }
+  })
+
+  it('a failed listing is logged and the platform safety copy still succeeds', async () => {
+    setPlatformEnv()
+    failRestic('snapshots')
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      const target = await openPlatform(ORG)
+      const safety = await takeSafetySnapshot(ORG, target)
+
+      expect(safety).toEqual({ snapshotId: SNAPSHOT_ID, kind: 'platform' })
+      expect(callsOf('snapshots')).toHaveLength(1)
+      expect(errors).toHaveBeenCalledWith(
+        expect.stringContaining('Could not list the restore points'),
+        expect.objectContaining({ error: expect.any(String) })
+      )
+      expect((h.platform.get(ORG) as Row).snapshots).toBeUndefined()
+    } finally {
+      errors.mockRestore()
+    }
+  })
+})
+
+const realBin = process.env.RESTIC_REAL_BIN
+
+describe.skipIf(!realBin)('runBackup with the real restic binary', () => {
+  it('takes two snapshots into a local folder, the second one small', async () => {
+    process.env.RESTIC_BIN = realBin
+    h.server = localFolder
+    addConnection({ connectorId: 'local-folder' })
+    // Incompressible, so the first run has to store it.
+    const random = Buffer.alloc(256 * 1024)
+    for (let i = 0; i < random.length; i++) random[i] = Math.floor(Math.random() * 256)
+    writeFileSync(path.join(uploadsDir, 'big.bin'), random)
+
+    const first = await runBackup(addRun())
+    expect(first.status, first.error ?? '').toBe('done')
+    expect(first.bytesAdded).toBeGreaterThan(200_000)
+
+    writeFileSync(path.join(uploadsDir, 'photo.jpg'), 'new jpeg bytes')
+    const second = await runBackup(addRun())
+    expect(second.status, second.error ?? '').toBe('done')
+    expect(second.snapshotId).not.toBe(first.snapshotId)
+    expect(second.bytesAdded).toBeLessThan(20_000)
+
+    const repo = path.join(dataDir, 'backups', ORG)
+    expect(readdirSync(repo)).toContain('config')
+    const snapshots = await resticSnapshots(
+      {
+        repository: { repository: repo, env: {} },
+        password: h.password,
+        cacheDir: path.join(dataDir, 'restic-cache', ORG),
+      },
+      { host: `torqvoice-${ORG}`, tags: [`org:${ORG}`] }
+    )
+    expect(snapshots).toHaveLength(2)
+    expect(snapshots.every((s) => s.paths.includes(stagingDirFor(ORG)))).toBe(true)
+    expect(existsSync(path.join(stagingDirFor(ORG), 'data.json'))).toBe(false)
+    const state = h.connections.get(CONN)?.state
+    expect(state['backup.repositorySize']).toBeGreaterThan(200_000)
+  }, 120_000)
+})

+ 891 - 0
src/__tests__/lib/backup/explore.test.ts

@@ -0,0 +1,891 @@
+// @vitest-environment node
+import {
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  utimesSync,
+  writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Looking inside a restore point without restoring it. The engine and
+ * restic are mocked: `resticFind` returns the paths a test chooses and
+ * `resticDumpToFile` writes a real file the test chooses to the target the
+ * explorer asks for. What is checked is which file is pulled out, how it is
+ * read into lists, what is searched and paged, which upload a file request
+ * may reach, and when an open restore point (and its folder) goes away.
+ */
+
+type Row = Record<string, any>
+
+const h = vi.hoisted(() => ({
+  root: '',
+  /** Organisation id `openTarget` reports for a destination target. */
+  orgId: 'org1',
+  /** What `resticFind` answers, by pattern. */
+  found: {} as Record<string, string[]>,
+  /** What `resticDumpToFile` writes, by snapshot path. */
+  files: {} as Record<string, string>,
+  finds: [] as { snapshotId: string; pattern: string }[],
+  dumps: [] as { snapshotId: string; path: string; target: string }[],
+  /** Set, holds every pull until released: for opens that must stay in flight. */
+  gate: null as Promise<void> | null,
+}))
+
+vi.mock('@/lib/upload-root', () => ({ dataRoot: () => h.root }))
+
+vi.mock('@/lib/backup/engine', () => ({
+  openTarget: vi.fn(async (target: Row) => ({
+    kind: target.kind,
+    organizationId: target.kind === 'platform' ? target.organizationId : h.orgId,
+    connectionId: target.kind === 'destination' ? target.connectionId : null,
+    base: { repository: { repository: '/repo' }, password: 'pw', cacheDir: '/cache' },
+  })),
+}))
+
+vi.mock('@/lib/backup/restic', () => ({
+  resticFind: vi.fn(async (_opts: unknown, input: { snapshotId: string; pattern: string }) => {
+    h.finds.push(input)
+    return (h.found[input.pattern] ?? []).map((p) => ({ path: p, size: null, mtime: null }))
+  }),
+  resticDumpToFile: vi.fn(
+    async (_opts: unknown, input: { snapshotId: string; path: string; target: string }) => {
+      h.dumps.push(input)
+      if (h.gate) await h.gate
+      const body = h.files[input.path]
+      if (body === undefined) throw new Error(`no such file in the fake snapshot: ${input.path}`)
+      mkdirSync(path.dirname(input.target), { recursive: true })
+      writeFileSync(input.target, body)
+      return { bytes: Buffer.byteLength(body) }
+    }
+  ),
+}))
+
+import { openTarget } from '@/lib/backup/engine'
+import {
+  closeRestorePoints,
+  exploreDirFor,
+  extractRestorePointFile,
+  getRestorePointRow,
+  listRestorePointRows,
+  openingStage,
+  openRestorePoint,
+  RESTORE_POINTS_BUSY,
+  RestorePointClosedError,
+  sweepRestorePoints,
+} from '@/lib/backup/explore'
+import { resticDumpToFile, resticFind } from '@/lib/backup/restic'
+import type { RunTarget } from '@/lib/backup/engine'
+
+h.root = mkdtempSync(path.join(tmpdir(), 'backup-explore-test-'))
+afterAll(() => rmSync(h.root, { recursive: true, force: true }))
+
+const ORG = 'org1'
+const SNAP = '28891c8f448a3c36'
+const SNAP2 = '9d6cb16e448a3c36'
+const TARGET: RunTarget = { kind: 'platform', organizationId: ORG }
+const STAGED = '/tmp/torqvoice-backup-staging/org1/data.json'
+const STAGED_OLD = '/tmp/torqvoice-backup-staging/cmoldinstall1/data.json'
+const UPLOADED = '/data/uploads/org1/services/data.json'
+
+const DATA = {
+  version: 2,
+  exportedAt: '2026-10-01T02:00:00.000Z',
+  data: {
+    customers: [
+      {
+        id: 'c1',
+        organizationId: ORG,
+        userId: 'u1',
+        name: 'Ada Lovelace',
+        email: 'ada@example.test',
+        phone: '+47 911 22 333',
+        customerNumber: 'C-1001',
+        createdAt: '2026-01-01T00:00:00.000Z',
+      },
+      {
+        id: 'c2',
+        organizationId: ORG,
+        userId: 'u1',
+        name: 'Bob Builder',
+        company: 'Bobs Garage',
+        createdAt: '2026-03-01T00:00:00.000Z',
+      },
+    ],
+    vehicles: [
+      {
+        id: 'v1',
+        organizationId: ORG,
+        licensePlate: 'AB12345',
+        year: 2019,
+        make: 'Volvo',
+        model: 'V70',
+        vin: 'YV1SW58D123456789',
+        customerId: 'c1',
+        createdAt: '2026-02-01T00:00:00.000Z',
+        serviceRecords: [
+          {
+            id: 'sr1',
+            organizationId: ORG,
+            invoiceNumber: 'INV-7',
+            title: 'Oil change',
+            status: 'completed',
+            serviceDate: '2026-04-01T10:00:00.000Z',
+            partItems: [{ id: 'pi1', name: 'Filter' }],
+          },
+          {
+            id: 'sr2',
+            invoiceNumber: 'INV-5',
+            title: 'Brake pads',
+            status: 'open',
+            serviceDate: '2026-03-15T10:00:00.000Z',
+          },
+        ],
+      },
+      {
+        id: 'v2',
+        licensePlate: 'CD99887',
+        make: 'Tesla',
+        model: 'Model 3',
+        createdAt: '2026-02-10T00:00:00.000Z',
+      },
+    ],
+    counterSales: [
+      { id: 'cs1', invoiceNumber: 'INV-9', title: 'Wipers', createdAt: '2026-05-01T00:00:00.000Z' },
+    ],
+    quotes: [
+      {
+        id: 'q1',
+        quoteNumber: 'Q-3',
+        title: 'Timing belt',
+        customerId: 'c2',
+        status: 'draft',
+        createdAt: '2026-06-01T00:00:00.000Z',
+      },
+    ],
+    inventoryParts: [
+      {
+        id: 'p1',
+        name: 'Oil filter',
+        partNumber: 'OF-123',
+        sku: 'SKU-9',
+        createdAt: '2026-01-05T00:00:00.000Z',
+      },
+    ],
+    // Not shown by the explorer, and must not upset it.
+    settings: [{ id: 's1', key: 'workshop.name', value: 'Garage' }],
+  },
+}
+
+function stage(data: unknown = DATA, staged: string[] = [STAGED]): void {
+  h.found['data.json'] = staged
+  for (const p of staged) h.files[p] = JSON.stringify(data)
+}
+
+function ids(items: { id: string }[]): string[] {
+  return items.map((i) => i.id)
+}
+
+beforeEach(() => {
+  delete (globalThis as { __torqvoiceExplore?: unknown }).__torqvoiceExplore
+  rmSync(path.join(h.root, 'restore-explore'), { recursive: true, force: true })
+  h.orgId = ORG
+  h.found = {}
+  h.files = {}
+  h.finds = []
+  h.dumps = []
+  h.gate = null
+  vi.mocked(openTarget).mockClear()
+})
+
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+describe('openRestorePoint', () => {
+  it('pulls the staged data file into the restore point folder and counts what it holds', async () => {
+    stage()
+    const summary = await openRestorePoint(TARGET, SNAP)
+
+    expect(summary).toEqual({
+      snapshotId: SNAP,
+      exportedAt: '2026-10-01T02:00:00.000Z',
+      counts: {
+        customers: 2,
+        vehicles: 2,
+        // Two lifted out of their vehicle, one counter sale.
+        serviceRecords: 3,
+        quotes: 1,
+        inventoryParts: 1,
+      },
+    })
+    expect(h.finds).toEqual([{ snapshotId: SNAP, pattern: 'data.json' }])
+    expect(h.dumps).toHaveLength(1)
+    expect(h.dumps[0].path).toBe(STAGED)
+    expect(h.dumps[0].snapshotId).toBe(SNAP)
+    expect(h.dumps[0].target).toBe(path.join(exploreDirFor(ORG, SNAP), 'data.json'))
+    expect(h.dumps[0].target.startsWith(path.join(h.root, 'restore-explore', ORG, SNAP))).toBe(true)
+  })
+
+  it('removes the data file from disk once it is parsed, keeping the rows in memory', async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    expect(existsSync(h.dumps[0].target)).toBe(false)
+    expect(listRestorePointRows(ORG, SNAP, 'customers', '', 1).total).toBe(2)
+    // The folder stays for the uploads pulled out later.
+    expect(existsSync(exploreDirFor(ORG, SNAP))).toBe(true)
+  })
+
+  it('removes the data file from disk when it cannot be read', async () => {
+    h.found['data.json'] = [STAGED]
+    h.files[STAGED] = '{"not json'
+    await expect(openRestorePoint(TARGET, SNAP)).rejects.toThrow('could not be read')
+    expect(existsSync(h.dumps[0].target)).toBe(false)
+  })
+
+  it('returns exportedAt as null when the file has none', async () => {
+    stage({ version: 2, data: { customers: [{ id: 'c1', name: 'A' }] } })
+    const summary = await openRestorePoint(TARGET, SNAP)
+    expect(summary.exportedAt).toBeNull()
+    expect(summary.counts).toEqual({
+      customers: 1,
+      vehicles: 0,
+      serviceRecords: 0,
+      quotes: 0,
+      inventoryParts: 0,
+    })
+  })
+
+  it('does not pull the data file again while the restore point is open', async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    const again = await openRestorePoint(TARGET, SNAP)
+    expect(again.counts.customers).toBe(2)
+    expect(h.dumps).toHaveLength(1)
+  })
+
+  it('prefers the data file under the workshop’s own id when another id is there too', async () => {
+    stage(DATA, [STAGED_OLD, STAGED])
+    h.files[STAGED_OLD] = JSON.stringify({ version: 2, data: { customers: [] } })
+    const summary = await openRestorePoint(TARGET, SNAP)
+    expect(h.dumps.map((d) => d.path)).toEqual([STAGED])
+    expect(summary.counts.customers).toBe(2)
+  })
+
+  it('falls back to the other id’s data file when only that one is there', async () => {
+    stage(DATA, [STAGED_OLD])
+    const summary = await openRestorePoint(TARGET, SNAP)
+    expect(h.dumps.map((d) => d.path)).toEqual([STAGED_OLD])
+    expect(summary.counts.customers).toBe(2)
+  })
+
+  it('ignores a data.json in the uploads folder', async () => {
+    stage(DATA, [UPLOADED, STAGED])
+    await openRestorePoint(TARGET, SNAP)
+    expect(h.dumps.map((d) => d.path)).toEqual([STAGED])
+  })
+
+  it('fails when the only data.json is an upload', async () => {
+    stage(DATA, [UPLOADED, '/srv/data.json', '/torqvoice-backup-staging/data.json'])
+    await expect(openRestorePoint(TARGET, SNAP)).rejects.toThrow(
+      'This restore point holds no data file'
+    )
+    expect(h.dumps).toEqual([])
+  })
+
+  it('rejects a snapshot id that is not a restic id before opening anything', async () => {
+    for (const bad of ['abc1234', 'ABCDEF12', '../../etc', `${'a'.repeat(65)}`, 'abcdefg1', '']) {
+      await expect(openRestorePoint(TARGET, bad)).rejects.toThrow('Invalid snapshot id')
+    }
+    expect(openTarget).not.toHaveBeenCalled()
+    expect(h.finds).toEqual([])
+  })
+
+  it('rejects a data file that is not a backup and removes its folder', async () => {
+    stage({ hello: 'world' })
+    await expect(openRestorePoint(TARGET, SNAP)).rejects.toThrow('could not be read')
+    expect(existsSync(exploreDirFor(ORG, SNAP))).toBe(false)
+    expect(() => listRestorePointRows(ORG, SNAP, 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+
+    stage({ version: '2', data: {} })
+    await expect(openRestorePoint(TARGET, SNAP)).rejects.toThrow('could not be read')
+    stage({ version: 2, data: [] })
+    await expect(openRestorePoint(TARGET, SNAP)).rejects.toThrow('could not be read')
+    expect(existsSync(exploreDirFor(ORG, SNAP))).toBe(false)
+  })
+
+  it('takes the organisation from the opened target', async () => {
+    stage(DATA, ['/tmp/torqvoice-backup-staging/org7/data.json'])
+    h.orgId = 'org7'
+    await openRestorePoint({ kind: 'destination', connectionId: 'conn1' }, SNAP)
+    expect(listRestorePointRows('org7', SNAP, 'customers', '', 1).total).toBe(2)
+    expect(() => listRestorePointRows(ORG, SNAP, 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+  })
+})
+
+describe('openingStage', () => {
+  /** Has the next find and dump write down the stage they see, then do what they always do. */
+  function watchStages(look: () => string | null): { find: unknown[]; dump: unknown[] } {
+    const seen = { find: [] as unknown[], dump: [] as unknown[] }
+    const find = vi.mocked(resticFind).getMockImplementation()
+    const dump = vi.mocked(resticDumpToFile).getMockImplementation()
+    if (!find || !dump) throw new Error('restic mocks have no implementation')
+    vi.mocked(resticFind).mockImplementationOnce(async (...args) => {
+      seen.find.push(look())
+      return find(...args)
+    })
+    vi.mocked(resticDumpToFile).mockImplementationOnce(async (...args) => {
+      seen.dump.push(look())
+      return dump(...args)
+    })
+    return seen
+  }
+
+  it('reports locating while the data file is looked for and pulling while it is pulled', async () => {
+    stage()
+    const seen = watchStages(() => openingStage(ORG, SNAP))
+    await openRestorePoint(TARGET, SNAP)
+    expect(seen).toEqual({ find: ['locating'], dump: ['pulling'] })
+  })
+
+  it('is null before anything was opened and once the restore point is open', async () => {
+    expect(openingStage(ORG, SNAP)).toBeNull()
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    expect(openingStage(ORG, SNAP)).toBeNull()
+  })
+
+  it('is null after an open that failed', async () => {
+    h.found['data.json'] = [STAGED]
+    // No file behind the path, so the dump throws.
+    const seen = watchStages(() => openingStage(ORG, SNAP))
+    await expect(openRestorePoint(TARGET, SNAP)).rejects.toThrow('no such file')
+    expect(seen.dump).toEqual(['pulling'])
+    expect(openingStage(ORG, SNAP)).toBeNull()
+  })
+
+  it('reports only the workshop’s own open', async () => {
+    stage()
+    const seen = watchStages(() => `${openingStage(ORG, SNAP)}/${openingStage('otherorg', SNAP)}`)
+    await openRestorePoint(TARGET, SNAP)
+    expect(seen).toEqual({ find: ['locating/null'], dump: ['pulling/null'] })
+  })
+})
+
+describe('listRestorePointRows', () => {
+  beforeEach(async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+  })
+
+  it('lists each dataset newest first, with a title and a line under it', () => {
+    const customers = listRestorePointRows(ORG, SNAP, 'customers', '', 1)
+    expect(customers).toMatchObject({ total: 2, page: 1, pages: 1 })
+    expect(customers.items).toEqual([
+      { id: 'c2', title: 'Bob Builder', subtitle: 'Bobs Garage', at: '2026-03-01T00:00:00.000Z' },
+      {
+        id: 'c1',
+        title: 'Ada Lovelace',
+        subtitle: '#C-1001 · ada@example.test · +47 911 22 333',
+        at: '2026-01-01T00:00:00.000Z',
+      },
+    ])
+
+    const vehicles = listRestorePointRows(ORG, SNAP, 'vehicles', '', 1)
+    expect(vehicles.items).toEqual([
+      { id: 'v2', title: 'CD99887 · Tesla Model 3', subtitle: '', at: '2026-02-10T00:00:00.000Z' },
+      {
+        id: 'v1',
+        title: 'AB12345 · 2019 Volvo V70',
+        subtitle: 'Ada Lovelace',
+        at: '2026-02-01T00:00:00.000Z',
+      },
+    ])
+
+    const work = listRestorePointRows(ORG, SNAP, 'serviceRecords', '', 1)
+    expect(ids(work.items)).toEqual(['cs1', 'sr1', 'sr2'])
+    expect(work.items[1]).toEqual({
+      id: 'sr1',
+      title: '#INV-7 Oil change',
+      subtitle: 'AB12345 · 2019 Volvo V70 · completed · 2026-04-01',
+      at: '2026-04-01T10:00:00.000Z',
+    })
+    expect(work.items[0]).toMatchObject({ title: '#INV-9 Wipers', subtitle: '' })
+
+    expect(listRestorePointRows(ORG, SNAP, 'quotes', '', 1).items).toEqual([
+      {
+        id: 'q1',
+        title: '#Q-3 Timing belt',
+        subtitle: 'Bob Builder · draft',
+        at: '2026-06-01T00:00:00.000Z',
+      },
+    ])
+    expect(listRestorePointRows(ORG, SNAP, 'inventoryParts', '', 1).items).toEqual([
+      { id: 'p1', title: 'Oil filter', subtitle: 'OF-123 · SKU-9', at: '2026-01-05T00:00:00.000Z' },
+    ])
+  })
+
+  it('searches titles and lines, ignoring case', () => {
+    expect(ids(listRestorePointRows(ORG, SNAP, 'customers', 'lovelace', 1).items)).toEqual(['c1'])
+    expect(ids(listRestorePointRows(ORG, SNAP, 'customers', 'BOBS', 1).items)).toEqual(['c2'])
+    expect(ids(listRestorePointRows(ORG, SNAP, 'serviceRecords', 'brake', 1).items)).toEqual([
+      'sr2',
+    ])
+    // The vehicle label in a work order's line.
+    expect(ids(listRestorePointRows(ORG, SNAP, 'serviceRecords', 'volvo', 1).items)).toEqual([
+      'sr1',
+      'sr2',
+    ])
+    expect(ids(listRestorePointRows(ORG, SNAP, 'quotes', 'bob builder', 1).items)).toEqual(['q1'])
+  })
+
+  it('searches plate, VIN, email, phone, part number, invoice and customer number', () => {
+    const one = (dataset: Parameters<typeof listRestorePointRows>[2], q: string) =>
+      ids(listRestorePointRows(ORG, SNAP, dataset, q, 1).items)
+    expect(one('vehicles', 'ab123')).toEqual(['v1'])
+    // Not in the title or the line (the line is the owner): the VIN field.
+    expect(one('vehicles', 'yv1sw58d')).toEqual(['v1'])
+    expect(one('customers', 'ADA@EXAMPLE')).toEqual(['c1'])
+    expect(one('customers', '911 22')).toEqual(['c1'])
+    expect(one('customers', 'c-1001')).toEqual(['c1'])
+    expect(one('inventoryParts', 'of-123')).toEqual(['p1'])
+    expect(one('inventoryParts', 'sku-9')).toEqual(['p1'])
+    expect(one('serviceRecords', 'inv-9')).toEqual(['cs1'])
+    expect(one('quotes', 'q-3')).toEqual(['q1'])
+    expect(one('customers', 'nobody')).toEqual([])
+  })
+
+  it('trims the query and treats a blank one as no filter', () => {
+    expect(listRestorePointRows(ORG, SNAP, 'customers', '   ', 1).total).toBe(2)
+    expect(ids(listRestorePointRows(ORG, SNAP, 'customers', '  ada  ', 1).items)).toEqual(['c1'])
+  })
+
+  it('pages by 50 and clamps the page', async () => {
+    const parts = Array.from({ length: 120 }, (_, i) => ({
+      id: `p${i}`,
+      name: `Part ${i}`,
+      createdAt: new Date(Date.UTC(2026, 0, 1) + i * 60_000).toISOString(),
+    }))
+    stage({ version: 2, data: { inventoryParts: parts } })
+    await openRestorePoint(TARGET, SNAP2)
+
+    const first = listRestorePointRows(ORG, SNAP2, 'inventoryParts', '', 1)
+    expect(first).toMatchObject({ total: 120, page: 1, pages: 3 })
+    expect(first.items).toHaveLength(50)
+    expect(first.items[0].id).toBe('p119')
+
+    const last = listRestorePointRows(ORG, SNAP2, 'inventoryParts', '', 3)
+    expect(last.items).toHaveLength(20)
+    expect(last.items.at(-1)?.id).toBe('p0')
+
+    expect(listRestorePointRows(ORG, SNAP2, 'inventoryParts', '', 99)).toMatchObject({
+      page: 3,
+      pages: 3,
+    })
+    expect(listRestorePointRows(ORG, SNAP2, 'inventoryParts', '', 0).page).toBe(1)
+    expect(listRestorePointRows(ORG, SNAP2, 'inventoryParts', '', -5).page).toBe(1)
+
+    // Nothing found is one empty page, not page zero.
+    expect(listRestorePointRows(ORG, SNAP2, 'inventoryParts', 'zzz', 4)).toEqual({
+      items: [],
+      total: 0,
+      page: 1,
+      pages: 1,
+    })
+  })
+
+  it('refuses a restore point that was never opened, or another organisation’s', () => {
+    expect(() => listRestorePointRows(ORG, SNAP2, 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+    expect(() => listRestorePointRows('org2', SNAP, 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+    try {
+      listRestorePointRows('org2', SNAP, 'customers', '', 1)
+    } catch (err) {
+      expect((err as Error).message).toBe('restorePointClosed')
+      expect((err as Error).name).toBe('RestorePointClosedError')
+    }
+  })
+})
+
+describe('getRestorePointRow', () => {
+  beforeEach(async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+  })
+
+  it('returns the row as the file has it, without the bookkeeping keys', () => {
+    const customer = getRestorePointRow(ORG, SNAP, 'customers', 'c1')
+    expect(customer?.item.title).toBe('Ada Lovelace')
+    expect(customer?.row).not.toHaveProperty('organizationId')
+    expect(customer?.row).not.toHaveProperty('userId')
+    expect(customer?.row).toMatchObject({ id: 'c1', email: 'ada@example.test' })
+
+    const work = getRestorePointRow(ORG, SNAP, 'serviceRecords', 'sr1')
+    expect(work?.row).not.toHaveProperty('_vehicle')
+    expect(work?.row).not.toHaveProperty('organizationId')
+    expect(work?.row).toMatchObject({
+      id: 'sr1',
+      vehicleId: 'v1',
+      partItems: [{ id: 'pi1', name: 'Filter' }],
+    })
+    // The label still makes the line under the title.
+    expect(work?.item.subtitle).toContain('AB12345')
+
+    expect(getRestorePointRow(ORG, SNAP, 'serviceRecords', 'cs1')?.row).toMatchObject({
+      id: 'cs1',
+      vehicleId: null,
+    })
+  })
+
+  it('returns null for an id that is not there', () => {
+    expect(getRestorePointRow(ORG, SNAP, 'customers', 'nope')).toBeNull()
+    // An id from another dataset is not found in this one.
+    expect(getRestorePointRow(ORG, SNAP, 'customers', 'v1')).toBeNull()
+  })
+
+  it('refuses another organisation', () => {
+    expect(() => getRestorePointRow('org2', SNAP, 'customers', 'c1')).toThrow(
+      RestorePointClosedError
+    )
+  })
+})
+
+describe('extractRestorePointFile', () => {
+  const OWN = '/srv/torqvoice/data/uploads/org1/services/photo.jpg'
+  const OTHER_ORG = '/srv/torqvoice/data/uploads/org2/services/photo.jpg'
+
+  beforeEach(async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    h.finds = []
+    h.dumps = []
+  })
+
+  it('needs the restore point open', async () => {
+    await expect(extractRestorePointFile(ORG, SNAP2, 'services', 'photo.jpg')).rejects.toThrow(
+      RestorePointClosedError
+    )
+    await expect(extractRestorePointFile('org2', SNAP, 'services', 'photo.jpg')).rejects.toThrow(
+      RestorePointClosedError
+    )
+  })
+
+  it('refuses a category it does not know', async () => {
+    for (const category of ['secrets', '..', 'services/..', '']) {
+      await expect(extractRestorePointFile(ORG, SNAP, category, 'photo.jpg')).rejects.toThrow(
+        'Unknown file category'
+      )
+    }
+    expect(h.finds).toEqual([])
+  })
+
+  it('refuses a name with a slash or two dots', async () => {
+    for (const name of [
+      'a/b.jpg',
+      '../photo.jpg',
+      '..',
+      'photo..jpg',
+      '.hidden',
+      'a\\b.jpg',
+      '',
+      'x'.repeat(202),
+    ]) {
+      await expect(extractRestorePointFile(ORG, SNAP, 'services', name)).rejects.toThrow(
+        'Invalid file name'
+      )
+    }
+    expect(h.finds).toEqual([])
+  })
+
+  it('pulls the file only from this workshop’s uploads folder, in that category', async () => {
+    h.found['photo.jpg'] = [
+      OTHER_ORG,
+      '/srv/torqvoice/data/uploads/org1/vehicles/photo.jpg',
+      '/srv/torqvoice/data/elsewhere/org1/services/photo.jpg',
+      OWN,
+    ]
+    h.files[OWN] = 'jpeg bytes'
+    h.files[OTHER_ORG] = 'not ours'
+
+    const out = await extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')
+    expect(h.finds).toEqual([{ snapshotId: SNAP, pattern: 'photo.jpg' }])
+    expect(h.dumps.map((d) => d.path)).toEqual([OWN])
+    expect(out.file).toBe(path.join(exploreDirFor(ORG, SNAP), 'files', 'services', 'photo.jpg'))
+    expect(out.bytes).toBe(10)
+    expect(readFileSync(out.file, 'utf8')).toBe('jpeg bytes')
+  })
+
+  it('does not return a same-named file from another organisation’s uploads folder', async () => {
+    h.found['photo.jpg'] = [OTHER_ORG, '/srv/torqvoice/data/uploads/org1/vehicles/photo.jpg']
+    h.files[OTHER_ORG] = 'not ours'
+    await expect(extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')).rejects.toThrow(
+      'That file is not in this restore point'
+    )
+    expect(h.dumps).toEqual([])
+  })
+
+  it('serves a file under the id the snapshot was taken with, for a repository an earlier install wrote', async () => {
+    await closeRestorePoints(ORG)
+    stage(DATA, [STAGED_OLD])
+    await openRestorePoint(TARGET, SNAP)
+    const OLD_OWN = '/srv/torqvoice/data/uploads/cmoldinstall1/services/photo.jpg'
+    h.found['photo.jpg'] = [OLD_OWN]
+    h.files[OLD_OWN] = 'old bytes'
+
+    const out = await extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')
+    expect(h.dumps.map((d) => d.path)).toEqual([STAGED_OLD, OLD_OWN])
+    expect(readFileSync(out.file, 'utf8')).toBe('old bytes')
+  })
+
+  it('still prefers the workshop’s own uploads folder when both ids are in the snapshot', async () => {
+    await closeRestorePoints(ORG)
+    stage(DATA, [STAGED_OLD])
+    await openRestorePoint(TARGET, SNAP)
+    const OLD_OWN = '/srv/torqvoice/data/uploads/cmoldinstall1/services/photo.jpg'
+    const NOW_OWN = '/srv/torqvoice/data/uploads/org1/services/photo.jpg'
+    h.found['photo.jpg'] = [OLD_OWN, NOW_OWN]
+    h.files[OLD_OWN] = 'old bytes'
+    h.files[NOW_OWN] = 'new bytes'
+
+    const out = await extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')
+    expect(h.dumps.at(-1)?.path).toBe(NOW_OWN)
+    expect(readFileSync(out.file, 'utf8')).toBe('new bytes')
+  })
+
+  it('keeps the file it pulled out, so a second request does not ask restic again', async () => {
+    h.found['photo.jpg'] = [OWN]
+    h.files[OWN] = 'jpeg bytes'
+    const first = await extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')
+    const second = await extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')
+    expect(second).toEqual(first)
+    expect(h.finds).toHaveLength(1)
+    expect(h.dumps).toHaveLength(1)
+  })
+
+  it('opens the restore point’s own target to look', async () => {
+    vi.mocked(openTarget).mockClear()
+    h.found['photo.jpg'] = [OWN]
+    h.files[OWN] = 'jpeg bytes'
+    await extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')
+    expect(openTarget).toHaveBeenCalledWith(TARGET)
+  })
+})
+
+describe('closing restore points', () => {
+  const MIN = 60 * 1000
+
+  it('closeRestorePoints removes the workshop’s open points and their folders', async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    stage(DATA, ['/tmp/torqvoice-backup-staging/org2/data.json'])
+    await openRestorePoint({ kind: 'platform', organizationId: 'org2' }, SNAP)
+    const dir = exploreDirFor(ORG, SNAP)
+    expect(existsSync(dir)).toBe(true)
+
+    await closeRestorePoints(ORG)
+
+    expect(existsSync(dir)).toBe(false)
+    expect(() => listRestorePointRows(ORG, SNAP, 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+    // Another workshop's stays open.
+    expect(listRestorePointRows('org2', SNAP, 'customers', '', 1).total).toBe(2)
+    expect(existsSync(exploreDirFor('org2', SNAP))).toBe(true)
+  })
+
+  it('closes a point open longer than 15 minutes on the next open', async () => {
+    vi.useFakeTimers({ toFake: ['Date'] })
+    vi.setSystemTime(new Date('2026-10-30T10:00:00Z'))
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    const dir = exploreDirFor(ORG, SNAP)
+
+    vi.setSystemTime(new Date(Date.parse('2026-10-30T10:00:00Z') + 15 * MIN + 1))
+    expect(() => listRestorePointRows(ORG, SNAP, 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+    expect(existsSync(dir)).toBe(true)
+
+    await openRestorePoint(TARGET, SNAP2)
+    expect(existsSync(dir)).toBe(false)
+    expect(existsSync(exploreDirFor(ORG, SNAP2))).toBe(true)
+  })
+
+  it('keeps a point open while it is being looked at', async () => {
+    vi.useFakeTimers({ toFake: ['Date'] })
+    const start = Date.parse('2026-10-30T10:00:00Z')
+    vi.setSystemTime(new Date(start))
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+
+    vi.setSystemTime(new Date(start + 10 * MIN))
+    listRestorePointRows(ORG, SNAP, 'customers', '', 1)
+    vi.setSystemTime(new Date(start + 20 * MIN))
+    await openRestorePoint(TARGET, SNAP2)
+    expect(listRestorePointRows(ORG, SNAP, 'customers', '', 1).total).toBe(2)
+    expect(existsSync(exploreDirFor(ORG, SNAP))).toBe(true)
+  })
+
+  it('removes folders left on disk by an earlier process on the next open', async () => {
+    // What a restart leaves: folders on disk, nothing in this process's registry.
+    const stale = exploreDirFor(ORG, SNAP2)
+    const otherOrg = exploreDirFor('org2', 'bbbbbbb1')
+    for (const dir of [stale, otherOrg]) {
+      mkdirSync(path.join(dir, 'files', 'services'), { recursive: true })
+      writeFileSync(path.join(dir, 'data.json'), JSON.stringify(DATA))
+      writeFileSync(path.join(dir, 'files', 'services', 'photo.jpg'), 'jpeg bytes')
+      const old = new Date(Date.now() - 60 * MIN)
+      utimesSync(dir, old, old)
+    }
+    // Something under the data root that is not a restore point folder is left alone.
+    const stray = path.join(h.root, 'restore-explore', 'not-an-org!')
+    mkdirSync(stray, { recursive: true })
+    const outside = path.join(h.root, 'uploads', ORG)
+    mkdirSync(outside, { recursive: true })
+
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+
+    expect(existsSync(stale)).toBe(false)
+    expect(existsSync(otherOrg)).toBe(false)
+    expect(existsSync(exploreDirFor(ORG, SNAP))).toBe(true)
+    expect(existsSync(stray)).toBe(true)
+    expect(existsSync(outside)).toBe(true)
+  })
+
+  it('opens fine when the restore-explore folder does not exist yet', async () => {
+    rmSync(path.join(h.root, 'restore-explore'), { recursive: true, force: true })
+    stage()
+    await expect(openRestorePoint(TARGET, SNAP)).resolves.toMatchObject({ snapshotId: SNAP })
+  })
+
+  it('keeps the folder of a point that is still open when another one is opened', async () => {
+    vi.useFakeTimers({ toFake: ['Date'] })
+    const start = Date.parse('2026-10-30T10:00:00Z')
+    vi.setSystemTime(new Date(start))
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    h.found['photo.jpg'] = ['/srv/torqvoice/data/uploads/org1/services/photo.jpg']
+    h.files['/srv/torqvoice/data/uploads/org1/services/photo.jpg'] = 'jpeg bytes'
+    const { file } = await extractRestorePointFile(ORG, SNAP, 'services', 'photo.jpg')
+
+    vi.setSystemTime(new Date(start + 5 * MIN))
+    await openRestorePoint(TARGET, SNAP2)
+
+    expect(existsSync(exploreDirFor(ORG, SNAP))).toBe(true)
+    expect(readFileSync(file, 'utf8')).toBe('jpeg bytes')
+    expect(listRestorePointRows(ORG, SNAP, 'customers', '', 1).total).toBe(2)
+  })
+
+  it('keeps at most two open, closing the oldest', async () => {
+    vi.useFakeTimers({ toFake: ['Date'] })
+    const start = Date.parse('2026-10-30T10:00:00Z')
+    const snaps = ['aaaaaaa1', 'aaaaaaa2', 'aaaaaaa3']
+    stage()
+    for (const [i, snap] of snaps.entries()) {
+      vi.setSystemTime(new Date(start + i * 1000))
+      await openRestorePoint(TARGET, snap)
+    }
+
+    expect(() => listRestorePointRows(ORG, snaps[0], 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+    expect(existsSync(exploreDirFor(ORG, snaps[0]))).toBe(false)
+    for (const snap of snaps.slice(1)) {
+      expect(listRestorePointRows(ORG, snap, 'customers', '', 1).total).toBe(2)
+      expect(existsSync(exploreDirFor(ORG, snap))).toBe(true)
+    }
+  })
+})
+
+describe('opens in flight', () => {
+  it('count against the cap in the order they came, so a third is refused while two are pulling', async () => {
+    stage()
+    let release!: () => void
+    h.gate = new Promise<void>((resolve) => {
+      release = resolve
+    })
+    const first = openRestorePoint(TARGET, 'aaaaaaa1')
+    const second = openRestorePoint(TARGET, 'aaaaaaa2')
+    const third = openRestorePoint(TARGET, 'aaaaaaa3')
+
+    await expect(third).rejects.toThrow(RESTORE_POINTS_BUSY)
+    expect(existsSync(exploreDirFor(ORG, 'aaaaaaa3'))).toBe(false)
+
+    release()
+    await expect(first).resolves.toMatchObject({ snapshotId: 'aaaaaaa1' })
+    await expect(second).resolves.toMatchObject({ snapshotId: 'aaaaaaa2' })
+    expect(listRestorePointRows(ORG, 'aaaaaaa1', 'customers', '', 1).total).toBe(2)
+    expect(listRestorePointRows(ORG, 'aaaaaaa2', 'customers', '', 1).total).toBe(2)
+  })
+
+  it('a second request for the snapshot being pulled joins it rather than counting twice', async () => {
+    stage()
+    let release!: () => void
+    h.gate = new Promise<void>((resolve) => {
+      release = resolve
+    })
+    const first = openRestorePoint(TARGET, 'aaaaaaa1')
+    const again = openRestorePoint(TARGET, 'aaaaaaa1')
+    const second = openRestorePoint(TARGET, 'aaaaaaa2')
+    release()
+    await expect(Promise.all([first, again, second])).resolves.toHaveLength(3)
+    expect(h.dumps.filter((d) => d.snapshotId === 'aaaaaaa1')).toHaveLength(1)
+  })
+})
+
+describe('sweepRestorePoints', () => {
+  const MIN = 60 * 1000
+
+  it('closes a point open longer than 15 minutes and removes its folder', async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+    const dir = exploreDirFor(ORG, SNAP)
+    expect(existsSync(dir)).toBe(true)
+
+    await sweepRestorePoints(Date.now() + 16 * MIN)
+
+    expect(existsSync(dir)).toBe(false)
+    expect(() => listRestorePointRows(ORG, SNAP, 'customers', '', 1)).toThrow(
+      RestorePointClosedError
+    )
+    expect(() => getRestorePointRow(ORG, SNAP, 'customers', 'c1')).toThrow(RestorePointClosedError)
+  })
+
+  it('keeps a point opened just now, with its folder and rows', async () => {
+    stage()
+    await openRestorePoint(TARGET, SNAP)
+
+    await sweepRestorePoints()
+
+    expect(existsSync(exploreDirFor(ORG, SNAP))).toBe(true)
+    expect(listRestorePointRows(ORG, SNAP, 'customers', '', 1).total).toBe(2)
+  })
+
+  it('removes a folder no open point owns and leaves names that are not ids alone', async () => {
+    const orphan = path.join(h.root, 'restore-explore', 'org9', 'a'.repeat(64))
+    mkdirSync(path.join(orphan, 'files', 'services'), { recursive: true })
+    writeFileSync(path.join(orphan, 'files', 'services', 'photo.jpg'), 'jpeg bytes')
+    const badOrg = path.join(h.root, 'restore-explore', 'not valid', 'x')
+    const badSnap = path.join(h.root, 'restore-explore', 'org9', 'not-a-snapshot')
+    for (const dir of [badOrg, badSnap]) mkdirSync(dir, { recursive: true })
+
+    await sweepRestorePoints()
+
+    expect(existsSync(orphan)).toBe(false)
+    expect(existsSync(badOrg)).toBe(true)
+    expect(existsSync(badSnap)).toBe(true)
+  })
+})

+ 124 - 0
src/__tests__/lib/backup/export-route.test.ts

@@ -0,0 +1,124 @@
+/**
+ * @vitest-environment node
+ *
+ * The manual export streams a zip: data.json from the writer, then the
+ * workshop's own upload folders, with photos and PDFs stored as they are.
+ */
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import JSZip from 'jszip'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+  uploads: '',
+  pages: {} as Record<string, unknown[][]>,
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: new Proxy(
+    {},
+    {
+      get: (_target, model: string) => ({
+        findMany: async () => state.pages[model]?.shift() ?? [],
+      }),
+    }
+  ),
+}))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/get-auth-context', () => ({
+  getAuthContext: vi.fn(async () => ({ organizationId: 'org-1', userId: 'user-1', isAdmin: true })),
+}))
+vi.mock('@/lib/upload-root', () => ({ uploadsRoot: () => state.uploads }))
+
+import { NextRequest } from 'next/server'
+import { POST } from '@/app/api/protected/backup/export/route'
+import { REDACTED } from '@/lib/backup/secrets'
+
+state.uploads = fs.mkdtempSync(path.join(os.tmpdir(), 'tv-export-'))
+fs.mkdirSync(path.join(state.uploads, 'org-1', 'services'), { recursive: true })
+fs.writeFileSync(path.join(state.uploads, 'org-1', 'services', 'photo.jpg'), Buffer.alloc(2048, 7))
+fs.writeFileSync(path.join(state.uploads, 'org-1', 'services', 'notes.txt'), 'a'.repeat(2048))
+fs.mkdirSync(path.join(state.uploads, 'org-2', 'services'), { recursive: true })
+fs.writeFileSync(path.join(state.uploads, 'org-2', 'services', 'other.jpg'), 'not ours')
+
+afterAll(() => fs.rmSync(state.uploads, { recursive: true, force: true }))
+
+/** Compression method per entry, from the local file headers. */
+function methods(buffer: Buffer): Record<string, number> {
+  const out: Record<string, number> = {}
+  let at = buffer.indexOf('PK\x03\x04', 0, 'latin1')
+  while (at !== -1) {
+    const method = buffer.readUInt16LE(at + 8)
+    const nameLength = buffer.readUInt16LE(at + 26)
+    out[buffer.toString('utf8', at + 30, at + 30 + nameLength)] = method
+    at = buffer.indexOf('PK\x03\x04', at + 30 + nameLength, 'latin1')
+  }
+  return out
+}
+
+async function exportWith(include?: Record<string, unknown>) {
+  const response = await POST(
+    new NextRequest('http://app.test/api/protected/backup/export', {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify(include ? { include } : {}),
+    })
+  )
+  const buffer = Buffer.from(await response.arrayBuffer())
+  const zip = await JSZip.loadAsync(buffer)
+  const data = JSON.parse(await zip.file('data.json')!.async('string'))
+  return { response, buffer, zip, data }
+}
+
+beforeEach(() => {
+  state.pages = {}
+})
+
+describe('backup export', () => {
+  it('streams data.json and only this workshop’s files', async () => {
+    state.pages.customer = [[{ id: 'c1', name: 'Ada' }]]
+    const { response, zip, data } = await exportWith()
+
+    expect(response.headers.get('content-type')).toBe('application/zip')
+    expect(response.headers.get('content-disposition')).toMatch(
+      /^attachment; filename="torqvoice-backup-\d{4}-\d{2}-\d{2}\.zip"$/
+    )
+    expect(data.version).toBe(2)
+    expect(data.data.customers).toEqual([{ id: 'c1', name: 'Ada' }])
+    expect(Object.keys(zip.files).sort()).toEqual([
+      'data.json',
+      'files/services/notes.txt',
+      'files/services/photo.jpg',
+    ])
+  })
+
+  it('stores photos without compression and deflates the rest', async () => {
+    const { buffer } = await exportWith()
+    const byName = methods(buffer)
+    expect(byName['files/services/photo.jpg']).toBe(0)
+    expect(byName['files/services/notes.txt']).toBe(8)
+    expect(byName['data.json']).toBe(8)
+  })
+
+  it('leaves secrets out unless include.secrets is true', async () => {
+    const settings = [{ id: 's1', key: 'ai.apiKey', value: 'sk-x' }]
+
+    state.pages.appSetting = [settings]
+    const redacted = await exportWith({ files: false })
+    expect(redacted.data.data.settings).toEqual([{ ...settings[0], value: REDACTED }])
+    expect(Object.keys(redacted.zip.files)).toEqual(['data.json'])
+    expect(redacted.data.contents).toBe('data')
+
+    state.pages.appSetting = [settings]
+    const truthy = await exportWith({ files: false, secrets: 'yes' })
+    expect(truthy.data.data.settings[0].value).toBe(REDACTED)
+
+    state.pages.appSetting = [settings]
+    const full = await exportWith({ files: false, secrets: true })
+    expect(full.data.data.settings).toEqual(settings)
+    expect(full.data.contents).toBe('dataAndSecrets')
+    // `secrets` is a request flag, never a dataset.
+    expect(Object.keys(full.data.data)).not.toContain('secrets')
+  })
+})

+ 124 - 0
src/__tests__/lib/backup/freeze.test.ts

@@ -0,0 +1,124 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  RESTORE_IN_PROGRESS_MESSAGE,
+  RestoreInProgressError,
+  assertNotRestoring,
+  freezeOrganization,
+  frozenOrganizationIds,
+  isRestoring,
+  renewFreeze,
+  unfreezeOrganization,
+} from '@/lib/backup/freeze'
+
+/** The read-only state a workshop is in while a restore replaces its rows. */
+
+const HOUR = 60 * 60 * 1000
+
+beforeEach(() => {
+  ;(globalThis as { __torqvoiceRestoring?: Map<string, number> }).__torqvoiceRestoring?.clear()
+})
+
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+describe('freezeOrganization', () => {
+  it('freezes one workshop until it is unfrozen, and no other', () => {
+    expect(isRestoring('org1')).toBe(false)
+    freezeOrganization('org1')
+    expect(isRestoring('org1')).toBe(true)
+    expect(isRestoring('org2')).toBe(false)
+    unfreezeOrganization('org1')
+    expect(isRestoring('org1')).toBe(false)
+  })
+
+  it('unfreezing a workshop that is not frozen does nothing', () => {
+    freezeOrganization('org2')
+    unfreezeOrganization('org1')
+    expect(isRestoring('org2')).toBe(true)
+  })
+
+  it('treats a freeze older than eight hours as expired, and forgets it', () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(new Date('2026-10-29T10:00:00Z'))
+    freezeOrganization('org1')
+
+    vi.setSystemTime(new Date('2026-10-29T17:59:00Z'))
+    expect(isRestoring('org1')).toBe(true)
+
+    vi.setSystemTime(new Date(Date.parse('2026-10-29T10:00:00Z') + 8 * HOUR + 1))
+    expect(isRestoring('org1')).toBe(false)
+    // Gone for good, not just hidden for this call.
+    vi.setSystemTime(new Date('2026-10-29T10:30:00Z'))
+    expect(isRestoring('org1')).toBe(false)
+  })
+
+  it('takes the time to judge by', () => {
+    freezeOrganization('org1')
+    expect(isRestoring('org1', Date.now() + HOUR)).toBe(true)
+    expect(isRestoring('org1', Date.now() + 8 * HOUR + 1000)).toBe(false)
+  })
+})
+
+describe('renewFreeze', () => {
+  it('keeps a renewed freeze alive past the first eight hours', () => {
+    vi.useFakeTimers()
+    const start = Date.parse('2026-10-29T10:00:00Z')
+    vi.setSystemTime(new Date(start))
+    freezeOrganization('org1')
+
+    vi.setSystemTime(new Date(start + 7 * HOUR))
+    renewFreeze('org1')
+
+    vi.setSystemTime(new Date(start + 12 * HOUR))
+    expect(isRestoring('org1')).toBe(true)
+    // The net still holds once the renewals stop.
+    vi.setSystemTime(new Date(start + 15 * HOUR + 1))
+    expect(isRestoring('org1')).toBe(false)
+  })
+
+  it('does not freeze a workshop that is not frozen', () => {
+    renewFreeze('org1')
+    expect(isRestoring('org1')).toBe(false)
+    expect(frozenOrganizationIds()).toEqual([])
+  })
+})
+
+describe('frozenOrganizationIds', () => {
+  it('lists the frozen workshops and drops the expired ones', () => {
+    vi.useFakeTimers()
+    const start = Date.parse('2026-10-29T10:00:00Z')
+    vi.setSystemTime(new Date(start))
+    freezeOrganization('old')
+    vi.setSystemTime(new Date(start + 6 * HOUR))
+    freezeOrganization('org1')
+    freezeOrganization('org2')
+    unfreezeOrganization('org2')
+
+    expect(frozenOrganizationIds().sort()).toEqual(['old', 'org1'])
+
+    vi.setSystemTime(new Date(start + 8 * HOUR + 1))
+    expect(frozenOrganizationIds()).toEqual(['org1'])
+    expect(isRestoring('old')).toBe(false)
+  })
+})
+
+describe('assertNotRestoring', () => {
+  it('throws RestoreInProgressError while the workshop is frozen, and not after', () => {
+    expect(() => assertNotRestoring('org1')).not.toThrow()
+    freezeOrganization('org1')
+    expect(() => assertNotRestoring('org1')).toThrow(RestoreInProgressError)
+    expect(() => assertNotRestoring('org1')).toThrow(RESTORE_IN_PROGRESS_MESSAGE)
+    expect(() => assertNotRestoring('org2')).not.toThrow()
+    unfreezeOrganization('org1')
+    expect(() => assertNotRestoring('org1')).not.toThrow()
+  })
+
+  it('names the error so it can be told apart', () => {
+    const err = new RestoreInProgressError()
+    expect(err).toBeInstanceOf(Error)
+    expect(err.name).toBe('RestoreInProgressError')
+    expect(err.message).toBe(RESTORE_IN_PROGRESS_MESSAGE)
+  })
+})

+ 152 - 0
src/__tests__/lib/backup/import-from-disk.test.ts

@@ -0,0 +1,152 @@
+/**
+ * @vitest-environment node
+ *
+ * A backup restic wrote out is a data.json and an uploads folder on disk.
+ * It goes through the same restore as the uploaded zip: the rows are
+ * replaced, then the files are moved into the workshop's uploads folder and
+ * the ones the backup did not bring back go through the file manager.
+ */
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const root = mkdtempSync(path.join(tmpdir(), 'import-from-disk-'))
+const uploads = path.join(root, 'uploads')
+
+const state = vi.hoisted(() => ({
+  calls: [] as { model: string; method: string; args: any }[],
+}))
+
+vi.mock('@/lib/db', () => {
+  const tx = new Proxy(
+    {},
+    {
+      get: (_target, model: string) =>
+        new Proxy(
+          {},
+          {
+            get: (_t, method: string) => async (args: any) => {
+              state.calls.push({ model, method, args })
+              if (method === 'findMany') return []
+              if (method === 'findFirst' || method === 'findUnique') return null
+              return { count: 0 }
+            },
+          }
+        ),
+    }
+  )
+  return { db: { $transaction: async (fn: (t: unknown) => unknown) => fn(tx) } }
+})
+vi.mock('@/lib/files/manager', () => ({ releaseFilesNotRestored: vi.fn(async () => undefined) }))
+vi.mock('@/lib/upload-root', () => ({ uploadsRoot: () => uploads }))
+
+import { releaseFilesNotRestored } from '@/lib/files/manager'
+import { directoryFileSource, restoreFromDisk } from '@/lib/backup/import'
+
+const actor = { organizationId: 'org1', userId: 'user1' }
+
+function writeBackup(dir: string, backup: unknown, files: Record<string, string> = {}) {
+  mkdirSync(path.join(dir, 'stage'), { recursive: true })
+  writeFileSync(path.join(dir, 'stage', 'data.json'), JSON.stringify(backup))
+  for (const [name, body] of Object.entries(files)) {
+    const file = path.join(dir, 'files', name)
+    mkdirSync(path.dirname(file), { recursive: true })
+    writeFileSync(file, body)
+  }
+}
+
+beforeEach(() => {
+  state.calls = []
+  vi.clearAllMocks()
+  rmSync(root, { recursive: true, force: true })
+  mkdirSync(root, { recursive: true })
+})
+
+afterAll(() => {
+  rmSync(root, { recursive: true, force: true })
+})
+
+describe('restoreFromDisk', () => {
+  it('replaces the rows and moves the files into the uploads folder', async () => {
+    const dir = path.join(root, 'restored')
+    writeBackup(
+      dir,
+      { version: 2, data: { settings: [{ id: 's1', key: 'workshop.phone', value: '+47 1' }] } },
+      {
+        'logos/logo.png': 'png bytes',
+        'logos/notes.txt': 'kept out of no category',
+        'not-a-category/x.bin': 'ignored',
+      }
+    )
+    mkdirSync(path.join(uploads, 'org1', 'logos'), { recursive: true })
+    writeFileSync(path.join(uploads, 'org1', 'logos', 'old.png'), 'old')
+
+    const result = await restoreFromDisk(actor, {
+      dataFile: path.join(dir, 'stage', 'data.json'),
+      filesDir: path.join(dir, 'files'),
+    })
+
+    expect(result).toEqual({ redactedSettings: [] })
+    const created = state.calls.find((c) => c.model === 'appSetting' && c.method === 'createMany')
+    expect(created?.args.data).toEqual([
+      { id: 's1', key: 'workshop.phone', value: '+47 1', userId: 'user1', organizationId: 'org1' },
+    ])
+    expect(readFileSync(path.join(uploads, 'org1', 'logos', 'logo.png'), 'utf8')).toBe('png bytes')
+    expect(readFileSync(path.join(uploads, 'org1', 'logos', 'notes.txt'), 'utf8')).toBe(
+      'kept out of no category'
+    )
+    expect(existsSync(path.join(uploads, 'org1', 'not-a-category'))).toBe(false)
+    // The file manager decides about what the backup did not bring back.
+    expect(existsSync(path.join(uploads, 'org1', 'logos', 'old.png'))).toBe(true)
+    expect(releaseFilesNotRestored).toHaveBeenCalledTimes(1)
+    expect(vi.mocked(releaseFilesNotRestored).mock.calls[0][0]).toBe('org1')
+    expect(vi.mocked(releaseFilesNotRestored).mock.calls[0][1]).toBe('logos')
+    expect([...vi.mocked(releaseFilesNotRestored).mock.calls[0][2]].sort()).toEqual([
+      'logo.png',
+      'notes.txt',
+    ])
+  })
+
+  it('restores a data-only snapshot without touching the uploads folder', async () => {
+    const dir = path.join(root, 'restored')
+    writeBackup(dir, { version: 2, data: { customers: [] } })
+
+    await restoreFromDisk(actor, { dataFile: path.join(dir, 'stage', 'data.json'), filesDir: null })
+
+    expect(existsSync(path.join(uploads, 'org1'))).toBe(false)
+    expect(releaseFilesNotRestored).not.toHaveBeenCalled()
+  })
+
+  it('refuses a file that is not a backup before any row is touched', async () => {
+    const dir = path.join(root, 'restored')
+    writeBackup(dir, { version: 3, data: {} })
+    await expect(
+      restoreFromDisk(actor, { dataFile: path.join(dir, 'stage', 'data.json'), filesDir: null })
+    ).rejects.toThrow('Unsupported or missing backup version')
+    writeBackup(dir, { version: 2 })
+    await expect(
+      restoreFromDisk(actor, { dataFile: path.join(dir, 'stage', 'data.json'), filesDir: null })
+    ).rejects.toThrow('No data found in backup')
+    expect(state.calls).toEqual([])
+  })
+
+  it('lists one entry per file inside a category folder', async () => {
+    const dir = path.join(root, 'files')
+    mkdirSync(path.join(dir, 'logos', 'nested'), { recursive: true })
+    mkdirSync(path.join(dir, 'service-files'), { recursive: true })
+    writeFileSync(path.join(dir, 'logos', 'a.png'), 'a')
+    writeFileSync(path.join(dir, 'service-files', 'b.pdf'), 'b')
+    writeFileSync(path.join(dir, 'loose.txt'), 'not in a category')
+
+    const source = await directoryFileSource(dir)
+    expect(source.entries.map((e) => `${e.category}/${e.name}`).sort()).toEqual([
+      'logos/a.png',
+      'service-files/b.pdf',
+    ])
+
+    const target = path.join(root, 'out', 'deep', 'b.pdf')
+    await source.extract({ category: 'service-files', name: 'b.pdf' }, target)
+    expect(readFileSync(target, 'utf8')).toBe('b')
+  })
+})

+ 230 - 0
src/__tests__/lib/backup/import-redaction.test.ts

@@ -0,0 +1,230 @@
+/**
+ * @vitest-environment node
+ *
+ * A "Data only" backup carries REDACTED where each secret was. Restoring it
+ * keeps whatever the install has now and tells the workshop which keys the
+ * file left out.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+  current: {} as Record<string, unknown[]>,
+  calls: [] as { model: string; method: string; args: any }[],
+}))
+
+vi.mock('@/lib/db', () => {
+  const tx = new Proxy(
+    {},
+    {
+      get: (_target, model: string) =>
+        new Proxy(
+          {},
+          {
+            get: (_t, method: string) => async (args: any) => {
+              state.calls.push({ model, method, args })
+              // The tenancy check asks for rows by id; this install has none of them.
+              if (method === 'findMany' && args?.where?.id?.in) return []
+              if (method === 'findMany') return state.current[model] ?? []
+              if (method === 'findFirst' || method === 'findUnique') return null
+              return { count: 0 }
+            },
+          }
+        ),
+    }
+  )
+  return { db: { $transaction: async (fn: (t: unknown) => unknown) => fn(tx) } }
+})
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/get-auth-context', () => ({
+  getAuthContext: vi.fn(async () => ({ organizationId: 'org-1', userId: 'user-1', isAdmin: true })),
+}))
+vi.mock('@/lib/files/manager', () => ({ releaseFilesNotRestored: vi.fn(async () => undefined) }))
+vi.mock('@/lib/upload-root', () => ({ uploadsRoot: () => '/tmp/uploads-test' }))
+
+import { NextRequest } from 'next/server'
+import { POST } from '@/app/api/protected/backup/import/route'
+import { REDACTED } from '@/lib/backup/secrets'
+
+function importJson(data: Record<string, unknown>, header: Record<string, unknown> = {}) {
+  return POST(
+    new NextRequest('http://app.test/api/protected/backup/import', {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ version: 2, exportedAt: new Date().toISOString(), ...header, data }),
+    })
+  )
+}
+
+const created = (model: string) =>
+  state.calls
+    .filter((c) => c.model === model && c.method === 'createMany')
+    .flatMap((c) => c.args.data)
+
+beforeEach(() => {
+  state.current = {}
+  state.calls = []
+})
+
+describe('restoring a backup without secret keys', () => {
+  it('keeps the install values, skips keys it has none for, and lists them', async () => {
+    state.current.appSetting = [
+      { id: 'x1', key: 'payment.stripe.secretKey', value: 'sk_now', userId: 'u0' },
+      { id: 'x2', key: 'workshop.phone', value: '+47 999', userId: 'u0' },
+    ]
+
+    const response = await importJson({
+      settings: [
+        { id: 's1', key: 'workshop.phone', value: '+47 123' },
+        { id: 's2', key: 'payment.stripe.secretKey', value: REDACTED },
+        { id: 's3', key: 'ai.apiKey', value: REDACTED },
+      ],
+    })
+
+    expect(response.status).toBe(200)
+    expect(await response.json()).toEqual({
+      success: true,
+      redactedSettings: ['ai.apiKey', 'payment.stripe.secretKey'],
+    })
+
+    // Read before the table is cleared.
+    const order = state.calls.map((c) => `${c.model}.${c.method}`)
+    expect(order.indexOf('appSetting.findMany')).toBeLessThan(
+      order.indexOf('appSetting.deleteMany')
+    )
+    const lookup = state.calls.find(
+      (c) => c.model === 'appSetting' && c.method === 'findMany' && c.args?.where?.key
+    )
+    expect(lookup).toBeUndefined()
+    // Every setting the workshop has, so a secret the file does not carry is kept too.
+    const read = state.calls.find(
+      (c) => c.model === 'appSetting' && c.method === 'findMany' && c.args?.select?.value
+    )
+    expect(read?.args.where).toEqual({ organizationId: 'org-1' })
+
+    const rows = created('appSetting')
+    expect(rows.map((r: any) => [r.key, r.value])).toEqual([
+      ['workshop.phone', '+47 123'],
+      ['payment.stripe.secretKey', 'sk_now'],
+    ])
+    expect(JSON.stringify(rows)).not.toContain(REDACTED)
+  })
+
+  it('keeps a secret entered after a data-only backup was taken', async () => {
+    state.current.appSetting = [
+      { id: 'x1', key: 'payment.stripe.secretKey', value: 'sk_now', userId: 'u0' },
+      { id: 'x2', key: 'ai.apiKey', value: 'sk-ai-now', userId: 'u0' },
+      { id: 'x3', key: 'workshop.email', value: 'now@x.test', userId: 'u0' },
+    ]
+
+    const response = await importJson({
+      settings: [
+        { id: 's1', key: 'workshop.phone', value: '+47 123' },
+        { id: 's2', key: 'payment.stripe.secretKey', value: REDACTED },
+      ],
+    })
+
+    expect(await response.json()).toEqual({
+      success: true,
+      redactedSettings: ['ai.apiKey', 'payment.stripe.secretKey'],
+    })
+    const rows = created('appSetting')
+    expect(rows.map((r: any) => [r.id, r.key, r.value])).toEqual([
+      ['s1', 'workshop.phone', '+47 123'],
+      ['s2', 'payment.stripe.secretKey', 'sk_now'],
+      ['x2', 'ai.apiKey', 'sk-ai-now'],
+    ])
+    expect(rows.every((r: any) => r.organizationId === 'org-1' && r.userId === 'user-1')).toBe(true)
+  })
+
+  it('drops a secret a full backup does not carry', async () => {
+    state.current.appSetting = [{ id: 'x2', key: 'ai.apiKey', value: 'sk-ai-now', userId: 'u0' }]
+    const response = await importJson({
+      settings: [{ id: 's1', key: 'workshop.phone', value: '+47 123' }],
+    })
+    expect(await response.json()).toEqual({ success: true, redactedSettings: [] })
+    expect(created('appSetting').map((r: any) => r.key)).toEqual(['workshop.phone'])
+  })
+
+  it('restores secrets a full backup carries', async () => {
+    state.current.appSetting = [{ key: 'ai.apiKey', value: 'sk-now' }]
+    const response = await importJson({
+      settings: [{ id: 's1', key: 'ai.apiKey', value: 'sk-backup' }],
+    })
+    expect(await response.json()).toEqual({ success: true, redactedSettings: [] })
+    expect(created('appSetting').map((r: any) => r.value)).toEqual(['sk-backup'])
+  })
+
+  it('keeps installed secrets for a "Data only" file that had no secret to leave out', async () => {
+    state.current.appSetting = [
+      { id: 'x1', key: 'ai.apiKey', value: 'sk-ai-now', userId: 'u0' },
+      { id: 'x2', key: 'workshop.email', value: 'now@x.test', userId: 'u0' },
+    ]
+    const response = await importJson(
+      { settings: [{ id: 's1', key: 'workshop.phone', value: '+47 123' }] },
+      { contents: 'data' }
+    )
+    expect(await response.json()).toEqual({ success: true, redactedSettings: ['ai.apiKey'] })
+    expect(created('appSetting').map((r: any) => [r.key, r.value])).toEqual([
+      ['workshop.phone', '+47 123'],
+      ['ai.apiKey', 'sk-ai-now'],
+    ])
+  })
+
+  it('restores a file the header says carries its secrets as written', async () => {
+    state.current.appSetting = [
+      { id: 'x1', key: 'ai.apiKey', value: 'sk-ai-now', userId: 'u0' },
+      { id: 'x2', key: 'telegram.botToken', value: 'tg-now', userId: 'u0' },
+    ]
+    const response = await importJson(
+      {
+        settings: [
+          { id: 's1', key: 'workshop.phone', value: '+47 123' },
+          { id: 's2', key: 'ai.apiKey', value: 'sk-backup' },
+        ],
+      },
+      { contents: 'dataAndSecrets' }
+    )
+    expect(await response.json()).toEqual({ success: true, redactedSettings: [] })
+    expect(created('appSetting').map((r: any) => [r.key, r.value])).toEqual([
+      ['workshop.phone', '+47 123'],
+      ['ai.apiKey', 'sk-backup'],
+    ])
+  })
+
+  it('does not treat a marker as "Data only" when the header says the secrets are in it', async () => {
+    state.current.appSetting = [
+      { id: 'x1', key: 'ai.apiKey', value: 'sk-ai-now', userId: 'u0' },
+      { id: 'x2', key: 'telegram.botToken', value: 'tg-now', userId: 'u0' },
+    ]
+    const response = await importJson(
+      { settings: [{ id: 's1', key: 'ai.apiKey', value: REDACTED }] },
+      { contents: 'dataAndSecrets' }
+    )
+    expect(await response.json()).toEqual({ success: true, redactedSettings: ['ai.apiKey'] })
+    const rows = created('appSetting')
+    expect(rows.map((r: any) => [r.key, r.value])).toEqual([['ai.apiKey', 'sk-ai-now']])
+    expect(JSON.stringify(rows)).not.toContain(REDACTED)
+  })
+
+  it('refuses a file whose header names contents this version does not know', async () => {
+    const response = await importJson({ settings: [] }, { contents: 'everything' })
+    expect(response.status).toBe(400)
+    expect(await response.json()).toEqual({ error: 'Unsupported backup contents' })
+  })
+
+  it('keeps the current webhook secret, or mints one', async () => {
+    state.current.webhook = [{ id: 'w1', url: 'https://one.test', secret: 'whsec_now' }]
+
+    await importJson({
+      webhooks: [
+        { id: 'w1', name: 'One', url: 'https://one.test', secret: REDACTED, events: '["*"]' },
+        { id: 'w2', name: 'Two', url: 'https://two.test', secret: REDACTED, events: '["*"]' },
+      ],
+    })
+
+    const rows = created('webhook')
+    expect(rows[0].secret).toBe('whsec_now')
+    expect(rows[1].secret).toMatch(/^whsec_[0-9a-f]{48}$/)
+    expect(rows.every((r: any) => r.organizationId === 'org-1')).toBe(true)
+  })
+})

+ 203 - 0
src/__tests__/lib/backup/import-tenancy.test.ts

@@ -0,0 +1,203 @@
+/**
+ * @vitest-environment node
+ *
+ * The upload route with a crafted file: ids that are another workshop's
+ * rows refuse the whole file before anything is written, foreign keys that
+ * point outside the file never reach a write, a second restore is told the
+ * server is busy, and on the cloud an administrator is sent to the owner.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+  /** Rows of other workshops, by model: what the tenancy check finds. */
+  foreign: {} as Record<string, string[]>,
+  /** Rows this workshop already has, by model. */
+  own: {} as Record<string, string[]>,
+  calls: [] as { model: string; method: string; args: any }[],
+  cloud: false,
+  role: 'admin',
+}))
+
+vi.mock('@/lib/db', () => {
+  const tx = new Proxy(
+    {},
+    {
+      get: (_target, model: string) =>
+        new Proxy(
+          {},
+          {
+            get: (_t, method: string) => async (args: any) => {
+              state.calls.push({ model, method, args })
+              if (method === 'findMany' && args?.where?.id?.in) {
+                const ids: string[] = args.where.id.in
+                const table = 'NOT' in args.where ? state.foreign : state.own
+                return (table[model] ?? []).filter((id) => ids.includes(id)).map((id) => ({ id }))
+              }
+              if (method === 'findMany') return []
+              if (method === 'findFirst' || method === 'findUnique') return null
+              return { count: 0 }
+            },
+          }
+        ),
+    }
+  )
+  return {
+    db: {
+      $transaction: async (fn: (t: unknown) => unknown) => fn(tx),
+      // The cloud check reads the membership itself, not the session's role.
+      organizationMember: { findFirst: async () => ({ role: state.role }) },
+    },
+  }
+})
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/get-auth-context', () => ({
+  getAuthContext: vi.fn(async () => ({
+    organizationId: 'org-a',
+    userId: 'user-1',
+    isAdmin: true,
+    role: state.role,
+  })),
+}))
+vi.mock('@/lib/features', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/features')>()),
+  isCloudMode: () => state.cloud,
+}))
+vi.mock('@/lib/files/manager', () => ({ releaseFilesNotRestored: vi.fn(async () => undefined) }))
+vi.mock('@/lib/upload-root', () => ({ uploadsRoot: () => '/tmp/uploads-test' }))
+
+import { NextRequest } from 'next/server'
+import { POST } from '@/app/api/protected/backup/import/route'
+import { acquireRestoreLock, releaseRestoreLock } from '@/lib/backup/tenancy'
+
+function importJson(data: Record<string, unknown>) {
+  return POST(
+    new NextRequest('http://app.test/api/protected/backup/import', {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ version: 2, exportedAt: new Date().toISOString(), data }),
+    })
+  )
+}
+
+const WRITES = new Set(['create', 'createMany', 'deleteMany', 'updateMany', 'update', 'upsert'])
+const writes = () => state.calls.filter((c) => WRITES.has(c.method))
+
+/** A workshop's file with a customer, a vehicle with a job, a quote and an inspection. */
+function file() {
+  return {
+    customers: [{ id: 'cust-a', name: 'Kari', userId: 'u' }],
+    technicians: [{ id: 'tech-a', name: 'Ola' }],
+    inspectionTemplates: [{ id: 'tmpl-a', name: 'Default', sections: [] }],
+    vehicles: [
+      {
+        id: 'veh-a',
+        make: 'Volvo',
+        model: 'V70',
+        year: 2015,
+        customerId: 'cust-a',
+        serviceRecords: [
+          {
+            id: 'job-a',
+            title: 'Brakes',
+            technicianId: 'tech-a',
+            workBayId: 'bay-of-org-b',
+            partItems: [{ id: 'part-a', name: 'Pads', inventoryPartId: 'inv-of-org-b' }],
+          },
+        ],
+      },
+    ],
+    quotes: [{ id: 'quote-a', title: 'Tyres', vehicleId: 'veh-a', inspectionId: 'insp-of-org-b' }],
+    inspections: [{ id: 'insp-a', vehicleId: 'veh-a', templateId: 'tmpl-of-org-b', items: [] }],
+  }
+}
+
+beforeEach(() => {
+  state.foreign = {}
+  state.own = {}
+  state.calls = []
+  state.cloud = false
+  state.role = 'admin'
+})
+
+describe("a file that reuses another workshop's ids", () => {
+  it('is refused before anything is written, with a reason', async () => {
+    state.foreign = { vehicle: ['veh-a'] }
+    const response = await importJson(file())
+    expect(response.status).toBe(400)
+    expect(await response.json()).toEqual({
+      error: 'The file holds 1 Vehicle id(s) that belong to another workshop',
+      code: 'foreign_ids',
+    })
+    expect(writes()).toEqual([])
+  })
+
+  it('is refused for a child row too', async () => {
+    state.foreign = { servicePart: ['part-a'] }
+    const response = await importJson(file())
+    expect(response.status).toBe(400)
+    expect((await response.json()).code).toBe('foreign_ids')
+    expect(writes()).toEqual([])
+    // Asked through the part's job, since a part has no organisation column.
+    const asked = state.calls.find(
+      (c) => c.model === 'servicePart' && c.method === 'findMany' && 'NOT' in c.args.where
+    )
+    expect(asked?.args.where.NOT).toEqual({ serviceRecord: { organizationId: 'org-a' } })
+  })
+})
+
+describe('a file whose foreign keys point at another workshop', () => {
+  it('never writes one of those ids', async () => {
+    const response = await importJson(file())
+    expect(response.status).toBe(200)
+    const written = JSON.stringify(writes())
+    expect(written).not.toContain('org-b')
+    // The job and the quote came through without the links; the inspection,
+    // whose template is required, did not come through at all.
+    expect(written).toContain('job-a')
+    expect(written).toContain('quote-a')
+    expect(written).not.toContain('insp-a')
+    expect(
+      state.calls.some(
+        (c) => c.model === 'inspection' && WRITES.has(c.method) && c.method !== 'deleteMany'
+      )
+    ).toBe(false)
+  })
+
+  it('keeps a link to a row this workshop already has', async () => {
+    state.own = { inventoryPart: ['inv-of-org-b'] }
+    await importJson(file())
+    const parts = state.calls.find((c) => c.model === 'servicePart' && c.method === 'createMany')
+    expect(parts?.args.data[0].inventoryPartId).toBe('inv-of-org-b')
+  })
+})
+
+describe('one restore at a time', () => {
+  it('tells a second restore the server is busy', async () => {
+    expect(acquireRestoreLock('another-restore')).toBe(true)
+    try {
+      const response = await importJson(file())
+      expect(response.status).toBe(409)
+      expect((await response.json()).code).toBe('busy')
+      expect(state.calls).toEqual([])
+    } finally {
+      releaseRestoreLock('another-restore')
+    }
+  })
+})
+
+describe('on the cloud', () => {
+  it('sends an administrator to the owner, and lets the owner through', async () => {
+    state.cloud = true
+    // The session says super_admin for a platform administrator; the
+    // membership is what decides.
+    state.role = 'admin'
+    const refused = await importJson(file())
+    expect(refused.status).toBe(403)
+    expect((await refused.json()).code).toBe('owner_only')
+    expect(state.calls).toEqual([])
+
+    state.role = 'owner'
+    const allowed = await importJson(file())
+    expect(allowed.status).toBe(200)
+  })
+})

+ 171 - 0
src/__tests__/lib/backup/platform.test.ts

@@ -0,0 +1,171 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The platform repository's configuration, read from the environment: on the
+ * cloud instance only, all of it or none of it, and a password per workshop
+ * derived from the master key.
+ */
+
+const h = vi.hoisted(() => ({ cloud: true }))
+
+vi.mock('@/lib/features', () => ({ isCloudMode: () => h.cloud }))
+
+type Platform = typeof import('@/lib/backup/platform')
+
+const FULL = {
+  PLATFORM_BACKUP_S3_ENDPOINT: 'https://acc.r2.cloudflarestorage.com',
+  PLATFORM_BACKUP_S3_BUCKET: 'torqvoice-workshop-backups',
+  PLATFORM_BACKUP_S3_ACCESS_KEY: 'platform-key-id',
+  PLATFORM_BACKUP_S3_SECRET_KEY: 'platform-key-secret',
+  PLATFORM_BACKUP_MASTER_KEY: 'k7Qe2Vx9Lm4Rt8Zp1Ws6Yd3Hn5Jc0Bf7Gu2Ia9Oq',
+}
+const KEYS = [...Object.keys(FULL), 'PLATFORM_BACKUP_S3_REGION']
+const saved: Record<string, string | undefined> = {}
+
+/** A fresh module, so the "said once in the log" flag starts over. */
+async function load(): Promise<Platform> {
+  vi.resetModules()
+  return import('@/lib/backup/platform')
+}
+
+let error: ReturnType<typeof vi.spyOn>
+
+beforeEach(() => {
+  h.cloud = true
+  for (const key of KEYS) {
+    saved[key] = process.env[key]
+    delete process.env[key]
+  }
+  error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+  for (const key of KEYS) {
+    if (saved[key] === undefined) delete process.env[key]
+    else process.env[key] = saved[key]
+  }
+  error.mockRestore()
+})
+
+describe('platformBackupConfig', () => {
+  it('reads a complete configuration, with the region defaulting to auto', async () => {
+    Object.assign(process.env, FULL)
+    const { platformBackupConfig, platformBackupsEnabled } = await load()
+    expect(platformBackupConfig()).toEqual({
+      endpoint: 'https://acc.r2.cloudflarestorage.com',
+      bucket: 'torqvoice-workshop-backups',
+      region: 'auto',
+      accessKeyId: 'platform-key-id',
+      secretAccessKey: 'platform-key-secret',
+      masterKey: FULL.PLATFORM_BACKUP_MASTER_KEY,
+    })
+    expect(platformBackupsEnabled()).toBe(true)
+    expect(error).not.toHaveBeenCalled()
+  })
+
+  it('is null when the instance is not the cloud one, however complete the variables are', async () => {
+    h.cloud = false
+    Object.assign(process.env, FULL)
+    const { platformBackupConfig } = await load()
+    expect(platformBackupConfig()).toBeNull()
+  })
+
+  it('is null, without a word, when nothing is set', async () => {
+    const { platformBackupConfig } = await load()
+    expect(platformBackupConfig()).toBeNull()
+    expect(error).not.toHaveBeenCalled()
+  })
+
+  it('is null and says so once in the log when only half is set', async () => {
+    process.env.PLATFORM_BACKUP_S3_ENDPOINT = FULL.PLATFORM_BACKUP_S3_ENDPOINT
+    process.env.PLATFORM_BACKUP_S3_BUCKET = FULL.PLATFORM_BACKUP_S3_BUCKET
+    const { platformBackupConfig } = await load()
+    expect(platformBackupConfig()).toBeNull()
+    expect(platformBackupConfig()).toBeNull()
+    expect(error).toHaveBeenCalledTimes(1)
+    expect(String(error.mock.calls[0][0])).toMatch(/platform backups are off/)
+    expect(String(error.mock.calls[0][0])).toMatch(/ACCESS_KEY/)
+    expect(String(error.mock.calls[0][0])).toMatch(/MASTER_KEY/)
+    // Nothing secret in the log line.
+    expect(String(error.mock.calls[0][0])).not.toContain('platform-key')
+  })
+
+  it.each([
+    ['http://acc.r2.cloudflarestorage.com'],
+    ['https://acc.r2.cloudflarestorage.com/some/path'],
+    ['https://user:pass@acc.r2.cloudflarestorage.com'],
+    ['not a url'],
+  ])('refuses the endpoint %s', async (endpoint) => {
+    Object.assign(process.env, FULL, { PLATFORM_BACKUP_S3_ENDPOINT: endpoint })
+    const { platformBackupConfig } = await load()
+    expect(platformBackupConfig()).toBeNull()
+    expect(String(error.mock.calls[0][0])).toMatch(/ENDPOINT/)
+  })
+
+  it.each([['ab'], ['Has_Underscore'], ['-leading-dash'], ['double--dash'], ['a'.repeat(64)]])(
+    'refuses the bucket %s',
+    async (bucket) => {
+      Object.assign(process.env, FULL, { PLATFORM_BACKUP_S3_BUCKET: bucket })
+      const { platformBackupConfig } = await load()
+      expect(platformBackupConfig()).toBeNull()
+      expect(String(error.mock.calls[0][0])).toMatch(/BUCKET/)
+    }
+  )
+
+  it('refuses a master key shorter than 32 characters', async () => {
+    Object.assign(process.env, FULL, { PLATFORM_BACKUP_MASTER_KEY: 'x'.repeat(31) })
+    const { platformBackupConfig } = await load()
+    expect(platformBackupConfig()).toBeNull()
+    expect(String(error.mock.calls[0][0])).toMatch(/MASTER_KEY/)
+  })
+
+  it('takes the region when one is set', async () => {
+    Object.assign(process.env, FULL, { PLATFORM_BACKUP_S3_REGION: 'eu-north-1' })
+    const { platformBackupConfig } = await load()
+    expect(platformBackupConfig()?.region).toBe('eu-north-1')
+  })
+})
+
+describe('platformRepositoryPassword', () => {
+  it('is the same for the same workshop and different for another', async () => {
+    const { platformRepositoryPassword } = await load()
+    const key = FULL.PLATFORM_BACKUP_MASTER_KEY
+    const a = platformRepositoryPassword(key, 'org1')
+    expect(a).toMatch(/^[0-9a-f]{64}$/)
+    expect(platformRepositoryPassword(key, 'org1')).toBe(a)
+    expect(platformRepositoryPassword(key, 'org2')).not.toBe(a)
+    expect(platformRepositoryPassword(`${key}x`, 'org1')).not.toBe(a)
+  })
+
+  it.each([['org-1'], ['../org1'], ['org1/x'], ['']])(
+    'refuses the organisation id %j',
+    async (organizationId) => {
+      const { platformRepositoryPassword, platformRepository } = await load()
+      expect(() => platformRepositoryPassword(FULL.PLATFORM_BACKUP_MASTER_KEY, organizationId)).toThrow(
+        /Unexpected organisation id/
+      )
+      expect(() =>
+        platformRepository(
+          {
+            endpoint: FULL.PLATFORM_BACKUP_S3_ENDPOINT,
+            bucket: FULL.PLATFORM_BACKUP_S3_BUCKET,
+            region: 'auto',
+            accessKeyId: 'a',
+            secretAccessKey: 'b',
+            masterKey: FULL.PLATFORM_BACKUP_MASTER_KEY,
+          },
+          organizationId
+        )
+      ).toThrow(/Unexpected organisation id/)
+    }
+  )
+})
+
+describe('platformBackupAllowed', () => {
+  it('follows the plan', async () => {
+    const { platformBackupAllowed } = await load()
+    expect(platformBackupAllowed({ platformBackups: true })).toBe(true)
+    expect(platformBackupAllowed({ platformBackups: false })).toBe(false)
+  })
+})

+ 568 - 0
src/__tests__/lib/backup/restic.test.ts

@@ -0,0 +1,568 @@
+// @vitest-environment node
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'
+import type { ResticRepository } from '@/features/integrations/Lib/backup-types'
+import {
+  ResticError,
+  redactArgv,
+  resticBackup,
+  resticCheck,
+  resticForget,
+  resticForgetSnapshot,
+  resticInit,
+  resticOpen,
+  resticSnapshots,
+  resticStats,
+  resticUnlock,
+  resticVersion,
+  runRestic,
+  resticRestore,
+} from '@/lib/backup/restic'
+
+// Lines below are copied from restic 0.19.1 `--json` output.
+const SUMMARY_LINE =
+  '{"message_type":"summary","files_new":2,"files_changed":0,"files_unmodified":0,"dirs_new":8,"dirs_changed":0,"dirs_unmodified":0,"data_blobs":2,"tree_blobs":9,"data_added":3851,"data_added_packed":2966,"total_files_processed":2,"total_bytes_processed":12,"total_duration":0.716071765,"backup_start":"2026-10-29T09:09:10.444930802+01:00","backup_end":"2026-10-29T09:09:11.161002555+01:00","snapshot_id":"28891c8f448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b"}'
+const STATUS_LINE =
+  '{"message_type":"status","seconds_elapsed":1,"percent_done":0.684350835,"total_files":1,"total_bytes":400000000,"bytes_done":273740334,"current_files":["/data/blob"]}'
+const WRONG_PASSWORD =
+  '{"message_type":"exit_error","code":12,"message":"Fatal: wrong password or no key found"}'
+const NO_REPOSITORY =
+  '{"message_type":"exit_error","code":10,"message":"Fatal: repository does not exist: unable to open config file: stat /repo/config: no such file or directory\\nIs there a repository at the following location?\\n/repo"}'
+const ALREADY_INITIALIZED =
+  '{"message_type":"exit_error","code":1,"message":"Fatal: Fatal: create repository at /repo failed: config file already exists"}'
+const UNREADABLE_FILE =
+  '{"message_type":"error","error":{"message":"open /data/bad: permission denied"},"during":"archival","item":"/data/bad"}'
+const PARTIAL_EXIT =
+  '{"message_type":"exit_error","code":3,"message":"Warning: at least one source file could not be read"}'
+const SNAPSHOTS_LINE =
+  '[{"time":"2026-10-29T09:09:10.444930802+01:00","tree":"ba33","paths":["/stage"],"hostname":"torqvoice-org1","username":"nextjs","uid":1000,"gid":1000,"tags":["org:org1","manual"],"program_version":"restic 0.19.1","summary":{"backup_start":"2026-10-29T09:09:10.444930802+01:00","backup_end":"2026-10-29T09:09:11.161002555+01:00","files_new":2,"files_changed":0,"files_unmodified":0,"dirs_new":8,"dirs_changed":0,"dirs_unmodified":0,"data_blobs":2,"tree_blobs":9,"data_added":3851,"data_added_packed":2966,"total_files_processed":2,"total_bytes_processed":12},"id":"28891c8f448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b","short_id":"28891c8f"}]'
+const STATS_LINE =
+  '{"total_size":6928,"total_uncompressed_size":11714,"compression_ratio":1.6908198614318706,"compression_progress":100,"compression_space_saving":40.85709407546525,"total_blob_count":29,"snapshots_count":3}'
+const FORGET_LINE =
+  '[{"tags":["manual","org:org1"],"host":"torqvoice-org1","paths":null,"keep":[{"id":"9d6c","short_id":"9d6cb16e"}],"remove":[{"id":"2889","short_id":"28891c8f"}],"reasons":[]},{"tags":["org:org1"],"host":"torqvoice-org1","paths":null,"keep":[{"id":"9284","short_id":"92846caf"}],"remove":null,"reasons":[]}]'
+const CHECK_LINE =
+  '{"message_type":"summary","num_errors":0,"broken_packs":null,"suggest_repair_index":false,"suggest_prune":false}'
+// From `restic --json restore <id> --target <dir>` on restic 0.19.1.
+const RESTORE_STATUS_LINE =
+  '{"message_type":"status","seconds_elapsed":0,"percent_done":0.5,"total_files":12,"files_restored":6,"total_bytes":19,"bytes_restored":9}'
+const RESTORE_LINE =
+  '{"message_type":"summary","total_files":12,"files_restored":12,"total_bytes":19,"bytes_restored":19}'
+const VERSION_LINE =
+  '{"message_type":"version","version":"0.19.1","go_version":"go1.26.4","go_os":"linux","go_arch":"amd64"}'
+
+const root = mkdtempSync(path.join(tmpdir(), 'restic-test-'))
+let cacheDir = ''
+let scriptCount = 0
+const savedEnv = { ...process.env }
+
+/** Writes a fake restic that records its argv and env in $HOME, then runs `body`. */
+function fakeRestic(body: string): void {
+  const file = path.join(root, `restic-${++scriptCount}.sh`)
+  writeFileSync(
+    file,
+    `#!/bin/sh\nrec="\${HOME:-${root}}"\nprintf '%s\\n' "$@" > "$rec/argv.txt"\n/usr/bin/env > "$rec/env.txt"\n${body}\n`
+  )
+  chmodSync(file, 0o755)
+  process.env.RESTIC_BIN = file
+}
+
+function out(...lines: string[]): string {
+  return `cat <<'EOF'\n${lines.join('\n')}\nEOF`
+}
+
+function err(...lines: string[]): string {
+  return `cat >&2 <<'EOF'\n${lines.join('\n')}\nEOF`
+}
+
+function readChildEnv(dir = cacheDir): Record<string, string> {
+  const env: Record<string, string> = {}
+  for (const line of readFileSync(path.join(dir, 'env.txt'), 'utf8').split('\n')) {
+    const eq = line.indexOf('=')
+    if (eq > 0) env[line.slice(0, eq)] = line.slice(eq + 1)
+  }
+  // Variables the shell sets for itself.
+  for (const key of ['PWD', 'OLDPWD', 'SHLVL', '_']) delete env[key]
+  return env
+}
+
+function readChildArgv(): string[] {
+  return readFileSync(path.join(cacheDir, 'argv.txt'), 'utf8').split('\n').slice(0, -1)
+}
+
+const repository: ResticRepository = {
+  repository: 's3:https://s3.eu-central-1.amazonaws.com/workshop-bucket/torqvoice',
+  env: { AWS_ACCESS_KEY_ID: 'theirs-id', AWS_SECRET_ACCESS_KEY: 'theirs-secret' },
+  options: { 's3.region': 'eu-central-1' },
+}
+
+function base() {
+  return { repository, password: 'correct horse battery', cacheDir }
+}
+
+beforeEach(() => {
+  cacheDir = path.join(root, `cache-${scriptCount + 1}`)
+})
+
+afterEach(() => {
+  for (const key of Object.keys(process.env)) {
+    if (!(key in savedEnv)) delete process.env[key]
+  }
+  Object.assign(process.env, savedEnv)
+})
+
+afterAll(() => {
+  rmSync(root, { recursive: true, force: true })
+})
+
+describe('runRestic environment', () => {
+  it('builds the child environment from scratch', async () => {
+    process.env.AWS_ACCESS_KEY_ID = 'ours'
+    process.env.AWS_SECRET_ACCESS_KEY = 'ours'
+    process.env.RESTIC_PASSWORD = 'ours'
+    process.env.RESTIC_REPOSITORY = 'ours'
+    process.env.DATABASE_URL = 'postgres://ours'
+    fakeRestic('exit 0')
+
+    await runRestic({ ...base(), args: ['snapshots'] })
+
+    const env = readChildEnv()
+    expect(env).toEqual({
+      PATH: '/usr/local/bin:/usr/bin:/bin',
+      HOME: cacheDir,
+      TMPDIR: cacheDir,
+      GOGC: '20',
+      RESTIC_CACHE_DIR: cacheDir,
+      RESTIC_REPOSITORY: repository.repository,
+      RESTIC_PASSWORD: 'correct horse battery',
+      AWS_ACCESS_KEY_ID: 'theirs-id',
+      AWS_SECRET_ACCESS_KEY: 'theirs-secret',
+    })
+    expect(Object.values(env).some((v) => v.includes('ours'))).toBe(false)
+  })
+
+  it('does not let a destination override the password or path', async () => {
+    fakeRestic('exit 0')
+    await runRestic({
+      ...base(),
+      repository: {
+        ...repository,
+        env: { RESTIC_PASSWORD: 'other', RESTIC_PASSWORD_COMMAND: 'cat /etc/x', PATH: '/tmp' },
+      },
+      args: ['snapshots'],
+    })
+    const env = readChildEnv()
+    expect(env.RESTIC_PASSWORD).toBe('correct horse battery')
+    expect(env.PATH).toBe('/usr/local/bin:/usr/bin:/bin')
+    expect(env.RESTIC_PASSWORD_COMMAND).toBeUndefined()
+  })
+
+  it('passes the repository and options on argv, never the password', async () => {
+    fakeRestic('exit 0')
+    await runRestic({ ...base(), args: ['snapshots', '--host', 'h'] })
+    const argv = readChildArgv()
+    expect(argv).toEqual([
+      '--json',
+      '--repo',
+      repository.repository,
+      '-o',
+      's3.region=eu-central-1',
+      'snapshots',
+      '--host',
+      'h',
+    ])
+    expect(argv.join(' ')).not.toContain('correct horse battery')
+  })
+
+  it('refuses to run without a password', async () => {
+    fakeRestic('exit 0')
+    await expect(runRestic({ ...base(), password: '', args: ['snapshots'] })).rejects.toThrow(
+      /password/
+    )
+  })
+})
+
+describe('argv redaction', () => {
+  it('hides secret option values and URL credentials', () => {
+    expect(
+      redactArgv([
+        '--repo',
+        'rest:https://user:pa55@backup.example.com/repo',
+        '-o',
+        's3.secret-key=shh',
+        '-o',
+        's3.region=eu',
+        '--option=azure.access-token=tok',
+        '-o',
+        'b2.password=pw',
+      ])
+    ).toEqual([
+      '--repo',
+      'rest:https://***@backup.example.com/repo',
+      '-o',
+      's3.secret-key=***',
+      '-o',
+      's3.region=eu',
+      '--option=azure.access-token=***',
+      '-o',
+      'b2.password=***',
+    ])
+  })
+
+  it('puts the redacted command on a ResticError', async () => {
+    fakeRestic(`${err(WRONG_PASSWORD)}\nexit 12`)
+    const error = await runRestic({
+      ...base(),
+      repository: { ...repository, options: { 's3.secret-key': 'shh' } },
+      args: ['snapshots'],
+    }).catch((e) => e)
+    expect(error).toBeInstanceOf(ResticError)
+    expect(error.command).toContain('s3.secret-key=***')
+    expect(error.command).not.toContain('shh')
+    expect(error.command).not.toContain('correct horse battery')
+    expect(readChildArgv()).toContain('s3.secret-key=shh')
+  })
+})
+
+describe('JSON parsing', () => {
+  it('splits JSON lines from text and drops status lines', async () => {
+    fakeRestic(out(STATUS_LINE, 'plain text', SUMMARY_LINE))
+    const seen: string[] = []
+    const result = await runRestic({ ...base(), args: ['backup'], onLine: (l) => seen.push(l) })
+    expect(result.code).toBe(0)
+    expect(result.stdout).toBe('plain text\n')
+    expect(result.json).toEqual([JSON.parse(SUMMARY_LINE)])
+    expect(seen).toEqual([STATUS_LINE, 'plain text', SUMMARY_LINE])
+  })
+
+  it('reads the backup summary', async () => {
+    fakeRestic(out(STATUS_LINE, SUMMARY_LINE))
+    const summary = await resticBackup(base(), {
+      paths: ['/stage', '/uploads/org1'],
+      host: 'torqvoice-org1',
+      tags: ['org:org1', 'manual'],
+      exclude: ['*.tmp'],
+    })
+    expect(summary).toEqual({
+      snapshotId: '28891c8f448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b',
+      filesNew: 2,
+      filesChanged: 0,
+      dataAdded: 3851,
+      totalBytesProcessed: 12,
+      totalFilesProcessed: 2,
+      partial: false,
+    })
+    expect(readChildArgv().slice(5)).toEqual([
+      'backup',
+      '--host',
+      'torqvoice-org1',
+      '--tag',
+      'org:org1',
+      '--tag',
+      'manual',
+      '--exclude',
+      '*.tmp',
+      '--',
+      '/stage',
+      '/uploads/org1',
+    ])
+  })
+
+  it('treats exit 3 on backup as a partial success', async () => {
+    fakeRestic(`${out(SUMMARY_LINE)}\n${err(UNREADABLE_FILE, PARTIAL_EXIT)}\nexit 3`)
+    const summary = await resticBackup(base(), { paths: ['/stage'], host: 'h', tags: [] })
+    expect(summary.partial).toBe(true)
+    expect(summary.snapshotId).toMatch(/^28891c8f/)
+  })
+
+  it('treats exit 3 on anything else as a failure', async () => {
+    fakeRestic(`${err(PARTIAL_EXIT)}\nexit 3`)
+    await expect(runRestic({ ...base(), args: ['snapshots'] })).rejects.toBeInstanceOf(ResticError)
+  })
+
+  it('reads snapshots, stats, forget and check', async () => {
+    fakeRestic(out(SNAPSHOTS_LINE))
+    expect(await resticSnapshots(base(), { host: 'torqvoice-org1', tags: ['org:org1'] })).toEqual([
+      {
+        id: '28891c8f448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b',
+        shortId: '28891c8f',
+        time: '2026-10-29T09:09:10.444930802+01:00',
+        hostname: 'torqvoice-org1',
+        tags: ['org:org1', 'manual'],
+        paths: ['/stage'],
+        summary: {
+          filesNew: 2,
+          filesChanged: 0,
+          dataAdded: 3851,
+          totalFilesProcessed: 2,
+          totalBytesProcessed: 12,
+        },
+      },
+    ])
+
+    fakeRestic(out(STATS_LINE))
+    expect(await resticStats(base(), { host: 'torqvoice-org1', tags: ['org:org1'] })).toEqual({
+      totalSize: 6928,
+      totalFileCount: null,
+      snapshotsCount: 3,
+    })
+    expect(readChildArgv().slice(5)).toEqual([
+      'stats',
+      '--mode',
+      'raw-data',
+      '--host',
+      'torqvoice-org1',
+      '--tag',
+      'org:org1',
+    ])
+
+    fakeRestic(out(FORGET_LINE))
+    expect(
+      await resticForget(base(), {
+        host: 'torqvoice-org1',
+        tags: ['org:org1', 'scheduled'],
+        keepHourly: 0,
+        keepDaily: 14,
+        keepWeekly: 8,
+        keepMonthly: 0,
+        prune: true,
+      })
+    ).toEqual({ kept: 2, removed: 1 })
+    expect(readChildArgv().slice(5)).toEqual([
+      'forget',
+      '--group-by',
+      'host',
+      '--host',
+      'torqvoice-org1',
+      '--tag',
+      'org:org1,scheduled',
+      '--keep-daily',
+      '14',
+      '--keep-weekly',
+      '8',
+      '--prune',
+      '--max-unused',
+      '10%',
+    ])
+
+    fakeRestic(out(CHECK_LINE))
+    expect(await resticCheck(base(), { readDataSubset: '2%' })).toEqual({
+      numErrors: 0,
+      suggestRepairIndex: false,
+      suggestPrune: false,
+    })
+    expect(readChildArgv().slice(5)).toEqual(['check', '--read-data-subset=2%'])
+  })
+
+  it('reads the version without a repository', async () => {
+    process.env.RESTIC_PASSWORD = 'ours'
+    fakeRestic(out(VERSION_LINE))
+    expect(await resticVersion()).toBe('0.19.1')
+    expect(readChildEnv(root)).toEqual({ PATH: '/usr/local/bin:/usr/bin:/bin' })
+  })
+
+  it('forgets nothing when every keep is 0, and only prunes', async () => {
+    fakeRestic('exit 0')
+    const none = { keepHourly: 0, keepDaily: 0, keepWeekly: 0, keepMonthly: 0 }
+    expect(await resticForget(base(), { host: 'h', tags: [], ...none, prune: false })).toEqual({
+      kept: 0,
+      removed: 0,
+    })
+    expect(existsSync(path.join(cacheDir, 'argv.txt'))).toBe(false)
+    await resticForget(base(), { host: 'h', tags: [], ...none, prune: true })
+    expect(readChildArgv().slice(5)).toEqual(['prune', '--max-unused', '10%'])
+  })
+
+  it('forgets one snapshot by id', async () => {
+    fakeRestic('exit 0')
+    await resticForgetSnapshot(base(), '28891c8f')
+    expect(readChildArgv().slice(5)).toEqual(['forget', '--', '28891c8f'])
+    await expect(resticForgetSnapshot(base(), '--prune')).rejects.toThrow(/snapshot id/)
+  })
+
+  it('runs unlock without --remove-all', async () => {
+    fakeRestic('exit 0')
+    await resticUnlock(base())
+    expect(readChildArgv().slice(5)).toEqual(['unlock'])
+  })
+
+  it('restores one snapshot into a target folder and reads the summary', async () => {
+    fakeRestic(out(RESTORE_STATUS_LINE, RESTORE_LINE))
+    const summary = await resticRestore(base(), { snapshotId: '28891c8f', target: '/tmp/out' })
+    expect(readChildArgv().slice(5)).toEqual(['restore', '--target', '/tmp/out', '--', '28891c8f'])
+    expect(summary).toEqual({ filesRestored: 12, bytesRestored: 19 })
+    await expect(
+      resticRestore(base(), { snapshotId: 'latest', target: '/tmp/out' })
+    ).rejects.toThrow(/snapshot id/)
+  })
+})
+
+describe('error mapping', () => {
+  it('maps exit 12 to wrong_password', async () => {
+    fakeRestic(`${err(WRONG_PASSWORD)}\nexit 12`)
+    const error = await resticOpen(base()).catch((e) => e)
+    expect(error).toBeInstanceOf(ResticError)
+    expect(error.reason).toBe('wrong_password')
+    expect(error.code).toBe(12)
+    expect(error.message).toBe('Fatal: wrong password or no key found')
+  })
+
+  it('maps exit 10 to no_repository', async () => {
+    fakeRestic(`${err(NO_REPOSITORY)}\nexit 10`)
+    const error = await resticOpen(base()).catch((e) => e)
+    expect(error.reason).toBe('no_repository')
+    expect(error.message).toMatch(/^Fatal: repository does not exist/)
+  })
+
+  it('reads the config on a good open', async () => {
+    fakeRestic(
+      out(
+        '{',
+        '  "version": 2,',
+        '  "id": "7f2af135",',
+        '  "chunker_polynomial": "3cc4840466c787"',
+        '}'
+      )
+    )
+    expect(await resticOpen(base())).toEqual({ id: '7f2af135', version: 2 })
+  })
+
+  it('treats an initialised repository as success', async () => {
+    fakeRestic(`${err(ALREADY_INITIALIZED)}\nexit 1`)
+    expect(await resticInit(base())).toEqual({ created: false })
+    expect(readChildArgv().slice(5)).toEqual(['init', '--repository-version', '2'])
+  })
+
+  it('keeps the last 2000 characters of stderr', async () => {
+    fakeRestic(`head -c 5000 /dev/zero | tr '\\0' 'x' >&2\nexit 1`)
+    const error = await runRestic({ ...base(), args: ['check'] }).catch((e) => e)
+    expect(error.stderr).toHaveLength(2000)
+    expect(error.reason).toBe('failed')
+  })
+
+  it('reports a missing binary', async () => {
+    process.env.RESTIC_BIN = path.join(root, 'no-such-restic')
+    const error = await runRestic({ ...base(), args: ['snapshots'] }).catch((e) => e)
+    expect(error.reason).toBe('not_installed')
+  })
+})
+
+describe('timeouts', () => {
+  it('stops a run that takes too long', async () => {
+    fakeRestic('exec sleep 30')
+    const started = Date.now()
+    const error = await runRestic({ ...base(), args: ['check'], timeoutMs: 200 }).catch((e) => e)
+    expect(error).toBeInstanceOf(ResticError)
+    expect(error.reason).toBe('timeout')
+    expect(Date.now() - started).toBeLessThan(5000)
+  })
+
+  it('kills a child that ignores SIGTERM', async () => {
+    fakeRestic("trap '' TERM\nexec sleep 30")
+    const started = Date.now()
+    const error = await runRestic({
+      ...base(),
+      args: ['check'],
+      timeoutMs: 200,
+      killGraceMs: 300,
+    }).catch((e) => e)
+    expect(error.reason).toBe('timeout')
+    expect(Date.now() - started).toBeGreaterThanOrEqual(500)
+    expect(Date.now() - started).toBeLessThan(5000)
+  })
+
+  it('stops on abort', async () => {
+    fakeRestic('exec sleep 30')
+    const controller = new AbortController()
+    setTimeout(() => controller.abort(), 100)
+    const error = await runRestic({ ...base(), args: ['check'], signal: controller.signal }).catch(
+      (e) => e
+    )
+    expect(error.reason).toBe('aborted')
+  })
+})
+
+// Opt-in: RESTIC_REAL_BIN=/path/to/restic npx vitest run src/__tests__/lib/backup
+const realBin = process.env.RESTIC_REAL_BIN
+describe.runIf(realBin && existsSync(realBin))('real restic', () => {
+  it('inits, backs up, lists, forgets and checks a local repository', async () => {
+    process.env.RESTIC_BIN = realBin
+    const work = mkdtempSync(path.join(root, 'real-'))
+    const source = path.join(work, 'source')
+    mkdirSync(path.join(source, 'a'), { recursive: true })
+    writeFileSync(path.join(source, 'a', 'one.txt'), 'hello')
+    writeFileSync(path.join(source, 'two.txt'), 'world')
+    const opts = {
+      repository: { repository: path.join(work, 'repo'), env: {} },
+      password: 'real-test-password',
+      cacheDir: path.join(work, 'cache'),
+    }
+
+    expect(await resticVersion()).toMatch(/^\d+\.\d+\.\d+/)
+
+    const missing = await resticOpen(opts).catch((e) => e)
+    expect(missing.reason).toBe('no_repository')
+
+    expect(await resticInit(opts)).toEqual({ created: true })
+    expect(await resticInit(opts)).toEqual({ created: false })
+    expect((await resticOpen(opts)).version).toBe(2)
+
+    const wrong = await resticOpen({ ...opts, password: 'nope' }).catch((e) => e)
+    expect(wrong.reason).toBe('wrong_password')
+
+    const target = { host: 'torqvoice-test', tags: ['org:test'] }
+    const first = await resticBackup(opts, { paths: [source], ...target })
+    expect(first.filesNew).toBe(2)
+    expect(first.snapshotId).toMatch(/^[0-9a-f]{64}$/)
+    writeFileSync(path.join(source, 'three.txt'), 'again')
+    const second = await resticBackup(opts, { paths: [source], ...target })
+    expect(second.filesNew).toBe(1)
+
+    const snapshots = await resticSnapshots(opts, target)
+    expect(snapshots.map((s) => s.id)).toEqual([first.snapshotId, second.snapshotId])
+    expect(snapshots[0].tags).toEqual(['org:test'])
+
+    const stats = await resticStats(opts, target)
+    expect(stats.totalSize).toBeGreaterThan(0)
+    expect(stats.snapshotsCount).toBe(2)
+
+    const forgot = await resticForget(opts, {
+      ...target,
+      keepHourly: 0,
+      keepDaily: 0,
+      keepWeekly: 0,
+      keepMonthly: 0,
+      prune: false,
+    })
+    expect(forgot.removed).toBe(0)
+
+    // keep-hourly 1 keeps only the newest of two snapshots taken in the same hour.
+    const pruned = await resticForget(opts, {
+      ...target,
+      keepHourly: 1,
+      keepDaily: 0,
+      keepWeekly: 0,
+      keepMonthly: 0,
+      prune: true,
+    })
+    expect(pruned).toEqual({ kept: 1, removed: 1 })
+    expect((await resticSnapshots(opts, target)).map((s) => s.id)).toEqual([second.snapshotId])
+
+    const probeTarget = { host: 'torqvoice-test', tags: ['torqvoice-probe'] }
+    const probe = await resticBackup(opts, { paths: [source], ...probeTarget })
+    await resticForgetSnapshot(opts, probe.snapshotId)
+    expect(await resticSnapshots(opts, probeTarget)).toEqual([])
+
+    expect((await resticCheck(opts, { readDataSubset: '100%' })).numErrors).toBe(0)
+    await resticUnlock(opts)
+  }, 120_000)
+})

+ 202 - 0
src/__tests__/lib/backup/restore-tenancy-gates.test.ts

@@ -0,0 +1,202 @@
+/**
+ * @vitest-environment node
+ *
+ * A tripwire on the restore's source, in the manner of security-gates: every
+ * write the import core makes has to name the restoring organisation. A
+ * delete, update or upsert without it in its where, or a create for a model
+ * with an organisation column that does not stamp it, fails this test before
+ * it can reach a database. The runtime guard (tenant-guard.ts) catches the
+ * same thing in production; this catches it at review.
+ */
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { TENANCY_SCHEMA } from '@/lib/backup/tenancy-schema'
+
+const source = fs.readFileSync(path.join(process.cwd(), 'src/lib/backup/import.ts'), 'utf-8')
+
+/** Where a line or block comment starting at `i` ends, or null when none starts there. */
+function commentEnd(text: string, i: number): number | null {
+  if (text.startsWith('//', i)) {
+    const end = text.indexOf('\n', i)
+    return end < 0 ? text.length : end
+  }
+  if (text.startsWith('/*', i)) {
+    const end = text.indexOf('*/', i + 2)
+    return end < 0 ? text.length : end + 2
+  }
+  return null
+}
+
+/** The text between the opening bracket at `from` and its closing one. */
+function balanced(text: string, from: number): string {
+  let depth = 0
+  let quote = ''
+  for (let i = from; i < text.length; i++) {
+    const char = text[i]
+    if (quote) {
+      if (char === '\\') i++
+      else if (char === quote) quote = ''
+      continue
+    }
+    const skip = commentEnd(text, i)
+    if (skip !== null) {
+      i = skip - 1
+      continue
+    }
+    if (char === "'" || char === '"' || char === '`') {
+      quote = char
+      continue
+    }
+    if ('([{'.includes(char)) depth++
+    if (')]}'.includes(char)) {
+      depth--
+      if (depth === 0) return text.slice(from + 1, i)
+    }
+  }
+  throw new Error('unbalanced')
+}
+
+interface Write {
+  model: string
+  operation: string
+  args: string
+  /** The override object of the restoreRows call the write sits in, if any. */
+  override: string | null
+  line: number
+}
+
+function writes(): Write[] {
+  const out: Write[] = []
+  const pattern =
+    /tx\.(\w+)\.(create|createMany|createManyAndReturn|delete|deleteMany|update|updateMany|upsert)\(/g
+  for (const match of source.matchAll(pattern)) {
+    const index = match.index ?? 0
+    const args = balanced(source, index + match[0].length - 1)
+    // Inside `restoreRows('what', (rows) => tx.model.createMany({ data: rows }), rows, override)`.
+    let override: string | null = null
+    const before = source.slice(0, index)
+    const call = before.lastIndexOf('restoreRows(')
+    if (call >= 0) {
+      const callArgs = balanced(source, call + 'restoreRows'.length)
+      const end = call + 'restoreRows('.length + callArgs.length
+      if (end > index) {
+        const parts = splitArguments(callArgs)
+        override = parts[3] ?? null
+      }
+    }
+    const model = match[1].charAt(0).toUpperCase() + match[1].slice(1)
+    out.push({
+      model,
+      operation: match[2],
+      args,
+      override,
+      line: before.split('\n').length,
+    })
+  }
+  return out
+}
+
+/** Top-level arguments of a call, split at commas outside brackets and strings. */
+function splitArguments(text: string): string[] {
+  const parts: string[] = []
+  let depth = 0
+  let quote = ''
+  let current = ''
+  for (let i = 0; i < text.length; i++) {
+    const char = text[i]
+    if (quote) {
+      current += char
+      if (char === '\\') {
+        current += text[++i]
+        continue
+      }
+      if (char === quote) quote = ''
+      continue
+    }
+    const skip = commentEnd(text, i)
+    if (skip !== null) {
+      current += text.slice(i, skip)
+      i = skip - 1
+      continue
+    }
+    if (char === "'" || char === '"' || char === '`') quote = char
+    if ('([{'.includes(char)) depth++
+    if (')]}'.includes(char)) depth--
+    if (char === ',' && depth === 0) {
+      parts.push(current.trim())
+      current = ''
+      continue
+    }
+    current += char
+  }
+  if (current.trim()) parts.push(current.trim())
+  return parts
+}
+
+const STAMPED = /\borganizationId\b/
+
+describe('the restore core only writes its own workshop', () => {
+  const all = writes()
+
+  it('finds the writes', () => {
+    expect(all.length).toBeGreaterThan(50)
+    expect(
+      all.every((w) => w.model in TENANCY_SCHEMA),
+      'every model is in the schema'
+    ).toBe(true)
+  })
+
+  it('names the organisation in every delete, update and upsert', () => {
+    const offenders = all
+      .filter((w) =>
+        ['delete', 'deleteMany', 'update', 'updateMany', 'upsert'].includes(w.operation)
+      )
+      .filter((w) => !/where:\s*\{[\s\S]*?\borganizationId\b/.test(w.args))
+      .map((w) => `${w.model}.${w.operation} at line ${w.line}`)
+    expect(offenders).toEqual([])
+  })
+
+  it('stamps the organisation on every create for a model that has the column', () => {
+    const offenders = all
+      .filter((w) => w.operation.startsWith('create'))
+      .filter((w) => TENANCY_SCHEMA[w.model]?.organizationId)
+      .filter((w) => !(STAMPED.test(w.args) || (w.override !== null && STAMPED.test(w.override))))
+      .map((w) => `${w.model}.${w.operation} at line ${w.line}`)
+    expect(offenders).toEqual([])
+  })
+
+  it('does not stamp anything but the restoring organisation', () => {
+    // The only organisation the file ever names is the one being restored.
+    const foreign = [
+      ...source.matchAll(
+        /organizationId:\s*(?!ctx\.organizationId\b|organizationId\b|opts\.organizationId\b|files\.organizationId\b|string\b)(\S+)/g
+      ),
+    ]
+      .map((m) => m[1])
+      .filter((v) => !/^(string|boolean)$/.test(v))
+    expect(foreign).toEqual([])
+  })
+
+  it('wraps the transaction in the tenant guard and runs the tenancy checks first', () => {
+    expect(source).toMatch(/const tx = tenantScoped\(raw, ctx\.organizationId\)/)
+    const guard = source.indexOf('tenantScoped(raw')
+    const refuse = source.indexOf('await assertNoForeignRows(')
+    const clean = source.indexOf('sanitiseForeignIds(')
+    const firstDelete = source.indexOf('.deleteMany(')
+    expect(guard).toBeGreaterThan(-1)
+    expect(refuse).toBeGreaterThan(guard)
+    expect(clean).toBeGreaterThan(refuse)
+    expect(firstDelete).toBeGreaterThan(clean)
+  })
+
+  it('never skips a duplicate quietly', () => {
+    expect(source).not.toContain('skipDuplicates')
+  })
+
+  it('takes the restore lock and gives the transaction the time a restore needs', () => {
+    expect(source).toMatch(/acquireRestoreLock\(lock\)/)
+    expect(source).toMatch(/releaseRestoreLock\(lock\)/)
+    expect(source).toMatch(/timeout: TRANSACTION_TIMEOUT_MS/)
+  })
+})

+ 778 - 0
src/__tests__/lib/backup/restore.test.ts

@@ -0,0 +1,778 @@
+// @vitest-environment node
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Restoring a snapshot from a backup destination, with restic and the
+ * import mocked: restic "writes" the snapshot into the target folder the
+ * engine asks for, and the import records what it was given. What is
+ * checked is the glue: the run row, the lock, where the folder goes, how
+ * the data file and the uploads folder are found, and that the folder is
+ * removed however the run ends.
+ *
+ * The safety copy is the engine's real takeSafetySnapshot, not a stub: the
+ * data writer is a fake and restic's init, unlock and backup are mocked, so
+ * what is checked is that an actual snapshot of the workshop (tagged
+ * pre-restore, with what the destination is set to keep) is asked of the
+ * repository the restore reads from, before anything in the workshop is
+ * replaced. A self-hosted install
+ * has no platform repository, so that is where it goes here.
+ */
+
+type Row = Record<string, any>
+
+const h = vi.hoisted(() => ({
+  runs: new Map<string, Row>(),
+  connections: new Map<string, Row>(),
+  seq: 0,
+  /** What the fake restic restore writes under the target, as relative paths. */
+  restoredTree: {} as Record<string, string>,
+  /** The folder restic was asked to restore into, kept to look at later. */
+  restoreTarget: null as string | null,
+  /** Whether the restored files still existed when the import ran. */
+  seenAtImport: null as { dataFile: boolean; filesDir: boolean | null; restoring: boolean } | null,
+  importResult: { redactedSettings: ['ai.apiKey'] } as Row,
+  importError: null as Error | null,
+  log: vi.fn(async () => undefined),
+  /** Where the fake destination keeps its repository: a folder under the test root. */
+  repoDir: '',
+  /** What happened, in order: the safety copy, restic's restore, the import. */
+  events: [] as string[],
+  /** Thrown by the fake restic backup, so the safety copy fails. */
+  safetyError: null as Error | null,
+  /** Every run's safetySnapshotId at the moment restic restored. */
+  safetyAtRestore: null as (string | null)[] | null,
+  owner: { userId: 'owner1' } as Row | null,
+  /** The destination's backup settings. */
+  settings: {} as Row,
+}))
+
+function merge(row: Row, data: Row): Row {
+  const out = { ...row }
+  for (const [k, v] of Object.entries(data)) if (v !== undefined) out[k] = v
+  return out
+}
+
+vi.mock('@/lib/db', () => {
+  const db: Record<string, any> = {
+    backupRun: {
+      create: vi.fn(async ({ data }: { data: Row }) => {
+        const id = `run${++h.seq}`
+        h.runs.set(id, {
+          id,
+          startedAt: null,
+          finishedAt: null,
+          snapshotId: null,
+          filesNew: null,
+          bytesAdded: null,
+          totalBytes: null,
+          error: null,
+          createdAt: new Date(),
+          ...data,
+        })
+        return { id }
+      }),
+      update: vi.fn(async ({ where, data }: { where: { id: string }; data: Row }) => {
+        const row = merge(h.runs.get(where.id) ?? {}, data)
+        h.runs.set(where.id, row)
+        return row
+      }),
+      findUnique: vi.fn(
+        async ({ where }: { where: { id: string } }) => h.runs.get(where.id) ?? null
+      ),
+      // The busy check (organisation, kind, connection, open statuses) and
+      // the queue's next run (status 'queued', oldest first).
+      findFirst: vi.fn(async ({ where, orderBy }: { where: Row; orderBy?: Row }) => {
+        const statuses: string[] =
+          typeof where.status === 'string' ? [where.status] : where.status.in
+        const rows = [...h.runs.values()].filter(
+          (r) =>
+            statuses.includes(r.status) &&
+            (where.organizationId === undefined || r.organizationId === where.organizationId) &&
+            (where.kind === undefined || r.kind === where.kind) &&
+            (where.connectionId === undefined || r.connectionId === where.connectionId) &&
+            (where.trigger === undefined ||
+              (typeof where.trigger === 'string'
+                ? r.trigger === where.trigger
+                : where.trigger.in.includes(r.trigger)))
+        )
+        if (orderBy) rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
+        return rows[0] ?? null
+      }),
+    },
+    integrationConnection: {
+      findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
+        const row = h.connections.get(where.id)
+        return row ? { ...row } : null
+      }),
+    },
+    organizationMember: {
+      findFirst: vi.fn(async () => h.owner),
+      findMany: vi.fn(async () => [{ user: { email: 'owner@workshop.test' } }]),
+    },
+  }
+  // The queue's transaction: the advisory lock is a no-op here, and the
+  // transaction client is the same fake.
+  db.$transaction = async (fn: (tx: Record<string, any>) => unknown) => fn(db)
+  db.$executeRaw = vi.fn(async () => 0)
+  return { db }
+})
+
+vi.mock('@/features/integrations/Lib/connections', () => ({
+  loadConnection: vi.fn(async (id: string) => {
+    const row = h.connections.get(id)
+    if (!row) throw new Error('Integration connection not found')
+    return {
+      ctx: {
+        connection: {
+          id,
+          organizationId: row.organizationId,
+          connectorId: row.connectorId,
+          settings: { ...h.settings },
+          state: {},
+        },
+        credentials: { password: 'correct horse battery' },
+        log: h.log,
+        saveState: vi.fn(async () => undefined),
+      },
+      server: {
+        backupTarget: { repository: async () => ({ repository: h.repoDir, env: {} }) },
+      },
+      status: row.status,
+    }
+  }),
+  setConnectionStatus: vi.fn(async () => undefined),
+}))
+
+vi.mock('@/integrations/registry', () => ({
+  getManifest: (id: string) => ({ id, name: 'Test storage', capabilities: ['backup.target'] }),
+  listManifests: () => [],
+}))
+
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn(async () => undefined) }))
+
+vi.mock('@/lib/backup/build-data', () => ({
+  writeBackupData: vi.fn(
+    async (organizationId: string, _options: Row, out: NodeJS.WritableStream) => {
+      out.write(JSON.stringify({ organizationId }))
+      return { datasets: ['customers'] }
+    }
+  ),
+}))
+
+vi.mock('@/lib/features', () => ({
+  isCloudMode: () => false,
+  getFeatures: vi.fn(async () => ({ platformBackups: false })),
+}))
+vi.mock('@/i18n/organization-translator', () => ({
+  organizationTranslator: async () =>
+    Object.assign((key: string) => key, { has: () => false, locale: 'en' }),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
+vi.mock('@/lib/notify', () => ({ notify: vi.fn(async () => undefined) }))
+vi.mock('@/lib/email', () => ({
+  getOrgFromAddress: vi.fn(async () => 'Workshop <post@workshop.test>'),
+  sendOrgMail: vi.fn(async () => undefined),
+  getFromAddress: vi.fn(async () => 'Torqvoice <noreply@torqvoice.test>'),
+  sendMail: vi.fn(async () => undefined),
+}))
+
+vi.mock('@/lib/backup/restic', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/backup/restic')>()
+  return {
+    ...actual,
+    resticInit: vi.fn(async () => ({ created: false })),
+    resticUnlock: vi.fn(async () => undefined),
+    resticBackup: vi.fn(async () => {
+      h.events.push('safety')
+      if (h.safetyError) throw h.safetyError
+      return {
+        snapshotId: SAFETY,
+        filesNew: 1,
+        filesChanged: 0,
+        dataAdded: 100,
+        totalBytesProcessed: 100,
+        totalFilesProcessed: 1,
+        partial: false,
+      }
+    }),
+    resticSnapshots: vi.fn(async () => []),
+    resticRestoreSize: vi.fn(async () => 0),
+    resticRestore: vi.fn(async (_opts: unknown, input: { snapshotId: string; target: string }) => {
+      h.events.push('restore')
+      h.safetyAtRestore = [...h.runs.values()].map((r) => r.safetySnapshotId ?? null)
+      h.restoreTarget = input.target
+      let bytes = 0
+      for (const [rel, body] of Object.entries(h.restoredTree)) {
+        const file = path.join(input.target, rel)
+        mkdirSync(path.dirname(file), { recursive: true })
+        writeFileSync(file, body)
+        bytes += body.length
+      }
+      return { filesRestored: Object.keys(h.restoredTree).length, bytesRestored: bytes }
+    }),
+  }
+})
+
+// A data file the server would hold is tiny here, so the cap can be tripped
+// without writing a hundred megabytes.
+vi.mock('@/lib/backup/limits', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/backup/limits')>()),
+  maxDataFileBytes: () => 1000,
+}))
+
+vi.mock('@/lib/backup/import', () => ({
+  restoreFromDisk: vi.fn(
+    async (_actor: unknown, input: { dataFile: string; filesDir: string | null }) => {
+      const { isRestoring } = await import('@/lib/backup/freeze')
+      h.events.push('import')
+      h.seenAtImport = {
+        dataFile: existsSync(input.dataFile),
+        filesDir: input.filesDir === null ? null : existsSync(input.filesDir),
+        restoring: isRestoring('org1'),
+      }
+      if (h.importError) throw h.importError
+      return h.importResult
+    }
+  ),
+}))
+
+import { logAudit } from '@/lib/audit'
+import { writeBackupData } from '@/lib/backup/build-data'
+import { BackupBusyError } from '@/lib/backup/engine'
+import { isRestoring } from '@/lib/backup/freeze'
+import { restoreFromDisk } from '@/lib/backup/import'
+import {
+  resticBackup,
+  resticRestore,
+  resticRestoreSize,
+  resticSnapshots,
+} from '@/lib/backup/restic'
+import { db } from '@/lib/db'
+import { notify } from '@/lib/notify'
+import {
+  listSnapshots,
+  locateRestored,
+  queueRestore,
+  restoreDirFor,
+  runRestore,
+  sweepAbandonedRestoreDirs,
+} from '@/lib/backup/restore'
+
+const ORG = 'org1'
+const CONN = 'conn1'
+const SNAPSHOT = '28891c8f448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b'
+const OLDER = '9d6cb16e448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b'
+const SAFETY = '5a1e7c3b448a3c36c2271f6dc4e03b08052265e671b6a8c438c9358a8a1e602b'
+const SAFETY_FAILED = 'Could not take a safety copy of the workshop first; nothing was changed.'
+
+const root = mkdtempSync(path.join(tmpdir(), 'backup-restore-test-'))
+const dataDir = path.join(root, 'data')
+const savedEnv = { ...process.env }
+
+/** A snapshot of the staged data file and the uploads folder, as the engine takes them. */
+const FULL_TREE = {
+  'tmp/torqvoice-backup-staging/org1/data.json': '{"version":2,"data":{}}',
+  'srv/old-server/data/uploads/org1/logos/logo.png': 'png bytes',
+  'srv/old-server/data/uploads/org1/service-files/a.pdf': 'pdf bytes',
+}
+
+function addConnection(): void {
+  h.connections.set(CONN, {
+    id: CONN,
+    organizationId: ORG,
+    connectorId: 'local-folder',
+    status: 'active',
+  })
+}
+
+function addRun(over: Row = {}): string {
+  const id = `run${++h.seq}`
+  h.runs.set(id, {
+    id,
+    organizationId: ORG,
+    kind: 'destination',
+    connectionId: CONN,
+    trigger: 'restore',
+    status: 'queued',
+    requestedBy: 'user1',
+    reason: null,
+    startedAt: null,
+    finishedAt: null,
+    snapshotId: SNAPSHOT,
+    safetySnapshotId: null,
+    filesNew: null,
+    bytesAdded: null,
+    totalBytes: null,
+    error: null,
+    createdAt: new Date(),
+    ...over,
+  })
+  return id
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  h.runs.clear()
+  h.connections.clear()
+  h.seq = 0
+  h.restoredTree = { ...FULL_TREE }
+  h.restoreTarget = null
+  h.seenAtImport = null
+  h.importResult = { redactedSettings: ['ai.apiKey'] }
+  h.importError = null
+  h.events = []
+  h.safetyError = null
+  h.safetyAtRestore = null
+  h.owner = { userId: 'owner1' }
+  h.settings = {}
+  h.repoDir = path.join(root, 'repo')
+  rmSync(dataDir, { recursive: true, force: true })
+  mkdirSync(dataDir, { recursive: true })
+  process.env.DATA_ROOT = dataDir
+  ;(globalThis as Row).__torqvoiceBackupLock = null
+  ;(globalThis as Row).__torqvoiceBackupDraining = false
+  ;(globalThis as Row).__torqvoiceRestoring?.clear()
+  addConnection()
+})
+
+afterEach(() => {
+  for (const key of Object.keys(process.env)) {
+    if (!(key in savedEnv)) delete process.env[key]
+  }
+  Object.assign(process.env, savedEnv)
+})
+
+afterAll(() => {
+  rmSync(root, { recursive: true, force: true })
+})
+
+describe('listSnapshots', () => {
+  it('lists the snapshots newest first, with size and whether files were included', async () => {
+    vi.mocked(resticSnapshots).mockResolvedValueOnce([
+      {
+        id: OLDER,
+        shortId: '9d6cb16e',
+        time: '2026-10-28T02:00:00+01:00',
+        hostname: 'torqvoice-oldorg',
+        tags: ['org:oldorg', 'scheduled'],
+        paths: ['/tmp/torqvoice-backup-staging/oldorg'],
+      },
+      {
+        id: 'probe000',
+        shortId: 'probe000',
+        time: '2026-10-30T00:00:00+01:00',
+        hostname: 'torqvoice-probe-org1',
+        tags: ['torqvoice-probe'],
+        paths: ['/tmp/torqvoice-backup-probe-x'],
+      },
+      {
+        id: SNAPSHOT,
+        shortId: '28891c8f',
+        time: '2026-10-29T09:09:10.44+01:00',
+        hostname: 'torqvoice-org1',
+        tags: ['org:org1', 'manual'],
+        paths: ['/tmp/torqvoice-backup-staging/org1', '/srv/data/uploads/org1'],
+        summary: {
+          filesNew: 2,
+          filesChanged: 0,
+          dataAdded: 3851,
+          totalFilesProcessed: 2,
+          totalBytesProcessed: 4096,
+        },
+      },
+    ])
+
+    const list = await listSnapshots(CONN)
+
+    expect(list).toEqual([
+      {
+        id: SNAPSHOT,
+        shortId: '28891c8f',
+        time: '2026-10-29T09:09:10.44+01:00',
+        bytes: 4096,
+        filesNew: 2,
+        dataAdded: 3851,
+        files: true,
+        trigger: 'manual',
+        thisWorkshop: true,
+      },
+      // An earlier install's snapshot in the same repository restores too;
+      // the connection check's probe does not show.
+      {
+        id: OLDER,
+        shortId: '9d6cb16e',
+        time: '2026-10-28T02:00:00+01:00',
+        bytes: null,
+        filesNew: null,
+        dataAdded: null,
+        files: false,
+        trigger: 'scheduled',
+        thisWorkshop: false,
+      },
+    ])
+    expect(vi.mocked(resticSnapshots).mock.calls[0][1]).toEqual({ tags: [] })
+  })
+})
+
+describe('queueRestore', () => {
+  it('makes a restore run that names the snapshot', async () => {
+    const { runId } = await queueRestore(CONN, SNAPSHOT)
+    expect(h.runs.get(runId)).toMatchObject({
+      organizationId: ORG,
+      kind: 'destination',
+      connectionId: CONN,
+      trigger: 'restore',
+      status: 'queued',
+      snapshotId: SNAPSHOT,
+      requestedBy: null,
+    })
+  })
+
+  it('takes a run target and keeps who asked and why', async () => {
+    const { runId } = await queueRestore({ kind: 'destination', connectionId: CONN }, SNAPSHOT, {
+      requestedBy: 'user1',
+      reason: 'Put back yesterday',
+    })
+    expect(h.runs.get(runId)).toMatchObject({
+      kind: 'destination',
+      connectionId: CONN,
+      trigger: 'restore',
+      snapshotId: SNAPSHOT,
+      requestedBy: 'user1',
+      reason: 'Put back yesterday',
+    })
+  })
+
+  it('refuses while a backup or restore is queued or running, and refuses a bad id', async () => {
+    addRun({ trigger: 'manual', status: 'running' })
+    await expect(queueRestore(CONN, SNAPSHOT)).rejects.toBeInstanceOf(BackupBusyError)
+    await expect(queueRestore(CONN, 'latest')).rejects.toThrow(/snapshot id/)
+  })
+})
+
+describe('sweepAbandonedRestoreDirs', () => {
+  it('removes the folders no running restore owns, and keeps the one holding the lock', async () => {
+    const live = restoreDirFor(ORG, 'runlive')
+    const dead = restoreDirFor(ORG, 'rundead')
+    const stranger = path.join(dataDir, 'restore-tmp', 'not a run')
+    for (const dir of [live, dead, stranger]) mkdirSync(dir, { recursive: true })
+    ;(globalThis as Row).__torqvoiceBackupLock = { runId: 'runlive', since: Date.now() }
+
+    await expect(sweepAbandonedRestoreDirs()).resolves.toBe(1)
+
+    expect(existsSync(live)).toBe(true)
+    expect(existsSync(dead)).toBe(false)
+    expect(existsSync(stranger)).toBe(true)
+  })
+
+  it('is quiet when the folder does not exist yet', async () => {
+    await expect(sweepAbandonedRestoreDirs()).resolves.toBe(0)
+  })
+})
+
+describe('runRestore', () => {
+  it('restores into a folder under the data root, imports from it, and removes it', async () => {
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('done')
+    expect(view.snapshotId).toBe(SNAPSHOT)
+    expect(view.filesNew).toBe(3)
+    expect(view.bytesAdded).toBe(
+      Object.values(FULL_TREE).reduce((sum, body) => sum + body.length, 0)
+    )
+    expect(view.error).toBeNull()
+
+    // Where restic was pointed: a folder of this run's own under the data root.
+    expect(h.restoreTarget).toBe(restoreDirFor(ORG, runId))
+    expect(h.restoreTarget?.startsWith(path.join(dataDir, 'restore-tmp'))).toBe(true)
+    expect(vi.mocked(resticRestore).mock.calls[0][1]).toEqual({
+      snapshotId: SNAPSHOT,
+      target: h.restoreTarget,
+    })
+
+    // The data file and the uploads folder, found wherever the old server kept them.
+    const target = h.restoreTarget as string
+    expect(vi.mocked(restoreFromDisk).mock.calls[0][0]).toEqual({
+      organizationId: ORG,
+      userId: 'user1',
+    })
+    expect(vi.mocked(restoreFromDisk).mock.calls[0][1]).toEqual({
+      dataFile: path.join(target, 'tmp/torqvoice-backup-staging/org1/data.json'),
+      filesDir: path.join(target, 'srv/old-server/data/uploads/org1'),
+    })
+    expect(h.seenAtImport).toEqual({ dataFile: true, filesDir: true, restoring: true })
+
+    // Gone afterwards, and the lock is free again.
+    expect(existsSync(target)).toBe(false)
+    expect((globalThis as Row).__torqvoiceBackupLock).toBeNull()
+
+    // The audit row is written after the import, which replaced the old log.
+    expect(logAudit).toHaveBeenCalledTimes(1)
+    expect(vi.mocked(logAudit).mock.calls[0][0]).toEqual({ userId: 'user1', organizationId: ORG })
+    expect(vi.mocked(logAudit).mock.calls[0][1]).toMatchObject({
+      action: 'integration.backup_restore',
+      entity: 'BackupRun',
+      entityId: runId,
+      details: { key: 'backup_restored', params: { name: 'Test storage', snapshot: '28891c8f' } },
+      metadata: { redactedSettings: ['ai.apiKey'] },
+    })
+    expect(h.log).toHaveBeenCalledWith('info', 'Backup restored', expect.any(Object))
+  })
+
+  it('restores a data-only snapshot with no uploads folder', async () => {
+    h.restoredTree = {
+      'tmp/torqvoice-backup-staging/org1/data.json': '{"version":2,"data":{}}',
+    }
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('done')
+    expect(vi.mocked(restoreFromDisk).mock.calls[0][1].filesDir).toBeNull()
+  })
+
+  it('fails the run, keeps the message and removes the folder when the import throws', async () => {
+    h.importError = new Error('Could not restore customers: boom')
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('failed')
+    expect(view.error).toBe(
+      `Could not restore customers: boom The safety copy taken first (${SAFETY.slice(0, 8)}) is among the restore points; restore it to undo.`
+    )
+    expect(existsSync(h.restoreTarget as string)).toBe(false)
+    expect((globalThis as Row).__torqvoiceBackupLock).toBeNull()
+    expect(logAudit).not.toHaveBeenCalled()
+  })
+
+  it('fails when the snapshot holds no data.json', async () => {
+    h.restoredTree = { 'srv/data/uploads/org1/logos/logo.png': 'png' }
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('failed')
+    expect(view.error).toBe('The snapshot holds no data.json Nothing in the workshop was changed.')
+    expect(restoreFromDisk).not.toHaveBeenCalled()
+    expect(existsSync(h.restoreTarget as string)).toBe(false)
+  })
+
+  it('refuses a snapshot the data disk cannot take, before restic writes a byte of it', async () => {
+    vi.mocked(resticRestoreSize).mockResolvedValueOnce(Number.MAX_SAFE_INTEGER)
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('failed')
+    expect(view.error).toMatch(/free; a restore keeps/)
+    expect(view.error).toMatch(/Nothing in the workshop was changed\.$/)
+    expect(vi.mocked(resticRestoreSize).mock.calls[0][1]).toBe(SNAPSHOT)
+    expect(resticRestore).not.toHaveBeenCalled()
+    expect(restoreFromDisk).not.toHaveBeenCalled()
+    expect(isRestoring(ORG)).toBe(false)
+  })
+
+  it('refuses a data file larger than the server reads into memory, and removes the folder', async () => {
+    h.restoredTree['tmp/torqvoice-backup-staging/org1/data.json'] =
+      `{"version":2,"padding":"${'x'.repeat(1200)}","data":{}}`
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('failed')
+    expect(view.error).toMatch(/more than this server reads into memory/)
+    expect(view.error).toMatch(/Nothing in the workshop was changed\.$/)
+    expect(restoreFromDisk).not.toHaveBeenCalled()
+    expect(existsSync(h.restoreTarget as string)).toBe(false)
+    expect(isRestoring(ORG)).toBe(false)
+  })
+
+  it('says why when restic refuses', async () => {
+    const { ResticError } = await import('@/lib/backup/restic')
+    vi.mocked(resticRestore).mockRejectedValueOnce(
+      new ResticError({
+        message: 'Fatal: wrong password or no key found',
+        code: 12,
+        stderr: '',
+        command: 'restic restore',
+        reason: 'wrong_password',
+      })
+    )
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('failed')
+    expect(view.error).toMatch(/different password/)
+    expect(restoreFromDisk).not.toHaveBeenCalled()
+  })
+
+  it('stays queued while a backup holds the lock', async () => {
+    ;(globalThis as Row).__torqvoiceBackupLock = { runId: 'other', since: Date.now() }
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('queued')
+    expect(view.error).toBeNull()
+    expect(h.runs.get(runId)).toMatchObject({ status: 'queued', startedAt: null })
+    expect(resticBackup).not.toHaveBeenCalled()
+    expect(resticRestore).not.toHaveBeenCalled()
+    expect(isRestoring(ORG)).toBe(false)
+    // Somebody else's lock is left alone.
+    expect((globalThis as Row).__torqvoiceBackupLock).toEqual({
+      runId: 'other',
+      since: expect.any(Number),
+    })
+  })
+
+  it('takes a safety copy of the workshop before restic restores, and keeps its id on the row', async () => {
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('done')
+    expect(view.safetySnapshotId).toBe(SAFETY)
+    expect(h.runs.get(runId)?.safetySnapshotId).toBe(SAFETY)
+    expect(h.events).toEqual(['safety', 'restore', 'import'])
+    // The id was on the row before restic restored anything.
+    expect(h.safetyAtRestore).toEqual([SAFETY])
+    // A snapshot of this workshop into the restore's own repository, with
+    // the secret keys in it even though the destination keeps data only.
+    expect(vi.mocked(writeBackupData).mock.calls[0][0]).toBe(ORG)
+    expect(vi.mocked(writeBackupData).mock.calls[0][1]).toMatchObject({ includeSecrets: true })
+    const [opts, input] = vi.mocked(resticBackup).mock.calls[0]
+    expect(opts.repository.repository).toBe(h.repoDir)
+    expect(input).toMatchObject({ host: `torqvoice-${ORG}` })
+    expect(input.tags).toEqual([`org:${ORG}`, 'pre-restore'])
+    expect(vi.mocked(logAudit).mock.calls[0][1]).toMatchObject({
+      metadata: { safetySnapshotId: SAFETY, bySupport: false },
+    })
+  })
+
+  it('keeps the secret keys in the safety copy whatever the destination is set to keep', async () => {
+    h.settings = { contents: 'data' }
+    await runRestore(addRun())
+    expect(vi.mocked(writeBackupData).mock.calls[0][1]).toMatchObject({ includeSecrets: true })
+  })
+
+  it('leaves the workshop untouched when the safety copy cannot be taken', async () => {
+    h.safetyError = new Error('bucket gone')
+    const runId = addRun()
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('failed')
+    expect(view.error?.startsWith(SAFETY_FAILED)).toBe(true)
+    expect(view.error).toContain('bucket gone')
+    expect(view.safetySnapshotId).toBeNull()
+    expect(resticRestore).not.toHaveBeenCalled()
+    expect(restoreFromDisk).not.toHaveBeenCalled()
+    expect(logAudit).not.toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+    expect(isRestoring(ORG)).toBe(false)
+    expect((globalThis as Row).__torqvoiceBackupLock).toBeNull()
+  })
+
+  it('freezes the workshop while the restore runs and unfreezes it after', async () => {
+    expect(isRestoring(ORG)).toBe(false)
+    await runRestore(addRun())
+    expect(h.seenAtImport?.restoring).toBe(true)
+    expect(isRestoring(ORG)).toBe(false)
+  })
+
+  it('unfreezes the workshop after a failed restore too', async () => {
+    h.importError = new Error('Could not restore customers: boom')
+    const view = await runRestore(addRun())
+    expect(view.status).toBe('failed')
+    expect(h.seenAtImport?.restoring).toBe(true)
+    expect(isRestoring(ORG)).toBe(false)
+  })
+
+  it("writes a restore support started in the name of the workshop's first owner", async () => {
+    const runId = addRun({ requestedBy: null, reason: 'Customer asked by phone' })
+
+    const view = await runRestore(runId)
+
+    expect(view.status).toBe('done')
+    expect(vi.mocked(db.organizationMember.findFirst).mock.calls[0][0]).toMatchObject({
+      where: { organizationId: ORG, role: 'owner' },
+    })
+    expect(vi.mocked(restoreFromDisk).mock.calls[0][0]).toEqual({
+      organizationId: ORG,
+      userId: 'owner1',
+    })
+    expect(vi.mocked(logAudit).mock.calls[0][0]).toEqual({ userId: 'owner1', organizationId: ORG })
+    expect(vi.mocked(logAudit).mock.calls[0][1]).toMatchObject({
+      metadata: { bySupport: true, reason: 'Customer asked by phone' },
+    })
+    // The workshop hears that support put it back, and why.
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({ organizationId: ORG, type: 'backup_restored' })
+    )
+    expect(vi.mocked(notify).mock.calls[0][0].message).toContain('backupAlerts.restoredBySupport')
+  })
+
+  it("a member's restore is written in that member's name, without looking up the owner", async () => {
+    await runRestore(addRun({ requestedBy: 'user1' }))
+    expect(db.organizationMember.findFirst).not.toHaveBeenCalled()
+    expect(vi.mocked(restoreFromDisk).mock.calls[0][0]).toEqual({
+      organizationId: ORG,
+      userId: 'user1',
+    })
+  })
+
+  it('fails a support restore of a workshop with no owner before anything is changed', async () => {
+    h.owner = null
+    const view = await runRestore(addRun({ requestedBy: null }))
+    expect(view.status).toBe('failed')
+    expect(view.error).toMatch(/no owner/)
+    expect(resticBackup).not.toHaveBeenCalled()
+    expect(restoreFromDisk).not.toHaveBeenCalled()
+  })
+
+  it('refuses a run that is not a restore, and leaves a finished one alone', async () => {
+    const backup = addRun({ trigger: 'manual', snapshotId: null })
+    expect((await runRestore(backup)).status).toBe('failed')
+    const done = addRun({ status: 'done' })
+    expect((await runRestore(done)).status).toBe('done')
+    expect(resticRestore).not.toHaveBeenCalled()
+  })
+})
+
+describe('locateRestored', () => {
+  it('finds data.json and the uploads folder named after the workshop', async () => {
+    const dir = path.join(root, 'locate')
+    rmSync(dir, { recursive: true, force: true })
+    for (const [rel, body] of Object.entries(FULL_TREE)) {
+      mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true })
+      writeFileSync(path.join(dir, rel), body)
+    }
+    // Another workshop's folder on the same old server is not ours.
+    mkdirSync(path.join(dir, 'srv/old-server/data/uploads/org2/logos'), { recursive: true })
+
+    expect(await locateRestored(dir, ORG)).toEqual({
+      dataFile: path.join(dir, 'tmp/torqvoice-backup-staging/org1/data.json'),
+      filesDir: path.join(dir, 'srv/old-server/data/uploads/org1'),
+    })
+    // A snapshot an earlier install took has its uploads under the old id.
+    expect(await locateRestored(dir, 'org3')).toEqual({
+      dataFile: path.join(dir, 'tmp/torqvoice-backup-staging/org1/data.json'),
+      filesDir: path.join(dir, 'srv/old-server/data/uploads/org1'),
+    })
+    expect(
+      readFileSync(path.join(dir, 'tmp/torqvoice-backup-staging/org1/data.json'), 'utf8')
+    ).toBe('{"version":2,"data":{}}')
+  })
+
+  it('names the restore folder after the workshop and the run, under the data root', () => {
+    expect(restoreDirFor(ORG, 'run1')).toBe(path.join(dataDir, 'restore-tmp', 'org1-run1'))
+    expect(() => restoreDirFor('../x', 'run1')).toThrow()
+  })
+})

+ 367 - 0
src/__tests__/lib/backup/schedule.test.ts

@@ -0,0 +1,367 @@
+import { describe, expect, it } from 'vitest'
+import type { BackupSettings } from '@/features/integrations/Lib/backup-types'
+import {
+  BACKUP_SETTING_DEFAULTS,
+  clampDestinationFrequency,
+  intervalMs,
+  isDue,
+  nextRunAfter,
+  readBackupSettings,
+  retentionFlags,
+  spreadOffsetMinutes,
+} from '@/lib/backup/schedule'
+
+const OSLO = 'Europe/Oslo'
+const NY = 'America/New_York'
+
+function settings(over: Partial<BackupSettings>): BackupSettings {
+  return { ...BACKUP_SETTING_DEFAULTS, ...over }
+}
+
+function next(over: Partial<BackupSettings>, after: string, tz: string, offset = 0): string {
+  return nextRunAfter(settings(over), new Date(after), tz, offset).toISOString()
+}
+
+/** Every run between two instants, by chaining nextRunAfter. */
+function runsBetween(
+  over: Partial<BackupSettings>,
+  from: string,
+  to: string,
+  tz: string
+): string[] {
+  const out: string[] = []
+  let t = new Date(from)
+  for (;;) {
+    t = nextRunAfter(settings(over), t, tz)
+    if (t.getTime() > new Date(to).getTime()) return out
+    out.push(t.toISOString())
+  }
+}
+
+describe('clampDestinationFrequency', () => {
+  const base = { ...BACKUP_SETTING_DEFAULTS }
+
+  it('runs an hourly or four-hourly destination daily on the cloud, and leaves the rest', () => {
+    expect(clampDestinationFrequency({ ...base, frequency: 'hourly' }, true).frequency).toBe(
+      'daily'
+    )
+    expect(clampDestinationFrequency({ ...base, frequency: 'every4h' }, true).frequency).toBe(
+      'daily'
+    )
+    for (const frequency of ['daily', 'weekly', 'monthly'] as const) {
+      expect(clampDestinationFrequency({ ...base, frequency }, true).frequency).toBe(frequency)
+    }
+  })
+
+  it('changes nothing self-hosted', () => {
+    const hourly = { ...base, frequency: 'hourly' as const }
+    expect(clampDestinationFrequency(hourly, false)).toBe(hourly)
+  })
+})
+
+describe('nextRunAfter: frequencies', () => {
+  it('hourly runs at :00 on the workshop clock', () => {
+    expect(next({ frequency: 'hourly' }, '2026-06-10T10:15:00Z', OSLO)).toBe(
+      '2026-06-10T11:00:00.000Z'
+    )
+    expect(next({ frequency: 'hourly' }, '2026-01-15T10:00:00Z', NY)).toBe(
+      '2026-01-15T11:00:00.000Z'
+    )
+    // India is UTC+5:30, so its :00 is UTC :30.
+    expect(next({ frequency: 'hourly' }, '2026-06-10T10:15:00Z', 'Asia/Kolkata')).toBe(
+      '2026-06-10T10:30:00.000Z'
+    )
+  })
+
+  it('every4h runs at 00, 04, 08, 12, 16 and 20', () => {
+    // 12:30 CEST → 16:00 CEST.
+    expect(next({ frequency: 'every4h' }, '2026-06-10T10:30:00Z', OSLO)).toBe(
+      '2026-06-10T14:00:00.000Z'
+    )
+    // 07:00 EST → 08:00 EST.
+    expect(next({ frequency: 'every4h' }, '2026-01-15T12:00:00Z', NY)).toBe(
+      '2026-01-15T13:00:00.000Z'
+    )
+    // 21:00 EST → 00:00 EST next day.
+    expect(next({ frequency: 'every4h' }, '2026-01-16T02:00:00Z', NY)).toBe(
+      '2026-01-16T05:00:00.000Z'
+    )
+  })
+
+  it('daily runs at the set time, strictly after', () => {
+    expect(next({ time: '02:00' }, '2026-06-10T10:00:00Z', OSLO)).toBe('2026-06-11T00:00:00.000Z')
+    expect(next({ time: '02:00' }, '2026-06-10T23:59:00Z', OSLO)).toBe('2026-06-11T00:00:00.000Z')
+    expect(next({ time: '02:00' }, '2026-06-11T00:00:00Z', OSLO)).toBe('2026-06-12T00:00:00.000Z')
+    expect(next({ time: '02:00' }, '2026-01-15T12:00:00Z', NY)).toBe('2026-01-16T07:00:00.000Z')
+    expect(next({ time: '23:45' }, '2026-01-15T12:00:00Z', NY)).toBe('2026-01-16T04:45:00.000Z')
+  })
+
+  it('weekly runs on the weekday and wraps the week', () => {
+    // Wednesday 10 June 2026 → Monday 15 June 03:00 CEST.
+    expect(
+      next({ frequency: 'weekly', weekday: 1, time: '03:00' }, '2026-06-10T10:00:00Z', OSLO)
+    ).toBe('2026-06-15T01:00:00.000Z')
+    // Saturday → Sunday the next day.
+    expect(
+      next({ frequency: 'weekly', weekday: 0, time: '03:00' }, '2026-06-13T10:00:00Z', OSLO)
+    ).toBe('2026-06-14T01:00:00.000Z')
+    // Sunday after the time → the Sunday after.
+    expect(
+      next({ frequency: 'weekly', weekday: 0, time: '03:00' }, '2026-06-14T10:00:00Z', OSLO)
+    ).toBe('2026-06-21T01:00:00.000Z')
+    // Saturday 23:30 EST is Sunday in UTC.
+    expect(
+      next({ frequency: 'weekly', weekday: 6, time: '23:30' }, '2026-01-18T04:45:00Z', NY)
+    ).toBe('2026-01-25T04:30:00.000Z')
+    expect(
+      next({ frequency: 'weekly', weekday: 6, time: '23:30' }, '2026-01-17T12:00:00Z', NY)
+    ).toBe('2026-01-18T04:30:00.000Z')
+  })
+
+  it('monthly runs on the set day', () => {
+    expect(
+      next({ frequency: 'monthly', monthDay: '15', time: '02:00' }, '2026-01-20T00:00:00Z', NY)
+    ).toBe('2026-02-15T07:00:00.000Z')
+    expect(
+      next({ frequency: 'monthly', monthDay: '1', time: '02:00' }, '2026-12-05T00:00:00Z', OSLO)
+    ).toBe('2027-01-01T01:00:00.000Z')
+  })
+
+  it("monthly 'last' is the last day, 29 February in a leap year", () => {
+    const last = { frequency: 'monthly' as const, monthDay: 'last', time: '02:00' }
+    expect(next(last, '2028-02-10T00:00:00Z', OSLO)).toBe('2028-02-29T01:00:00.000Z')
+    expect(next(last, '2027-02-10T00:00:00Z', OSLO)).toBe('2027-02-28T01:00:00.000Z')
+    expect(next(last, '2026-01-31T01:00:00Z', OSLO)).toBe('2026-02-28T01:00:00.000Z')
+    expect(next(last, '2026-04-01T00:00:00Z', NY)).toBe('2026-04-30T06:00:00.000Z')
+  })
+})
+
+describe('nextRunAfter: DST', () => {
+  it('spring forward: a time in the gap runs at the first instant after it (Oslo)', () => {
+    // 29 March 2026, 02:00 CET jumps to 03:00 CEST at 01:00Z.
+    expect(next({ time: '02:30' }, '2026-03-28T12:00:00Z', OSLO)).toBe('2026-03-29T01:00:00.000Z')
+    expect(next({ time: '02:30' }, '2026-03-29T01:00:00Z', OSLO)).toBe('2026-03-30T00:30:00.000Z')
+    expect(next({ time: '02:00' }, '2026-03-28T12:00:00Z', OSLO)).toBe('2026-03-29T01:00:00.000Z')
+  })
+
+  it('spring forward: a time in the gap runs at the first instant after it (New York)', () => {
+    // 8 March 2026, 02:00 EST jumps to 03:00 EDT at 07:00Z.
+    expect(next({ time: '02:30' }, '2026-03-07T12:00:00Z', NY)).toBe('2026-03-08T07:00:00.000Z')
+    expect(next({ time: '02:30' }, '2026-03-08T07:00:00Z', NY)).toBe('2026-03-09T06:30:00.000Z')
+  })
+
+  it('spring forward: the times either side keep their wall clock', () => {
+    expect(next({ time: '01:30' }, '2026-03-28T12:00:00Z', OSLO)).toBe('2026-03-29T00:30:00.000Z')
+    expect(next({ time: '03:30' }, '2026-03-28T12:00:00Z', OSLO)).toBe('2026-03-29T01:30:00.000Z')
+  })
+
+  it('autumn repeat runs once (Oslo)', () => {
+    // 25 October 2026, 03:00 CEST falls back to 02:00 CET at 01:00Z.
+    const runs = runsBetween(
+      { time: '02:30' },
+      '2026-10-24T12:00:00Z',
+      '2026-10-26T12:00:00Z',
+      OSLO
+    )
+    expect(runs).toHaveLength(2)
+    expect(['2026-10-25T00:30:00.000Z', '2026-10-25T01:30:00.000Z']).toContain(runs[0])
+    expect(runs[1]).toBe('2026-10-26T01:30:00.000Z')
+  })
+
+  it('autumn repeat runs once (New York)', () => {
+    // 1 November 2026, 02:00 EDT falls back to 01:00 EST at 06:00Z.
+    const runs = runsBetween({ time: '01:30' }, '2026-10-31T12:00:00Z', '2026-11-02T12:00:00Z', NY)
+    expect(runs).toHaveLength(2)
+    expect(['2026-11-01T05:30:00.000Z', '2026-11-01T06:30:00.000Z']).toContain(runs[0])
+    expect(runs[1]).toBe('2026-11-02T06:30:00.000Z')
+  })
+
+  it('daily runs keep the wall clock across a change', () => {
+    const runs = runsBetween(
+      { time: '02:00' },
+      '2026-10-23T12:00:00Z',
+      '2026-10-27T12:00:00Z',
+      OSLO
+    )
+    expect(runs).toHaveLength(4)
+    expect(runs[0]).toBe('2026-10-24T00:00:00.000Z')
+    // 02:00 happens twice on the 25th; either is fine, once.
+    expect(['2026-10-25T00:00:00.000Z', '2026-10-25T01:00:00.000Z']).toContain(runs[1])
+    expect(runs.slice(2)).toEqual(['2026-10-26T01:00:00.000Z', '2026-10-27T01:00:00.000Z'])
+  })
+
+  it('hourly runs every real hour through both changes', () => {
+    expect(
+      runsBetween({ frequency: 'hourly' }, '2026-10-24T22:30:00Z', '2026-10-25T03:30:00Z', OSLO)
+    ).toHaveLength(5)
+    expect(
+      runsBetween({ frequency: 'hourly' }, '2026-03-28T22:30:00Z', '2026-03-29T03:30:00Z', OSLO)
+    ).toHaveLength(5)
+  })
+
+  it('weekly on a spring-forward Sunday', () => {
+    expect(
+      next({ frequency: 'weekly', weekday: 0, time: '02:15' }, '2026-03-27T12:00:00Z', OSLO)
+    ).toBe('2026-03-29T01:00:00.000Z')
+  })
+})
+
+describe('nextRunAfter: offsets', () => {
+  it('adds the offset to the slot', () => {
+    expect(next({ time: '02:00' }, '2026-06-10T12:00:00Z', OSLO, 7)).toBe(
+      '2026-06-11T00:07:00.000Z'
+    )
+  })
+
+  it('counts the offset as part of the run time', () => {
+    expect(next({ time: '02:00' }, '2026-06-11T00:03:00Z', OSLO, 7)).toBe(
+      '2026-06-11T00:07:00.000Z'
+    )
+    expect(next({ time: '02:00' }, '2026-06-11T00:07:00Z', OSLO, 7)).toBe(
+      '2026-06-12T00:07:00.000Z'
+    )
+  })
+
+  it('works for hourly', () => {
+    expect(next({ frequency: 'hourly' }, '2026-06-10T10:05:00Z', OSLO, 9)).toBe(
+      '2026-06-10T10:09:00.000Z'
+    )
+    expect(next({ frequency: 'hourly' }, '2026-06-10T10:09:00Z', OSLO, 9)).toBe(
+      '2026-06-10T11:09:00.000Z'
+    )
+  })
+})
+
+describe('isDue', () => {
+  const daily = settings({ time: '02:00' })
+
+  it('is due once the next slot after the last run has passed', () => {
+    const last = new Date('2026-06-11T00:07:00Z')
+    expect(isDue(daily, last, new Date('2026-06-12T00:06:00Z'), OSLO, 7)).toBe(false)
+    expect(isDue(daily, last, new Date('2026-06-12T00:10:00Z'), OSLO, 7)).toBe(true)
+  })
+
+  it('a late run still counts for its slot', () => {
+    const last = new Date('2026-06-11T00:12:00Z')
+    expect(isDue(daily, last, new Date('2026-06-11T12:00:00Z'), OSLO, 7)).toBe(false)
+  })
+
+  it('a missed slot is caught up', () => {
+    const last = new Date('2026-06-09T00:07:00Z')
+    expect(isDue(daily, last, new Date('2026-06-11T12:00:00Z'), OSLO, 7)).toBe(true)
+  })
+
+  it('a new connection waits for the next slot', () => {
+    expect(isDue(daily, null, new Date('2026-06-10T14:00:00Z'), OSLO, 0)).toBe(false)
+    expect(isDue(daily, null, new Date('2026-06-11T00:05:00Z'), OSLO, 0)).toBe(true)
+    expect(isDue(daily, null, new Date('2026-06-11T00:12:00Z'), OSLO, 0)).toBe(false)
+  })
+})
+
+describe('spreadOffsetMinutes', () => {
+  it('is stable and between 0 and 59', () => {
+    const values = new Set<number>()
+    for (let i = 0; i < 200; i++) {
+      const id = `org_${i.toString(36)}_cm${i * 7919}`
+      const v = spreadOffsetMinutes(id)
+      expect(v).toBe(spreadOffsetMinutes(id))
+      expect(Number.isInteger(v) && v >= 0 && v <= 59).toBe(true)
+      values.add(v)
+    }
+    expect(values.size).toBeGreaterThanOrEqual(40)
+  })
+})
+
+describe('intervalMs', () => {
+  it('is one period of the frequency', () => {
+    expect(intervalMs({ frequency: 'hourly' })).toBe(3_600_000)
+    expect(intervalMs({ frequency: 'every4h' })).toBe(4 * 3_600_000)
+    expect(intervalMs({ frequency: 'daily' })).toBe(24 * 3_600_000)
+    expect(intervalMs({ frequency: 'weekly' })).toBe(7 * 24 * 3_600_000)
+  })
+})
+
+describe('retentionFlags', () => {
+  it('lists the keep flags in order', () => {
+    expect(retentionFlags(BACKUP_SETTING_DEFAULTS)).toEqual([
+      '--keep-hourly',
+      '24',
+      '--keep-daily',
+      '14',
+      '--keep-weekly',
+      '8',
+      '--keep-monthly',
+      '12',
+    ])
+  })
+
+  it('skips zeros', () => {
+    expect(retentionFlags({ keepHourly: 0, keepDaily: 7, keepWeekly: 0, keepMonthly: 3 })).toEqual([
+      '--keep-daily',
+      '7',
+      '--keep-monthly',
+      '3',
+    ])
+    expect(retentionFlags({ keepHourly: 0, keepDaily: 0, keepWeekly: 0, keepMonthly: 0 })).toEqual(
+      []
+    )
+  })
+})
+
+describe('readBackupSettings', () => {
+  it('fills defaults', () => {
+    expect(readBackupSettings({})).toEqual(BACKUP_SETTING_DEFAULTS)
+  })
+
+  it('keeps valid values, including strings from the form', () => {
+    expect(
+      readBackupSettings({
+        frequency: 'weekly',
+        time: '23:15',
+        weekday: '3',
+        monthDay: 15,
+        contents: 'dataAndSecrets',
+        includeFiles: 'false',
+        keepHourly: '0',
+        keepDaily: 30,
+        keepWeekly: 4,
+        keepMonthly: 24,
+      })
+    ).toEqual({
+      frequency: 'weekly',
+      time: '23:15',
+      weekday: 3,
+      monthDay: '15',
+      contents: 'dataAndSecrets',
+      includeFiles: false,
+      keepHourly: 0,
+      keepDaily: 30,
+      keepWeekly: 4,
+      keepMonthly: 24,
+    })
+    expect(readBackupSettings({ monthDay: 'last' }).monthDay).toBe('last')
+  })
+
+  it('rejects invalid values', () => {
+    const s = readBackupSettings({
+      frequency: 'yearly',
+      time: '25:00',
+      weekday: 9,
+      monthDay: '29',
+      contents: 'everything',
+      includeFiles: 'maybe',
+      keepHourly: 5000,
+      keepDaily: -3,
+      keepWeekly: 'lots',
+      keepMonthly: 1.5,
+    })
+    expect(s).toEqual({
+      ...BACKUP_SETTING_DEFAULTS,
+      keepHourly: 1000,
+      keepDaily: 0,
+    })
+    expect(readBackupSettings({ time: '2:00' }).time).toBe('02:00')
+    expect(readBackupSettings({ monthDay: '0' }).monthDay).toBe('1')
+    expect(readBackupSettings({ monthDay: '31' }).monthDay).toBe('1')
+  })
+})

+ 218 - 0
src/__tests__/lib/backup/secrets.test.ts

@@ -0,0 +1,218 @@
+import { describe, expect, it } from 'vitest'
+import {
+  isRedacted,
+  isSecretSettingKey,
+  keepCurrentSecrets,
+  keepCurrentWebhookSecrets,
+  REDACTED,
+  redactSettingRow,
+  redactWebhookRow,
+  SECRET_SETTING_KEYS,
+} from '@/lib/backup/secrets'
+
+describe('SECRET_SETTING_KEYS', () => {
+  it('is exactly the reviewed set', () => {
+    // A new secret setting fails here until someone decides it is one.
+    expect([...SECRET_SETTING_KEYS]).toEqual([
+      'ai.apiKey',
+      'email.mailgun.apiKey',
+      'email.postmark.apiKey',
+      'email.resend.apiKey',
+      'email.sendgrid.apiKey',
+      'email.ses.secretAccessKey',
+      'email.smtp.pass',
+      'license.key',
+      'license.token',
+      'payment.paypal.clientSecret',
+      'payment.stripe.secretKey',
+      'payment.stripe.webhookSecret',
+      'payment.vipps.clientSecret',
+      'payment.vipps.subscriptionKey',
+      'sms.telnyx.apiKey',
+      'sms.twilio.authToken',
+      'sms.vonage.apiKey',
+      'sms.vonage.apiSecret',
+      'sms.webhookSecret',
+      'telegram.botToken',
+      'telegram.webhookSecret',
+    ])
+  })
+
+  it('treats every WhatsApp credential as secret', () => {
+    expect(isSecretSettingKey('whatsapp.cred.twilio.authToken')).toBe(true)
+    expect(isSecretSettingKey('whatsapp.cred.meta.webhookToken')).toBe(true)
+    expect(isSecretSettingKey('whatsapp.template.name')).toBe(false)
+  })
+
+  it('leaves ordinary settings alone', () => {
+    for (const key of [
+      'workshop.logo',
+      'payment.stripe.publishableKey',
+      'payment.vipps.clientId',
+      'vehicle.typeKeyEnabled',
+      'email.smtp.user',
+    ]) {
+      expect(isSecretSettingKey(key), key).toBe(false)
+    }
+  })
+})
+
+describe('redaction', () => {
+  it('replaces the value of a secret setting', () => {
+    const row = { id: 's1', key: 'payment.stripe.secretKey', value: 'sk_live_x', userId: 'u1' }
+    expect(redactSettingRow(row)).toEqual({ ...row, value: REDACTED })
+    expect(row.value).toBe('sk_live_x')
+  })
+
+  it('keeps a non-secret setting as it is', () => {
+    const row = { key: 'workshop.phone', value: '+47 123' }
+    expect(redactSettingRow(row)).toBe(row)
+  })
+
+  it('replaces a webhook secret', () => {
+    const row = { id: 'w1', url: 'https://x.test', secret: 'whsec_abc' }
+    expect(redactWebhookRow(row)).toEqual({ ...row, secret: REDACTED })
+  })
+
+  it('recognises only the marker', () => {
+    expect(isRedacted(REDACTED)).toBe(true)
+    expect(isRedacted('')).toBe(false)
+    expect(isRedacted(null)).toBe(false)
+    expect(isRedacted('sk_live_x')).toBe(false)
+  })
+})
+
+describe('keepCurrentSecrets', () => {
+  it('keeps the install value, drops a key it has none for, and lists both', () => {
+    const rows = [
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'b', key: 'payment.stripe.secretKey', value: REDACTED },
+      { id: 'c', key: 'ai.apiKey', value: REDACTED },
+    ]
+    const result = keepCurrentSecrets(rows, new Map([['payment.stripe.secretKey', 'sk_now']]))
+    expect(result.rows).toEqual([
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'b', key: 'payment.stripe.secretKey', value: 'sk_now' },
+    ])
+    expect(result.redacted).toEqual(['ai.apiKey', 'payment.stripe.secretKey'])
+  })
+
+  it('keeps a secret the install has that a data-only file does not carry', () => {
+    const rows = [
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'b', key: 'payment.stripe.secretKey', value: REDACTED },
+    ]
+    const installed = [
+      { id: 'x1', key: 'payment.stripe.secretKey', value: 'sk_now' },
+      // Entered after the backup was taken: not in the file at all.
+      { id: 'x2', key: 'ai.apiKey', value: 'sk-ai-now' },
+      { id: 'x3', key: 'whatsapp.cred.twilio.authToken', value: 'tw-now' },
+      // Not a secret: the file is the truth for it, so it is not kept.
+      { id: 'x4', key: 'workshop.email', value: 'post@x.test' },
+    ]
+    const result = keepCurrentSecrets(
+      rows,
+      new Map([['payment.stripe.secretKey', 'sk_now']]),
+      installed
+    )
+    expect(result.rows).toEqual([
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'b', key: 'payment.stripe.secretKey', value: 'sk_now' },
+      { id: 'x2', key: 'ai.apiKey', value: 'sk-ai-now' },
+      { id: 'x3', key: 'whatsapp.cred.twilio.authToken', value: 'tw-now' },
+    ])
+    expect(result.redacted).toEqual([
+      'ai.apiKey',
+      'payment.stripe.secretKey',
+      'whatsapp.cred.twilio.authToken',
+    ])
+  })
+
+  it('keeps nothing extra from the install when the file carries its secrets', () => {
+    const rows = [
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'b', key: 'payment.stripe.secretKey', value: 'sk_backup' },
+    ]
+    const installed = [{ id: 'x2', key: 'ai.apiKey', value: 'sk-ai-now' }]
+    const result = keepCurrentSecrets(rows, new Map([['ai.apiKey', 'sk-ai-now']]), installed)
+    expect(result.rows).toEqual(rows)
+    expect(result.redacted).toEqual([])
+  })
+
+  it('still drops a redacted key the install has no value for', () => {
+    const rows = [{ id: 'c', key: 'ai.apiKey', value: REDACTED }]
+    const installed = [{ id: 'x1', key: 'telegram.botToken', value: 'tg-now' }]
+    const result = keepCurrentSecrets(rows, new Map(), installed)
+    expect(result.rows).toEqual([{ id: 'x1', key: 'telegram.botToken', value: 'tg-now' }])
+    expect(result.redacted).toEqual(['ai.apiKey', 'telegram.botToken'])
+  })
+
+  it('keeps installed secrets for a file the header calls data-only, with no marker in it', () => {
+    // A "Data only" backup of a workshop that had no secret to leave out.
+    const rows = [{ id: 'a', key: 'workshop.phone', value: '123' }]
+    const installed = [
+      { id: 'x1', key: 'ai.apiKey', value: 'sk-ai-now' },
+      { id: 'x2', key: 'workshop.email', value: 'post@x.test' },
+    ]
+    const result = keepCurrentSecrets(rows, new Map(), installed, { dataOnly: true })
+    expect(result.rows).toEqual([
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'x1', key: 'ai.apiKey', value: 'sk-ai-now' },
+    ])
+    expect(result.redacted).toEqual(['ai.apiKey'])
+  })
+
+  it('keeps nothing extra for a file the header says carries its secrets, even with a marker', () => {
+    const rows = [
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'b', key: 'payment.stripe.secretKey', value: REDACTED },
+    ]
+    const installed = [
+      { id: 'x1', key: 'payment.stripe.secretKey', value: 'sk_now' },
+      { id: 'x2', key: 'ai.apiKey', value: 'sk-ai-now' },
+    ]
+    const result = keepCurrentSecrets(
+      rows,
+      new Map([['payment.stripe.secretKey', 'sk_now']]),
+      installed,
+      { dataOnly: false }
+    )
+    // The marker never reaches the database, but nothing else is added.
+    expect(result.rows).toEqual([
+      { id: 'a', key: 'workshop.phone', value: '123' },
+      { id: 'b', key: 'payment.stripe.secretKey', value: 'sk_now' },
+    ])
+    expect(result.redacted).toEqual(['payment.stripe.secretKey'])
+  })
+
+  it('writes a secret the backup carries in full', () => {
+    const rows = [{ key: 'ai.apiKey', value: 'sk-from-backup' }]
+    const result = keepCurrentSecrets(rows, new Map([['ai.apiKey', 'sk-now']]))
+    expect(result.rows).toEqual(rows)
+    expect(result.redacted).toEqual([])
+  })
+})
+
+describe('keepCurrentWebhookSecrets', () => {
+  const current = [
+    { id: 'w1', url: 'https://one.test', secret: 'whsec_one' },
+    { id: 'w2', url: 'https://two.test', secret: 'whsec_two' },
+  ]
+
+  it('matches by id, then URL, then mints', () => {
+    const rows = [
+      { id: 'w1', url: 'https://changed.test', secret: REDACTED },
+      { id: 'old', url: 'https://two.test', secret: REDACTED },
+      { id: 'w3', url: 'https://three.test', secret: REDACTED },
+      { id: 'w4', url: 'https://four.test', secret: 'whsec_backup' },
+    ]
+    const result = keepCurrentWebhookSecrets(rows, current, () => 'whsec_new')
+    expect(result.rows.map((r) => r.secret)).toEqual([
+      'whsec_one',
+      'whsec_two',
+      'whsec_new',
+      'whsec_backup',
+    ])
+    expect(result.minted).toEqual(['w3'])
+  })
+})

+ 113 - 0
src/__tests__/lib/backup/tenancy-schema.test.ts

@@ -0,0 +1,113 @@
+/**
+ * @vitest-environment node
+ *
+ * The tenancy guard reads a generated picture of the schema and a hand
+ * written picture of the backup file. Both have to stay true: the generated
+ * file is compared with the schema it came from, and the layout with the
+ * manifest and the export, so a new model or dataset cannot slip past the
+ * guard unnoticed.
+ */
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
+import { BACKUP_LAYOUT, layoutModels, walkBackup } from '@/lib/backup/layout'
+import { BACKUP_DATASET_KEYS } from '@/lib/backup/build-data'
+import { BACKUP_ENTITIES } from '@/lib/backup/manifest'
+import { parseSchemaGraph } from '@/lib/backup/schema-graph'
+import { ownershipWhere } from '@/lib/backup/tenancy'
+import { TENANCY_SCHEMA } from '@/lib/backup/tenancy-schema'
+
+describe('tenancy-schema.ts', () => {
+  it('is what the Prisma schema says (run scripts/generate-tenancy-schema.ts after a schema change)', () => {
+    expect(TENANCY_SCHEMA).toEqual(parseSchemaGraph(readPrismaSchema()))
+  })
+
+  it('knows the organisation column and the parent chain of the models a backup holds', () => {
+    expect(TENANCY_SCHEMA.Customer.organizationId).toBe(true)
+    expect(TENANCY_SCHEMA.ServicePart.organizationId).toBe(false)
+    expect(TENANCY_SCHEMA.ServicePart.ownerPaths).toEqual([['serviceRecord']])
+    expect(TENANCY_SCHEMA.Inspection.foreignKeys.templateId).toEqual({
+      target: 'InspectionTemplate',
+      required: true,
+      relation: 'template',
+    })
+    expect(TENANCY_SCHEMA.Quote.foreignKeys.vehicleId.required).toBe(false)
+  })
+})
+
+describe('the backup layout', () => {
+  const inManifest = new Set(BACKUP_ENTITIES.map((e) => e.model))
+  const inLayout = layoutModels()
+
+  it('holds every model the manifest classifies, and nothing else', () => {
+    expect([...inManifest].filter((m) => !inLayout.has(m))).toEqual([])
+    expect([...inLayout].filter((m) => !inManifest.has(m))).toEqual([])
+  })
+
+  it('has every dataset key the export writes, and every nested include', () => {
+    expect([...BACKUP_DATASET_KEYS].filter((key) => !(key in BACKUP_LAYOUT))).toEqual([])
+    const source = fs.readFileSync(
+      path.join(process.cwd(), 'src/lib/backup/build-data.ts'),
+      'utf-8'
+    )
+    const childKeys = new Set<string>()
+    const collect = (node: { children?: Record<string, unknown> }) => {
+      for (const [key, child] of Object.entries(node.children ?? {})) {
+        childKeys.add(key)
+        collect(child as { children?: Record<string, unknown> })
+      }
+    }
+    for (const node of Object.values(BACKUP_LAYOUT)) collect(node)
+    // Every `name: true` or `name: { include` inside an include block.
+    const included = new Set<string>()
+    for (const block of source.matchAll(/include:\s*\{([\s\S]*?)\n\s*\}/g)) {
+      for (const m of block[1].matchAll(/^\s*(\w+):\s*(true|\{)/gm)) included.add(m[1])
+    }
+    for (const m of source.matchAll(/^\s*(\w+): true,$/gm)) {
+      // serviceRecordTree is spelled out as a const and spread into includes.
+      if (!['settings', 'customers', 'vehicles'].includes(m[1])) included.add(m[1])
+    }
+    const exportOptions = new Set([
+      'settings',
+      'customers',
+      'vehicles',
+      'quotes',
+      'inventory',
+      'customFields',
+      'files',
+      'technicians',
+      'inspections',
+      'auditLogs',
+      'smsMessages',
+      'scheduledMessages',
+      'notifications',
+      'tireHotel',
+      'workshopConfig',
+      'include',
+    ])
+    const missing = [...included].filter((k) => !exportOptions.has(k) && !childKeys.has(k))
+    expect(missing, 'nested keys the export writes but the layout does not know').toEqual([])
+  })
+
+  it('can say whose row every model it holds is', () => {
+    const blind = [...inLayout].filter((m) => ownershipWhere(m, 'org') === null)
+    expect(blind).toEqual([])
+  })
+
+  it('walks parents before children and drops a parent with what nests in it', () => {
+    const data = {
+      vehicles: [
+        { id: 'v1', serviceRecords: [{ id: 'j1', partItems: [{ id: 'p1' }] }] },
+        { id: 'v2', inspectionStatus: { id: 's2' } },
+      ],
+    }
+    const seen: string[] = []
+    walkBackup(data, (model, row) => {
+      seen.push(`${model}:${row.id}`)
+      return row.id === 'v1' ? 'drop' : 'keep'
+    })
+    expect(seen).toEqual(['Vehicle:v1', 'Vehicle:v2', 'VehicleInspectionStatus:s2'])
+    expect(data.vehicles.map((v) => v.id)).toEqual(['v2'])
+  })
+})

+ 252 - 0
src/__tests__/lib/backup/tenancy.test.ts

@@ -0,0 +1,252 @@
+/**
+ * @vitest-environment node
+ *
+ * Nothing in a backup file may be, or point at, another workshop's row.
+ * The file's ids are collected by model, foreign keys that point outside
+ * the file are looked up in this workshop and otherwise cleared or take
+ * their row with them, and an id that already belongs to another workshop
+ * refuses the whole file.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  acquireRestoreLock,
+  assertNoForeignRows,
+  collectFileIds,
+  danglingReferences,
+  lookupOwnIds,
+  mergeIds,
+  ownershipWhere,
+  releaseRestoreLock,
+  RestoreRefusedError,
+  restoreLockHolder,
+  sanitiseForeignIds,
+} from '@/lib/backup/tenancy'
+
+const ORG = 'org-a'
+
+/** A workshop file: a customer, a vehicle with a job and a part, a quote, an inspection. */
+function file() {
+  return {
+    customers: [{ id: 'cust1', name: 'Kari' }],
+    technicians: [{ id: 'tech1', name: 'Ola' }],
+    workBays: [{ id: 'bay1', name: 'Bay 1' }],
+    inspectionTemplates: [{ id: 'tmpl1', name: 'Default', sections: [] }],
+    vehicles: [
+      {
+        id: 'veh1',
+        customerId: 'cust1',
+        serviceRecords: [
+          {
+            id: 'job1',
+            vehicleId: 'veh1',
+            customerId: 'cust1',
+            technicianId: 'tech1',
+            workBayId: 'bay1',
+            partItems: [{ id: 'part1', serviceRecordId: 'job1', inventoryPartId: 'inv-gone' }],
+            timeEntries: [{ id: 'time1', serviceRecordId: 'job1', technicianId: 'tech1' }],
+          },
+        ],
+      },
+    ],
+    quotes: [{ id: 'quote1', vehicleId: 'veh1', customerId: 'cust1', inspectionId: 'insp-gone' }],
+    inspections: [
+      { id: 'insp1', vehicleId: 'veh1', templateId: 'tmpl1', items: [{ id: 'item1' }] },
+    ],
+  }
+}
+
+describe('collectFileIds', () => {
+  it('lists every id in the file under the model it is bound for', () => {
+    const ids = collectFileIds(file())
+    expect([...ids.keys()].sort()).toEqual([
+      'Customer',
+      'Inspection',
+      'InspectionItem',
+      'InspectionTemplate',
+      'Quote',
+      'ServicePart',
+      'ServiceRecord',
+      'Technician',
+      'TimeEntry',
+      'Vehicle',
+      'WorkBay',
+    ])
+    expect([...(ids.get('ServiceRecord') ?? [])]).toEqual(['job1'])
+    expect([...(ids.get('ServicePart') ?? [])]).toEqual(['part1'])
+  })
+})
+
+describe('danglingReferences', () => {
+  it('names the foreign keys that point outside the file, by target model', () => {
+    const data = file()
+    const dangling = danglingReferences(data, collectFileIds(data))
+    expect([...dangling.entries()].map(([m, s]) => [m, [...s]])).toEqual([
+      ['InventoryPart', ['inv-gone']],
+      ['Inspection', ['insp-gone']],
+    ])
+  })
+})
+
+describe('sanitiseForeignIds', () => {
+  it('clears an optional link to a row the file does not carry, and keeps the rest', () => {
+    const data = file()
+    const result = sanitiseForeignIds(data, collectFileIds(data))
+    expect(result).toEqual({ cleared: 2, dropped: 0 })
+    expect(data.vehicles[0].serviceRecords[0].partItems[0].inventoryPartId).toBeNull()
+    expect(data.quotes[0].inspectionId).toBeNull()
+    // Links inside the file stay.
+    expect(data.vehicles[0].customerId).toBe('cust1')
+    expect(data.vehicles[0].serviceRecords[0].technicianId).toBe('tech1')
+    expect(data.inspections[0].templateId).toBe('tmpl1')
+  })
+
+  it('keeps a link to a row this workshop already has', () => {
+    const data = file()
+    const own = new Map([['InventoryPart', new Set(['inv-gone'])]])
+    sanitiseForeignIds(data, mergeIds(collectFileIds(data), own))
+    expect(data.vehicles[0].serviceRecords[0].partItems[0].inventoryPartId).toBe('inv-gone')
+    expect(data.quotes[0].inspectionId).toBeNull()
+  })
+
+  it('drops a row whose required link points outside the file, with what nests in it', () => {
+    const data = file()
+    // A crafted file: a job that claims someone else's vehicle, a time entry
+    // for a technician the file does not carry, an inspection on a template
+    // it does not carry, and a quote on someone else's vehicle.
+    const job = data.vehicles[0].serviceRecords[0]
+    ;(job as Record<string, unknown>).vehicleId = 'veh-of-org-b'
+    job.timeEntries[0].technicianId = 'tech-of-org-b'
+    data.inspections[0].templateId = 'tmpl-of-org-b'
+    data.quotes[0].vehicleId = 'veh-of-org-b'
+
+    const result = sanitiseForeignIds(data, collectFileIds(data))
+
+    // Cleared: the job's and the quote's vehicle (optional links), the
+    // quote's inspection and the part's inventory part (not in the file).
+    // Dropped: the time entry and the inspection, whose links are required.
+    expect(result).toEqual({ cleared: 4, dropped: 2 })
+    expect(job.vehicleId).toBeNull()
+    expect(job.timeEntries).toEqual([])
+    expect(data.inspections).toEqual([])
+    expect(data.quotes).toHaveLength(1)
+    expect(data.quotes[0].vehicleId).toBeNull()
+    expect(JSON.stringify(data)).not.toContain('org-b')
+  })
+
+  it('leaves columns that are not foreign keys, and links to people, alone', () => {
+    const data = {
+      smsMessages: [{ id: 'sms1', providerMsgId: 'SM-external', customerId: 'cust-gone' }],
+      vehicles: [{ id: 'v1', userId: 'user-elsewhere' }],
+    }
+    sanitiseForeignIds(data, collectFileIds(data))
+    expect(data.smsMessages[0].providerMsgId).toBe('SM-external')
+    expect(data.smsMessages[0].customerId).toBeNull()
+    expect(data.vehicles[0].userId).toBe('user-elsewhere')
+  })
+})
+
+describe('ownershipWhere', () => {
+  it('uses the model column, or the parent chain for a child model', () => {
+    expect(ownershipWhere('Customer', ORG)).toEqual({ organizationId: ORG })
+    expect(ownershipWhere('ServicePart', ORG)).toEqual({ serviceRecord: { organizationId: ORG } })
+    expect(ownershipWhere('InspectionTemplateItem', ORG)).toEqual({
+      section: { template: { organizationId: ORG } },
+    })
+    // Two possible parents: either makes it ours.
+    expect(ownershipWhere('StoredImage', ORG)).toEqual({
+      OR: [
+        { inventoryPart: { organizationId: ORG } },
+        { tireMeasurement: { tireSet: { organizationId: ORG } } },
+        { tireMeasurement: { movement: { organizationId: ORG } } },
+      ],
+    })
+    expect(ownershipWhere('NoSuchModel', ORG)).toBeNull()
+  })
+})
+
+/** A client whose findMany answers from a table of rows with an owner. */
+function fakeTx(rows: Record<string, { id: string; owner: string }[]>) {
+  const calls: { model: string; where: unknown }[] = []
+  const tx = new Proxy(
+    {},
+    {
+      get: (_t, model: string) => ({
+        findMany: async ({ where }: { where: Record<string, unknown> }) => {
+          calls.push({ model, where })
+          const ids = (where.id as { in: string[] }).in
+          const wantOurs = !('NOT' in where)
+          return (rows[model] ?? [])
+            .filter((r) => ids.includes(r.id))
+            .filter((r) => (r.owner === ORG) === wantOurs)
+            .map((r) => ({ id: r.id }))
+        },
+      }),
+    }
+  )
+  return { tx, calls }
+}
+
+describe('assertNoForeignRows', () => {
+  it("passes when the ids are new or this workshop's own", async () => {
+    const { tx, calls } = fakeTx({ customer: [{ id: 'cust1', owner: ORG }] })
+    await expect(assertNoForeignRows(tx, ORG, collectFileIds(file()))).resolves.toBeUndefined()
+    const customers = calls.find((c) => c.model === 'customer')
+    expect(customers?.where).toEqual({
+      id: { in: ['cust1'] },
+      NOT: { organizationId: ORG },
+    })
+    // Child models are asked through their parent.
+    expect(calls.find((c) => c.model === 'servicePart')?.where).toEqual({
+      id: { in: ['part1'] },
+      NOT: { serviceRecord: { organizationId: ORG } },
+    })
+  })
+
+  it("refuses the file when an id is another workshop's row", async () => {
+    const { tx } = fakeTx({ vehicle: [{ id: 'veh1', owner: 'org-b' }] })
+    const error = await assertNoForeignRows(tx, ORG, collectFileIds(file())).catch((e) => e)
+    expect(error).toBeInstanceOf(RestoreRefusedError)
+    expect(error.code).toBe('foreign_ids')
+    expect(error.message).toMatch(/1 Vehicle id/)
+  })
+
+  it('refuses a child row of another workshop too', async () => {
+    const { tx } = fakeTx({ servicePart: [{ id: 'part1', owner: 'org-b' }] })
+    await expect(assertNoForeignRows(tx, ORG, collectFileIds(file()))).rejects.toThrow(
+      /ServicePart/
+    )
+  })
+})
+
+describe('lookupOwnIds', () => {
+  it('returns the candidates that are rows of this workshop', async () => {
+    const { tx, calls } = fakeTx({
+      inventoryPart: [
+        { id: 'inv-gone', owner: ORG },
+        { id: 'inv-theirs', owner: 'org-b' },
+      ],
+    })
+    const own = await lookupOwnIds(
+      tx,
+      ORG,
+      new Map([['InventoryPart', new Set(['inv-gone', 'inv-theirs', 'inv-none'])]])
+    )
+    expect([...(own.get('InventoryPart') ?? [])]).toEqual(['inv-gone'])
+    expect(calls[0].where).toEqual({
+      id: { in: ['inv-gone', 'inv-theirs', 'inv-none'] },
+      organizationId: ORG,
+    })
+  })
+})
+
+describe('the restore lock', () => {
+  it('admits one holder at a time and only the holder releases it', () => {
+    releaseRestoreLock(restoreLockHolder() ?? '')
+    expect(acquireRestoreLock('one')).toBe(true)
+    expect(acquireRestoreLock('two')).toBe(false)
+    releaseRestoreLock('two')
+    expect(restoreLockHolder()).toBe('one')
+    releaseRestoreLock('one')
+    expect(restoreLockHolder()).toBeNull()
+  })
+})

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است