mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f1fb508db | |||
| 1f5deeba6c | |||
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 |
@@ -62,6 +62,7 @@ body:
|
|||||||
- Docker (standalone)
|
- Docker (standalone)
|
||||||
- Kubernetes / Helm
|
- Kubernetes / Helm
|
||||||
- Unraid template
|
- Unraid template
|
||||||
|
- Proxmox Community Script
|
||||||
- Sources
|
- Sources
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ jobs:
|
|||||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
for (const pull of pulls) {
|
for (const pull of pulls) {
|
||||||
|
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
|
||||||
|
if (hasBypass) continue;
|
||||||
|
|
||||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||||
if (!hasLabel) continue;
|
if (!hasLabel) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'wiki/**'
|
- 'wiki/**'
|
||||||
- '.github/workflows/wiki.yml'
|
- '.github/workflows/**'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump:
|
bump:
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ jobs:
|
|||||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||||
const prNumber = context.payload.pull_request.number;
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
// bypass-branch-check label skips all enforcement
|
||||||
|
if (labels.includes('bypass-branch-check')) {
|
||||||
|
console.log('bypass-branch-check label present, skipping enforcement.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the base was fixed, remove the label and let it through
|
// If the base was fixed, remove the label and let it through
|
||||||
if (base !== 'main') {
|
if (base !== 'main') {
|
||||||
if (labels.includes('wrong-base-branch')) {
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Trademark Policy
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||||
|
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||||
|
license does not include a license to use our trademarks.
|
||||||
|
|
||||||
|
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||||
|
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||||
|
indicators of the software we release; and 2) our community members' desire to
|
||||||
|
be full participants in the TREK project.
|
||||||
|
|
||||||
|
## Our trademarks
|
||||||
|
|
||||||
|
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||||
|
goodwill, or designs (our "Marks").
|
||||||
|
|
||||||
|
## In general
|
||||||
|
|
||||||
|
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||||
|
anyone about exactly who is the source of the software. For example, you cannot
|
||||||
|
say you are distributing TREK when you're distributing a modified version of it,
|
||||||
|
because people would think they would be getting the same software that they
|
||||||
|
can get directly from us when they aren't. You also cannot use our Marks on
|
||||||
|
your website in a way that suggests that your website is an official TREK
|
||||||
|
website or that we endorse your website. But, if true, you can say you like
|
||||||
|
TREK, that you participate in the TREK community, that you are providing an
|
||||||
|
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||||
|
TREK.
|
||||||
|
|
||||||
|
This fundamental requirement, that it is always clear to people what they are
|
||||||
|
getting and from whom, is reflected throughout this policy. It should also
|
||||||
|
serve as your guide if you are not sure about how you are using the Marks.
|
||||||
|
|
||||||
|
In addition:
|
||||||
|
|
||||||
|
* You may not use or register, in whole or in part, the Marks as part of your
|
||||||
|
own trademark, service mark, domain name, company name, trade name, product
|
||||||
|
name or service name.
|
||||||
|
* Trademark law does not allow your use of names or trademarks that are too
|
||||||
|
similar to ours. You therefore may not use an obvious variation of any of our
|
||||||
|
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||||
|
abbreviation for a similar or compatible product or service.
|
||||||
|
* You agree that you will not acquire any rights in the Marks and that any
|
||||||
|
goodwill generated by your use of the Marks and participation in our
|
||||||
|
community inures solely to our benefit.
|
||||||
|
|
||||||
|
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||||
|
|
||||||
|
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||||
|
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||||
|
the software to identify your redistribution. This kind of use only applies if
|
||||||
|
you are redistributing an official TREK distribution that has not been changed
|
||||||
|
in any way.
|
||||||
|
|
||||||
|
## Distribution of executable code that you have compiled, or modified code
|
||||||
|
|
||||||
|
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||||
|
describe the origin of the software that you are providing, that is, that the
|
||||||
|
code you are distributing is a modification of TREK. You may say, for example,
|
||||||
|
that "this software is derived from the source code for TREK."
|
||||||
|
|
||||||
|
Of course, you can place your own trademarks or logos on versions of the
|
||||||
|
software to which you have made substantive modifications, because by modifying
|
||||||
|
the software, you have become the origin of that exact version. In that case,
|
||||||
|
you should not use our Marks.
|
||||||
|
|
||||||
|
However, you may use our Marks for the distribution of code (source or
|
||||||
|
executable) on the condition that any executable is built from an official TREK
|
||||||
|
source code release and that any modifications are limited to switching on or
|
||||||
|
off features already included in the software, translations into other
|
||||||
|
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||||
|
further modification is not permitted.
|
||||||
|
|
||||||
|
## Mobile wrappers, hosted instances, and forks
|
||||||
|
|
||||||
|
The following clarifications apply specifically to common ways TREK is
|
||||||
|
redistributed:
|
||||||
|
|
||||||
|
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||||
|
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||||
|
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||||
|
Official," etc.).
|
||||||
|
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||||
|
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||||
|
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||||
|
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||||
|
unless your wrapper distributes only an unmodified, official TREK instance
|
||||||
|
and you have obtained permission.
|
||||||
|
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||||
|
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||||
|
of TREK," but the project name itself must be your own.
|
||||||
|
|
||||||
|
## Statements about your software's relation to TREK
|
||||||
|
|
||||||
|
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||||
|
relationship between your software and ours. The word mark "TREK" should be
|
||||||
|
used after a verb or preposition that describes the relationship between your
|
||||||
|
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||||
|
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||||
|
|
||||||
|
* [Your software] uses TREK
|
||||||
|
* [Your software] is powered by TREK
|
||||||
|
* [Your software] runs on TREK
|
||||||
|
* [Your software] for use with TREK
|
||||||
|
* [Your software] for TREK
|
||||||
|
|
||||||
|
## Questions and permission requests
|
||||||
|
|
||||||
|
If you are not sure whether your intended use of the Marks is permitted under
|
||||||
|
this policy, or if you would like to request explicit permission for a use that
|
||||||
|
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||||
|
the maintainers directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
These guidelines are based on the
|
||||||
|
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||||
|
under a
|
||||||
|
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.5
|
version: 3.0.11
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.5"
|
appVersion: "3.0.11"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ data:
|
|||||||
{{- if .Values.env.FORCE_HTTPS }}
|
{{- if .Values.env.FORCE_HTTPS }}
|
||||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ env:
|
|||||||
# Also used as the base URL for links in email notifications and other external links.
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# FORCE_HTTPS: "false"
|
# FORCE_HTTPS: "false"
|
||||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||||
|
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||||
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.5",
|
"version": "3.0.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.5",
|
"version": "3.0.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
@@ -8907,9 +8907,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.9",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.5",
|
"version": "3.0.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
|
|||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
const { pathname } = window.location
|
const { pathname } = window.location
|
||||||
if (!isAuthPublicPath(pathname)) {
|
if (!isAuthPublicPath(pathname)) {
|
||||||
const currentPath = pathname + window.location.search
|
const currentPath = pathname + window.location.search + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
}
|
}
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
if (!newName.trim() || newName.trim() === oldName) return
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
const items = grouped[oldName] || []
|
const items = grouped.get(oldName) || []
|
||||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||||
}
|
}
|
||||||
const handleAddCategory = () => {
|
const handleAddCategory = () => {
|
||||||
|
|||||||
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
position: 'fixed', inset: 0, zIndex: 99999,
|
||||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: 16, overflow: 'auto',
|
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||||
|
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||||
|
paddingLeft: 16, paddingRight: 16,
|
||||||
|
overflow: 'auto',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}} onClick={() => setDismissed(true)}>
|
}} onClick={() => setDismissed(true)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||||
maxWidth: 480, width: '100%',
|
maxWidth: 480, width: '100%',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
maxHeight: '90vh', overflow: 'auto',
|
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{
|
<div style={{
|
||||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
position: 'sticky', bottom: 0, background: 'white',
|
||||||
|
marginTop: 'auto',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||||
<Github size={13} />
|
<Github size={13} />
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
|||||||
id: 400,
|
id: 400,
|
||||||
title: 'Flight to Rome',
|
title: 'Flight to Rome',
|
||||||
type: 'flight',
|
type: 'flight',
|
||||||
|
day_id: 10,
|
||||||
reservation_time: '2025-06-01T14:30:00',
|
reservation_time: '2025-06-01T14:30:00',
|
||||||
confirmation_number: 'ABC123',
|
confirmation_number: 'ABC123',
|
||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
|
|||||||
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const totalCost = Object.values(assignments || {})
|
const totalCost = Object.values(assignments || {})
|
||||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||||
|
|
||||||
|
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||||
|
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||||
|
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (!startId || startId === endId) return 'single'
|
||||||
|
if (dayId === startId) return 'start'
|
||||||
|
if (dayId === endId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||||
|
const phase = pdfGetSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||||
|
if (phase === 'single') return null
|
||||||
|
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||||
|
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||||
|
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||||
|
}
|
||||||
|
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (startId == null) return false
|
||||||
|
if (endId !== startId) {
|
||||||
|
const startDay = sorted.find(d => d.id === startId)
|
||||||
|
const endDay = sorted.find(d => d.id === endId)
|
||||||
|
const thisDay = sorted.find(d => d.id === dayId)
|
||||||
|
if (!startDay || !endDay || !thisDay) return false
|
||||||
|
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||||
|
}
|
||||||
|
return startId === dayId
|
||||||
|
})
|
||||||
|
|
||||||
// Build day HTML
|
// Build day HTML
|
||||||
const daysHtml = sorted.map((day, di) => {
|
const daysHtml = sorted.map((day, di) => {
|
||||||
const assigned = assignments[String(day.id)] || []
|
const assigned = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
// Reservations for this day (hotel rendered via accommodations block)
|
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||||
const dayReservations = (reservations || []).filter(r => {
|
const dayReservations = pdfGetTransportForDay(day.id)
|
||||||
if (!r.reservation_time || r.type === 'hotel') return false
|
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
dayReservations.forEach(r => {
|
dayReservations.forEach(r => {
|
||||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||||
merged.push({ type: 'reservation', k: pos, data: r })
|
merged.push({ type: 'reservation', k: pos, data: r })
|
||||||
})
|
})
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
@@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
const locationLine = r.location || meta.location || ''
|
const locationLine = r.location || meta.location || ''
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
|
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||||
|
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||||
|
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||||
return `
|
return `
|
||||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||||
<div class="note-line" style="background: ${color};"></div>
|
<div class="note-line" style="background: ${color};"></div>
|
||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
|
|||||||
@@ -892,6 +892,183 @@ describe('DayDetailPanel', () => {
|
|||||||
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
|
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
|
||||||
|
|
||||||
|
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
|
||||||
|
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
|
||||||
|
function buildNonMonotonicDays() {
|
||||||
|
return [
|
||||||
|
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
|
||||||
|
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
|
||||||
|
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
|
||||||
|
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
|
||||||
|
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
|
||||||
|
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
|
||||||
|
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
|
||||||
|
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
|
||||||
|
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
|
||||||
|
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
|
||||||
|
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
|
||||||
|
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
|
||||||
|
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
|
||||||
|
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
|
||||||
|
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
|
||||||
|
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the two CustomSelect trigger buttons for start/end day pickers.
|
||||||
|
// When no dropdown is open, these are the only globally-visible buttons whose textContent
|
||||||
|
// matches /Day \d+/ (the main panel title is a div, not a button).
|
||||||
|
// [0] = start trigger, [1] = end trigger (DOM source order).
|
||||||
|
function getDayPickerTriggers() {
|
||||||
|
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 50, name: 'Range Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
|
||||||
|
|
||||||
|
// Both triggers show "Day 1"; the second one is the end picker.
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
|
||||||
|
expect(capturedBody?.start_day_id).toBe(17);
|
||||||
|
expect(capturedBody?.end_day_id).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 51, name: 'Span Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
|
||||||
|
|
||||||
|
// Set end to day 16 (id=7, low ID but last day by position).
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
|
||||||
|
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
|
||||||
|
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
|
||||||
|
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
|
||||||
|
await userEvent.click(getDayPickerTriggers()[0]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedBody?.start_day_id).toBe(25); // day 9
|
||||||
|
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
|
||||||
|
|
||||||
|
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
|
||||||
|
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// days[0].id=17 (first by position), days[15].id=7 (last by position)
|
||||||
|
expect(capturedBody?.start_day_id).toBe(17);
|
||||||
|
expect(capturedBody?.end_day_id).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
|
||||||
|
const seqDays = [
|
||||||
|
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
|
||||||
|
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
|
||||||
|
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
|
||||||
|
];
|
||||||
|
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
|
||||||
|
|
||||||
|
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedBody?.start_day_id).toBe(101);
|
||||||
|
expect(capturedBody?.end_day_id).toBe(103);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
||||||
seedStore(useSettingsStore, {
|
seedStore(useSettingsStore, {
|
||||||
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
const fmtTime = (v) => formatTime12(v, is12h)
|
const fmtTime = (v) => {
|
||||||
|
if (!v) return v
|
||||||
|
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||||
|
return formatTime12(v, is12h)
|
||||||
|
}
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const collapsed = collapsedProp
|
const collapsed = collapsedProp
|
||||||
const toggleCollapse = () => onToggleCollapse?.()
|
const toggleCollapse = () => onToggleCollapse?.()
|
||||||
@@ -459,7 +463,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={hotelDayRange.start}
|
value={hotelDayRange.start}
|
||||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? v : prev.end }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
@@ -474,7 +478,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={hotelDayRange.end}
|
value={hotelDayRange.end}
|
||||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, end: v }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
|
|||||||
@@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{res.reservation_time?.includes('T') && (
|
{res.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontWeight: 400 }}>
|
<span style={{ fontWeight: 400 }}>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
{res.reservation_end_time && ` – ${(() => {
|
||||||
|
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
||||||
|
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||||
|
})()}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
let combinedEndTime = form.reservation_end_time
|
let combinedEndTime = form.reservation_end_time
|
||||||
if (form.end_date) {
|
if (form.end_date) {
|
||||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||||
|
} else if (form.reservation_end_time && form.reservation_time) {
|
||||||
|
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
if (isBudgetEnabled) {
|
||||||
if (form.price) metadata.price = form.price
|
if (form.price) metadata.price = form.price
|
||||||
|
|||||||
@@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{fmtDate(r.reservation_time)}
|
{fmtDate(r.reservation_time)}
|
||||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
{(() => {
|
||||||
|
const endDatePart = r.reservation_end_time
|
||||||
|
? r.reservation_end_time.includes('T')
|
||||||
|
? r.reservation_end_time.split('T')[0]
|
||||||
|
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
||||||
|
? r.reservation_end_time
|
||||||
|
: null
|
||||||
|
: null
|
||||||
|
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
||||||
|
})() && (
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
<> – {fmtDate(r.reservation_end_time)}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '../../tests/helpers/render';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../tests/helpers/store';
|
||||||
|
import LoginPage from './LoginPage';
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoginPage — OIDC redirect preservation', () => {
|
||||||
|
let savedLocation: Location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
mockNavigate.mockClear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
savedLocation = window.location;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: savedLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setSearch(search: string) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, search },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||||
|
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||||
|
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write to sessionStorage when no redirect param is present', async () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/oidc/exchange', () =>
|
||||||
|
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||||
|
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
|
||||||
|
setSearch('?oidc_code=testcode123');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
|
'/oauth/authorize?client_id=foo&state=xyz',
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
|
||||||
|
setSearch('?oidc_code=testcode123');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||||
|
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||||
|
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
|
||||||
|
setSearch('?oidc_error=token_failed');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return '/dashboard'
|
return '/dashboard'
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirectTarget !== '/dashboard') {
|
||||||
|
sessionStorage.setItem('oidc_redirect', redirectTarget)
|
||||||
|
}
|
||||||
|
}, [redirectTarget])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
await loadUser()
|
await loadUser()
|
||||||
navigate('/dashboard', { replace: true })
|
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
|
||||||
|
sessionStorage.removeItem('oidc_redirect')
|
||||||
|
navigate(savedRedirect, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || t('login.oidcFailed'))
|
setError(data.error || t('login.oidcFailed'))
|
||||||
}
|
}
|
||||||
@@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
invalid_state: t('login.oidc.invalidState'),
|
invalid_state: t('login.oidc.invalidState'),
|
||||||
}
|
}
|
||||||
setError(errorMessages[oidcError] || oidcError)
|
setError(errorMessages[oidcError] || oidcError)
|
||||||
|
sessionStorage.removeItem('oidc_redirect')
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLoginRedirect() {
|
function handleLoginRedirect() {
|
||||||
const next = '/oauth/authorize?' + params.toString()
|
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) tripActions.loadReservations(tripId)
|
if (tripId) {
|
||||||
|
tripActions.loadReservations(tripId)
|
||||||
|
tripActions.loadBudgetItems?.(tripId)
|
||||||
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
@@ -1106,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
|
|||||||
expect(useJourneyStore.getState().loading).toBe(false);
|
expect(useJourneyStore.getState().loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── reorderEntries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
|
||||||
|
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
|
||||||
|
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
|
||||||
|
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
|
||||||
|
);
|
||||||
|
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([202, 201, 203]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
|
||||||
|
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
|
||||||
|
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
|
||||||
|
);
|
||||||
|
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([211, 212]);
|
||||||
|
});
|
||||||
|
|
||||||
// ── clear ────────────────────────────────────────────────────────────────
|
// ── clear ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||||
|
|||||||
@@ -223,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
)
|
)
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||||
const atime = a.entry_time || ''
|
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||||
const btime = b.entry_time || ''
|
return a.id - b.id
|
||||||
if (atime !== btime) return atime.localeCompare(btime)
|
|
||||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
|
||||||
})
|
})
|
||||||
return { current: { ...s.current, entries } }
|
return { current: { ...s.current, entries } }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
|
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||||
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
|
|||||||
|
|
||||||
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
||||||
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
|
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
|
||||||
|
# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP.
|
||||||
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
|
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
|
||||||
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
|
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
|
||||||
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
||||||
|
|||||||
Generated
+913
-589
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.5",
|
"version": "3.0.11",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jimp": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
@@ -30,12 +31,11 @@
|
|||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"undici": "^7.0.0",
|
"undici": "^7.0.0",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^14.0.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-3
@@ -53,7 +53,7 @@ export function createApp(): express.Application {
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) {
|
||||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +67,13 @@ export function createApp(): express.Application {
|
|||||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||||
else callback(new Error('Not allowed by CORS'));
|
else callback(new Error('Not allowed by CORS'));
|
||||||
};
|
};
|
||||||
} else if (process.env.NODE_ENV === 'production') {
|
} else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||||
corsOrigin = false;
|
corsOrigin = false;
|
||||||
} else {
|
} else {
|
||||||
corsOrigin = true;
|
corsOrigin = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||||
// HSTS is worth enabling any time we're serving production traffic,
|
// HSTS is worth enabling any time we're serving production traffic,
|
||||||
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
|
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
|
||||||
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
|
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
|
||||||
@@ -124,6 +124,7 @@ export function createApp(): express.Application {
|
|||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
||||||
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (shouldForceHttps) {
|
if (shouldForceHttps) {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const ENCRYPTION_KEY = _encryptionKey;
|
|||||||
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
|
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
|
||||||
// Kept duplicated here because server and client are separate npm packages.
|
// Kept duplicated here because server and client are separate npm packages.
|
||||||
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
|
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
|
||||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE || 'en';
|
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
||||||
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
||||||
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const db = new Proxy({} as Database.Database, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.DEMO_MODE === 'true') {
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true') {
|
||||||
try {
|
try {
|
||||||
const { seedDemoData } = require('../demo/demo-seed');
|
const { seedDemoData } = require('../demo/demo-seed');
|
||||||
seedDemoData(_db);
|
seedDemoData(_db);
|
||||||
|
|||||||
@@ -2107,6 +2107,29 @@ function runMigrations(db: Database.Database): void {
|
|||||||
!= substr(reservations.reservation_time, 1, 10)
|
!= substr(reservations.reservation_time, 1, 10)
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// #846: make sort_order authoritative within a day. Previous ORDER BY put
|
||||||
|
// entry_time before sort_order, silently ignoring reorder clicks when two
|
||||||
|
// same-date entries had different times. Backfill renumbers using the old
|
||||||
|
// effective key (entry_time ASC, id ASC) so existing journeys retain their
|
||||||
|
// current visual order.
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY journey_id, entry_date
|
||||||
|
ORDER BY entry_time ASC, id ASC
|
||||||
|
) - 1 AS rn
|
||||||
|
FROM journey_entries
|
||||||
|
)
|
||||||
|
UPDATE journey_entries
|
||||||
|
SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id)
|
||||||
|
`);
|
||||||
|
db.exec(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' +
|
||||||
|
'ON journey_entries(journey_id, entry_date, sort_order)'
|
||||||
|
);
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import crypto from 'crypto';
|
|||||||
// are only relevant after the first user exists; at that point seeds have already
|
// are only relevant after the first user exists; at that point seeds have already
|
||||||
// finished and skip via the userCount > 0 guard above.
|
// finished and skip via the userCount > 0 guard above.
|
||||||
function isOidcOnlyConfigured(): boolean {
|
function isOidcOnlyConfigured(): boolean {
|
||||||
if (process.env.OIDC_ONLY !== 'true') return false;
|
if (process.env.OIDC_ONLY?.toLowerCase() !== 'true') return false;
|
||||||
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
|
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -29,8 +29,9 @@ const server = app.listen(PORT, () => {
|
|||||||
const banner = [
|
const banner = [
|
||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
' TREK API started',
|
' TREK API started',
|
||||||
|
` Version ${process.env.APP_VERSION}`,
|
||||||
` Port: ${PORT}`,
|
` Port: ${PORT}`,
|
||||||
` Environment: ${process.env.NODE_ENV || 'development'}`,
|
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||||
` Timezone: ${tz}`,
|
` Timezone: ${tz}`,
|
||||||
` Origins: ${origins}`,
|
` Origins: ${origins}`,
|
||||||
` Log level: ${LOG_LVL}`,
|
` Log level: ${LOG_LVL}`,
|
||||||
@@ -40,8 +41,8 @@ const server = app.listen(PORT, () => {
|
|||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
];
|
];
|
||||||
banner.forEach(l => console.log(l));
|
banner.forEach(l => console.log(l));
|
||||||
if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED');
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
|
||||||
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||||
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
||||||
}
|
}
|
||||||
scheduler.start();
|
scheduler.start();
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
|
|||||||
|
|
||||||
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
|
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(authReq.user?.email)) {
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(authReq.user?.email)) {
|
||||||
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
|
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ router.put('/default-user-settings', (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV?.toLowerCase() === 'development') {
|
||||||
const { send } = require('../services/notificationService');
|
const { send } = require('../services/notificationService');
|
||||||
|
|
||||||
router.post('/dev/test-notification', async (req: Request, res: Response) => {
|
router.post('/dev/test-notification', async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ router.put('/auto-settings', (req: Request, res: Response) => {
|
|||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Could not save auto-backup settings',
|
error: 'Could not save auto-backup settings',
|
||||||
detail: process.env.NODE_ENV !== 'production' ? msg : undefined,
|
detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ router.get('/login', async (req: Request, res: Response) => {
|
|||||||
const config = getOidcConfig();
|
const config = getOidcConfig();
|
||||||
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
||||||
|
|
||||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
|
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||||
return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
|
return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||||||
const config = getOidcConfig();
|
const config = getOidcConfig();
|
||||||
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
||||||
|
|
||||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
|
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||||
return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https'));
|
return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-47
@@ -2,6 +2,7 @@ import cron, { type ScheduledTask } from 'node-cron';
|
|||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { logInfo, logError } from './services/auditLog';
|
||||||
|
|
||||||
const dataDir = path.join(__dirname, '../data');
|
const dataDir = path.join(__dirname, '../data');
|
||||||
const backupsDir = path.join(dataDir, 'backups');
|
const backupsDir = path.join(dataDir, 'backups');
|
||||||
@@ -79,11 +80,9 @@ async function runBackup(): Promise<void> {
|
|||||||
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
|
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
|
||||||
archive.finalize();
|
archive.finalize();
|
||||||
});
|
});
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
logInfo(`Auto-Backup created: ${filename}`);
|
||||||
li(`Auto-Backup created: ${filename}`);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
|
|
||||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,23 +93,28 @@ async function runBackup(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupOldBackups(keepDays: number): void {
|
function autoBackupTimestampMs(filename: string): number | null {
|
||||||
|
// auto-backup-2026-04-27T00-00-00.zip → 2026-04-27T00:00:00
|
||||||
|
const stamp = filename.slice('auto-backup-'.length, -'.zip'.length);
|
||||||
|
const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3');
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
return Number.isNaN(ms) ? null : ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupOldBackups(keepDays: number, now: number = Date.now()): void {
|
||||||
try {
|
try {
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
|
||||||
const cutoff = Date.now() - keepDays * MS_PER_DAY;
|
const files = fs.readdirSync(backupsDir).filter(f => f.startsWith('auto-backup-') && f.endsWith('.zip'));
|
||||||
const files = fs.readdirSync(backupsDir).filter(f => f.endsWith('.zip'));
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = path.join(backupsDir, file);
|
const filePath = path.join(backupsDir, file);
|
||||||
const stat = fs.statSync(filePath);
|
const ageMs = autoBackupTimestampMs(file) ?? fs.statSync(filePath).mtimeMs;
|
||||||
if (stat.birthtimeMs < cutoff) {
|
if (ageMs < cutoff) {
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
logInfo(`Auto-Backup old backup deleted: ${file}`);
|
||||||
li(`Auto-Backup old backup deleted: ${file}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,16 +126,14 @@ function start(): void {
|
|||||||
|
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
logInfo('Auto-Backup disabled');
|
||||||
li('Auto-Backup disabled');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expression = buildCronExpression(settings);
|
const expression = buildCronExpression(settings);
|
||||||
const tz = process.env.TZ || 'UTC';
|
const tz = process.env.TZ || 'UTC';
|
||||||
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
|
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
|
||||||
const { logInfo: li2 } = require('./services/auditLog');
|
logInfo(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||||
li2(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo mode: hourly reset of demo user data
|
// Demo mode: hourly reset of demo user data
|
||||||
@@ -139,19 +141,17 @@ let demoTask: ScheduledTask | null = null;
|
|||||||
|
|
||||||
function startDemoReset(): void {
|
function startDemoReset(): void {
|
||||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||||
if (process.env.DEMO_MODE !== 'true') return;
|
if (process.env.DEMO_MODE?.toLowerCase() !== 'true') return;
|
||||||
|
|
||||||
demoTask = cron.schedule('0 * * * *', () => {
|
demoTask = cron.schedule('0 * * * *', () => {
|
||||||
try {
|
try {
|
||||||
const { resetDemoUser } = require('./demo/demo-reset');
|
const { resetDemoUser } = require('./demo/demo-reset');
|
||||||
resetDemoUser();
|
resetDemoUser();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Demo reset: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Demo reset: ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const { logInfo: li3 } = require('./services/auditLog');
|
logInfo('Demo hourly reset scheduled');
|
||||||
li3('Demo hourly reset scheduled');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trip reminders: daily check at 9 AM local time for trips starting tomorrow
|
// Trip reminders: daily check at 9 AM local time for trips starting tomorrow
|
||||||
@@ -167,14 +167,12 @@ function startTripReminders(): void {
|
|||||||
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||||
if (!reminderEnabled) {
|
if (!reminderEnabled) {
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
logInfo('Trip reminders: disabled in settings');
|
||||||
li('Trip reminders: disabled in settings');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
||||||
const { logInfo: liSetup } = require('./services/auditLog');
|
logInfo(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||||
liSetup(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -196,13 +194,11 @@ function startTripReminders(): void {
|
|||||||
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
|
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
|
||||||
if (trips.length > 0) {
|
if (trips.length > 0) {
|
||||||
li(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
|
logInfo(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
}
|
||||||
}, { timezone: tz });
|
}, { timezone: tz });
|
||||||
}
|
}
|
||||||
@@ -222,12 +218,10 @@ function startTodoReminders(): void {
|
|||||||
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||||
const enabled = getSetting('notify_todo_due') !== 'false';
|
const enabled = getSetting('notify_todo_due') !== 'false';
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
logInfo('Todo due reminders: disabled in settings');
|
||||||
li('Todo due reminders: disabled in settings');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { logInfo: liSetup } = require('./services/auditLog');
|
logInfo(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
|
||||||
liSetup(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
|
|
||||||
|
|
||||||
const tz = process.env.TZ || 'UTC';
|
const tz = process.env.TZ || 'UTC';
|
||||||
todoReminderTask = cron.schedule('0 9 * * *', async () => {
|
todoReminderTask = cron.schedule('0 9 * * *', async () => {
|
||||||
@@ -271,13 +265,11 @@ function startTodoReminders(): void {
|
|||||||
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
|
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
|
||||||
if (todos.length > 0) {
|
if (todos.length > 0) {
|
||||||
li(`Todo reminders sent for ${todos.length} item(s)`);
|
logInfo(`Todo reminders sent for ${todos.length} item(s)`);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
}
|
||||||
}, { timezone: tz });
|
}, { timezone: tz });
|
||||||
}
|
}
|
||||||
@@ -294,8 +286,7 @@ function startVersionCheck(): void {
|
|||||||
const { checkAndNotifyVersion } = require('./services/adminService');
|
const { checkAndNotifyVersion } = require('./services/adminService');
|
||||||
await checkAndNotifyVersion();
|
await checkAndNotifyVersion();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Version check: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Version check: ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
}
|
||||||
}, { timezone: tz });
|
}, { timezone: tz });
|
||||||
}
|
}
|
||||||
@@ -313,12 +304,10 @@ function startIdempotencyCleanup(): void {
|
|||||||
const cutoff = Math.floor(Date.now() / 1000) - 86400;
|
const cutoff = Math.floor(Date.now() / 1000) - 86400;
|
||||||
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
||||||
li(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
}
|
||||||
}, { timezone: tz });
|
}, { timezone: tz });
|
||||||
}
|
}
|
||||||
@@ -340,8 +329,7 @@ function startTrekPhotoCacheCleanup(): void {
|
|||||||
const { sweepExpired } = require('./services/memories/trekPhotoCache');
|
const { sweepExpired } = require('./services/memories/trekPhotoCache');
|
||||||
sweepExpired();
|
sweepExpired();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
logError(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
||||||
le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { updateJwtSecret } from '../config';
|
|||||||
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
||||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||||
|
import { deleteUserCompletely } from './userCleanupService';
|
||||||
import { validatePassword } from './passwordPolicy';
|
import { validatePassword } from './passwordPolicy';
|
||||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||||
import { send as sendNotification } from './notificationService';
|
import { send as sendNotification } from './notificationService';
|
||||||
@@ -170,7 +171,7 @@ export function deleteUser(id: string, currentUserId: number) {
|
|||||||
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
|
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
|
||||||
if (!userToDel) return { error: 'User not found', status: 404 };
|
if (!userToDel) return { error: 'User not found', status: 404 };
|
||||||
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
deleteUserCompletely(userToDel.id);
|
||||||
return { email: userToDel.email };
|
return { email: userToDel.email };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +288,7 @@ export function updateOidcSettings(data: {
|
|||||||
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function saveDemoBaseline(): { error?: string; status?: number; message?: string } {
|
export function saveDemoBaseline(): { error?: string; status?: number; message?: string } {
|
||||||
if (process.env.DEMO_MODE !== 'true') {
|
if (process.env.DEMO_MODE?.toLowerCase() !== 'true') {
|
||||||
return { error: 'Not found', status: 404 };
|
return { error: 'Not found', status: 404 };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKe
|
|||||||
import { createEphemeralToken } from './ephemeralTokens';
|
import { createEphemeralToken } from './ephemeralTokens';
|
||||||
import { revokeUserSessions } from '../mcp';
|
import { revokeUserSessions } from '../mcp';
|
||||||
import { startTripReminders } from '../scheduler';
|
import { startTripReminders } from '../scheduler';
|
||||||
|
import { deleteUserCompletely } from './userCleanupService';
|
||||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||||
@@ -130,7 +131,7 @@ export function resolveAuthToggles(): {
|
|||||||
oidc_login: get('oidc_login') !== 'false',
|
oidc_login: get('oidc_login') !== 'false',
|
||||||
oidc_registration: get('oidc_registration') !== 'false',
|
oidc_registration: get('oidc_registration') !== 'false',
|
||||||
};
|
};
|
||||||
if (process.env.OIDC_ONLY === 'true') {
|
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
||||||
result.password_login = false;
|
result.password_login = false;
|
||||||
result.password_registration = false;
|
result.password_registration = false;
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ export function resolveAuthToggles(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback
|
// Legacy fallback
|
||||||
const oidcOnlyEnabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
|
const oidcOnlyEnabled = process.env.OIDC_ONLY?.toLowerCase() === 'true' || get('oidc_only') === 'true';
|
||||||
const oidcConfigured = !!(
|
const oidcConfigured = !!(
|
||||||
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
|
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
|
||||||
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
|
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
|
||||||
@@ -252,7 +253,7 @@ export function getPendingMfaSecret(userId: number): string | null {
|
|||||||
|
|
||||||
export function getAppConfig(authenticatedUser: { id: number } | null) {
|
export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||||
const isDemo = process.env.DEMO_MODE === 'true';
|
const isDemo = process.env.DEMO_MODE?.toLowerCase() === 'true';
|
||||||
const toggles = resolveAuthToggles();
|
const toggles = resolveAuthToggles();
|
||||||
const version: string = process.env.APP_VERSION ?? require('../../package.json').version;
|
const version: string = process.env.APP_VERSION ?? require('../../package.json').version;
|
||||||
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
||||||
@@ -527,7 +528,7 @@ export function deleteAccount(userId: number, userEmail: string, userRole: strin
|
|||||||
return { error: 'Cannot delete the last admin account', status: 400 };
|
return { error: 'Cannot delete the last admin account', status: 400 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
deleteUserCompletely(userId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ const COOKIE_NAME = 'trek_session';
|
|||||||
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
||||||
*/
|
*/
|
||||||
export function cookieOptions(clear = false, req?: Request) {
|
export function cookieOptions(clear = false, req?: Request) {
|
||||||
if (process.env.COOKIE_SECURE === 'false') {
|
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
|
||||||
return buildOptions(clear, false);
|
return buildOptions(clear, false);
|
||||||
}
|
}
|
||||||
const envSecure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true';
|
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||||
const requestSecure = req?.secure === true;
|
const requestSecure = req?.secure === true;
|
||||||
return buildOptions(clear, envSecure || requestSecure);
|
return buildOptions(clear, envSecure || requestSecure);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
if (!journey) return null;
|
if (!journey) return null;
|
||||||
|
|
||||||
const entries = db.prepare(
|
const entries = db.prepare(
|
||||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||||
).all(journeyId) as JourneyEntry[];
|
).all(journeyId) as JourneyEntry[];
|
||||||
|
|
||||||
const photos = db.prepare(
|
const photos = db.prepare(
|
||||||
@@ -306,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
|||||||
).all(journeyId, tripId) as { source_place_id: number }[];
|
).all(journeyId, tripId) as { source_place_id: number }[];
|
||||||
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
||||||
|
|
||||||
|
// Track next sort_order per date so synced skeletons get unique, sequential positions.
|
||||||
|
const dateMaxOrder = new Map<string, number>();
|
||||||
|
const maxRows = db.prepare(
|
||||||
|
'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date'
|
||||||
|
).all(journeyId) as { entry_date: string; m: number }[];
|
||||||
|
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
|
||||||
|
|
||||||
for (const place of places) {
|
for (const place of places) {
|
||||||
if (existingPlaceIds.has(place.id)) continue;
|
if (existingPlaceIds.has(place.id)) continue;
|
||||||
existingPlaceIds.add(place.id);
|
existingPlaceIds.add(place.id);
|
||||||
|
|
||||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||||
const entryTime = place.assignment_time || place.place_time || null;
|
const entryTime = place.assignment_time || place.place_time || null;
|
||||||
|
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
|
||||||
|
dateMaxOrder.set(entryDate, nextOrder);
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||||
@@ -320,7 +329,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
|||||||
journeyId, tripId, place.id, authorId,
|
journeyId, tripId, place.id, authorId,
|
||||||
place.name, entryDate, entryTime,
|
place.name, entryDate, entryTime,
|
||||||
place.address || place.name, place.lat || null, place.lng || null,
|
place.address || place.name, place.lat || null, place.lng || null,
|
||||||
place.day_number || 0, now, now
|
nextOrder, now, now
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) {
|
|||||||
|
|
||||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
||||||
const entryDate = place.day_date;
|
const entryDate = place.day_date;
|
||||||
|
const maxOrder = db.prepare(
|
||||||
|
'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
|
||||||
|
).get(link.journey_id, entryDate) as { m: number | null };
|
||||||
|
const nextOrder = (maxOrder?.m ?? -1) + 1;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
link.journey_id, tripId, placeId, journey.user_id,
|
link.journey_id, tripId, placeId, journey.user_id,
|
||||||
place.name, entryDate, place.assignment_time || place.place_time || null,
|
place.name, entryDate, place.assignment_time || place.place_time || null,
|
||||||
place.address || place.name, place.lat || null, place.lng || null,
|
place.address || place.name, place.lat || null, place.lng || null,
|
||||||
now, now
|
nextOrder, now, now
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,7 +464,7 @@ export function listEntries(journeyId: number, userId: number) {
|
|||||||
if (!canAccessJourney(journeyId, userId)) return null;
|
if (!canAccessJourney(journeyId, userId)) return null;
|
||||||
|
|
||||||
const entries = db.prepare(
|
const entries = db.prepare(
|
||||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||||
).all(journeyId) as JourneyEntry[];
|
).all(journeyId) as JourneyEntry[];
|
||||||
|
|
||||||
const photos = db.prepare(
|
const photos = db.prepare(
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import sharp from 'sharp'
|
import { Jimp } from 'jimp'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { isAddonEnabled } from '../adminService'
|
||||||
|
import { ADDON_IDS } from '../../addons'
|
||||||
|
|
||||||
const THUMB_MAX = 800
|
const THUMB_MAX = 800
|
||||||
const THUMB_QUALITY = 80
|
const THUMB_QUALITY = 80
|
||||||
@@ -10,12 +12,14 @@ export async function ensureLocalThumbnail(
|
|||||||
uploadsRoot: string,
|
uploadsRoot: string,
|
||||||
originalRelPath: string,
|
originalRelPath: string,
|
||||||
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
|
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
|
||||||
|
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return null
|
||||||
|
|
||||||
const originalAbs = path.join(uploadsRoot, originalRelPath)
|
const originalAbs = path.join(uploadsRoot, originalRelPath)
|
||||||
try { await fs.access(originalAbs) } catch { return null }
|
try { await fs.access(originalAbs) } catch { return null }
|
||||||
|
|
||||||
// Deterministic name so concurrent requests don't race on the same photo.
|
// Deterministic name so concurrent requests don't race on the same photo.
|
||||||
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
|
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
|
||||||
const thumbRel = `journey/thumbs/${hash}.webp`
|
const thumbRel = `journey/thumbs/${hash}.jpg`
|
||||||
const thumbAbs = path.join(uploadsRoot, thumbRel)
|
const thumbAbs = path.join(uploadsRoot, thumbRel)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,18 +28,21 @@ export async function ensureLocalThumbnail(
|
|||||||
fs.stat(thumbAbs).catch(() => null),
|
fs.stat(thumbAbs).catch(() => null),
|
||||||
])
|
])
|
||||||
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
|
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
|
||||||
const meta = await sharp(thumbAbs).metadata()
|
const img = await Jimp.read(thumbAbs)
|
||||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
|
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
|
||||||
await sharp(originalAbs)
|
|
||||||
.rotate()
|
// Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior.
|
||||||
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
|
const img = await Jimp.read(originalAbs)
|
||||||
.webp({ quality: THUMB_QUALITY })
|
const { width: w, height: h } = img.bitmap
|
||||||
.toFile(thumbAbs)
|
if (w > THUMB_MAX || h > THUMB_MAX) {
|
||||||
const meta = await sharp(thumbAbs).metadata()
|
img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX })
|
||||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
}
|
||||||
|
await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY })
|
||||||
|
|
||||||
|
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
|
||||||
} catch {
|
} catch {
|
||||||
// Unsupported format, corrupt file, etc. — fall back to original in caller.
|
// Unsupported format, corrupt file, etc. — fall back to original in caller.
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export async function send(payload: NotificationPayload): Promise<void> {
|
|||||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||||
if (!configEntry) {
|
if (!configEntry) {
|
||||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||||
if (process.env.NODE_ENV === 'development' && actorId != null) {
|
if (process.env.NODE_ENV?.toLowerCase() === 'development' && actorId != null) {
|
||||||
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
||||||
createNotificationForRecipient({
|
createNotificationForRecipient({
|
||||||
type: 'simple',
|
type: 'simple',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
|
import { avatarUrl } from './authService';
|
||||||
|
|
||||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||||
|
|
||||||
@@ -131,7 +132,10 @@ export function listBags(tripId: string | number) {
|
|||||||
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
||||||
membersByBag.get(m.bag_id)!.push(m);
|
membersByBag.get(m.bag_id)!.push(m);
|
||||||
}
|
}
|
||||||
return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
|
return bags.map(b => ({
|
||||||
|
...b,
|
||||||
|
members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
||||||
@@ -140,11 +144,12 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
|
|||||||
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
||||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||||
for (const uid of userIds) ins.run(bagId, uid);
|
for (const uid of userIds) ins.run(bagId, uid);
|
||||||
return db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT bm.user_id, u.username, u.avatar
|
SELECT bm.user_id, u.username, u.avatar
|
||||||
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
||||||
WHERE bm.bag_id = ?
|
WHERE bm.bag_id = ?
|
||||||
`).all(bagId);
|
`).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
|
||||||
|
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
||||||
@@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) {
|
|||||||
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
||||||
for (const row of rows as any[]) {
|
for (const row of rows as any[]) {
|
||||||
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
||||||
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
|
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignees;
|
return assignees;
|
||||||
@@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
|
|||||||
for (const uid of userIds) insert.run(tripId, categoryName, uid);
|
for (const uid of userIds) insert.run(tripId, categoryName, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.prepare(`
|
const updated = db.prepare(`
|
||||||
SELECT pca.user_id, u.username, u.avatar
|
SELECT pca.user_id, u.username, u.avatar
|
||||||
FROM packing_category_assignees pca
|
FROM packing_category_assignees pca
|
||||||
JOIN users u ON pca.user_id = u.id
|
JOIN users u ON pca.user_id = u.id
|
||||||
WHERE pca.trip_id = ? AND pca.category_name = ?
|
WHERE pca.trip_id = ? AND pca.category_name = ?
|
||||||
`).all(tripId, categoryName);
|
`).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
|
||||||
|
return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reorder ────────────────────────────────────────────────────────────────
|
// ── Reorder ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -61,16 +61,24 @@ function resolveDayIdFromTime(
|
|||||||
return row?.id ?? null;
|
return row?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
|
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
|
||||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
|
// Bind the transaction lazily on each call. Binding at module load time
|
||||||
const insert = db.prepare(`
|
// captures the DB connection that was open then, which becomes invalid
|
||||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
// after demo-reset / restore-from-backup closes and reinitialises the
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
// connection — every later endpoint save would throw
|
||||||
`);
|
// "The database connection is not open".
|
||||||
endpoints.forEach((e, i) => {
|
const tx = db.transaction((rid: number, eps: EndpointInput[]) => {
|
||||||
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(rid);
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
eps.forEach((e, i) => {
|
||||||
|
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
tx(reservationId, endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
export function listReservations(tripId: string | number) {
|
export function listReservations(tripId: string | number) {
|
||||||
const reservations = db.prepare(`
|
const reservations = db.prepare(`
|
||||||
|
|||||||
@@ -117,10 +117,11 @@ export function generateDays(tripId: number | bigint | string, startDate: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overflow dated days (trip shrunk): convert to dateless instead of deleting
|
// Overflow dated days (trip shrunk): delete them (issue #909).
|
||||||
const nullify = db.prepare('UPDATE days SET date = NULL, day_number = ? WHERE id = ?');
|
// Cascade removes their assignments, notes, and accommodations.
|
||||||
|
const del = db.prepare('DELETE FROM days WHERE id = ?');
|
||||||
for (let i = targetDates.length; i < dated.length; i++) {
|
for (let i = targetDates.length; i < dated.length; i++) {
|
||||||
nullify.run(targetDates.length + (i - targetDates.length) + 1, dated[i].id);
|
del.run(dated[i].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any remaining unused dateless days: keep as dateless, just renumber.
|
// Any remaining unused dateless days: keep as dateless, just renumber.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { db } from '../db/database';
|
||||||
|
|
||||||
|
function cleanupUserReferences(userId: number): void {
|
||||||
|
db.prepare('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?').run(userId);
|
||||||
|
db.prepare('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?').run(userId);
|
||||||
|
db.prepare('DELETE FROM share_tokens WHERE created_by = ?').run(userId);
|
||||||
|
db.prepare('DELETE FROM journey_share_tokens WHERE created_by = ?').run(userId);
|
||||||
|
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
|
||||||
|
db.prepare('DELETE FROM journeys WHERE user_id = ?').run(userId);
|
||||||
|
// Entries authored on other users' journeys (not covered by the cascade above)
|
||||||
|
db.prepare('DELETE FROM journey_entries WHERE author_id = ?').run(userId);
|
||||||
|
db.prepare('DELETE FROM journey_contributors WHERE user_id = ?').run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUserCompletely(userId: number): void {
|
||||||
|
const tx = db.transaction((id: number) => {
|
||||||
|
cleanupUserReferences(id);
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||||
|
});
|
||||||
|
tx(userId);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import dns from 'node:dns/promises';
|
import dns from 'node:dns/promises';
|
||||||
import { Agent } from 'undici';
|
import { Agent } from 'undici';
|
||||||
|
|
||||||
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true';
|
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true';
|
||||||
|
|
||||||
export interface SsrfResult {
|
export interface SsrfResult {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
|||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
import { resetTestDb } from '../helpers/test-db';
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
import { createUser, createAdmin, createInviteToken } from '../helpers/factories';
|
import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||||
import { authCookie } from '../helpers/auth';
|
import { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
@@ -148,6 +148,216 @@ describe('Admin user management', () => {
|
|||||||
expect(deleted).toBeUndefined();
|
expect(deleted).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ADMIN-005b — DELETE /admin/users/:id succeeds when user has FK references', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const { user: target } = createUser(testDb);
|
||||||
|
const { user: otherUser } = createUser(testDb);
|
||||||
|
const { user: thirdUser } = createUser(testDb);
|
||||||
|
|
||||||
|
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
||||||
|
// (trip survives deletion; only invited_by should become NULL)
|
||||||
|
const otherTrip = createTrip(testDb, otherUser.id);
|
||||||
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
|
||||||
|
|
||||||
|
// share_tokens.created_by: target created a share token for otherUser's trip
|
||||||
|
testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-admin-test', ?)").run(otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
||||||
|
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
||||||
|
testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id);
|
||||||
|
|
||||||
|
// journey_contributors: target is a contributor on otherUser's journey
|
||||||
|
const otherJourney = createJourney(testDb, otherUser.id);
|
||||||
|
addJourneyContributor(testDb, otherJourney.id, target.id);
|
||||||
|
|
||||||
|
// journey_entries: target authored an entry on otherUser's journey
|
||||||
|
createJourneyEntry(testDb, otherJourney.id, target.id);
|
||||||
|
|
||||||
|
// journey_share_tokens: target created a share token for otherUser's journey
|
||||||
|
testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-admin-test', ?)").run(otherJourney.id, target.id);
|
||||||
|
|
||||||
|
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
||||||
|
const sentNotif = testDb.prepare(
|
||||||
|
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||||
|
).run(otherTrip.id, target.id, otherUser.id);
|
||||||
|
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||||
|
).run(otherTrip.id, otherUser.id, target.id);
|
||||||
|
|
||||||
|
// user_notice_dismissals (CASCADE): target dismissed a notice
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
|
||||||
|
).run(target.id, Date.now());
|
||||||
|
|
||||||
|
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
||||||
|
const ownedJourney = createJourney(testDb, target.id);
|
||||||
|
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
||||||
|
|
||||||
|
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
||||||
|
const fileRow = testDb.prepare(
|
||||||
|
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
|
||||||
|
).run(otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
||||||
|
const trekPhotoRow = testDb.prepare(
|
||||||
|
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-admin-test', ?)"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
||||||
|
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-admin', 'immich');
|
||||||
|
|
||||||
|
// trips.user_id (CASCADE): target owns a trip
|
||||||
|
const ownedTrip = createTrip(testDb, target.id);
|
||||||
|
|
||||||
|
// trip_members.user_id (CASCADE): target is a member of otherUser's trip
|
||||||
|
addTripMember(testDb, otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// categories.user_id (SET NULL): target created a category
|
||||||
|
const userCategory = createCategory(testDb, { user_id: target.id });
|
||||||
|
|
||||||
|
// tags.user_id (CASCADE): target created a tag
|
||||||
|
const userTag = createTag(testDb, target.id);
|
||||||
|
|
||||||
|
// todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
|
||||||
|
const todoItem = createTodoItem(testDb, otherTrip.id);
|
||||||
|
testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
|
||||||
|
|
||||||
|
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
||||||
|
const packBagRow = testDb.prepare(
|
||||||
|
"INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
|
||||||
|
).run(otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
||||||
|
createMcpToken(testDb, target.id);
|
||||||
|
|
||||||
|
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-admin-test', ?, 'App', 'cid-admin-test', 'h')"
|
||||||
|
).run(otherUser.id);
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-admin-test', ?, 'ath-admin', 'rth-admin', datetime('now','+1 hour'), datetime('now','+30 days'))"
|
||||||
|
).run(target.id);
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-admin-test', ?)"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
||||||
|
const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
|
||||||
|
|
||||||
|
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
||||||
|
const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
|
||||||
|
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||||
|
|
||||||
|
// bucket_list.user_id (CASCADE): target has a bucket list item
|
||||||
|
createBucketListItem(testDb, target.id);
|
||||||
|
|
||||||
|
// visited_countries.user_id (CASCADE): target has visited a country
|
||||||
|
createVisitedCountry(testDb, target.id, 'JP');
|
||||||
|
|
||||||
|
// visited_regions.user_id (CASCADE): target has visited a region
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// packing_templates.created_by (CASCADE): target created a packing template
|
||||||
|
const packTemplateRow = testDb.prepare(
|
||||||
|
"INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// invite_tokens.created_by (CASCADE): target created an invite token
|
||||||
|
createInviteToken(testDb, { created_by: target.id });
|
||||||
|
|
||||||
|
// collab_notes.user_id (CASCADE): target authored a collab note on otherUser's trip
|
||||||
|
createCollabNote(testDb, otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// settings.user_id (CASCADE): target has a user setting
|
||||||
|
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
|
||||||
|
|
||||||
|
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-admin', datetime('now','+1 hour'))"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// audit_log.user_id (SET NULL): target performed an audited action
|
||||||
|
const auditRow = testDb.prepare(
|
||||||
|
"INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
||||||
|
testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/admin/users/${target.id}`)
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// trip_members row survives but invited_by is now NULL
|
||||||
|
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
|
||||||
|
expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
|
||||||
|
expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||||
|
// sent notification survives but sender_id becomes NULL
|
||||||
|
expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
|
||||||
|
// received notification is cascade-deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// notice dismissals are cascade-deleted
|
||||||
|
expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
|
||||||
|
// owned journey and its entries are cascade-deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
|
||||||
|
// uploaded file survives but uploaded_by is now NULL
|
||||||
|
expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
|
||||||
|
// trek_photos row survives but owner_id is now NULL
|
||||||
|
expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
|
||||||
|
// trip_photos row for target is cascade-deleted
|
||||||
|
expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
|
||||||
|
// owned trip is cascade-deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
|
||||||
|
// trip membership on others' trips is removed
|
||||||
|
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
|
||||||
|
// category survives but user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
|
||||||
|
// tag is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
|
||||||
|
// todo assigned_user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
|
||||||
|
// packing bag survives but user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||||
|
// MCP tokens are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// OAuth tokens and consents are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// owned vacay plan is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
|
||||||
|
// vacay plan membership on others' plans is removed
|
||||||
|
expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
|
||||||
|
// bucket list items are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// travel history is deleted
|
||||||
|
expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// packing template is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
|
||||||
|
// invite tokens created by target are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||||
|
// collab content is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
|
||||||
|
// user settings are deleted
|
||||||
|
expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
|
||||||
|
// password reset tokens are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// audit log entry survives but user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||||
|
// notification channel preferences are deleted
|
||||||
|
expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
||||||
const { user: admin } = createAdmin(testDb);
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { createApp } from '../../src/app';
|
|||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
import { resetTestDb } from '../helpers/test-db';
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken } from '../helpers/factories';
|
import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||||
import { authCookie, authHeader } from '../helpers/auth';
|
import { authCookie, authHeader } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
@@ -509,6 +509,225 @@ describe('Extended auth scenarios', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Account deletion
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Account deletion', () => {
|
||||||
|
it('AUTH-040 — DELETE /auth/me succeeds when user has FK references', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const { user: target } = createUser(testDb);
|
||||||
|
const { user: otherUser } = createUser(testDb);
|
||||||
|
const { user: thirdUser } = createUser(testDb);
|
||||||
|
|
||||||
|
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
||||||
|
// (trip survives deletion; only invited_by should become NULL)
|
||||||
|
const otherTrip = createTrip(testDb, otherUser.id);
|
||||||
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
|
||||||
|
|
||||||
|
// share_tokens.created_by: target created a share token for otherUser's trip
|
||||||
|
testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-auth-test', ?)").run(otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
||||||
|
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
||||||
|
testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id);
|
||||||
|
|
||||||
|
// journey_contributors: target is a contributor on otherUser's journey
|
||||||
|
const otherJourney = createJourney(testDb, otherUser.id);
|
||||||
|
addJourneyContributor(testDb, otherJourney.id, target.id);
|
||||||
|
|
||||||
|
// journey_entries: target authored an entry on otherUser's journey
|
||||||
|
createJourneyEntry(testDb, otherJourney.id, target.id);
|
||||||
|
|
||||||
|
// journey_share_tokens: target created a share token for otherUser's journey
|
||||||
|
testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-auth-test', ?)").run(otherJourney.id, target.id);
|
||||||
|
|
||||||
|
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
||||||
|
const sentNotif = testDb.prepare(
|
||||||
|
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||||
|
).run(otherTrip.id, target.id, otherUser.id);
|
||||||
|
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||||
|
).run(otherTrip.id, otherUser.id, target.id);
|
||||||
|
|
||||||
|
// user_notice_dismissals (CASCADE): target dismissed a notice
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
|
||||||
|
).run(target.id, Date.now());
|
||||||
|
|
||||||
|
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
||||||
|
const ownedJourney = createJourney(testDb, target.id);
|
||||||
|
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
||||||
|
|
||||||
|
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
||||||
|
const fileRow = testDb.prepare(
|
||||||
|
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
|
||||||
|
).run(otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
||||||
|
const trekPhotoRow = testDb.prepare(
|
||||||
|
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-auth-test', ?)"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
||||||
|
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-auth', 'immich');
|
||||||
|
|
||||||
|
// trips.user_id (CASCADE): target owns a trip
|
||||||
|
const ownedTrip = createTrip(testDb, target.id);
|
||||||
|
|
||||||
|
// trip_members.user_id (CASCADE): target is a member of otherUser's trip
|
||||||
|
addTripMember(testDb, otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// categories.user_id (SET NULL): target created a category
|
||||||
|
const userCategory = createCategory(testDb, { user_id: target.id });
|
||||||
|
|
||||||
|
// tags.user_id (CASCADE): target created a tag
|
||||||
|
const userTag = createTag(testDb, target.id);
|
||||||
|
|
||||||
|
// todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
|
||||||
|
const todoItem = createTodoItem(testDb, otherTrip.id);
|
||||||
|
testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
|
||||||
|
|
||||||
|
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
||||||
|
const packBagRow = testDb.prepare(
|
||||||
|
"INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
|
||||||
|
).run(otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
||||||
|
createMcpToken(testDb, target.id);
|
||||||
|
|
||||||
|
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-auth-test', ?, 'App', 'cid-auth-test', 'h')"
|
||||||
|
).run(otherUser.id);
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-auth-test', ?, 'ath-auth', 'rth-auth', datetime('now','+1 hour'), datetime('now','+30 days'))"
|
||||||
|
).run(target.id);
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-auth-test', ?)"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
||||||
|
const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
|
||||||
|
|
||||||
|
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
||||||
|
const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
|
||||||
|
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||||
|
|
||||||
|
// bucket_list.user_id (CASCADE): target has a bucket list item
|
||||||
|
createBucketListItem(testDb, target.id);
|
||||||
|
|
||||||
|
// visited_countries.user_id (CASCADE): target has visited a country
|
||||||
|
createVisitedCountry(testDb, target.id, 'JP');
|
||||||
|
|
||||||
|
// visited_regions.user_id (CASCADE): target has visited a region
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// packing_templates.created_by (CASCADE): target created a packing template
|
||||||
|
const packTemplateRow = testDb.prepare(
|
||||||
|
"INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// invite_tokens.created_by (CASCADE): target created an invite token
|
||||||
|
createInviteToken(testDb, { created_by: target.id });
|
||||||
|
|
||||||
|
// collab_notes.user_id (CASCADE): target authored a collab note on otherUser's trip
|
||||||
|
createCollabNote(testDb, otherTrip.id, target.id);
|
||||||
|
|
||||||
|
// settings.user_id (CASCADE): target has a user setting
|
||||||
|
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
|
||||||
|
|
||||||
|
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-auth', datetime('now','+1 hour'))"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// audit_log.user_id (SET NULL): target performed an audited action
|
||||||
|
const auditRow = testDb.prepare(
|
||||||
|
"INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
|
||||||
|
).run(target.id);
|
||||||
|
|
||||||
|
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
||||||
|
testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
|
||||||
|
|
||||||
|
// admin exists to ensure target (non-admin user) passes the last-admin guard
|
||||||
|
void admin;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(target.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// trip_members row survives but invited_by is now NULL
|
||||||
|
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
|
||||||
|
expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
|
||||||
|
expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||||
|
// sent notification survives but sender_id becomes NULL
|
||||||
|
expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
|
||||||
|
// received notification is cascade-deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// notice dismissals are cascade-deleted
|
||||||
|
expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
|
||||||
|
// owned journey and its entries are cascade-deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
|
||||||
|
// uploaded file survives but uploaded_by is now NULL
|
||||||
|
expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
|
||||||
|
// trek_photos row survives but owner_id is now NULL
|
||||||
|
expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
|
||||||
|
// trip_photos row for target is cascade-deleted
|
||||||
|
expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
|
||||||
|
// owned trip is cascade-deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
|
||||||
|
// trip membership on others' trips is removed
|
||||||
|
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
|
||||||
|
// category survives but user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
|
||||||
|
// tag is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
|
||||||
|
// todo assigned_user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
|
||||||
|
// packing bag survives but user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||||
|
// MCP tokens are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// OAuth tokens and consents are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// owned vacay plan is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
|
||||||
|
// vacay plan membership on others' plans is removed
|
||||||
|
expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
|
||||||
|
// bucket list items are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// travel history is deleted
|
||||||
|
expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
|
||||||
|
expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// packing template is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
|
||||||
|
// invite tokens created by target are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||||
|
// collab content is deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
|
||||||
|
// user settings are deleted
|
||||||
|
expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
|
||||||
|
// password reset tokens are deleted
|
||||||
|
expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||||
|
// audit log entry survives but user_id is NULL
|
||||||
|
expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||||
|
// notification channel preferences are deleted
|
||||||
|
expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ describe('Update trip', () => {
|
|||||||
expect(notesAfter!.day_id).toBe(daysAfter[1].id);
|
expect(notesAfter!.day_id).toBe(daysAfter[1].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TRIP-024 — Shrinking trip date range keeps overflow days as dateless with content intact', async () => {
|
it('TRIP-024 — Shrinking trip date range deletes overflow days and their content', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
|
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
|
||||||
|
|
||||||
@@ -481,13 +481,12 @@ describe('Update trip', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
|
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
|
||||||
expect(daysAfter).toHaveLength(5);
|
expect(daysAfter).toHaveLength(3);
|
||||||
expect(daysAfter.filter(d => d.date !== null)).toHaveLength(3);
|
expect(daysAfter.every(d => d.date !== null)).toBe(true);
|
||||||
expect(daysAfter.filter(d => d.date === null)).toHaveLength(2);
|
|
||||||
|
|
||||||
// Overflow assignments survived
|
// Overflow days and their assignments deleted
|
||||||
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as { id: number }[];
|
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as { id: number }[];
|
||||||
expect(all).toHaveLength(2);
|
expect(all).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
// Prevent node-cron from scheduling anything at import time
|
// Prevent node-cron from scheduling anything at import time
|
||||||
vi.mock('node-cron', () => ({
|
vi.mock('node-cron', () => ({
|
||||||
@@ -17,6 +17,7 @@ vi.mock('node:fs', () => ({
|
|||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
readdirSync: vi.fn(() => []),
|
readdirSync: vi.fn(() => []),
|
||||||
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||||
|
unlinkSync: vi.fn(),
|
||||||
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||||
},
|
},
|
||||||
existsSync: vi.fn(() => false),
|
existsSync: vi.fn(() => false),
|
||||||
@@ -25,14 +26,20 @@ vi.mock('node:fs', () => ({
|
|||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
readdirSync: vi.fn(() => []),
|
readdirSync: vi.fn(() => []),
|
||||||
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||||
|
unlinkSync: vi.fn(),
|
||||||
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
vi.mock('../../../src/db/database', () => ({
|
vi.mock('../../../src/db/database', () => ({
|
||||||
db: { prepare: () => ({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }) },
|
db: { prepare: () => ({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }) },
|
||||||
}));
|
}));
|
||||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||||
|
vi.mock('../../src/services/auditLog', () => ({
|
||||||
|
logInfo: vi.fn(),
|
||||||
|
logError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { buildCronExpression } from '../../src/scheduler';
|
import fs from 'node:fs';
|
||||||
|
import { buildCronExpression, cleanupOldBackups } from '../../src/scheduler';
|
||||||
|
|
||||||
interface BackupSettings {
|
interface BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -130,3 +137,82 @@ describe('buildCronExpression', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('cleanupOldBackups', () => {
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
const NOW = new Date('2026-04-27T02:00:00Z').getTime();
|
||||||
|
|
||||||
|
function isoFilename(daysAgo: number, prefix: 'auto-backup' | 'backup' = 'auto-backup'): string {
|
||||||
|
const d = new Date(NOW - daysAgo * DAY);
|
||||||
|
const stamp = d.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
return `${prefix}-${stamp}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fs.readdirSync).mockReset();
|
||||||
|
vi.mocked(fs.statSync).mockReset();
|
||||||
|
vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ mtime: new Date(), mtimeMs: NOW, birthtimeMs: NOW, size: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never deletes manual backup-*.zip files regardless of age', () => {
|
||||||
|
const manual = isoFilename(365 * 5, 'backup');
|
||||||
|
const auto = isoFilename(0);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([manual, auto] as unknown as string[]);
|
||||||
|
cleanupOldBackups(7, NOW);
|
||||||
|
const deleted = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls.map((c: unknown[]) => c[0] as string);
|
||||||
|
expect(deleted.some((p: string) => p.includes(manual))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps auto-backups newer than retention', () => {
|
||||||
|
const recent = isoFilename(3);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([recent] as unknown as string[]);
|
||||||
|
cleanupOldBackups(7, NOW);
|
||||||
|
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes auto-backups older than retention', () => {
|
||||||
|
const old = isoFilename(30);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([old] as unknown as string[]);
|
||||||
|
cleanupOldBackups(7, NOW);
|
||||||
|
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
|
||||||
|
const [calledPath] = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls[0] as string[];
|
||||||
|
expect(calledPath).toContain(old);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlayfs regression: birthtimeMs=0 does not delete a same-day backup', () => {
|
||||||
|
const fresh = isoFilename(0);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([fresh] as unknown as string[]);
|
||||||
|
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW, mtime: new Date(NOW), size: 100 });
|
||||||
|
cleanupOldBackups(7, NOW);
|
||||||
|
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('malformed filename falls back to mtimeMs: keeps recent file', () => {
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]);
|
||||||
|
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 1 * DAY, mtime: new Date(NOW - 1 * DAY), size: 0 });
|
||||||
|
cleanupOldBackups(7, NOW);
|
||||||
|
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('malformed filename falls back to mtimeMs: deletes stale file', () => {
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]);
|
||||||
|
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 30 * DAY, mtime: new Date(NOW - 30 * DAY), size: 0 });
|
||||||
|
cleanupOldBackups(7, NOW);
|
||||||
|
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-zip files and does not crash', () => {
|
||||||
|
const old = isoFilename(30);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([old, 'notes.txt'] as unknown as string[]);
|
||||||
|
cleanupOldBackups(7, NOW);
|
||||||
|
const calls = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls as string[][];
|
||||||
|
expect(calls.every(([p]: string[]) => !p.includes('notes.txt'))).toBe(true);
|
||||||
|
expect(calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows readdirSync errors without throwing', () => {
|
||||||
|
vi.mocked(fs.readdirSync).mockImplementation(() => { throw new Error('ENOENT'); });
|
||||||
|
expect(() => cleanupOldBackups(7, NOW)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
removeContributor,
|
removeContributor,
|
||||||
getSuggestions,
|
getSuggestions,
|
||||||
syncTripPlaces,
|
syncTripPlaces,
|
||||||
|
reorderEntries,
|
||||||
onPlaceCreated,
|
onPlaceCreated,
|
||||||
onPlaceUpdated,
|
onPlaceUpdated,
|
||||||
onPlaceDeleted,
|
onPlaceDeleted,
|
||||||
@@ -1465,3 +1466,108 @@ describe('addProviderPhoto — passphrase', () => {
|
|||||||
expect(row?.passphrase).not.toBe('secret-pp');
|
expect(row?.passphrase).not.toBe('secret-pp');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -- reorderEntries (#846) ----------------------------------------------------
|
||||||
|
|
||||||
|
function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const res = testDb.prepare(`
|
||||||
|
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?)
|
||||||
|
`).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
|
||||||
|
return { id: Number(res.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reorderEntries', () => {
|
||||||
|
it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 });
|
||||||
|
const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 });
|
||||||
|
|
||||||
|
const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
|
||||||
|
const entries = listEntries(journey.id, user.id)!;
|
||||||
|
const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
|
||||||
|
expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const j1 = createJourney(testDb, user.id);
|
||||||
|
const j2 = createJourney(testDb, user.id);
|
||||||
|
const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' });
|
||||||
|
|
||||||
|
const ok = reorderEntries(j1.id, user.id, [entry.id]);
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 });
|
||||||
|
const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 });
|
||||||
|
const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 });
|
||||||
|
|
||||||
|
reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
|
||||||
|
|
||||||
|
const entries = listEntries(journey.id, user.id)!;
|
||||||
|
const day2Entry = entries.find(e => e.id === day2.id)!;
|
||||||
|
expect(day2Entry.sort_order).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncTripPlaces sort_order', () => {
|
||||||
|
it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const trip = createTrip(testDb, user.id, {
|
||||||
|
title: 'Order Trip',
|
||||||
|
start_date: '2026-09-01',
|
||||||
|
end_date: '2026-09-02',
|
||||||
|
});
|
||||||
|
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||||
|
const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
|
||||||
|
const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
|
||||||
|
const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
|
||||||
|
createDayAssignment(testDb, day.id, p1.id);
|
||||||
|
createDayAssignment(testDb, day.id, p2.id);
|
||||||
|
createDayAssignment(testDb, day.id, p3.id);
|
||||||
|
|
||||||
|
syncTripPlaces(journey.id, trip.id, user.id);
|
||||||
|
|
||||||
|
const rows = testDb.prepare(
|
||||||
|
'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC'
|
||||||
|
).all(journey.id) as { sort_order: number }[];
|
||||||
|
const orders = rows.map(r => r.sort_order);
|
||||||
|
expect(new Set(orders).size).toBe(orders.length);
|
||||||
|
expect(orders).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onPlaceCreated sort_order', () => {
|
||||||
|
it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const trip = createTrip(testDb, user.id, {
|
||||||
|
title: 'Append Trip',
|
||||||
|
start_date: '2026-10-01',
|
||||||
|
end_date: '2026-10-02',
|
||||||
|
});
|
||||||
|
addTripToJourney(journey.id, trip.id, user.id);
|
||||||
|
|
||||||
|
const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string };
|
||||||
|
insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
|
||||||
|
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
|
||||||
|
createDayAssignment(testDb, day.id, place.id);
|
||||||
|
onPlaceCreated(trip.id, place.id);
|
||||||
|
|
||||||
|
const newEntry = testDb.prepare(
|
||||||
|
'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
|
||||||
|
).get(journey.id, place.id) as { sort_order: number } | undefined;
|
||||||
|
expect(newEntry).toBeDefined();
|
||||||
|
expect(newEntry!.sort_order).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,33 +96,37 @@ describe('generateDays', () => {
|
|||||||
expect(getNotes(day2.id)[0].id).toBe(note.id);
|
expect(getNotes(day2.id)[0].id).toBe(note.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TRIP-SVC-011: shrinking range converts overflow days to dateless, preserves their assignments', () => {
|
it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
|
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
|
||||||
const daysBefore = getDays(trip.id);
|
const daysBefore = getDays(trip.id);
|
||||||
expect(daysBefore).toHaveLength(5);
|
expect(daysBefore).toHaveLength(5);
|
||||||
|
|
||||||
const place = createPlace(testDb, trip.id);
|
const place = createPlace(testDb, trip.id);
|
||||||
// Assign places to days 4 and 5 (will become overflow)
|
createDayAssignment(testDb, daysBefore[3].id, place.id);
|
||||||
const a4 = createDayAssignment(testDb, daysBefore[3].id, place.id);
|
createDayAssignment(testDb, daysBefore[4].id, place.id);
|
||||||
const a5 = createDayAssignment(testDb, daysBefore[4].id, place.id);
|
|
||||||
|
|
||||||
// Shrink from 5 to 3 days
|
// Shrink from 5 to 3 days — surplus days and their content are removed
|
||||||
generateDays(trip.id, '2025-07-01', '2025-07-03');
|
generateDays(trip.id, '2025-07-01', '2025-07-03');
|
||||||
|
|
||||||
const daysAfter = getDays(trip.id);
|
const daysAfter = getDays(trip.id);
|
||||||
expect(daysAfter).toHaveLength(5); // no rows deleted
|
expect(daysAfter).toHaveLength(3);
|
||||||
|
expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
|
||||||
|
});
|
||||||
|
|
||||||
const dated = daysAfter.filter(d => d.date !== null);
|
it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => {
|
||||||
const dateless = daysAfter.filter(d => d.date === null);
|
const { user } = createUser(testDb);
|
||||||
expect(dated).toHaveLength(3);
|
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' });
|
||||||
expect(dateless).toHaveLength(2);
|
expect(getDays(trip.id)).toHaveLength(7);
|
||||||
|
|
||||||
// Overflow days still have their assignments
|
// Shrink 7 → 5; days 6 and 7 have no content
|
||||||
expect(getAssignments(dateless[0].id)).toHaveLength(1);
|
generateDays(trip.id, '2025-07-01', '2025-07-05');
|
||||||
expect(getAssignments(dateless[0].id)[0].id).toBe(a4.id);
|
|
||||||
expect(getAssignments(dateless[1].id)).toHaveLength(1);
|
const daysAfter = getDays(trip.id);
|
||||||
expect(getAssignments(dateless[1].id)[0].id).toBe(a5.id);
|
expect(daysAfter).toHaveLength(5);
|
||||||
|
expect(daysAfter.map(d => d.date)).toEqual([
|
||||||
|
'2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => {
|
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
|
<Config Name="HSTS_INCLUDE_SUBDOMAINS" Target="HSTS_INCLUDE_SUBDOMAINS" Default="false" Mode="" Description="When true: adds includeSubDomains to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
||||||
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
||||||
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
|
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Install: Proxmox VE (LXC)
|
||||||
|
|
||||||
|
Install TREK on Proxmox VE as an LXC container using the [Proxmox VE Community Scripts](https://community-scripts.org/scripts/trek).
|
||||||
|
|
||||||
|
> A big thank you to the members of [community-scripts](https://github.com/community-scripts) for adding TREK to their collection and maintaining the install and update scripts.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Proxmox VE with shell access
|
||||||
|
- Internet access from the Proxmox host
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Run the following command in the **Proxmox VE Shell**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/trek.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** Always verify the latest command on the [community-scripts TREK page](https://community-scripts.org/scripts/trek) before running — the script URL may change between releases.
|
||||||
|
|
||||||
|
The script will prompt you to choose between **Default** and **Advanced** settings.
|
||||||
|
|
||||||
|
### Default container specs
|
||||||
|
|
||||||
|
| Resource | Value |
|
||||||
|
|---|---|
|
||||||
|
| OS | Debian 13 |
|
||||||
|
| CPU | 2 cores |
|
||||||
|
| RAM | 2048 MB |
|
||||||
|
| Storage | 8 GB |
|
||||||
|
| Port | 3000 |
|
||||||
|
|
||||||
|
The container is unprivileged. TREK is installed at `/opt/trek`.
|
||||||
|
|
||||||
|
## After Install
|
||||||
|
|
||||||
|
Once the container starts, open your browser at:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<container-ip>:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
On first boot, TREK automatically creates an admin account. The credentials are printed to the container log — check them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u trek -n 50
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ENCRYPTION_KEY` is auto-generated during setup and saved to `/opt/trek/server/.env`. Record that file in your backups.
|
||||||
|
|
||||||
|
## Viewing Logs
|
||||||
|
|
||||||
|
TREK runs as a systemd service named `trek` inside the LXC. To view logs from within the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow live logs
|
||||||
|
journalctl -u trek -f
|
||||||
|
|
||||||
|
# Show last 100 lines
|
||||||
|
journalctl -u trek -n 100
|
||||||
|
|
||||||
|
# Show logs since last boot
|
||||||
|
journalctl -u trek -b
|
||||||
|
```
|
||||||
|
|
||||||
|
To access the container shell from the Proxmox VE host, click the container in the UI and open **Console**, or run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct enter <container-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The environment file is located at `/opt/trek/server/.env` inside the container. Edit it to set variables like `ALLOWED_ORIGINS`, `APP_URL`, or `TZ`, then restart the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl restart trek
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Environment-Variables](Environment-Variables) for the full variable reference.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
Run the following command inside the **LXC container** and select **Update** when prompted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/trek.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** Always check the [community-scripts TREK page](https://community-scripts.org/scripts/trek) to confirm the latest command before running.
|
||||||
|
|
||||||
|
The script stops the service, backs up your data and uploads, applies the new release, restores the backup, and restarts. No manual steps required.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Environment-Variables](Environment-Variables) — complete variable reference
|
||||||
|
- [Reverse-Proxy](Reverse-Proxy) — put TREK behind Nginx or Caddy
|
||||||
|
- [Updating](Updating) — general update notes
|
||||||
@@ -36,18 +36,20 @@ When you have a day selected, a dark dashed line connects consecutive places in
|
|||||||
|
|
||||||
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
|
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
|
||||||
|
|
||||||
|
> **Requires:** Settings → Display → **Route calculation** must be ON. When this setting is OFF, TREK never queries the routing service, so no pills are calculated or drawn at any zoom level.
|
||||||
|
|
||||||
## Reservation and transport overlay
|
## Reservation and transport overlay
|
||||||
|
|
||||||
Flights, trains, cars, and cruises are drawn as overlays between their endpoint places:
|
Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** — activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it.
|
||||||
|
|
||||||
- **Flights and cruises** — geodesic great-circle arcs
|
- **Flights and cruises** — geodesic great-circle arcs
|
||||||
- **Trains and cars** — straight lines
|
- **Trains and cars** — straight lines
|
||||||
- **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
|
- **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
|
||||||
- **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
|
- **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
|
||||||
- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights.
|
- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings → Display → **Route calculation** to be ON.
|
||||||
- **Confirmed reservations** — solid line; **Pending** — dashed line
|
- **Confirmed reservations** — solid line; **Pending** — dashed line
|
||||||
|
|
||||||
> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`).
|
> **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings → Display (`map_booking_labels`).
|
||||||
|
|
||||||
## Location button
|
## Location button
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts
|
||||||
|
|
||||||
|
**Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.
|
||||||
|
|
||||||
|
**Fix:** Set `APP_URL` to the public URL of your instance:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- APP_URL=https://trek.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the container after adding the variable. Once set, clicking **Connect** in the MCP client should redirect to your TREK instance and complete the OAuth flow normally.
|
||||||
|
|
||||||
|
> **Note:** `APP_URL` is required for any MCP OAuth integration. Without it, the authorization endpoint resolves to `http://localhost:<PORT>`, which is unreachable from external MCP clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MCP integration: "Too many requests" or "Session limit reached"
|
## MCP integration: "Too many requests" or "Session limit reached"
|
||||||
|
|
||||||
**Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.
|
**Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY`
|
|||||||
|
|
||||||
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure.
|
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure.
|
||||||
|
|
||||||
|
## Proxmox VE (LXC)
|
||||||
|
|
||||||
|
If you installed TREK via the [Proxmox VE Community Scripts](https://community-scripts.org/scripts/trek), run the following command inside the **LXC container** and select **Update** when prompted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/trek.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** Always check the [community-scripts TREK page](https://community-scripts.org/scripts/trek) to confirm the latest command before running.
|
||||||
|
|
||||||
|
The script stops the service, backs up your data and uploads, applies the new release, restores the backup, and restarts. No manual steps required.
|
||||||
|
|
||||||
|
To verify the update completed and check for errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inside the container (pct enter <id> from the Proxmox shell)
|
||||||
|
journalctl -u trek -n 50
|
||||||
|
```
|
||||||
|
|
||||||
## Unraid
|
## Unraid
|
||||||
|
|
||||||
In the Unraid Docker tab, click the TREK container and select **Update**. Unraid will pull the latest image and restart with the same volumes.
|
In the Unraid Docker tab, click the TREK container and select **Update**. Unraid will pull the latest image and restart with the same volumes.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
- [[Install: Docker|Install-Docker]]
|
- [[Install: Docker|Install-Docker]]
|
||||||
- [[Install: Docker Compose|Install-Docker-Compose]]
|
- [[Install: Docker Compose|Install-Docker-Compose]]
|
||||||
- [[Install: Helm|Install-Helm]]
|
- [[Install: Helm|Install-Helm]]
|
||||||
|
- [[Install: Proxmox VE (LXC)|Install-Proxmox]]
|
||||||
- [[Install: Unraid|Install-Unraid]]
|
- [[Install: Unraid|Install-Unraid]]
|
||||||
- [[Reverse Proxy|Reverse-Proxy]]
|
- [[Reverse Proxy|Reverse-Proxy]]
|
||||||
- [[Environment Variables|Environment-Variables]]
|
- [[Environment Variables|Environment-Variables]]
|
||||||
|
|||||||
Reference in New Issue
Block a user