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;