Compare commits

..

No commits in common. "925c5a66a613e0a78523af231974de8c6da15726" and "cb6e811c52e4cffd88f7ce0c02e34cebea0fb1a4" have entirely different histories.

21 changed files with 25 additions and 27830 deletions

View file

@ -278,24 +278,6 @@ cutover) is documented in [`infra/runbooks/production-migration.md`](runbooks/pr
--- ---
## Backups (production)
`infra/scripts/backup.sh` runs daily at 03:45 on the production server via
`/etc/cron.d/church-website-backup` (log: `/var/log/church-website-backup.log`):
- **Local** (`/opt/backups` on production): `pg_dump -Fc` of the database +
tarball of both upload volumes, 7 days retention (~5 GB).
- **Off-site** (staging server `178.104.35.59`, `/opt/backups/production`):
DB dumps with 14 days retention + an rsync mirror of the uploads (single
current copy — the staging server is short on disk, so no tarball history
there).
Production's root SSH key (`/root/.ssh/id_ed25519`) is authorized on the
staging server for the transfer. To restore, use the drop/restore + volume
steps from the migration runbook with the backup files as source.
---
## Troubleshooting ## Troubleshooting
### Build fails with OOM ### Build fails with OOM

View file

@ -3,7 +3,7 @@ all:
production-vps: production-vps:
ansible_host: 217.154.211.139 ansible_host: 217.154.211.139
ansible_user: root ansible_user: root
ansible_ssh_private_key_file: ~/.ssh/id_rsa ansible_ssh_private_key_file: ~/.ssh/id_ed25519
vars: vars:
# No Forgejo on production — code is pulled from git.skick.app # No Forgejo on production — code is pulled from git.skick.app
@ -19,13 +19,13 @@ all:
postgres_container_name: postgres postgres_container_name: postgres
postgres_image: postgis/postgis:16-3.4 postgres_image: postgis/postgis:16-3.4
postgres_volume: pgdata postgres_volume: pgdata
# Tuned for 4 vCPU / 4 GB shared with the Next.js app and on-server builds # Tuned for 4 vCPU / 8 GB shared with the Next.js app and on-server builds
postgres_server_args: >- postgres_server_args: >-
-c max_connections=40 -c max_connections=40
-c shared_buffers=256MB -c shared_buffers=512MB
-c effective_cache_size=1GB -c effective_cache_size=2GB
-c work_mem=8MB -c work_mem=16MB
-c maintenance_work_mem=64MB -c maintenance_work_mem=128MB
-c checkpoint_completion_target=0.9 -c checkpoint_completion_target=0.9
-c max_wal_size=1GB -c max_wal_size=1GB
-c random_page_cost=1.1 -c random_page_cost=1.1
@ -44,23 +44,6 @@ all:
caddy_redirects: caddy_redirects:
- from: www.hl-mutter-teresa-chemnitz.de - from: www.hl-mutter-teresa-chemnitz.de
to: hl-mutter-teresa-chemnitz.de to: hl-mutter-teresa-chemnitz.de
# Parish shortcut subdomains (replace the HTTP-only IONOS forwards;
# to_path drops the incoming URI and redirects to a fixed page)
- from: st-antonius.hl-mutter-teresa-chemnitz.de
to: hl-mutter-teresa-chemnitz.de
to_path: /gemeinde/st-antonius
- from: st-franziskus.hl-mutter-teresa-chemnitz.de
to: hl-mutter-teresa-chemnitz.de
to_path: /gemeinde/st-franziskus
- from: st-johannes-nepomuk.hl-mutter-teresa-chemnitz.de
to: hl-mutter-teresa-chemnitz.de
to_path: /gemeinde/st-johannes-nepomuk
- from: st-joseph.hl-mutter-teresa-chemnitz.de
to: hl-mutter-teresa-chemnitz.de
to_path: /gemeinde/st-joseph
- from: st-marien.hl-mutter-teresa-chemnitz.de
to: hl-mutter-teresa-chemnitz.de
to_path: /gemeinde/st-marien-zschopau
# App environments # App environments
app_environments: app_environments:

View file

@ -26,15 +26,12 @@ all:
# Caddy # Caddy
caddy_domains: caddy_domains:
- domain: mutter-teresa.skick.app
proxy_port: 3001
- domain: mutter-teresa-test.skick.app - domain: mutter-teresa-test.skick.app
proxy_port: 3002 proxy_port: 3002
- domain: git.skick.app - domain: git.skick.app
proxy_port: 3003 proxy_port: 3003
# Old staging domain redirects to production (app-staging container is
# stopped; the site now lives on the production VPS)
caddy_redirects:
- from: mutter-teresa.skick.app
to: hl-mutter-teresa-chemnitz.de
# Forgejo # Forgejo
forgejo_domain: git.skick.app forgejo_domain: git.skick.app

View file

@ -6,7 +6,7 @@
{% endfor %} {% endfor %}
{% for redirect in caddy_redirects | default([]) %} {% for redirect in caddy_redirects | default([]) %}
{{ redirect.from }} { {{ redirect.from }} {
redir https://{{ redirect.to }}{{ redirect.to_path | default('{uri}') }} permanent redir https://{{ redirect.to }}{uri} permanent
} }
{% endfor %} {% endfor %}

View file

@ -45,7 +45,7 @@ Sanity checks:
```bash ```bash
ssh $NEW "curl -sI http://127.0.0.1:3001 | head -1" # HTTP 200/30x ssh $NEW "curl -sI http://127.0.0.1:3001 | head -1" # HTTP 200/30x
ssh $NEW "docker exec postgres psql -U postgres -c 'SHOW shared_buffers;'" # 256MB ssh $NEW "docker exec postgres psql -U postgres -c 'SHOW shared_buffers;'" # 512MB
ssh $NEW "swapon --show" # 4G /swapfile ssh $NEW "swapon --show" # 4G /swapfile
``` ```
@ -112,12 +112,8 @@ ssh $OLD "docker run --rm -v uploads-staging-documents:/src:ro alpine tar -C /sr
### 2c. Restart + verify ### 2c. Restart + verify
Recreate the container rather than just starting it — Next.js caches rendered
pages in the container's filesystem, and a plain `docker start` serves stale
pages (e.g. a cached 404 for `/`) from before the restore:
```bash ```bash
ssh $NEW "docker rm -f app-production && docker run -d --name app-production --restart unless-stopped --network church-website-net --env-file /opt/church-website/envs/production/.env -v uploads-production-media:/app/media -v uploads-production-documents:/app/documents -p 127.0.0.1:3001:3000 church-website:production" ssh $NEW "docker start app-production"
ssh $NEW "docker exec -u 0 app-production chown -R 1001:1001 /app/media /app/documents" ssh $NEW "docker exec -u 0 app-production chown -R 1001:1001 /app/media /app/documents"
ssh $NEW "curl -sI http://127.0.0.1:3001 | head -1" # 200 ssh $NEW "curl -sI http://127.0.0.1:3001 | head -1" # 200
ssh $NEW "curl -sI http://127.0.0.1:3001/admin | head -1" # 200/30x ssh $NEW "curl -sI http://127.0.0.1:3001/admin | head -1" # 200/30x

View file

@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Daily production backup.
# - Local point-in-time archives (DB dump + uploads tarball), 7 days retention.
# - Off-site copy on the staging server: DB dumps (14 days) + incremental
# uploads mirror (rsync, single current copy — the staging server is short
# on disk, so no remote tarball history).
# Installed at /opt/church-website/scripts/backup.sh, run by
# /etc/cron.d/church-website-backup.
set -euo pipefail
DATE=$(date +%F)
BACKUP_DIR=/opt/backups
REMOTE=root@178.104.35.59
REMOTE_DIR=/opt/backups/production
VOLUMES_DIR=/var/lib/docker/volumes
mkdir -p "$BACKUP_DIR"
# Local point-in-time backups
docker exec postgres pg_dump -U postgres -Fc church_website > "$BACKUP_DIR/db-$DATE.dump"
tar -czf "$BACKUP_DIR/uploads-$DATE.tar.gz" -C "$VOLUMES_DIR" \
uploads-production-media/_data uploads-production-documents/_data
# Off-site copy to the staging server
ssh -o StrictHostKeyChecking=accept-new "$REMOTE" "mkdir -p $REMOTE_DIR/db $REMOTE_DIR/uploads/media $REMOTE_DIR/uploads/documents"
scp -q "$BACKUP_DIR/db-$DATE.dump" "$REMOTE:$REMOTE_DIR/db/"
rsync -a --delete "$VOLUMES_DIR/uploads-production-media/_data/" "$REMOTE:$REMOTE_DIR/uploads/media/"
rsync -a --delete "$VOLUMES_DIR/uploads-production-documents/_data/" "$REMOTE:$REMOTE_DIR/uploads/documents/"
# Retention
find "$BACKUP_DIR" -type f -mtime +7 -delete
ssh "$REMOTE" "find $REMOTE_DIR/db -type f -mtime +14 -delete"
echo "$(date -Is) backup OK: db-$DATE.dump + uploads-$DATE.tar.gz (local), synced to $REMOTE"

View file

@ -133,14 +133,13 @@ export const Churches: CollectionConfig = {
{ label: '2.', value: 'second' }, { label: '2.', value: 'second' },
{ label: '3.', value: 'third' }, { label: '3.', value: 'third' },
{ label: '4.', value: 'fourth' }, { label: '4.', value: 'fourth' },
{ label: '5.', value: 'fifth' },
{ label: 'Letzter', value: 'last' }, { label: 'Letzter', value: 'last' },
], ],
admin: { admin: {
condition: (_data, siblingData) => condition: (_data, siblingData) =>
siblingData?.frequency === 'monthlyByWeekday', siblingData?.frequency === 'monthlyByWeekday',
description: description:
'z. B. „3.“ + „Sonntag“ = jeden 3. Sonntag im Monat.', 'z. B. „3.“ + „Sonntag“ = jeden 3. Sonntag im Monat',
}, },
}, },
{ {

View file

@ -325,7 +325,6 @@ export const Events: CollectionConfig = {
{ label: '2.', value: 'second' }, { label: '2.', value: 'second' },
{ label: '3.', value: 'third' }, { label: '3.', value: 'third' },
{ label: '4.', value: 'fourth' }, { label: '4.', value: 'fourth' },
{ label: '5.', value: 'fifth' },
{ label: 'Letzter', value: 'last' }, { label: 'Letzter', value: 'last' },
], ],
admin: { admin: {

View file

@ -66,7 +66,7 @@ export const Worship: CollectionConfig = {
value: 'MASS', value: 'MASS',
}, },
{ {
label: 'Familiengottesdienst', label: 'Familien Messe',
value: 'FAMILY', value: 'FAMILY',
}, },
{ {

View file

@ -25,6 +25,8 @@ export const MassTableRow = ({
}: MassTableRowProps) => { }: MassTableRowProps) => {
const [symbol, setSymbol] = useState('') const [symbol, setSymbol] = useState('')
const time = useTime(date) const time = useTime(date)
//const compactDate = useCompactDate(date)
//const day = useShortDayName(date)
const day = new Date(date).toLocaleDateString("de-DE", {weekday: 'long'}) const day = new Date(date).toLocaleDateString("de-DE", {weekday: 'long'})
return ( return (
@ -45,7 +47,7 @@ export const MassTableRow = ({
</span> </span>
{!cancelled && type === 'FAMILY' && ( {!cancelled && type === 'FAMILY' && (
<Image src={family} width={18} height={18} alt={'Familiengottesdienst'} /> <Image src={family} width={18} height={18} alt={'Familien Messe'} />
)} )}
{!cancelled && type === 'WORD' && ( {!cancelled && type === 'WORD' && (

View file

@ -47,7 +47,7 @@ export const FooterView = ({ prayers, footer }: FooterViewProps) => {
))} ))}
<Col> <Col>
<p> <p>
<strong>Gebet</strong> <strong>Stoßgebet</strong>
</p> </p>
<RandomPrayer prayers={prayers} /> <RandomPrayer prayers={prayers} />
</Col> </Col>

View file

@ -142,37 +142,6 @@ describe('generateOccurrenceDates monthlyByWeekday', () => {
expect(result.map((x) => x.getDate())).toEqual([23]) expect(result.map((x) => x.getDate())).toEqual([23])
}) })
it('yields the 5th thursday only in months that have one', () => {
const entry: ScheduleEntry = {
frequency: 'monthlyByWeekday',
day: 'thursday',
weekOfMonth: 'fifth',
}
// 2026: Jul has 5 Thursdays (2,9,16,23,30), Aug 4 (6,13,20,27), Sep 4, Oct 5 (1,8,15,22,29), Nov 4, Dec 5 (3,10,17,24,31)
const result = generateOccurrenceDates(entry, d(2026, 7, 1), d(2026, 12, 31))
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'7-30',
'10-29',
'12-31',
])
})
it('"fifth" and "last" differ in months with only 4 occurrences', () => {
// Aug 2026 Thursdays: 6, 13, 20, 27 — last = 27, fifth = none
const last = generateOccurrenceDates(
{ frequency: 'monthlyByWeekday', day: 'thursday', weekOfMonth: 'last' },
d(2026, 8, 1),
d(2026, 8, 31),
)
const fifth = generateOccurrenceDates(
{ frequency: 'monthlyByWeekday', day: 'thursday', weekOfMonth: 'fifth' },
d(2026, 8, 1),
d(2026, 8, 31),
)
expect(last.map((x) => x.getDate())).toEqual([27])
expect(fifth).toEqual([])
})
it('works when the start-of-window is after that month\'s occurrence', () => { it('works when the start-of-window is after that month\'s occurrence', () => {
// 3rd Sunday of April 2026 is 4/19. Start the window on 4/20. // 3rd Sunday of April 2026 is 4/19. Start the window on 4/20.
// April should be skipped; May should still appear. // April should be skipped; May should still appear.

View file

@ -14,7 +14,7 @@ export type ScheduleFrequency =
| 'monthlyByDate' | 'monthlyByDate'
| 'monthlyByWeekday' | 'monthlyByWeekday'
export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'fifth' | 'last' export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'last'
export interface ScheduleEntry { export interface ScheduleEntry {
frequency: ScheduleFrequency frequency: ScheduleFrequency
@ -40,7 +40,6 @@ const WEEK_OF_MONTH_N: Record<WeekOfMonth, number> = {
second: 2, second: 2,
third: 3, third: 3,
fourth: 4, fourth: 4,
fifth: 5,
last: -1, last: -1,
} }
@ -66,7 +65,7 @@ const firstOccurrenceOnOrAfter = (from: Date, targetDay: number): Date => {
/** /**
* For a given (year, month), return the date of the Nth occurrence of * For a given (year, month), return the date of the Nth occurrence of
* `targetDay`. `n` is 1..5 for first..fifth, or -1 for "last". * `targetDay`. `n` is 1..4 for first..fourth, or -1 for "last".
* Returns null if the month has no such occurrence (e.g. asking for the * Returns null if the month has no such occurrence (e.g. asking for the
* 5th Monday in a month that only has 4). * 5th Monday in a month that only has 4).
*/ */

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_church_recurring_schedule_week_of_month" ADD VALUE 'fifth' BEFORE 'last';
ALTER TYPE "public"."enum_event_recurrence_rules_week_of_month" ADD VALUE 'fifth' BEFORE 'last';
ALTER TYPE "public"."enum__event_v_version_recurrence_rules_week_of_month" ADD VALUE 'fifth' BEFORE 'last';`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
UPDATE "church_recurring_schedule" SET "week_of_month" = 'last' WHERE "week_of_month" = 'fifth';
UPDATE "event_recurrence_rules" SET "week_of_month" = 'last' WHERE "week_of_month" = 'fifth';
UPDATE "_event_v_version_recurrence_rules" SET "week_of_month" = 'last' WHERE "week_of_month" = 'fifth';
ALTER TABLE "church_recurring_schedule" ALTER COLUMN "week_of_month" SET DATA TYPE text;
DROP TYPE "public"."enum_church_recurring_schedule_week_of_month";
CREATE TYPE "public"."enum_church_recurring_schedule_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
ALTER TABLE "church_recurring_schedule" ALTER COLUMN "week_of_month" SET DATA TYPE "public"."enum_church_recurring_schedule_week_of_month" USING "week_of_month"::"public"."enum_church_recurring_schedule_week_of_month";
ALTER TABLE "event_recurrence_rules" ALTER COLUMN "week_of_month" SET DATA TYPE text;
DROP TYPE "public"."enum_event_recurrence_rules_week_of_month";
CREATE TYPE "public"."enum_event_recurrence_rules_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
ALTER TABLE "event_recurrence_rules" ALTER COLUMN "week_of_month" SET DATA TYPE "public"."enum_event_recurrence_rules_week_of_month" USING "week_of_month"::"public"."enum_event_recurrence_rules_week_of_month";
ALTER TABLE "_event_v_version_recurrence_rules" ALTER COLUMN "week_of_month" SET DATA TYPE text;
DROP TYPE "public"."enum__event_v_version_recurrence_rules_week_of_month";
CREATE TYPE "public"."enum__event_v_version_recurrence_rules_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
ALTER TABLE "_event_v_version_recurrence_rules" ALTER COLUMN "week_of_month" SET DATA TYPE "public"."enum__event_v_version_recurrence_rules_week_of_month" USING "week_of_month"::"public"."enum__event_v_version_recurrence_rules_week_of_month";`)
}

View file

@ -54,7 +54,6 @@ import * as migration_20260611_084920_add_image_with_text_block from './20260611
import * as migration_20260716_090032_add_user_parish_page_assignments from './20260716_090032_add_user_parish_page_assignments'; import * as migration_20260716_090032_add_user_parish_page_assignments from './20260716_090032_add_user_parish_page_assignments';
import * as migration_20260716_113205_add_blog_pinned from './20260716_113205_add_blog_pinned'; import * as migration_20260716_113205_add_blog_pinned from './20260716_113205_add_blog_pinned';
import * as migration_20260721_090435_add_mass_times_range from './20260721_090435_add_mass_times_range'; import * as migration_20260721_090435_add_mass_times_range from './20260721_090435_add_mass_times_range';
import * as migration_20260828_100000_add_fifth_week_of_month from './20260828_100000_add_fifth_week_of_month';
export const migrations = [ export const migrations = [
{ {
@ -337,9 +336,4 @@ export const migrations = [
down: migration_20260721_090435_add_mass_times_range.down, down: migration_20260721_090435_add_mass_times_range.down,
name: '20260721_090435_add_mass_times_range' name: '20260721_090435_add_mass_times_range'
}, },
{
up: migration_20260828_100000_add_fifth_week_of_month.up,
down: migration_20260828_100000_add_fifth_week_of_month.down,
name: '20260828_100000_add_fifth_week_of_month',
},
]; ];

View file

@ -154,7 +154,7 @@ export function EventPage(
<TextDiv text={description} /> <TextDiv text={description} />
<br/> <br/>
<Title title={"Ort"} size={"sm"} align={"center"} fontStyle={"sans-serif"}/> <Title title={"Location"} size={"sm"} align={"center"} fontStyle={"sans-serif"}/>
<TextDiv text={where} /> <TextDiv text={where} />
{ typeof contact !== "undefined" && { typeof contact !== "undefined" &&

View file

@ -52,12 +52,9 @@ export const Worship = ({ worship }: WorshipPageProps) => {
</EventExcerptRow> </EventExcerptRow>
<EventExcerptRow label={"Wo:"}> <EventExcerptRow label={"Wo:"}>
{ typeof worship.location == "object" && { typeof worship.location == "object" &&
<TextDiv text={`${worship.location.name}\n${worship.location.address}`} /> <TextDiv text={`${worship.location.name}\n${worship.location.address}\n${time} Uhr`} />
} }
</EventExcerptRow> </EventExcerptRow>
<EventExcerptRow label={"Wann:"}>
{time} Uhr
</EventExcerptRow>
{ worship.celebrant && { worship.celebrant &&
<EventExcerptRow label={"Zelebrant:"}> <EventExcerptRow label={"Zelebrant:"}>
{worship.celebrant} {worship.celebrant}

View file

@ -368,9 +368,9 @@ export interface Church {
day: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday'; day: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
time: string; time: string;
/** /**
* z. B. 3. + Sonntag = jeden 3. Sonntag im Monat. * z. B. 3. + Sonntag = jeden 3. Sonntag im Monat
*/ */
weekOfMonth?: ('first' | 'second' | 'third' | 'fourth' | 'fifth' | 'last') | null; weekOfMonth?: ('first' | 'second' | 'third' | 'fourth' | 'last') | null;
/** /**
* Ein Datum, an dem dieser Termin stattfindet. Davon ausgehend wird im 2-Wochen-Rhythmus weitergerechnet. * Ein Datum, an dem dieser Termin stattfindet. Davon ausgehend wird im 2-Wochen-Rhythmus weitergerechnet.
*/ */
@ -1231,7 +1231,7 @@ export interface Event {
| { | {
frequency: 'daily' | 'weekly' | 'monthlyByDate' | 'monthlyByWeekday'; frequency: 'daily' | 'weekly' | 'monthlyByDate' | 'monthlyByWeekday';
weekday?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday') | null; weekday?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday') | null;
weekOfMonth?: ('first' | 'second' | 'third' | 'fourth' | 'fifth' | 'last') | null; weekOfMonth?: ('first' | 'second' | 'third' | 'fourth' | 'last') | null;
dayOfMonth?: number | null; dayOfMonth?: number | null;
id?: string | null; id?: string | null;
}[] }[]

View file

@ -7,7 +7,7 @@ import { EventRowProps } from '@/components/EventRow/EventRow'
export const transformCategory = (category: "MASS" | "FAMILY" | "WORD" | "LANGUAGE" | "OTHER"): string => { export const transformCategory = (category: "MASS" | "FAMILY" | "WORD" | "LANGUAGE" | "OTHER"): string => {
const type = { const type = {
'MASS': 'Eucharistiefeier', 'MASS': 'Eucharistiefeier',
'FAMILY': 'Familiengottesdienst', 'FAMILY': 'Familienmesse',
'WORD': 'Wort-Gottes-Feier', 'WORD': 'Wort-Gottes-Feier',
'LANGUAGE': 'Eucharistiefeier in Fremdsprache', 'LANGUAGE': 'Eucharistiefeier in Fremdsprache',
'OTHER': 'Gottesdienst', 'OTHER': 'Gottesdienst',

View file

@ -25,7 +25,6 @@ const WEEK_OF_MONTH_LABEL: Record<string, string> = {
second: '2.', second: '2.',
third: '3.', third: '3.',
fourth: '4.', fourth: '4.',
fifth: '5.',
last: 'letzten', last: 'letzten',
} }