#302: configurable pond start page, created with every new pond #310

Merged
opus-5 merged 3 commits from issue-302-pond-start-page into main 2026-08-01 12:14:27 +02:00
24 changed files with 375 additions and 41 deletions

View File

@ -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();

View File

@ -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();

View File

@ -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();

View File

@ -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'],

View File

@ -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();

View File

@ -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();

View File

@ -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],

View File

@ -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<string, string> = {};
const cookies: Record<string, string> = {};
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).

View File

@ -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();

View File

@ -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();
});

View File

@ -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()

View File

@ -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],

View File

@ -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<void> {
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<PondView[]> {
@ -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 },

View File

@ -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<void> {
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 } } });
}

View File

@ -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 () => {

View File

@ -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$`));

View File

@ -173,10 +173,14 @@ test('page read & edit — the 404-vs-403 policy holds per subject', async () =>
});
test('sidebar list is filtered to each subjects 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);

View File

@ -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,

View File

@ -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 <FormError error={pond.error ?? pages.error} />;
if (!pond.data || !pages.data || pages.data.length > 0) return <></>;

View File

@ -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 {
</section>
)}
{canModify && <PondPluginSettings pondId={pond.data.id} />}
{canModify && (
<section>
<h2>{tPonds('startPage.label')}</h2>
<StartPageSetting
pondId={pond.data.id}
pondSlug={pondSlug}
value={pond.data.settings.startPageId}
/>
</section>
)}
{canModify && (
<section>
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>

View File

@ -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<unknown>(null);
const [saved, setSaved] = useState(false);
const pages = useQuery({
queryKey: ['pages', pondId],
queryFn: () => apiGet<PageView[]>(`/ponds/${pondId}/pages`),
});
const save = async (next: string): Promise<void> => {
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 (
<div className="start-page-setting">
<FormError error={error} />
<p className="start-page-setting__hint">{t('startPage.hint')}</p>
<label>
{t('startPage.label')}{' '}
<select
value={selected}
disabled={pages.isLoading}
onChange={(event) => void save(event.target.value)}
>
<option value="">{t('startPage.none')}</option>
{(pages.data ?? []).map((page) => (
<option key={page.id} value={page.id}>
{page.title}
</option>
))}
</select>
</label>
{value && !known && !pages.isLoading && (
<p className="start-page-setting__hint" role="status">
{t('startPage.missing')}
</p>
)}
{saved && <FormSuccess message={t('startPage.saved')} />}
</div>
);
}

View File

@ -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."
}
}

View File

@ -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."
}
}

View File

@ -60,6 +60,12 @@ export const pondSettingsSchema = z.object({
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
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<typeof updatePondInputSchema>;