#302: configurable pond start page, created with every new pond
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.
This commit is contained in:
parent
5a4a99196e
commit
45f1925917
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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 { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,7 +62,7 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const all = Object.values(ids);
|
const all = Object.values(ids);
|
||||||
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
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.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import request from 'supertest';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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)', () => {
|
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Verified users own a personal pond (#21) — remove it before them.
|
// 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.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp } from '../testing/test-app';
|
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 { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
import { FileStorageService } from './file-storage.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 } });
|
await prisma.attachment.deleteMany({ where: { pondId } });
|
||||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
await prisma.roleGrant.deleteMany({ where });
|
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.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
||||||
import deMails from '@dorfteich/shared/i18n/de/mails.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 deTasks from '@dorfteich/shared/i18n/de/tasks.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||||
import enMails from '@dorfteich/shared/i18n/en/mails.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 enTasks from '@dorfteich/shared/i18n/en/tasks.json';
|
||||||
import { createInstance, type i18n as I18n } from 'i18next';
|
import { createInstance, type i18n as I18n } from 'i18next';
|
||||||
|
|
||||||
@ -17,8 +19,8 @@ export const apiI18n: I18n = createInstance();
|
|||||||
|
|
||||||
void apiI18n.init({
|
void apiI18n.init({
|
||||||
resources: {
|
resources: {
|
||||||
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
|
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds },
|
||||||
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
|
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds },
|
||||||
},
|
},
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['de', 'en'],
|
supportedLngs: ['de', 'en'],
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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 { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
import { ConversionJobService } from './conversion-job.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.
|
// grant); clear those before the users they reference.
|
||||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
await prisma.roleGrant.deleteMany({ where });
|
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.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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 { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => {
|
|||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } });
|
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.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
|
||||||
import { WatchesModule } from '../watches/watches.module';
|
import { WatchesModule } from '../watches/watches.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
|
|
||||||
@ -10,7 +9,7 @@ import { PluginApiController } from './plugin-api.controller';
|
|||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PondsModule, SearchModule, WatchesModule],
|
imports: [SearchModule, WatchesModule],
|
||||||
controllers: [PagesController, PluginApiController],
|
controllers: [PagesController, PluginApiController],
|
||||||
providers: [PagesService, TasksService],
|
providers: [PagesService, TasksService],
|
||||||
exports: [PagesService, TasksService],
|
exports: [PagesService, TasksService],
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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 { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,6 +23,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
const userIds: Record<string, string> = {};
|
const userIds: Record<string, string> = {};
|
||||||
const cookies: Record<string, string> = {};
|
const cookies: Record<string, string> = {};
|
||||||
let pondId: string;
|
let pondId: string;
|
||||||
|
let startPageId: string;
|
||||||
let openPageId: string;
|
let openPageId: string;
|
||||||
let secretPageId: string;
|
let secretPageId: string;
|
||||||
|
|
||||||
@ -71,6 +72,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
.send({ name: `PlugApi Pond ${suffix}` })
|
.send({ name: `PlugApi Pond ${suffix}` })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
pondId = pond.body.id;
|
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()
|
const open = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
.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.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.page.deleteMany({ where: { pondId } });
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
await prisma.pond.deleteMany({ where: { id: pondId } });
|
await deletePondsWhere(prisma, { id: pondId });
|
||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
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.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
@ -144,7 +149,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
.set('Cookie', cookies.owner!)
|
.set('Cookie', cookies.owner!)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
|
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) });
|
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
|
||||||
// Label *names* travel with each summary (issue #77, page-index filter).
|
// Label *names* travel with each summary (issue #77, page-index filter).
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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 { 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.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.page.deleteMany({ where: { 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.
|
// Personal ponds (and their grants) before their users.
|
||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
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.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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 { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
import { PluginStorageService } from './plugin-storage.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 } });
|
await prisma.plugin.deleteMany({ where: { id: pluginId } });
|
||||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
await prisma.roleGrant.deleteMany({ where });
|
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.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
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 { UsersService } from '../users/users.service';
|
||||||
import { PondAccessNotifier } from './pond-access-notifier.service';
|
import { PondAccessNotifier } from './pond-access-notifier.service';
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
|||||||
await prisma.quotaOverride.deleteMany({
|
await prisma.quotaOverride.deleteMany({
|
||||||
where: { subjectId: { in: users.map((u) => u.id) } },
|
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.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
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);
|
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 () => {
|
it('creates shared ponds with deterministic slug suffixes', async () => {
|
||||||
const name = `Gartenteich ${suffix}`;
|
const name = `Gartenteich ${suffix}`;
|
||||||
const first = await api()
|
const first = await api()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PagesModule } from '../pages/pages.module';
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ import { PondsController } from './ponds.controller';
|
|||||||
import { PondsService } from './ponds.service';
|
import { PondsService } from './ponds.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotasModule, SearchModule],
|
imports: [PagesModule, QuotasModule, SearchModule],
|
||||||
controllers: [PondsController],
|
controllers: [PondsController],
|
||||||
providers: [PondsService, PondAccessNotifier],
|
providers: [PondsService, PondAccessNotifier],
|
||||||
exports: [PondsService, PondAccessNotifier],
|
exports: [PondsService, PondAccessNotifier],
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
import { Pond, Prisma, User } from '@prisma/client';
|
import { Pond, Prisma, User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
import { PagesService } from '../pages/pages.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { QuotaService } from '../quotas/quota.service';
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
@ -23,11 +25,52 @@ export class PondsService {
|
|||||||
private readonly quotas: QuotaService,
|
private readonly quotas: QuotaService,
|
||||||
private readonly accessNotifier: PondAccessNotifier,
|
private readonly accessNotifier: PondAccessNotifier,
|
||||||
private readonly search: SearchProvider,
|
private readonly search: SearchProvider,
|
||||||
|
private readonly pages: PagesService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PondsService.name);
|
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 {
|
viewOf(pond: Pond): PondView {
|
||||||
return {
|
return {
|
||||||
id: pond.id,
|
id: pond.id,
|
||||||
@ -105,7 +148,12 @@ export class PondsService {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond 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;
|
return created;
|
||||||
});
|
});
|
||||||
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond 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[]> {
|
async listVisible(user: User): Promise<PondView[]> {
|
||||||
@ -157,7 +206,8 @@ export class PondsService {
|
|||||||
input.commentPolicy !== undefined ||
|
input.commentPolicy !== undefined ||
|
||||||
input.apiEnabled !== undefined ||
|
input.apiEnabled !== undefined ||
|
||||||
input.mcpEnabled !== undefined ||
|
input.mcpEnabled !== undefined ||
|
||||||
input.theme !== undefined;
|
input.theme !== undefined ||
|
||||||
|
input.startPageId !== undefined;
|
||||||
const settings = !settingsChanged
|
const settings = !settingsChanged
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
@ -169,6 +219,7 @@ export class PondsService {
|
|||||||
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
||||||
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
|
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
|
||||||
...(input.theme !== undefined ? { theme: input.theme } : {}),
|
...(input.theme !== undefined ? { theme: input.theme } : {}),
|
||||||
|
...(input.startPageId !== undefined ? { startPageId: input.startPageId } : {}),
|
||||||
};
|
};
|
||||||
const updated = await this.prisma.pond.update({
|
const updated = await this.prisma.pond.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@ -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). */
|
/** True when database-backed tests can run (see vitest.global-setup.ts). */
|
||||||
export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL);
|
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 } } });
|
||||||
|
}
|
||||||
|
|||||||
@ -231,7 +231,10 @@ describe.skipIf(!hasTestDb)('pond purge (e2e, issue #193)', () => {
|
|||||||
where: { action: 'pond.purged', targetId: pondId },
|
where: { action: 'pond.purged', targetId: pondId },
|
||||||
});
|
});
|
||||||
expect(audit).not.toBeNull();
|
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 () => {
|
it('purges due ponds on the retention path with an audit event', async () => {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
|||||||
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
||||||
import deMembers from '@dorfteich/shared/i18n/de/members.json';
|
import deMembers from '@dorfteich/shared/i18n/de/members.json';
|
||||||
import deNotifications from '@dorfteich/shared/i18n/de/notifications.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 dePlugins from '@dorfteich/shared/i18n/de/plugins.json';
|
||||||
import dePublic from '@dorfteich/shared/i18n/de/public.json';
|
import dePublic from '@dorfteich/shared/i18n/de/public.json';
|
||||||
import deQuotas from '@dorfteich/shared/i18n/de/quotas.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 enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||||
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
||||||
import enNotifications from '@dorfteich/shared/i18n/en/notifications.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 enPlugins from '@dorfteich/shared/i18n/en/plugins.json';
|
||||||
import enPublic from '@dorfteich/shared/i18n/en/public.json';
|
import enPublic from '@dorfteich/shared/i18n/en/public.json';
|
||||||
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
|
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
|
||||||
@ -85,6 +87,7 @@ void i18n
|
|||||||
links: enLinks,
|
links: enLinks,
|
||||||
members: enMembers,
|
members: enMembers,
|
||||||
notifications: enNotifications,
|
notifications: enNotifications,
|
||||||
|
ponds: enPonds,
|
||||||
plugins: enPlugins,
|
plugins: enPlugins,
|
||||||
public: enPublic,
|
public: enPublic,
|
||||||
quotas: enQuotas,
|
quotas: enQuotas,
|
||||||
@ -114,6 +117,7 @@ void i18n
|
|||||||
links: deLinks,
|
links: deLinks,
|
||||||
members: deMembers,
|
members: deMembers,
|
||||||
notifications: deNotifications,
|
notifications: deNotifications,
|
||||||
|
ponds: dePonds,
|
||||||
plugins: dePlugins,
|
plugins: dePlugins,
|
||||||
public: dePublic,
|
public: dePublic,
|
||||||
quotas: deQuotas,
|
quotas: deQuotas,
|
||||||
|
|||||||
@ -31,10 +31,19 @@ export function PondHomePage(): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pages.data && pages.data.length > 0) {
|
if (!pages.data || pages.data.length === 0) return;
|
||||||
navigate(`/p/${pondSlug}/${pages.data[0]!.slug}`, { replace: true });
|
// 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,
|
||||||
}, [pages.data, pondSlug, navigate]);
|
// 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.error || pages.error) return <FormError error={pond.error ?? pages.error} />;
|
||||||
if (!pond.data || !pages.data || pages.data.length > 0) return <></>;
|
if (!pond.data || !pages.data || pages.data.length > 0) return <></>;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { EffectivePermissionsInspector } from '../access/EffectivePermissionsIns
|
|||||||
import { PondFileManager } from '../files/PondFileManager';
|
import { PondFileManager } from '../files/PondFileManager';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
import { VaultImportSection } from '../import/VaultImportSection';
|
import { VaultImportSection } from '../import/VaultImportSection';
|
||||||
|
import { StartPageSetting } from '../ponds/StartPageSetting';
|
||||||
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
||||||
import { MemberManager } from '../members/MemberManager';
|
import { MemberManager } from '../members/MemberManager';
|
||||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||||
@ -44,6 +45,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
const { t: tFont } = useTranslation('font');
|
const { t: tFont } = useTranslation('font');
|
||||||
const { t: tCommon } = useTranslation();
|
const { t: tCommon } = useTranslation();
|
||||||
const { t: tImport } = useTranslation('import');
|
const { t: tImport } = useTranslation('import');
|
||||||
|
const { t: tPonds } = useTranslation('ponds');
|
||||||
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@ -115,6 +117,16 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
{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 && (
|
{canModify && (
|
||||||
<section>
|
<section>
|
||||||
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>
|
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>
|
||||||
|
|||||||
84
apps/web/src/ponds/StartPageSetting.tsx
Normal file
84
apps/web/src/ponds/StartPageSetting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
packages/shared/i18n/de/ponds.json
Normal file
10
packages/shared/i18n/de/ponds.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/shared/i18n/en/ponds.json
Normal file
10
packages/shared/i18n/en/ponds.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,6 +60,12 @@ export const pondSettingsSchema = z.object({
|
|||||||
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
|
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
|
||||||
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
|
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
|
||||||
fonts: pondFontsSchema.default({}),
|
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. */
|
/** Who may write comments (issue #91): every reader, or editors only. */
|
||||||
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
||||||
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
||||||
@ -103,6 +109,7 @@ export const updatePondInputSchema = z
|
|||||||
apiEnabled: z.boolean(),
|
apiEnabled: z.boolean(),
|
||||||
mcpEnabled: z.boolean(),
|
mcpEnabled: z.boolean(),
|
||||||
theme: pondThemeSchema,
|
theme: pondThemeSchema,
|
||||||
|
startPageId: z.string().uuid().nullable(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
|
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user