From 45f1925917ed384ce73eb3ac34b9a181198c383c Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 08:06:35 +0200 Subject: [PATCH 1/3] #302: configurable pond start page, created with every new pond MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a pond landed on whatever sorted first in the sidebar — stable, but a rule nobody could see, and one whose target moved as soon as someone added a page ahead of it. New ponds landed on the empty-pond hint instead of anything useful. - `startPageId` joins the pond settings. No migration: `Pond.settings` is already jsonb. It stores an id, not a slug, so renaming or moving the page keeps it working. - `PondHomePage` prefers it, but only when the page is in this user's page list. That list already holds just what they may see, so a start page hidden by a page-scoped grant — or trashed — falls back silently instead of landing them on a 404, and it costs no extra request. - Both creation paths give the pond a start page, titled from the creator's stored locale. It happens after the creating transaction commits: the owner's grant is written inside it and permissions cache per pond, so creating the page any earlier would ask about rights the grant has not published yet. A failure is logged, not fatal — a pond without a start page still works. `PagesModule` imported `PondsModule` without using it. Removing that vestigial edge let PondsModule depend on PagesModule in the honest direction instead of tying the two together with forwardRef. Every pond created through the api now owns a page, which broke eight suites whose teardown deleted ponds directly — `Page.pond` deliberately has no cascade, because a real purge removes contents explicitly and audits it. A shared `deletePondsWhere` helper deletes pages first. Two tests that counted pages now account for the start page rather than pretending the pond began empty. --- apps/api/src/admin/user-admin.e2e.db.test.ts | 4 +- apps/api/src/auth/auth.e2e.db.test.ts | 4 +- .../files/attachment-integrity.e2e.db.test.ts | 4 +- apps/api/src/i18n/api-i18n.ts | 6 +- .../conversion-job.e2e.db.test.ts | 4 +- apps/api/src/members/members.e2e.db.test.ts | 4 +- apps/api/src/pages/pages.module.ts | 3 +- apps/api/src/pages/plugin-api.e2e.db.test.ts | 13 ++- .../permissions/permissions.e2e.db.test.ts | 6 +- apps/api/src/plugins/plugins.e2e.db.test.ts | 4 +- apps/api/src/ponds/ponds.e2e.db.test.ts | 105 +++++++++++++++++- apps/api/src/ponds/ponds.module.ts | 3 +- apps/api/src/ponds/ponds.service.ts | 55 ++++++++- apps/api/src/testing/test-db.ts | 26 ++++- apps/api/src/trash/pond-purge.e2e.db.test.ts | 5 +- apps/web/src/i18n/index.ts | 4 + apps/web/src/pages/PondHomePage.tsx | 17 ++- apps/web/src/pages/PondSettingsPage.tsx | 12 ++ apps/web/src/ponds/StartPageSetting.tsx | 84 ++++++++++++++ packages/shared/i18n/de/ponds.json | 10 ++ packages/shared/i18n/en/ponds.json | 10 ++ packages/shared/src/ponds.ts | 7 ++ 22 files changed, 356 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/ponds/StartPageSetting.tsx create mode 100644 packages/shared/i18n/de/ponds.json create mode 100644 packages/shared/i18n/en/ponds.json diff --git a/apps/api/src/admin/user-admin.e2e.db.test.ts b/apps/api/src/admin/user-admin.e2e.db.test.ts index 731c123..5fb17f8 100644 --- a/apps/api/src/admin/user-admin.e2e.db.test.ts +++ b/apps/api/src/admin/user-admin.e2e.db.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PondsService } from '../ponds/ponds.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** @@ -62,7 +62,7 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => { afterAll(async () => { const all = Object.values(ids); await prisma.session.deleteMany({ where: { userId: { in: all } } }); - await prisma.pond.deleteMany({ where: { ownerId: { in: all } } }); + await deletePondsWhere(prisma, { ownerId: { in: all } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); await prisma.user.deleteMany({ where: { id: { in: all } } }); await prisma.$disconnect(); diff --git a/apps/api/src/auth/auth.e2e.db.test.ts b/apps/api/src/auth/auth.e2e.db.test.ts index e92a8f2..067159a 100644 --- a/apps/api/src/auth/auth.e2e.db.test.ts +++ b/apps/api/src/auth/auth.e2e.db.test.ts @@ -4,7 +4,7 @@ import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { let app: INestApplication; @@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { afterAll(async () => { // Verified users own a personal pond (#21) — remove it before them. - await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); await prisma.$disconnect(); diff --git a/apps/api/src/files/attachment-integrity.e2e.db.test.ts b/apps/api/src/files/attachment-integrity.e2e.db.test.ts index aa8972a..e9fd249 100644 --- a/apps/api/src/files/attachment-integrity.e2e.db.test.ts +++ b/apps/api/src/files/attachment-integrity.e2e.db.test.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { FileStorageService } from './file-storage.service'; @@ -74,7 +74,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => { await prisma.attachment.deleteMany({ where: { pondId } }); const where = { pond: { owner: { username: { contains: suffix } } } }; await prisma.roleGrant.deleteMany({ where }); - await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); diff --git a/apps/api/src/i18n/api-i18n.ts b/apps/api/src/i18n/api-i18n.ts index c22fa4f..bcbc4b5 100644 --- a/apps/api/src/i18n/api-i18n.ts +++ b/apps/api/src/i18n/api-i18n.ts @@ -1,10 +1,12 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json'; +import dePonds from '@dorfteich/shared/i18n/de/ponds.json'; import deTasks from '@dorfteich/shared/i18n/de/tasks.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json'; +import enPonds from '@dorfteich/shared/i18n/en/ponds.json'; import enTasks from '@dorfteich/shared/i18n/en/tasks.json'; import { createInstance, type i18n as I18n } from 'i18next'; @@ -17,8 +19,8 @@ export const apiI18n: I18n = createInstance(); void apiI18n.init({ resources: { - en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks }, - de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks }, + en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds }, + de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds }, }, fallbackLng: 'en', supportedLngs: ['de', 'en'], diff --git a/apps/api/src/import-export/conversion-job.e2e.db.test.ts b/apps/api/src/import-export/conversion-job.e2e.db.test.ts index c829278..d51f992 100644 --- a/apps/api/src/import-export/conversion-job.e2e.db.test.ts +++ b/apps/api/src/import-export/conversion-job.e2e.db.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { ConversionJobService } from './conversion-job.service'; @@ -108,7 +108,7 @@ describe.skipIf(!hasTestDb)('conversion job queue (e2e, issue #62)', () => { // grant); clear those before the users they reference. const where = { pond: { owner: { username: { contains: suffix } } } }; await prisma.roleGrant.deleteMany({ where }); - await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); diff --git a/apps/api/src/members/members.e2e.db.test.ts b/apps/api/src/members/members.e2e.db.test.ts index 0fbbf40..4549e1c 100644 --- a/apps/api/src/members/members.e2e.db.test.ts +++ b/apps/api/src/members/members.e2e.db.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PondsService } from '../ponds/ponds.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** @@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => { const ids = Object.values(userIds); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } }); - await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); + await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts index ab9eace..0a3c640 100644 --- a/apps/api/src/pages/pages.module.ts +++ b/apps/api/src/pages/pages.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; -import { PondsModule } from '../ponds/ponds.module'; import { WatchesModule } from '../watches/watches.module'; import { SearchModule } from '../search/search.module'; @@ -10,7 +9,7 @@ import { PluginApiController } from './plugin-api.controller'; import { TasksService } from './tasks.service'; @Module({ - imports: [PondsModule, SearchModule, WatchesModule], + imports: [SearchModule, WatchesModule], controllers: [PagesController, PluginApiController], providers: [PagesService, TasksService], exports: [PagesService, TasksService], diff --git a/apps/api/src/pages/plugin-api.e2e.db.test.ts b/apps/api/src/pages/plugin-api.e2e.db.test.ts index bd71d32..af94409 100644 --- a/apps/api/src/pages/plugin-api.e2e.db.test.ts +++ b/apps/api/src/pages/plugin-api.e2e.db.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PondsService } from '../ponds/ponds.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** @@ -23,6 +23,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => { const userIds: Record = {}; const cookies: Record = {}; let pondId: string; + let startPageId: string; let openPageId: string; let secretPageId: string; @@ -71,6 +72,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => { .send({ name: `PlugApi Pond ${suffix}` }) .expect(201); pondId = pond.body.id; + // Every pond created through the api starts with a page (issue #302); + // a "full reader sees everything" assertion has to include it rather + // than pretend the pond began empty. + startPageId = pond.body.settings.startPageId as string; const open = await api() .post(`/api/v1/ponds/${pondId}/pages`) @@ -128,10 +133,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => { await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.page.deleteMany({ where: { pondId } }); - await prisma.pond.deleteMany({ where: { id: pondId } }); + await deletePondsWhere(prisma, { id: pondId }); const ids = Object.values(userIds); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); - await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); + await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); @@ -144,7 +149,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => { .set('Cookie', cookies.owner!) .expect(200); expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual( - [openPageId, secretPageId].sort(), + [startPageId, openPageId, secretPageId].sort(), ); expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) }); // Label *names* travel with each summary (issue #77, page-index filter). diff --git a/apps/api/src/permissions/permissions.e2e.db.test.ts b/apps/api/src/permissions/permissions.e2e.db.test.ts index 08ebe2e..3ac6055 100644 --- a/apps/api/src/permissions/permissions.e2e.db.test.ts +++ b/apps/api/src/permissions/permissions.e2e.db.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PondsService } from '../ponds/ponds.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** @@ -120,11 +120,11 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => { await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.page.deleteMany({ where: { pondId } }); - await prisma.pond.deleteMany({ where: { id: pondId } }); + await deletePondsWhere(prisma, { id: pondId }); // Personal ponds (and their grants) before their users. const ids = Object.values(userIds); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); - await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); + await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index fc013fd..4fd9433 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { PluginStorageService } from './plugin-storage.service'; @@ -456,7 +456,7 @@ describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => { await prisma.plugin.deleteMany({ where: { id: pluginId } }); const where = { pond: { owner: { username: { contains: suffix } } } }; await prisma.roleGrant.deleteMany({ where }); - await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await app.close(); }); diff --git a/apps/api/src/ponds/ponds.e2e.db.test.ts b/apps/api/src/ponds/ponds.e2e.db.test.ts index 390b305..7464b30 100644 --- a/apps/api/src/ponds/ponds.e2e.db.test.ts +++ b/apps/api/src/ponds/ponds.e2e.db.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; -import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { PondAccessNotifier } from './pond-access-notifier.service'; @@ -81,7 +81,7 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => { await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } }, }); - await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); @@ -106,6 +106,107 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => { expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1); }); + it('gives a new shared pond a start page and points settings at it (issue #302)', async () => { + const created = await api() + .post('/api/v1/ponds') + .set('Cookie', ownerCookie) + .send({ name: `Startseitenteich ${suffix}` }) + .expect(201); + + const pages = await api() + .get(`/api/v1/ponds/${created.body.id}/pages`) + .set('Cookie', ownerCookie) + .expect(200); + expect(pages.body).toHaveLength(1); + + // The title follows the creator's stored locale — this owner is 'de'. + expect(pages.body[0].title).toBe('Startseite'); + + const pond = await api() + .get(`/api/v1/ponds/${created.body.slug}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(pond.body.settings.startPageId).toBe(pages.body[0].id); + }); + + it("titles the start page in the creator's locale (issue #302)", async () => { + // The owner is 'de' and got "Startseite" above; an 'en' account must get + // the English title. Without both halves the test would pass on a + // hardcoded string just as happily. + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + const username = `ellie-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `Ellie English ${suffix}`, + password, + locale: 'en', + }); + const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token }).expect(204); + + const pond = await prisma.pond.findFirstOrThrow({ + where: { ownerId: user.id, type: 'PERSONAL' }, + }); + const pages = await prisma.page.findMany({ where: { pondId: pond.id } }); + expect(pages.map((page) => page.title)).toEqual(['Home']); + }); + + it('gives the personal pond a start page too (issue #302)', async () => { + const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); + const personal = res.body.find((p: { type: string }) => p.type === 'personal'); + const pages = await api() + .get(`/api/v1/ponds/${personal.id}/pages`) + .set('Cookie', ownerCookie) + .expect(200); + expect(pages.body).toHaveLength(1); + expect(personal.settings.startPageId).toBe(pages.body[0].id); + }); + + it('changes the start page without losing other settings (issue #302)', async () => { + const created = await api() + .post('/api/v1/ponds') + .set('Cookie', ownerCookie) + .send({ name: `Wechselteich ${suffix}` }) + .expect(201); + await api() + .patch(`/api/v1/ponds/${created.body.id}`) + .set('Cookie', ownerCookie) + .send({ commentPolicy: 'editors' }) + .expect(200); + + const second = await api() + .post(`/api/v1/ponds/${created.body.id}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Zweite ${suffix}` }) + .expect(201); + const updated = await api() + .patch(`/api/v1/ponds/${created.body.id}`) + .set('Cookie', ownerCookie) + .send({ startPageId: second.body.id }) + .expect(200); + + expect(updated.body.settings.startPageId).toBe(second.body.id); + // The neighbouring key must survive the merge — settings hold only + // deviations, so an assigning write would silently reset it. + expect(updated.body.settings.commentPolicy).toBe('editors'); + }); + + it('clears the start page back to the sort-order default (issue #302)', async () => { + const created = await api() + .post('/api/v1/ponds') + .set('Cookie', ownerCookie) + .send({ name: `Leerteich ${suffix}` }) + .expect(201); + const cleared = await api() + .patch(`/api/v1/ponds/${created.body.id}`) + .set('Cookie', ownerCookie) + .send({ startPageId: null }) + .expect(200); + expect(cleared.body.settings.startPageId).toBeNull(); + }); + it('creates shared ponds with deterministic slug suffixes', async () => { const name = `Gartenteich ${suffix}`; const first = await api() diff --git a/apps/api/src/ponds/ponds.module.ts b/apps/api/src/ponds/ponds.module.ts index 5e4f696..4389687 100644 --- a/apps/api/src/ponds/ponds.module.ts +++ b/apps/api/src/ponds/ponds.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { PagesModule } from '../pages/pages.module'; import { QuotasModule } from '../quotas/quotas.module'; import { SearchModule } from '../search/search.module'; @@ -8,7 +9,7 @@ import { PondsController } from './ponds.controller'; import { PondsService } from './ponds.service'; @Module({ - imports: [QuotasModule, SearchModule], + imports: [PagesModule, QuotasModule, SearchModule], controllers: [PondsController], providers: [PondsService, PondAccessNotifier], exports: [PondsService, PondAccessNotifier], diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 8d38dea..e62d4cb 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -9,6 +9,8 @@ import { import { Pond, Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; +import { apiI18n } from '../i18n/api-i18n'; +import { PagesService } from '../pages/pages.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService } from '../quotas/quota.service'; @@ -23,11 +25,52 @@ export class PondsService { private readonly quotas: QuotaService, private readonly accessNotifier: PondAccessNotifier, private readonly search: SearchProvider, + private readonly pages: PagesService, private readonly logger: PinoLogger, ) { this.logger.setContext(PondsService.name); } + /** + * Every new pond opens on a start page instead of the empty-pond hint + * (issue #302). Deliberately AFTER the creating transaction commits: the + * owner's POND_ADMIN grant is written inside it and the permission layer + * caches per pond, so creating the page in the same transaction would ask + * about rights the grant has not yet published. + * + * Goes through PagesService so the page carries every invariant a page + * needs — unique slug, appended sort key, derived content cache, search + * indexing, and an `emptyPageState()` the collab server can bind to. A + * hand-rolled insert here would produce a page the editor cannot open. + * + * Failure is logged, not fatal: a pond without a start page simply falls + * back to the historical behaviour, which is a working state. Losing the + * whole pond over its first page would not be. + */ + private async createStartPage(owner: User, pondId: string): Promise { + try { + const title = apiI18n.t('ponds:startPage.title', { + lng: owner.locale === 'de' ? 'de' : 'en', + }); + const page = await this.pages.create(owner, pondId, { title }); + const pond = await this.prisma.pond.findUniqueOrThrow({ + where: { id: pondId }, + select: { settings: true }, + }); + // Merge rather than assign: stored settings hold only deviations from + // the defaults, and overwriting the object would drop them. + await this.prisma.pond.update({ + where: { id: pondId }, + data: { settings: { ...(pond.settings as object), startPageId: page.id } }, + }); + } catch (error) { + this.logger.error( + { pondId, err: error instanceof Error ? error.message : String(error) }, + 'start page for new pond could not be created', + ); + } + } + viewOf(pond: Pond): PondView { return { id: pond.id, @@ -105,7 +148,12 @@ export class PondsService { return created; }); this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created'); - return this.viewOf(pond); + await this.createStartPage(owner, pond.id); + // Re-read: the row captured in the transaction predates the start page, + // so returning it would hand the caller `startPageId: null` for a pond + // that has one. + const withStartPage = await this.prisma.pond.findUniqueOrThrow({ where: { id: pond.id } }); + return this.viewOf(withStartPage); } /** @@ -128,6 +176,7 @@ export class PondsService { return created; }); this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created'); + await this.createStartPage(user, pond.id); } async listVisible(user: User): Promise { @@ -157,7 +206,8 @@ export class PondsService { input.commentPolicy !== undefined || input.apiEnabled !== undefined || input.mcpEnabled !== undefined || - input.theme !== undefined; + input.theme !== undefined || + input.startPageId !== undefined; const settings = !settingsChanged ? undefined : { @@ -169,6 +219,7 @@ export class PondsService { ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), ...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}), ...(input.theme !== undefined ? { theme: input.theme } : {}), + ...(input.startPageId !== undefined ? { startPageId: input.startPageId } : {}), }; const updated = await this.prisma.pond.update({ where: { id }, diff --git a/apps/api/src/testing/test-db.ts b/apps/api/src/testing/test-db.ts index 5d57c76..e0a8de9 100644 --- a/apps/api/src/testing/test-db.ts +++ b/apps/api/src/testing/test-db.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; /** True when database-backed tests can run (see vitest.global-setup.ts). */ export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL); @@ -40,3 +40,27 @@ export async function grantOwnerAdmin( }, }); } + +/** + * Deletes the ponds matching `where`, their pages first. + * + * `Page.pond` deliberately carries no `onDelete: Cascade` — a real purge + * (TrashService) removes a pond's contents explicitly and audits it, and a + * silent database cascade would hide that. Since issue #302 every pond + * created through the api starts with a page, so teardowns that went + * straight for `pond.deleteMany` now hit the foreign key. + * + * Page-owned rows (updates, comments, links, …) do cascade from the page. + */ +export async function deletePondsWhere( + prisma: PrismaClient, + where: Prisma.PondWhereInput, +): Promise { + const pondIds = (await prisma.pond.findMany({ where, select: { id: true } })).map( + (pond) => pond.id, + ); + if (pondIds.length === 0) return; + await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.pond.deleteMany({ where: { id: { in: pondIds } } }); +} diff --git a/apps/api/src/trash/pond-purge.e2e.db.test.ts b/apps/api/src/trash/pond-purge.e2e.db.test.ts index 6a7a974..d58b6fc 100644 --- a/apps/api/src/trash/pond-purge.e2e.db.test.ts +++ b/apps/api/src/trash/pond-purge.e2e.db.test.ts @@ -231,7 +231,10 @@ describe.skipIf(!hasTestDb)('pond purge (e2e, issue #193)', () => { where: { action: 'pond.purged', targetId: pondId }, }); expect(audit).not.toBeNull(); - expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 2, attachments: 1 }); + // Two pages created here plus the pond's own start page (issue #302) — + // the audit records what was actually removed, so the count moves with + // the pond's real contents rather than with what the test typed out. + expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 3, attachments: 1 }); }); it('purges due ponds on the retention path with an audit event', async () => { diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index e7ff8e8..c35905a 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -14,6 +14,7 @@ import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deNotifications from '@dorfteich/shared/i18n/de/notifications.json'; +import dePonds from '@dorfteich/shared/i18n/de/ponds.json'; import dePlugins from '@dorfteich/shared/i18n/de/plugins.json'; import dePublic from '@dorfteich/shared/i18n/de/public.json'; import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; @@ -41,6 +42,7 @@ import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enNotifications from '@dorfteich/shared/i18n/en/notifications.json'; +import enPonds from '@dorfteich/shared/i18n/en/ponds.json'; import enPlugins from '@dorfteich/shared/i18n/en/plugins.json'; import enPublic from '@dorfteich/shared/i18n/en/public.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; @@ -85,6 +87,7 @@ void i18n links: enLinks, members: enMembers, notifications: enNotifications, + ponds: enPonds, plugins: enPlugins, public: enPublic, quotas: enQuotas, @@ -114,6 +117,7 @@ void i18n links: deLinks, members: deMembers, notifications: deNotifications, + ponds: dePonds, plugins: dePlugins, public: dePublic, quotas: deQuotas, diff --git a/apps/web/src/pages/PondHomePage.tsx b/apps/web/src/pages/PondHomePage.tsx index 77ae254..8c73b6a 100644 --- a/apps/web/src/pages/PondHomePage.tsx +++ b/apps/web/src/pages/PondHomePage.tsx @@ -31,10 +31,19 @@ export function PondHomePage(): React.JSX.Element { }); useEffect(() => { - if (pages.data && pages.data.length > 0) { - navigate(`/p/${pondSlug}/${pages.data[0]!.slug}`, { replace: true }); - } - }, [pages.data, pondSlug, navigate]); + if (!pages.data || pages.data.length === 0) return; + // The pond's chosen start page wins (issue #302), but only when it is in + // this user's page list. That list already holds just what they may see, + // so a start page hidden from them by a page-scoped grant falls back + // silently instead of landing them on a 404 — and it costs no extra + // request. A trashed start page is a stale id, not an error: it simply + // is not in the list either. + const startPageId = pond.data?.settings.startPageId ?? null; + const target = + (startPageId ? pages.data.find((page) => page.id === startPageId) : undefined) ?? + pages.data[0]!; + navigate(`/p/${pondSlug}/${target.slug}`, { replace: true }); + }, [pages.data, pond.data, pondSlug, navigate]); if (pond.error || pages.error) return ; if (!pond.data || !pages.data || pages.data.length > 0) return <>; diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 91e6361..70c82d9 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -17,6 +17,7 @@ import { EffectivePermissionsInspector } from '../access/EffectivePermissionsIns import { PondFileManager } from '../files/PondFileManager'; import { apiGet } from '../lib/api'; import { VaultImportSection } from '../import/VaultImportSection'; +import { StartPageSetting } from '../ponds/StartPageSetting'; import { SidebarViewSetting } from '../layout/SidebarViewSetting'; import { MemberManager } from '../members/MemberManager'; import { DeletePondSection } from '../ponds/DeletePondSection'; @@ -44,6 +45,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tFont } = useTranslation('font'); const { t: tCommon } = useTranslation(); const { t: tImport } = useTranslation('import'); + const { t: tPonds } = useTranslation('ponds'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -115,6 +117,16 @@ export function PondSettingsPage(): React.JSX.Element { )} {canModify && } + {canModify && ( +
+

{tPonds('startPage.label')}

+ +
+ )} {canModify && (

{tCommon('layout.sidebar.view.defaultTitle')}

diff --git a/apps/web/src/ponds/StartPageSetting.tsx b/apps/web/src/ponds/StartPageSetting.tsx new file mode 100644 index 0000000..ed374ed --- /dev/null +++ b/apps/web/src/ponds/StartPageSetting.tsx @@ -0,0 +1,84 @@ +import type { PageView } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormError, FormSuccess } from '../components/forms'; +import { apiGet, apiPatch } from '../lib/api'; + +/** + * Which page the pond opens on (issue #302). Before this, `/p/:pondSlug` + * always redirected to the first page in the sidebar's sort order — stable, + * but a rule nobody could see, and one whose target moved as soon as someone + * added a page that sorted ahead of it. + * + * The empty option is a real choice, not a blank entry: it restores exactly + * that historical behaviour. A pond whose stored start page has since been + * trashed shows the same empty state, because the id no longer resolves — + * `PondHomePage` falls back for the same reason. + */ +export function StartPageSetting({ + pondId, + pondSlug, + value, +}: { + pondId: string; + pondSlug: string; + value: string | null; +}): React.JSX.Element { + const { t } = useTranslation('ponds'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + const pages = useQuery({ + queryKey: ['pages', pondId], + queryFn: () => apiGet(`/ponds/${pondId}/pages`), + }); + + const save = async (next: string): Promise => { + setError(null); + setSaved(false); + try { + await apiPatch(`/ponds/${pondId}`, { startPageId: next === '' ? null : next }); + await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); + setSaved(true); + } catch (err) { + setError(err); + } + }; + + // A stored id that is not among the pond's pages (trashed, or not visible + // to this user) must not silently select the first option — show the + // fallback state instead, which is what the pond actually does. + const known = (pages.data ?? []).some((page) => page.id === value); + const selected = value && known ? value : ''; + + return ( +
+ +

{t('startPage.hint')}

+ + {value && !known && !pages.isLoading && ( +

+ {t('startPage.missing')} +

+ )} + {saved && } +
+ ); +} diff --git a/packages/shared/i18n/de/ponds.json b/packages/shared/i18n/de/ponds.json new file mode 100644 index 0000000..0d927ad --- /dev/null +++ b/packages/shared/i18n/de/ponds.json @@ -0,0 +1,10 @@ +{ + "startPage": { + "title": "Startseite", + "label": "Seite beim Öffnen des Teichs", + "hint": "Legt fest, welche Seite erscheint, wenn der Teich ausgewählt wird.", + "none": "Keine — erste Seite der Sortierung", + "missing": "Die gespeicherte Startseite gibt es nicht mehr. Der Teich öffnet die erste Seite der Sortierung.", + "saved": "Startseite gespeichert." + } +} diff --git a/packages/shared/i18n/en/ponds.json b/packages/shared/i18n/en/ponds.json new file mode 100644 index 0000000..3cd93b1 --- /dev/null +++ b/packages/shared/i18n/en/ponds.json @@ -0,0 +1,10 @@ +{ + "startPage": { + "title": "Home", + "label": "Page shown when the pond opens", + "hint": "Decides which page appears when the pond is selected.", + "none": "None — first page by sort order", + "missing": "The saved start page no longer exists. The pond opens the first page by sort order.", + "saved": "Start page saved." + } +} diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts index 295a4ac..9b93760 100644 --- a/packages/shared/src/ponds.ts +++ b/packages/shared/src/ponds.ts @@ -60,6 +60,12 @@ export const pondSettingsSchema = z.object({ * every member can override it locally (`ui.sidebar.view.`). */ sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'), fonts: pondFontsSchema.default({}), + /** Which page the pond opens on (issue #302). `null` keeps the historical + * behaviour — the first page in the sidebar's sort order, which is stable + * but invisible to the user and moves when a page sorts ahead of it. Held + * as an id, not a slug, so renaming or moving the page does not break it; + * a dangling id (page trashed) falls back rather than erroring. */ + startPageId: z.string().uuid().nullable().default(null), /** Who may write comments (issue #91): every reader, or editors only. */ commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), /** Per-pond opt-in to the public REST API (issue #104, default off): @@ -103,6 +109,7 @@ export const updatePondInputSchema = z apiEnabled: z.boolean(), mcpEnabled: z.boolean(), theme: pondThemeSchema, + startPageId: z.string().uuid().nullable(), }) .partial(); export type UpdatePondInput = z.infer; -- 2.45.2 From 30fd1ff53b783eed9f994fbac4c35a276d993921 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 08:25:51 +0200 Subject: [PATCH 2/3] #302: the permission matrix counts the start page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pond created through the api now carries one, and the matrix pond is created that way. The start page is an ordinary page with no grant of its own, so it follows the pond-wide permissions: the three member subjects each see one more, the label-restricted editor too, and the outsider — who reaches only the explicitly public page — still sees one. The 429 in the same run was the login rate limit, reached through the retries of this failure rather than on its own. --- apps/web/e2e/permission-matrix.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/e2e/permission-matrix.spec.ts b/apps/web/e2e/permission-matrix.spec.ts index dea7c1e..b27f063 100644 --- a/apps/web/e2e/permission-matrix.spec.ts +++ b/apps/web/e2e/permission-matrix.spec.ts @@ -173,10 +173,14 @@ test('page read & edit — the 404-vs-403 policy holds per subject', async () => }); test('sidebar list is filtered to each subject’s visible pages', async () => { - expect(await listCount(f.admin.request, f.pondId)).toBe(3); - expect(await listCount(f.owner.request, f.pondId)).toBe(3); - expect(await listCount(f.reader.request, f.pondId)).toBe(3); - expect(await listCount(f.editor.request, f.pondId)).toBe(2); // secret hidden + // Three fixture pages plus the pond's own start page (issue #302), which + // every pond created through the api now carries. It is an ordinary page + // with no grant of its own, so it follows the pond-wide permissions: the + // outsider, who reaches only the explicitly public page, still sees one. + expect(await listCount(f.admin.request, f.pondId)).toBe(4); + expect(await listCount(f.owner.request, f.pondId)).toBe(4); + expect(await listCount(f.reader.request, f.pondId)).toBe(4); + expect(await listCount(f.editor.request, f.pondId)).toBe(3); // secret hidden expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page // Anonymous cannot hit the authenticated list endpoint at all. expect(await listCount(f.anon, f.pondId)).toBe(401); -- 2.45.2 From f9149eba13a101d23f148586461850525182a0dd Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 11:29:54 +0200 Subject: [PATCH 3/3] #302: the vault import test reaches its page through the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Obsidian fixture vault contains a note called "Startseite", and the pond now creates one too — the seeded fixtures use locale `de`. Two consequences, and the second is the one that mattered: - the unscoped title locator matched two sidebar entries; - `/p//startseite` no longer belongs to the imported note. The pond's own start page took that slug, so the import landed on a suffixed one and the test was about to assert against the wrong page. Both are fixed by scoping to the mount page and navigating through the sidebar instead of guessing a slug. The test stays meaningful: it then clicks a wikilink inside the page content, which the empty auto-created start page would not have. CI caught this; the local run passed it. Worth remembering that a title-based locator can go green by luck. --- apps/web/e2e/import-vault.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/e2e/import-vault.spec.ts b/apps/web/e2e/import-vault.spec.ts index d0d05f8..1533f31 100644 --- a/apps/web/e2e/import-vault.spec.ts +++ b/apps/web/e2e/import-vault.spec.ts @@ -93,11 +93,19 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async await expect( mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }), ).toBeVisible(); - await expect(page.locator('.sidebar__page:text-is("Startseite")')).toBeVisible(); + // Scoped to the mount: the pond has its own "Startseite" since issue #302, + // so an unscoped title match now finds two entries. + const importedHome = mountItem + .locator('.sidebar__tree-children .sidebar__page') + .filter({ hasText: /^Startseite$/ }) + .first(); + await expect(importedHome).toBeVisible(); // A rewritten Obsidian link navigates to the right imported page, and the - // display text still reads like the original note name. - await page.goto(`/p/${pond.slug}/startseite`); + // display text still reads like the original note name. Reached through the + // sidebar rather than by slug — `/startseite` belongs to the pond's own + // start page, so the imported note landed on a suffixed slug. + await importedHome.click(); await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click(); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`)); -- 2.45.2