diff --git a/client/src/components/Admin/usePermissionsAdmin.ts b/client/src/components/Admin/usePermissionsAdmin.ts index c8920a5eb..5f467cd47 100644 --- a/client/src/components/Admin/usePermissionsAdmin.ts +++ b/client/src/components/Admin/usePermissionsAdmin.ts @@ -24,7 +24,7 @@ export const CATEGORIES = [ { id: 'members', keys: ['member_manage'] }, { id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] }, { id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] }, - { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] }, + { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] }, ]; /** diff --git a/server/scripts/query-api-baseline.json b/server/scripts/query-api-baseline.json index 9ef394cb0..2c7943708 100644 --- a/server/scripts/query-api-baseline.json +++ b/server/scripts/query-api-baseline.json @@ -15,7 +15,6 @@ "src/db/repositories/Days.repository.ts": 7, "src/db/repositories/InviteTokens.repository.ts": 3, "src/db/repositories/JourneyBooks.repository.ts": 3, - "src/db/repositories/JourneyContributors.repository.ts": 3, "src/db/repositories/JourneyEntries.repository.ts": 9, "src/db/repositories/JourneyPhotos.repository.ts": 6, "src/db/repositories/Journeys.repository.ts": 4, diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index d9258eded..be1d68a8d 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -117,6 +117,8 @@ "TripDocumentLinks": "doc-sync", "TripFiles": "files", "TripInviteTokens": "trip-invite", + "TripJournalLinkMembers": "journey", + "TripJournalLinks": "journey", "TripMembers": "trip-membership", "TripPhotos": "photos", "Trips": "trips", diff --git a/server/src/db/entities/Journeys.entity.ts b/server/src/db/entities/Journeys.entity.ts index d553100b2..0af94f20a 100644 --- a/server/src/db/entities/Journeys.entity.ts +++ b/server/src/db/entities/Journeys.entity.ts @@ -6,6 +6,7 @@ import { JourneyEntries } from './JourneyEntries.entity'; import { JourneyPhotos } from './JourneyPhotos.entity'; import { JourneyShareTokens } from './JourneyShareTokens.entity'; import { JourneyTrips } from './JourneyTrips.entity'; +import { TripJournalLinks } from './TripJournalLinks.entity'; import { Users } from './Users.entity'; export class Journeys { @@ -32,6 +33,7 @@ export class Journeys { journey_photos_collection = new Collection(this); journey_share_tokens: Ref | null = null; journey_trips_collection = new Collection(this); + trip_journal_links_collection = new Collection(this); } export const JourneysSchema = defineEntity({ @@ -60,5 +62,6 @@ export const JourneysSchema = defineEntity({ journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(), journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(), journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(), + trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('journey').hidden(), }, }); diff --git a/server/src/db/entities/TripJournalLinkMembers.entity.ts b/server/src/db/entities/TripJournalLinkMembers.entity.ts new file mode 100644 index 000000000..7f49b8333 --- /dev/null +++ b/server/src/db/entities/TripJournalLinkMembers.entity.ts @@ -0,0 +1,38 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripJournalLinkMembersRepository } from '../repositories/TripJournalLinkMembers.repository'; +import { TripJournalLinks } from './TripJournalLinks.entity'; +import { Users } from './Users.entity'; + +export class TripJournalLinkMembers { + [EntityRepositoryType]?: TripJournalLinkMembersRepository; + [PrimaryKeyProp]?: ['trip', 'user']; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + role: TTripJournalLinkMembersRole & Opt = TripJournalLinkMembersRole.VIEWER; + hide_skeletons: number & Opt = 0; + added_at!: number; +} + +export const TripJournalLinkMembersRole = { + VIEWER: 'viewer', + EDITOR: 'editor', +} as const; + +export type TTripJournalLinkMembersRole = (typeof TripJournalLinkMembersRole)[keyof typeof TripJournalLinkMembersRole]; + +export const TripJournalLinkMembersSchema = defineEntity({ + class: TripJournalLinkMembers, + repository: () => TripJournalLinkMembersRepository, + uniques: [{ properties: ['trip', 'user'] }], + properties: { + trip: () => p.manyToOne(TripJournalLinks).primary().ref().name('trip_id').hidden(), + trip_id: p.integer().persist(false), + user: () => p.manyToOne(Users).primary().ref().hidden().index('idx_trip_journal_link_members_user'), + user_id: p.integer().persist(false).index('idx_trip_journal_link_members_user'), + role: p.enum(() => TripJournalLinkMembersRole).default('viewer'), + hide_skeletons: p.integer().default(0), + added_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/TripJournalLinks.entity.ts b/server/src/db/entities/TripJournalLinks.entity.ts new file mode 100644 index 000000000..bb74412a9 --- /dev/null +++ b/server/src/db/entities/TripJournalLinks.entity.ts @@ -0,0 +1,34 @@ +import { Collection, EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripJournalLinksRepository } from '../repositories/TripJournalLinks.repository'; +import { Journeys } from './Journeys.entity'; +import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripJournalLinks { + [EntityRepositoryType]?: TripJournalLinksRepository; + [PrimaryKeyProp]?: 'trip'; + trip?: Ref | null; + trip_id?: number | null; + journey!: Ref; + journey_id!: number; + linked_by?: number | null; + created_at!: number; + linkedByRef?: Ref | null; + trip_journal_link_members_collection = new Collection(this); +} + +export const TripJournalLinksSchema = defineEntity({ + class: TripJournalLinks, + repository: () => TripJournalLinksRepository, + properties: { + trip: () => p.oneToOne(Trips).primary().ref().deleteRule('cascade').nullable().hidden(), + trip_id: p.integer().nullable().persist(false), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_trip_journal_links_journey'), + journey_id: p.integer().persist(false).index('idx_trip_journal_links_journey'), + linked_by: p.integer().nullable().persist(false).index('idx_trip_journal_links_linked_by'), + created_at: p.integer(), + linkedByRef: () => p.manyToOne(Users).ref().joinColumn('linked_by').nullable().hidden().index('idx_trip_journal_links_linked_by'), + trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('trip').hidden(), + }, +}); diff --git a/server/src/db/entities/Trips.entity.ts b/server/src/db/entities/Trips.entity.ts index 6f6b6099e..25c14ec77 100644 --- a/server/src/db/entities/Trips.entity.ts +++ b/server/src/db/entities/Trips.entity.ts @@ -32,6 +32,7 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity'; import { TripDocumentLinks } from './TripDocumentLinks.entity'; import { TripFiles } from './TripFiles.entity'; import { TripInviteTokens } from './TripInviteTokens.entity'; +import { TripJournalLinks } from './TripJournalLinks.entity'; import { TripMembers } from './TripMembers.entity'; import { TripPhotos } from './TripPhotos.entity'; import { Users } from './Users.entity'; @@ -84,6 +85,7 @@ export class Trips { trip_document_links_collection = new Collection(this); trip_files_collection = new Collection(this); trip_invite_tokens_collection = new Collection(this); + trip_journal_links: Ref | null = null; trip_members_collection = new Collection(this); trip_photos_collection = new Collection(this); } @@ -145,6 +147,7 @@ export const TripsSchema = defineEntity({ trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(), trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(), trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(), + trip_journal_links: () => p.oneToOne(TripJournalLinks).ref().mappedBy('trip').hidden(), trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(), trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(), }, diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts index 21f9f032d..f05cdb1c9 100644 --- a/server/src/db/entities/Users.entity.ts +++ b/server/src/db/entities/Users.entity.ts @@ -57,6 +57,8 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity'; import { TripDocumentLinks } from './TripDocumentLinks.entity'; import { TripFiles } from './TripFiles.entity'; import { TripInviteTokens } from './TripInviteTokens.entity'; +import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity'; +import { TripJournalLinks } from './TripJournalLinks.entity'; import { TripMembers } from './TripMembers.entity'; import { TripPhotos } from './TripPhotos.entity'; import { Trips } from './Trips.entity'; @@ -182,6 +184,8 @@ export class Users { trip_document_links_collection = new Collection(this); trip_files_collection = new Collection(this); trip_invite_tokens_collection = new Collection(this); + trip_journal_link_members_collection = new Collection(this); + trip_journal_links_collection = new Collection(this); trip_members_collection = new Collection(this); trip_members_collection1 = new Collection(this); trip_photos_collection = new Collection(this); @@ -325,6 +329,8 @@ export const UsersSchema = defineEntity({ trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(), trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(), trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(), + trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('user').hidden(), + trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('linkedByRef').hidden(), trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(), trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(), trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(), diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index cf28dd9c9..9c2d23b29 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -116,6 +116,8 @@ import { TripAlbumLinksSchema } from './TripAlbumLinks.entity'; import { TripDocumentLinksSchema } from './TripDocumentLinks.entity'; import { TripFilesSchema } from './TripFiles.entity'; import { TripInviteTokensSchema } from './TripInviteTokens.entity'; +import { TripJournalLinkMembersSchema } from './TripJournalLinkMembers.entity'; +import { TripJournalLinksSchema } from './TripJournalLinks.entity'; import { TripMembersSchema } from './TripMembers.entity'; import { TripPhotosSchema } from './TripPhotos.entity'; import { TripsSchema } from './Trips.entity'; @@ -256,6 +258,8 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ TripDocumentLinksSchema, TripFilesSchema, TripInviteTokensSchema, + TripJournalLinkMembersSchema, + TripJournalLinksSchema, TripMembersSchema, TripPhotosSchema, TripsSchema, diff --git a/server/src/db/kysely/TripJournalLinkMembers.table.ts b/server/src/db/kysely/TripJournalLinkMembers.table.ts new file mode 100644 index 000000000..e429330ba --- /dev/null +++ b/server/src/db/kysely/TripJournalLinkMembers.table.ts @@ -0,0 +1,11 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface TripJournalLinkMembersTable { + trip_id: number; + user_id: number; + role: InsertOptional; + hide_skeletons: InsertOptional; + added_at: number; +} diff --git a/server/src/db/kysely/TripJournalLinks.table.ts b/server/src/db/kysely/TripJournalLinks.table.ts new file mode 100644 index 000000000..0dc7d4944 --- /dev/null +++ b/server/src/db/kysely/TripJournalLinks.table.ts @@ -0,0 +1,10 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface TripJournalLinksTable { + trip_id: InsertOptional; + journey_id: number; + linked_by: InsertOptional; + created_at: number; +} diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts index 1e17ce653..0c1412c00 100644 --- a/server/src/db/kysely/db.ts +++ b/server/src/db/kysely/db.ts @@ -122,6 +122,8 @@ import type { TripAlbumLinksTable } from './TripAlbumLinks.table'; import type { TripDocumentLinksTable } from './TripDocumentLinks.table'; import type { TripFilesTable } from './TripFiles.table'; import type { TripInviteTokensTable } from './TripInviteTokens.table'; +import type { TripJournalLinkMembersTable } from './TripJournalLinkMembers.table'; +import type { TripJournalLinksTable } from './TripJournalLinks.table'; import type { TripMembersTable } from './TripMembers.table'; import type { TripPhotosTable } from './TripPhotos.table'; import type { TripsTable } from './Trips.table'; @@ -268,6 +270,8 @@ export interface DB { trip_document_links: TripDocumentLinksTable; trip_files: TripFilesTable; trip_invite_tokens: TripInviteTokensTable; + trip_journal_link_members: TripJournalLinkMembersTable; + trip_journal_links: TripJournalLinksTable; trip_members: TripMembersTable; trip_photos: TripPhotosTable; trips: TripsTable; diff --git a/server/src/db/migrations/Migration20200101044300_a_trip_links_a_journal.ts b/server/src/db/migrations/Migration20200101044300_a_trip_links_a_journal.ts new file mode 100644 index 000000000..2ce862901 --- /dev/null +++ b/server/src/db/migrations/Migration20200101044300_a_trip_links_a_journal.ts @@ -0,0 +1,43 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * A trip can show one journal from the Journey addon as its own. The link + * belongs to the trip, not to the journal's own trips (`journey_trips`): any + * journal its owner chooses, and one journal may hang off several trips. + * `linked_by` names who linked it, kept when that account goes as unknown. + * + * `trip_journal_link_members` lists the trip members the link lets into the + * journal, as viewer or editor, apart from `journey_contributors` so that + * unlinking, deselecting someone or their leaving the trip takes away exactly + * the access the link gave and nothing the journal's owner granted directly. + * `hide_skeletons` is that member's own journal preference, which for a direct + * contributor sits on the contributor row. A member's rows go with the link, + * with the trip and with the account. + */ +export class Migration20200101044300_a_trip_links_a_journal extends Migration { + override name = 'Migration20200101044300_a_trip_links_a_journal'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_journal_links ( + trip_id INTEGER PRIMARY KEY REFERENCES trips(id) ON DELETE CASCADE, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + linked_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL + ) + `); + this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_journey ON trip_journal_links(journey_id)'); + this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_linked_by ON trip_journal_links(linked_by)'); + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_journal_link_members ( + trip_id INTEGER NOT NULL REFERENCES trip_journal_links(trip_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')), + hide_skeletons INTEGER NOT NULL DEFAULT 0, + added_at INTEGER NOT NULL, + PRIMARY KEY (trip_id, user_id) + ) + `); + this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_link_members_user ON trip_journal_link_members(user_id)'); + } +} diff --git a/server/src/db/repositories/JourneyContributors.repository.ts b/server/src/db/repositories/JourneyContributors.repository.ts index 6f2ef1cf8..62cd6b03d 100644 --- a/server/src/db/repositories/JourneyContributors.repository.ts +++ b/server/src/db/repositories/JourneyContributors.repository.ts @@ -1,5 +1,6 @@ import type { JourneyContributors } from '../entities/JourneyContributors.entity'; import type { DB } from '../kysely/db'; +import { readLinkHideSkeletons, readLinkRole, type TripJournalLinkKyselyDB } from './TripJournalLinks.repository'; import { TrekRepository } from './_shared/trek-repository'; /** JG18's contributor-list row (`getJourneyFull`) — the junction plus the joined username/avatar. */ @@ -15,6 +16,9 @@ export interface JourneyContributorListRow { /** The `journey_contributors`/`users` tables {@link JourneyContributorsRepository.listForJourney} reads. */ type JourneyContributorListKyselyDB = Pick; +/** The table the access reads below take a person's own contributor row from. */ +type JourneyContributorRowKyselyDB = Pick; + /** * `journey_contributors` — a genuine TWO-column composite primary key * (`journey` + `user`, both `.primary()`, R3 — pinned against @@ -26,36 +30,53 @@ type JourneyContributorListKyselyDB = Pick * pinned in `task-1-report.md`. */ export class JourneyContributorsRepository extends TrekRepository { - /** JG4 — `canAccessJourney`'s contributor-match primitive: `SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */ - async existsForUser(journeyId: number, userId: number): Promise { - const row = await this.qb('jc') - .select(['jc.journey']) - .where({ journey: journeyId, user: userId }) - .execute('get', false); - return !!row; + /** The person's own contributor row on the journal, if they have one. */ + private async directRow(journeyId: number, userId: number) { + return await this.kysely() + .selectFrom('journey_contributors') + .select(['role', 'hide_skeletons']) + .where('journey_id', '=', journeyId) + .where('user_id', '=', userId) + .executeTakeFirst(); } - /** JG7/JG21 — `canEdit`'s edit-access primitive and `getJourneyFull`'s `myRole` resolution: `SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */ - async findRole(journeyId: number, userId: number): Promise { - const row = await this.qb('jc') - .select(['jc.role']) - .where({ journey: journeyId, user: userId }) - .execute<{ role: string } | undefined>('get', false); - return row?.role; + /** JG4 — whether a person may open the journal: as a contributor, or through a trip's link. */ + async hasAccess(journeyId: number, userId: number): Promise { + return (await this.findAccessRole(journeyId, userId)) !== undefined; } - /** JG20/JG27 — `getJourneyFull`'s `hide_skeletons` preference read and `updateJourneyPreferences`'s re-read, one statement text. */ + /** + * JG7/JG21 — the role a person acts with on the journal, for `canEdit` and + * `my_role`: their contributor role, or the role a trip's link gives them + * when that is the stronger one. Undefined with neither. + */ + async findAccessRole(journeyId: number, userId: number): Promise { + const direct = await this.directRow(journeyId, userId); + if (direct?.role === 'owner' || direct?.role === 'editor') return direct.role; + const linked = await readLinkRole(this.kysely(), journeyId, userId); + return linked ?? direct?.role; + } + + /** JG20/JG27 — the person's "hide skeletons" choice, from their contributor row or else from their link. */ async getHideSkeletons(journeyId: number, userId: number): Promise { - const row = await this.qb('jc') - .select(['jc.hide_skeletons']) - .where({ journey: journeyId, user: userId }) - .execute<{ hide_skeletons: number } | undefined>('get', false); - return !!row?.hide_skeletons; + const direct = await this.directRow(journeyId, userId); + if (direct) return !!direct.hide_skeletons; + return !!(await readLinkHideSkeletons(this.kysely(), journeyId, userId)); } - /** JG26 — `updateJourneyPreferences`'s write: `UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?`. */ + /** JG26 — stores that choice where `getHideSkeletons` reads it: the contributor row, or else the person's links. */ async setHideSkeletons(journeyId: number, userId: number, value: number): Promise { - await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value }); + if (await this.directRow(journeyId, userId)) { + await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value }); + return; + } + const db = this.kysely(); + await db + .updateTable('trip_journal_link_members') + .set({ hide_skeletons: value }) + .where('user_id', '=', userId) + .where('trip_id', 'in', db.selectFrom('trip_journal_links').select('trip_id').where('journey_id', '=', journeyId)) + .execute(); } /** JG18 — `getJourneyFull`'s contributor list, joined to `users` for `username`/`avatar` (`avatar_url` is computed in the SERVICE, unchanged). */ diff --git a/server/src/db/repositories/Journeys.repository.ts b/server/src/db/repositories/Journeys.repository.ts index c1f504928..c8880b029 100644 --- a/server/src/db/repositories/Journeys.repository.ts +++ b/server/src/db/repositories/Journeys.repository.ts @@ -1,5 +1,6 @@ import type { Journeys } from '../entities/Journeys.entity'; import type { DB } from '../kysely/db'; +import { linkReach, type TripJournalLinkKyselyDB } from './TripJournalLinks.repository'; import { TrekRepository } from './_shared/trek-repository'; /** @@ -114,6 +115,12 @@ export class JourneysRepository extends TrekRepository { .where('journey_id', '=', id) .execute(); const ids = new Set(contributors.map((c) => c.user_id)); + // The trip members a trip's link lets in follow the journal live as well. + const linked = await linkReach(this.kysely()) + .select('m.user_id') + .where('l.journey_id', '=', id) + .execute(); + for (const l of linked) ids.add(l.user_id); const ownerId = await this.findOwnerId(id); if (ownerId !== undefined) ids.add(ownerId); return [...ids]; @@ -169,7 +176,18 @@ export class JourneysRepository extends TrekRepository { .whereRef('jt2.journey_id', '=', 'j.id') .as('trip_date_max'), ]) - .where((eb) => eb.or([eb('j.user_id', '=', user_id), eb('jc.user_id', '=', user_id)])) + // A journal a trip's link lets this person into is theirs to open here too. + .where((eb) => + eb.or([ + eb('j.user_id', '=', user_id), + eb('jc.user_id', '=', user_id), + eb( + 'j.id', + 'in', + linkReach(this.kysely()).select('l.journey_id').where('m.user_id', '=', user_id), + ), + ]), + ) .orderBy('j.updated_at', 'desc') .execute(); return rows as JourneyListRow[]; diff --git a/server/src/db/repositories/TripJournalLinkMembers.repository.ts b/server/src/db/repositories/TripJournalLinkMembers.repository.ts new file mode 100644 index 000000000..cba2ed01a --- /dev/null +++ b/server/src/db/repositories/TripJournalLinkMembers.repository.ts @@ -0,0 +1,4 @@ +import type { TripJournalLinkMembers } from '../entities/TripJournalLinkMembers.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class TripJournalLinkMembersRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TripJournalLinks.repository.ts b/server/src/db/repositories/TripJournalLinks.repository.ts new file mode 100644 index 000000000..b52241560 --- /dev/null +++ b/server/src/db/repositories/TripJournalLinks.repository.ts @@ -0,0 +1,227 @@ +import type { TripJournalLinks } from '../entities/TripJournalLinks.entity'; +import type { DB } from '../kysely/db'; +import { TrekRepository } from './_shared/trek-repository'; + +import type { Kysely } from 'kysely'; + +/** The tables a link's reach is read from. */ +export type TripJournalLinkKyselyDB = Pick< + DB, + 'trip_journal_links' | 'trip_journal_link_members' | 'trips' | 'trip_members' | 'journeys' | 'users' +>; + +export type TripJournalLinkRole = 'viewer' | 'editor'; + +/** A trip's link with the journal it shows, as the settings dialog reads it. */ +export interface TripJournalLinkRow { + trip_id: number; + journey_id: number; + linked_by: number | null; + created_at: number; + journey_title: string; + journey_subtitle: string | null; + journey_cover_image: string | null; + journey_owner_id: number; +} + +/** Someone on the trip, as the member picker lists them. */ +export interface TripPersonRow { + user_id: number; + username: string; + avatar: string | null; + is_guest: boolean; + is_trip_owner: boolean; +} + +/** One person the link lets into its journal. */ +export interface TripJournalLinkMemberRow { + user_id: number; + role: TripJournalLinkRole; + username: string; + avatar: string | null; +} + +/** + * The link members who still belong to the linking trip, as its owner or on its + * roster, with the link they hold. Every read of link access starts here, so + * leaving a trip ends what its link gave in the same instant, whether or not + * anything cleaned up after it. A caller narrows it by journal or by person and + * picks its own columns. + */ +export function linkReach(db: Kysely) { + return db + .selectFrom('trip_journal_links as l') + .innerJoin('trip_journal_link_members as m', 'm.trip_id', 'l.trip_id') + .innerJoin('trips as t', 't.id', 'l.trip_id') + .where((eb) => + eb.or([ + eb('t.user_id', '=', eb.ref('m.user_id')), + eb.exists( + eb + .selectFrom('trip_members as tm') + .select('tm.id') + .whereRef('tm.trip_id', '=', 'l.trip_id') + .whereRef('tm.user_id', '=', 'm.user_id'), + ), + ]), + ); +} + +/** The stronger of two link roles; a person linked through two trips gets the better one. */ +function strongest(roles: string[]): TripJournalLinkRole | null { + if (roles.includes('editor')) return 'editor'; + return roles.length > 0 ? 'viewer' : null; +} + +/** The role a person holds on a journal through any trip's link, or null when none lets them in. */ +export async function readLinkRole( + db: Kysely, + journeyId: number, + userId: number, +): Promise { + const rows = await linkReach(db) + .select('m.role') + .where('l.journey_id', '=', journeyId) + .where('m.user_id', '=', userId) + .execute(); + return strongest(rows.map((r) => r.role)); +} + +/** A link member's own "hide skeletons" choice on the journal, or null when no link lets them in. */ +export async function readLinkHideSkeletons( + db: Kysely, + journeyId: number, + userId: number, +): Promise { + const row = await linkReach(db) + .select('m.hide_skeletons') + .where('l.journey_id', '=', journeyId) + .where('m.user_id', '=', userId) + .executeTakeFirst(); + return row?.hide_skeletons ?? null; +} + +/** + * `trip_journal_links` and its members: which journal a trip shows, and who + * of the trip may read or write it through that link. The access this table + * gives is read through {@link linkReach} everywhere, never copied into + * `journey_contributors`, so a direct contributor's role is never overwritten + * and undoing a link takes away exactly what it gave. + */ +export class TripJournalLinksRepository extends TrekRepository { + /** The trip's link with its journal's title and cover, or null without one. */ + async findForTrip(tripId: number): Promise { + const row = await this.kysely() + .selectFrom('trip_journal_links as l') + .innerJoin('journeys as j', 'j.id', 'l.journey_id') + .select([ + 'l.trip_id', + 'l.journey_id', + 'l.linked_by', + 'l.created_at', + 'j.title as journey_title', + 'j.subtitle as journey_subtitle', + 'j.cover_image as journey_cover_image', + 'j.user_id as journey_owner_id', + ]) + .where('l.trip_id', '=', tripId) + .executeTakeFirst(); + return row ?? null; + } + + /** The people the trip's link lets in, still on the trip or not, with their name for the list. */ + async listMembers(tripId: number): Promise { + const rows = await this.kysely() + .selectFrom('trip_journal_link_members as m') + .innerJoin('users as u', 'u.id', 'm.user_id') + .select(['m.user_id', 'm.role', 'u.username', 'u.avatar']) + .where('m.trip_id', '=', tripId) + .orderBy('u.username', 'asc') + .execute(); + return rows.map((r) => ({ ...r, role: r.role === 'editor' ? 'editor' : 'viewer' })); + } + + /** Everyone on the trip, its owner first, with what the member picker shows of them. */ + async listTripPeople(tripId: number): Promise { + const db = this.kysely(); + const owner = await db + .selectFrom('trips as t') + .innerJoin('users as u', 'u.id', 't.user_id') + .select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest']) + .where('t.id', '=', tripId) + .executeTakeFirst(); + const members = await db + .selectFrom('trip_members as tm') + .innerJoin('users as u', 'u.id', 'tm.user_id') + .select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest']) + .where('tm.trip_id', '=', tripId) + .orderBy('u.username', 'asc') + .execute(); + const people = owner ? [{ ...owner, is_trip_owner: true }] : []; + for (const m of members) if (m.user_id !== owner?.user_id) people.push({ ...m, is_trip_owner: false }); + return people.map((p) => ({ ...p, is_guest: p.is_guest === 1 })); + } + + /** Points the trip at a journal, replacing the one it showed. */ + async upsertLink(tripId: number, journeyId: number, linkedBy: number, now: number): Promise { + await this.kysely() + .insertInto('trip_journal_links') + .values({ trip_id: tripId, journey_id: journeyId, linked_by: linkedBy, created_at: now }) + .onConflict((oc) => + oc.column('trip_id').doUpdateSet({ journey_id: journeyId, linked_by: linkedBy, created_at: now }), + ) + .execute(); + } + + /** Sets who the trip's link lets in, replacing the list it had. */ + async replaceMembers( + tripId: number, + members: { user_id: number; role: TripJournalLinkRole }[], + now: number, + ): Promise { + const db = this.kysely(); + await db.deleteFrom('trip_journal_link_members').where('trip_id', '=', tripId).execute(); + if (members.length === 0) return; + await db + .insertInto('trip_journal_link_members') + .values(members.map((m) => ({ trip_id: tripId, user_id: m.user_id, role: m.role, added_at: now }))) + .execute(); + } + + /** Removes the trip's link; its members go with it. */ + async deleteForTrip(tripId: number): Promise { + await this.kysely() + .deleteFrom('trip_journal_links') + .where('trip_id', '=', tripId) + .execute(); + } + + /** Takes one person off the trip's link, as when they leave the trip. */ + async deleteMember(tripId: number, userId: number): Promise { + await this.kysely() + .deleteFrom('trip_journal_link_members') + .where('trip_id', '=', tripId) + .where('user_id', '=', userId) + .execute(); + } + + /** Everyone a journal's links let in, for its live updates. */ + async linkRecipientUserIds(journeyId: number): Promise { + const rows = await linkReach(this.kysely()) + .select('m.user_id') + .distinct() + .where('l.journey_id', '=', journeyId) + .execute(); + return rows.map((r) => r.user_id); + } + + /** The trips that show this journal, for telling their members when it changes hands. */ + async listTripIdsForJourney(journeyId: number): Promise { + const rows = await this.kysely() + .selectFrom('trip_journal_links') + .select('trip_id') + .where('journey_id', '=', journeyId) + .execute(); + return rows.map((r) => r.trip_id); + } +} diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index e54520052..e32e3ee6f 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -52,6 +52,7 @@ import { AirtrailModule } from './integrations/airtrail.module'; import { DawarichModule } from './integrations/dawarich.module'; import { JourneyAccessModule } from './journey/journey-access.module'; import { JourneyModule } from './journey/journey.module'; +import { TripJournalLinkModule } from './journey/trip-journal-link.module'; import { LlmParseModule } from './llm-parse/llm-parse.module'; import { ManagedExtModule } from './managed/managed-ext.module'; import { MapsModule } from './maps/maps.module'; @@ -166,6 +167,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; AirtrailModule, DawarichModule, JourneyModule, + TripJournalLinkModule, CollectionsModule, ShareModule, TripInviteModule, diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index 3987d329a..d8a3485f0 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -178,7 +178,7 @@ export class JourneyDomainService { // `SELECT *` read this repository now serves. const own = await this.journeysRepo.findOwnedByUser(journeyId, userId); if (own) return own as Journey; - const isContributor = await this.contributorsRepo.existsForUser(journeyId, userId); + const isContributor = await this.contributorsRepo.hasAccess(journeyId, userId); if (isContributor) return ((await this.journeysRepo.findById(journeyId)) as Journey) || null; return null; } @@ -189,7 +189,7 @@ export class JourneyDomainService { async canEdit(journeyId: number, userId: number): Promise { if (await this.isOwner(journeyId, userId)) return true; - const role = await this.contributorsRepo.findRole(journeyId, userId); + const role = await this.contributorsRepo.findAccessRole(journeyId, userId); return role === 'editor' || role === 'owner'; } @@ -293,7 +293,7 @@ export class JourneyDomainService { myRole = 'owner'; } else { // JG21 — same text as JG7 (`findRole`). - const role = await this.contributorsRepo.findRole(journeyId, userId); + const role = await this.contributorsRepo.findAccessRole(journeyId, userId); myRole = (role as 'editor' | 'viewer' | undefined) ?? null; } diff --git a/server/src/nest/journey/trip-journal-link.controller.ts b/server/src/nest/journey/trip-journal-link.controller.ts new file mode 100644 index 000000000..6e18b0f5a --- /dev/null +++ b/server/src/nest/journey/trip-journal-link.controller.ts @@ -0,0 +1,70 @@ +import { ADDON_IDS } from '../../addons'; +import type { User } from '../../types'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { CurrentUser } from '../auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { TripJournalCandidatesQueryDto, TripJournalLinkDto, TripJournalLinkMembersDto } from './trip-journal-link.dto'; +import { TripJournalLinkService } from './trip-journal-link.service'; +import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Patch, Put, Query, UseGuards } from '@nestjs/common'; +import { tripJournalCandidatesResponseSchema, tripJournalLinkResponseSchema } from '@trek/shared'; + +/** + * The journal a trip shows, at `/api/trips/:tripId/journal`. Every member of + * the trip may read the link as it concerns them; changing it is decided in the + * service, because it turns on who owns the journal as well as on the trip + * permission. Behind the Journey addon: with it off the routes answer 404. + */ +@Controller('api/trips/:tripId/journal') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.JOURNEY, 'Journey') +export class TripJournalLinkController { + constructor(private readonly links: TripJournalLinkService) {} + + @Get() + @ResponseContract(tripJournalLinkResponseSchema) + async get(@Param('tripId') tripId: string, @CurrentUser() user: User) { + return this.links.get(Number(tripId), user); + } + + @Get('candidates') + @ResponseContract(tripJournalCandidatesResponseSchema) + async candidates( + @Param('tripId') tripId: string, + @CurrentUser() user: User, + @Query() query: TripJournalCandidatesQueryDto, + ) { + return { candidates: await this.links.candidates(Number(tripId), user, query.journey_id) }; + } + + @Put() + @ResponseContract(tripJournalLinkResponseSchema) + async link( + @Param('tripId') tripId: string, + @CurrentUser() user: User, + @Body() body: TripJournalLinkDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.links.link(Number(tripId), user, body.journey_id, body.members, socketId); + } + + @Patch('members') + @ResponseContract(tripJournalLinkResponseSchema) + async setMembers( + @Param('tripId') tripId: string, + @CurrentUser() user: User, + @Body() body: TripJournalLinkMembersDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.links.setMembers(Number(tripId), user, body.members, socketId); + } + + @Delete() + @HttpCode(200) + @ResponseContract(tripJournalLinkResponseSchema) + async unlink(@Param('tripId') tripId: string, @CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) { + return this.links.unlink(Number(tripId), user, socketId); + } +} diff --git a/server/src/nest/journey/trip-journal-link.dto.ts b/server/src/nest/journey/trip-journal-link.dto.ts new file mode 100644 index 000000000..0f6659ba6 --- /dev/null +++ b/server/src/nest/journey/trip-journal-link.dto.ts @@ -0,0 +1,11 @@ +import { + tripJournalCandidatesQuerySchema, + tripJournalLinkMembersRequestSchema, + tripJournalLinkRequestSchema, +} from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class TripJournalLinkDto extends createZodDto(tripJournalLinkRequestSchema) {} +export class TripJournalLinkMembersDto extends createZodDto(tripJournalLinkMembersRequestSchema) {} +export class TripJournalCandidatesQueryDto extends createZodDto(tripJournalCandidatesQuerySchema) {} diff --git a/server/src/nest/journey/trip-journal-link.mcp.ts b/server/src/nest/journey/trip-journal-link.mcp.ts new file mode 100644 index 000000000..d8abe6999 --- /dev/null +++ b/server/src/nest/journey/trip-journal-link.mcp.ts @@ -0,0 +1,97 @@ +import { ADDON_IDS } from '../../addons'; +import { + McpController, + TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_READONLY, + TOOL_ANNOTATIONS_WRITE, + Tool, + ok, + type McpContext, +} from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; +import { TripJournalLinkService, type TripJournalActor } from './trip-journal-link.service'; +import { idSchema, tripJournalLinkRequestSchema } from '@trek/shared'; + +const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY); + +/** + * The trip's linked journal over MCP: the same service, the same permission and + * journal-owner rules as `/api/trips/:tripId/journal`, behind the Journey addon. + */ +@McpController() +export class TripJournalLinkMcp { + constructor( + private readonly links: TripJournalLinkService, + private readonly guards: McpToolGuardsService, + readonly addons: AddonsService, + ) {} + + private async actor(tripId: number, userId: number): Promise { + const { role } = await this.guards.tripWriter(tripId, userId); + return { id: userId, role }; + } + + @Tool({ + name: 'get_trip_journal', + description: + 'The journal from the Journey addon a trip shows as its own, if any: its id and title, your access to it, and whether you may link, unlink or change who it is open to. Open the journal itself with get_journey.', + inputSchema: { tripId: idSchema }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: journeyAddonOn, + access: { group: 'journey', mode: 'read' }, + }) + async getTripJournal({ tripId }: { tripId: number }, ctx: McpContext) { + return ok(await this.links.get(tripId, await this.actor(tripId, ctx.userId))); + } + + @Tool({ + name: 'link_trip_journal', + description: + 'Show one of your own journals in a trip, replacing the journal it showed. The trip members listed get into the journal as viewers or editors and see it in their own journal list; members left out do not. Needs the journal to be yours and the trip permission to link journals.', + inputSchema: { tripId: idSchema, journey_id: idSchema, members: tripJournalLinkRequestSchema.shape.members }, + annotations: TOOL_ANNOTATIONS_WRITE, + when: journeyAddonOn, + access: { group: 'journey', mode: 'write' }, + }) + async linkTripJournal( + { + tripId, + journey_id, + members, + }: { tripId: number; journey_id: number; members: { user_id: number; role: 'viewer' | 'editor' }[] }, + ctx: McpContext, + ) { + return ok(await this.links.link(tripId, await this.actor(tripId, ctx.userId), journey_id, members)); + } + + @Tool({ + name: 'set_trip_journal_members', + description: + "Change which trip members the trip's linked journal is open to, and as viewer or editor. The list replaces the one before; anyone left out loses the access the link gave them. Only the journal's owner can do this.", + inputSchema: { tripId: idSchema, members: tripJournalLinkRequestSchema.shape.members }, + annotations: TOOL_ANNOTATIONS_WRITE, + when: journeyAddonOn, + access: { group: 'journey', mode: 'write' }, + }) + async setTripJournalMembers( + { tripId, members }: { tripId: number; members: { user_id: number; role: 'viewer' | 'editor' }[] }, + ctx: McpContext, + ) { + return ok(await this.links.setMembers(tripId, await this.actor(tripId, ctx.userId), members)); + } + + @Tool({ + name: 'unlink_trip_journal', + description: + 'Take the linked journal off a trip. The journal itself stays; the members the link let in lose that access, while its owner and direct contributors keep theirs.', + inputSchema: { tripId: idSchema }, + annotations: TOOL_ANNOTATIONS_DELETE, + when: journeyAddonOn, + access: { group: 'journey', mode: 'write' }, + }) + async unlinkTripJournal({ tripId }: { tripId: number }, ctx: McpContext) { + return ok(await this.links.unlink(tripId, await this.actor(tripId, ctx.userId))); + } +} diff --git a/server/src/nest/journey/trip-journal-link.module.ts b/server/src/nest/journey/trip-journal-link.module.ts new file mode 100644 index 000000000..0d685f0fa --- /dev/null +++ b/server/src/nest/journey/trip-journal-link.module.ts @@ -0,0 +1,37 @@ +import { JourneyContributors } from '../../db/entities/JourneyContributors.entity'; +import { TripJournalLinks } from '../../db/entities/TripJournalLinks.entity'; +import { AddonsModule } from '../addons/addons.module'; +import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { RealtimeModule } from '../realtime/realtime.module'; +import { TripMembershipModule } from '../trip-membership/trip-membership.module'; +import { JourneyDomainModule } from './journey-domain.module'; +import { TripJournalLinkController } from './trip-journal-link.controller'; +import { TripJournalLinkMcp } from './trip-journal-link.mcp'; +import { TripJournalLinkService } from './trip-journal-link.service'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; + +/** + * A trip showing one journal from the Journey addon: the link, its routes and + * its MCP tools. Its own module rather than part of JourneyModule, because the + * trip roster needs `TripJournalLinkService.dropMember` when someone leaves, + * and TripMembersModule must not pull in the whole journey surface (uploads, + * memories, both photo providers) for that. It imports nothing from the trip + * roster in return, so the edge runs one way. + */ +@Module({ + imports: [ + JourneyDomainModule, + TripMembershipModule, + PermissionsModule, + RealtimeModule, + AddonsModule, + McpSharedModule, + MikroOrmModule.forFeature([TripJournalLinks, JourneyContributors]), + ], + controllers: [TripJournalLinkController], + providers: [TripJournalLinkService, TripJournalLinkMcp], + exports: [TripJournalLinkService], +}) +export class TripJournalLinkModule {} diff --git a/server/src/nest/journey/trip-journal-link.service.ts b/server/src/nest/journey/trip-journal-link.service.ts new file mode 100644 index 000000000..b93ba4407 --- /dev/null +++ b/server/src/nest/journey/trip-journal-link.service.ts @@ -0,0 +1,218 @@ +import { JourneyContributors } from '../../db/entities/JourneyContributors.entity'; +import { TripJournalLinks } from '../../db/entities/TripJournalLinks.entity'; +import type { JourneyContributorsRepository } from '../../db/repositories/JourneyContributors.repository'; +import type { TripJournalLinksRepository, TripPersonRow } from '../../db/repositories/TripJournalLinks.repository'; +import { avatarUrl } from '../common/avatarUrl'; +import { forbidden, notFound } from '../common/domain-error'; +import { UnitOfWork } from '../database/unit-of-work'; +import { PermissionsService } from '../permissions/permissions.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { TripAccessService } from '../trip-membership/trip-access.service'; +import { JourneyDomainService } from './journey-domain.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import type { + TripJournalAccessRole, + TripJournalCandidate, + TripJournalLinkMemberRequest, + TripJournalLinkResponse, +} from '@trek/shared'; + +/** Who is acting: their id, and their account role for the admin bypass of trip permissions. */ +export interface TripJournalActor { + id: number; + role: string; +} + +const NOT_LINKED = 'No journal is linked to this trip'; + +/** + * A trip that shows one journal from the Journey addon as its own. + * + * Linking opens the journal to the trip members chosen for it, as viewers or + * editors, for as long as the link holds them and they stay on the trip. That + * access is read wherever journal access is read (`JourneyContributorsRepository` + * folds it in), so the journal's own screens, its live updates and its MCP tools + * serve linked members without knowing about trips. + * + * Linking a journal, or changing who it is open to, hands someone else's access + * to it, so it takes the journal's owner, and on the trip the `journal_link` + * permission. Taking the link down only takes access away, so the permission + * alone or the journal's owner alone is enough. + */ +@Injectable() +export class TripJournalLinkService { + constructor( + @InjectRepository(TripJournalLinks) private readonly links: TripJournalLinksRepository, + @InjectRepository(JourneyContributors) private readonly contributors: JourneyContributorsRepository, + private readonly journeys: JourneyDomainService, + private readonly tripAccess: TripAccessService, + private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, + private readonly uow: UnitOfWork, + ) {} + + /** The trip, or a 404 for someone who is not on it. */ + private async requireTrip(tripId: number, actor: TripJournalActor) { + const trip = await this.tripAccess.findAccessible(tripId, actor.id); + if (!trip) throw notFound('Trip not found'); + return trip; + } + + private async mayLink(trip: { user_id: number }, actor: TripJournalActor): Promise { + return this.permissions.checkPermission( + 'journal_link', + actor.role, + trip.user_id, + actor.id, + trip.user_id !== actor.id, + ); + } + + /** The access someone holds on a journal today, as owner, contributor or through a link. */ + private async accessOf(journeyId: number, ownerId: number, userId: number): Promise { + if (userId === ownerId) return 'owner'; + const role = await this.contributors.findAccessRole(journeyId, userId); + return role === 'owner' || role === 'editor' || role === 'viewer' ? role : null; + } + + /** The trip's journal as this person sees it, and what they may do with it. */ + async get(tripId: number, actor: TripJournalActor): Promise { + const trip = await this.requireTrip(tripId, actor); + const canLink = await this.mayLink(trip, actor); + const row = await this.links.findForTrip(tripId); + if (!row) return { link: null, can_link: canLink, can_unlink: false, can_edit_members: false }; + + const isJournalOwner = row.journey_owner_id === actor.id; + const myRole = await this.accessOf(row.journey_id, row.journey_owner_id, actor.id); + const canEditMembers = canLink && isJournalOwner; + const canUnlink = canLink || isJournalOwner; + // Someone the journal is closed to, and who can do nothing about the link, + // does not see that the trip has one. + if (!myRole && !canLink) return { link: null, can_link: false, can_unlink: false, can_edit_members: false }; + + const members = canLink || isJournalOwner ? await this.links.listMembers(tripId) : null; + return { + link: { + journey_id: row.journey_id, + title: row.journey_title, + subtitle: row.journey_subtitle, + cover_image: row.journey_cover_image, + owner_id: row.journey_owner_id, + linked_by: row.linked_by, + created_at: row.created_at, + my_role: myRole, + members: + members?.map((m) => ({ user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), role: m.role })) ?? + null, + }, + can_link: canLink, + can_unlink: canUnlink, + can_edit_members: canEditMembers, + }; + } + + /** + * The people of the trip who could be let into a journal, with the access they + * hold there today. A null `access` is someone the link would let in for the + * first time, the person the dialog warns about. Only for the journal's owner. + */ + async candidates(tripId: number, actor: TripJournalActor, journeyId: number): Promise { + await this.requireTrip(tripId, actor); + if (!(await this.journeys.isOwner(journeyId, actor.id))) throw forbidden('Only the journal owner can link it'); + const people = (await this.links.listTripPeople(tripId)).filter((p) => !p.is_guest && p.user_id !== actor.id); + return await Promise.all( + people.map(async (p) => ({ + user_id: p.user_id, + username: p.username, + avatar_url: avatarUrl(p), + is_trip_owner: p.is_trip_owner, + access: await this.accessOf(journeyId, actor.id, p.user_id), + })), + ); + } + + /** + * The members a link may hold: people on the trip, never a guest (who cannot + * sign in) and never the journal's owner (who has it anyway). A repeated id + * keeps its last role. + */ + private allowedMembers( + people: TripPersonRow[], + ownerId: number, + members: TripJournalLinkMemberRequest[], + ): TripJournalLinkMemberRequest[] { + const onTrip = new Set(people.filter((p) => !p.is_guest).map((p) => p.user_id)); + const byUser = new Map(); + for (const m of members) { + if (m.user_id === ownerId) continue; + if (!onTrip.has(m.user_id)) throw forbidden('Only members of this trip can be given the journal'); + byUser.set(m.user_id, m); + } + return [...byUser.values()]; + } + + /** Shows a journal in the trip, to these members, replacing the one it showed. */ + async link( + tripId: number, + actor: TripJournalActor, + journeyId: number, + members: TripJournalLinkMemberRequest[], + socketId?: string, + ): Promise { + const trip = await this.requireTrip(tripId, actor); + if (!(await this.mayLink(trip, actor))) throw forbidden('No permission to link a journal'); + if (!(await this.journeys.isOwner(journeyId, actor.id))) throw forbidden('Only the journal owner can link it'); + const allowed = this.allowedMembers(await this.links.listTripPeople(tripId), actor.id, members); + const now = Date.now(); + await this.uow.transactional(async () => { + await this.links.upsertLink(tripId, journeyId, actor.id, now); + await this.links.replaceMembers(tripId, allowed, now); + }); + this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId); + return this.get(tripId, actor); + } + + /** Changes who the linked journal is open to. */ + async setMembers( + tripId: number, + actor: TripJournalActor, + members: TripJournalLinkMemberRequest[], + socketId?: string, + ): Promise { + const trip = await this.requireTrip(tripId, actor); + const row = await this.links.findForTrip(tripId); + if (!row) throw notFound(NOT_LINKED); + if (!(await this.mayLink(trip, actor)) || row.journey_owner_id !== actor.id) { + throw forbidden('Only the journal owner can change who it is open to'); + } + const allowed = this.allowedMembers(await this.links.listTripPeople(tripId), row.journey_owner_id, members); + await this.uow.transactional(async () => { + await this.links.replaceMembers(tripId, allowed, Date.now()); + }); + this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId); + return this.get(tripId, actor); + } + + /** Takes the journal off the trip; everyone it let in loses that access. */ + async unlink(tripId: number, actor: TripJournalActor, socketId?: string): Promise { + const trip = await this.requireTrip(tripId, actor); + const row = await this.links.findForTrip(tripId); + if (!row) throw notFound(NOT_LINKED); + if (!(await this.mayLink(trip, actor)) && row.journey_owner_id !== actor.id) { + throw forbidden('No permission to unlink the journal'); + } + await this.links.deleteForTrip(tripId); + this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId); + return this.get(tripId, actor); + } + + /** + * Someone left the trip: their place on its link goes too, so coming back + * later does not quietly reopen the journal. Their access already ended with + * the membership; this only tidies the row. Runs in the caller's transaction. + */ + async dropMember(tripId: number, userId: number): Promise { + await this.links.deleteMember(tripId, userId); + } +} diff --git a/server/src/nest/memories/memories-access.service.ts b/server/src/nest/memories/memories-access.service.ts index e828cf879..4ab4bc136 100644 --- a/server/src/nest/memories/memories-access.service.ts +++ b/server/src/nest/memories/memories-access.service.ts @@ -57,7 +57,7 @@ export class MemoriesAccessService { /** MA2's owner-OR-contributor check, shared by MA2 and MA6's per-journey loop — the same two-branch `journeys`/`journey_contributors` logic `canAccessJourney` uses, re-implemented here (see the class docstring on why it isn't a call to `JourneyDomainService`). */ private async ownerOrContributor(journeyId: number, userId: number): Promise { if (await this.journeys.isOwnedByUser(journeyId, userId)) return true; - return await this.journeyContributors.existsForUser(journeyId, userId); + return await this.journeyContributors.hasAccess(journeyId, userId); } async canAccessUserPhoto( diff --git a/server/src/nest/trip-members/trip-members.module.ts b/server/src/nest/trip-members/trip-members.module.ts index 0d80157a9..db6a3c5ac 100644 --- a/server/src/nest/trip-members/trip-members.module.ts +++ b/server/src/nest/trip-members/trip-members.module.ts @@ -4,6 +4,7 @@ import { Users } from '../../db/entities/Users.entity'; import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; import { BudgetModule } from '../budget/budget.module'; +import { TripJournalLinkModule } from '../journey/trip-journal-link.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { RealtimeModule } from '../realtime/realtime.module'; @@ -34,6 +35,7 @@ import { Module } from '@nestjs/common'; AuditModule, AuthModule, BudgetModule, + TripJournalLinkModule, MikroOrmModule.forFeature([Trips, TripMembers, Users]), ], controllers: [TripMembersController], diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index b224f7d15..1655a9a6d 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -11,6 +11,7 @@ import { BudgetService } from '../budget/budget.service'; import { avatarUrl } from '../common/avatarUrl'; import { NotFoundError, ValidationError } from '../common/domain-errors'; import { UnitOfWork } from '../database/unit-of-work'; +import { TripJournalLinkService } from '../journey/trip-journal-link.service'; import { NotificationsService } from '../notifications/notifications.service'; import { PermissionsService } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; @@ -79,6 +80,7 @@ export class TripMembersService { private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, private readonly notifications: NotificationsService, + private readonly journalLinks: TripJournalLinkService, private readonly uow: UnitOfWork, @InjectRepository(Trips) private readonly tripsRepo: TripsRepository, @InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository, @@ -197,7 +199,12 @@ export class TripMembersService { * the route's one remaining raw-string caller). */ async removeMember(tripId: number, targetUserId: number): Promise { - await this.tripMembersRepo.remove(tripId, targetUserId); + // A linked journal stops being open to someone who left; coming back later + // must not quietly reopen it. + await this.uow.transactional(async () => { + await this.tripMembersRepo.remove(tripId, targetUserId); + await this.journalLinks.dropMember(tripId, targetUserId); + }); } /** diff --git a/server/tests/integration/trip-journal-link.test.ts b/server/tests/integration/trip-journal-link.test.ts new file mode 100644 index 000000000..9a8b487f4 --- /dev/null +++ b/server/tests/integration/trip-journal-link.test.ts @@ -0,0 +1,341 @@ +import { buildApp } from '../../src/bootstrap'; +import { withRequestContext } from '../../src/nest/database/request-context'; +import { TripJournalLinkMcp } from '../../src/nest/journey/trip-journal-link.mcp'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { authCookie } from '../helpers/auth'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys'; +import { setAddonEnabled } from '../helpers/factories/settings'; +import { addTripMember, makeTrip } from '../helpers/factories/trips'; +import { makeUser } from '../helpers/factories/users'; +import { MikroORM } from '@mikro-orm/core'; +import type { INestApplication } from '@nestjs/common'; + +import request from 'supertest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; + +/** + * A trip that shows one journal from the Journey addon (TJL-*). + * + * The link opens the journal to the trip members chosen for it, as viewers or + * editors, and that access is read wherever journal access is read. So these + * cases go through the real routes end to end: the link's own routes, and the + * journal's existing ones a linked member then reaches. + */ + +let app: INestApplication; +let orm: FactoryOrm; +let broadcast: ReturnType; + +beforeAll(async () => { + app = await buildApp(); + await app.init(); + orm = app.get(MikroORM); + broadcast = vi.spyOn(app.get(RealtimeService), 'broadcast'); +}); + +afterAll(async () => { + await app?.close(); +}); + +beforeEach(async () => { + await setAddonEnabled(orm, 'journey', true); + broadcast.mockClear(); +}); + +const http = () => request(app.getHttpServer()); + +/** A trip owned by `owner` with three members, and a journal of the owner's. */ +async function scene() { + const { user: owner } = await makeUser(orm, { username: 'Owner' }); + const { user: lena } = await makeUser(orm, { username: 'Lena' }); + const { user: tom } = await makeUser(orm, { username: 'Tom' }); + const { user: mia } = await makeUser(orm, { username: 'Mia' }); + const { user: stranger } = await makeUser(orm, { username: 'Stranger' }); + const trip = await makeTrip(orm, owner.id); + for (const m of [lena, tom, mia]) await addTripMember(orm, trip.id, m.id); + const journal = await makeJourney(orm, owner.id, { title: 'New York, the long weekend' }); + return { owner, lena, tom, mia, stranger, trip, journal }; +} + +async function link(s: Awaited>, members: { user_id: number; role: 'viewer' | 'editor' }[]) { + return http() + .put(`/api/trips/${s.trip.id}/journal`) + .set('Cookie', authCookie(s.owner.id)) + .send({ journey_id: s.journal.id, members }); +} + +describe('linking a journal to a trip', () => { + it('TJL-001: a trip without a link says so, and its owner may link one', async () => { + const s = await scene(); + const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ link: null, can_link: true, can_unlink: false, can_edit_members: false }); + }); + + it('TJL-002: links the journal, opens it to the chosen members and tells the trip', async () => { + const s = await scene(); + const res = await link(s, [ + { user_id: s.lena.id, role: 'viewer' }, + { user_id: s.tom.id, role: 'editor' }, + ]); + expect(res.status).toBe(200); + expect(res.body.link).toMatchObject({ + journey_id: s.journal.id, + title: 'New York, the long weekend', + my_role: 'owner', + }); + expect(res.body.link.members.map((m: { username: string; role: string }) => [m.username, m.role])).toEqual([ + ['Lena', 'viewer'], + ['Tom', 'editor'], + ]); + expect(res.body).toMatchObject({ can_link: true, can_unlink: true, can_edit_members: true }); + expect(broadcast).toHaveBeenCalledWith(s.trip.id, 'trip:journal:changed', {}, undefined); + }); + + it('TJL-003: a viewer reads the journal, finds it in their list, and cannot write to it', async () => { + const s = await scene(); + await link(s, [{ user_id: s.lena.id, role: 'viewer' }]); + const cookie = authCookie(s.lena.id); + + const seen = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie); + expect(seen.body.link).toMatchObject({ journey_id: s.journal.id, my_role: 'viewer', members: null }); + expect(seen.body).toMatchObject({ can_link: false, can_unlink: false, can_edit_members: false }); + + const journal = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie); + expect(journal.status).toBe(200); + expect(journal.body.my_role).toBe('viewer'); + + const list = await http().get('/api/journeys').set('Cookie', cookie); + expect(list.body.journeys.map((j: { id: number }) => j.id)).toContain(s.journal.id); + + const write = await http() + .post(`/api/journeys/${s.journal.id}/entries`) + .set('Cookie', cookie) + .send({ title: 'not mine to write', entry_date: '2026-05-01' }); + expect(write.status).toBe(404); + }); + + it('TJL-004: an editor writes to the journal', async () => { + const s = await scene(); + await link(s, [{ user_id: s.tom.id, role: 'editor' }]); + const res = await http() + .post(`/api/journeys/${s.journal.id}/entries`) + .set('Cookie', authCookie(s.tom.id)) + .send({ title: 'Brooklyn Bridge at dawn', entry_date: '2026-05-01' }); + expect(res.status).toBe(201); + }); + + it('TJL-005: a member left out sees no journal in the trip and cannot open it', async () => { + const s = await scene(); + await link(s, [{ user_id: s.lena.id, role: 'viewer' }]); + const cookie = authCookie(s.mia.id); + const seen = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie); + expect(seen.body).toEqual({ link: null, can_link: false, can_unlink: false, can_edit_members: false }); + expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie)).status).toBe(404); + }); + + it('TJL-006: someone outside the trip gets a 404 for its journal', async () => { + const s = await scene(); + const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.stranger.id)); + expect(res.status).toBe(404); + }); + + it('TJL-007: refuses a member without the permission, a journal not theirs, and people off the trip', async () => { + const s = await scene(); + const asLena = await http() + .put(`/api/trips/${s.trip.id}/journal`) + .set('Cookie', authCookie(s.lena.id)) + .send({ journey_id: s.journal.id, members: [] }); + expect(asLena.status).toBe(403); + + const other = await makeJourney(orm, s.lena.id); + const notHers = await http() + .put(`/api/trips/${s.trip.id}/journal`) + .set('Cookie', authCookie(s.owner.id)) + .send({ journey_id: other.id, members: [] }); + expect(notHers.status).toBe(403); + expect(notHers.body.error).toBe('Only the journal owner can link it'); + + const offTrip = await link(s, [{ user_id: s.stranger.id, role: 'viewer' }]); + expect(offTrip.status).toBe(403); + expect(offTrip.body.error).toBe('Only members of this trip can be given the journal'); + }); + + it('TJL-008: never lets a guest in, and leaves the journal owner off the list', async () => { + const s = await scene(); + const { user: guest } = await makeUser(orm, { is_guest: 1 }); + await addTripMember(orm, s.trip.id, guest.id); + const withGuest = await link(s, [{ user_id: guest.id, role: 'viewer' }]); + expect(withGuest.status).toBe(403); + + const withOwner = await link(s, [ + { user_id: s.owner.id, role: 'viewer' }, + { user_id: s.lena.id, role: 'viewer' }, + { user_id: s.lena.id, role: 'editor' }, + ]); + expect(withOwner.body.link.members.map((m: { user_id: number; role: string }) => [m.user_id, m.role])).toEqual([ + [s.lena.id, 'editor'], + ]); + }); + + it('TJL-009: the owner may only link a journal they own, even with the admin permission set wide', async () => { + const s = await scene(); + const lenaJournal = await makeJourney(orm, s.lena.id); + const res = await http() + .get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=${lenaJournal.id}`) + .set('Cookie', authCookie(s.owner.id)); + expect(res.status).toBe(403); + }); +}); + +describe('who a journal would be opened to', () => { + it('TJL-010: lists the trip people but the caller and guests, with the access they hold today', async () => { + const s = await scene(); + await addJourneyContributor(orm, s.journal.id, s.tom.id, 'editor'); + const res = await http() + .get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=${s.journal.id}`) + .set('Cookie', authCookie(s.owner.id)); + expect(res.status).toBe(200); + const byName = Object.fromEntries( + res.body.candidates.map((c: { username: string; access: string | null }) => [c.username, c.access]), + ); + expect(byName).toEqual({ Lena: null, Mia: null, Tom: 'editor' }); + }); + + it('TJL-011: refuses a journey id that is not a number', async () => { + const s = await scene(); + const res = await http() + .get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=abc`) + .set('Cookie', authCookie(s.owner.id)); + expect(res.status).toBe(400); + }); +}); + +describe('changing and ending a link', () => { + it('TJL-012: changing the members takes the access of whoever was left out', async () => { + const s = await scene(); + await link(s, [ + { user_id: s.lena.id, role: 'viewer' }, + { user_id: s.tom.id, role: 'editor' }, + ]); + const res = await http() + .patch(`/api/trips/${s.trip.id}/journal/members`) + .set('Cookie', authCookie(s.owner.id)) + .send({ members: [{ user_id: s.tom.id, role: 'viewer' }] }); + expect(res.status).toBe(200); + expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404); + const tom = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.tom.id)); + expect(tom.body.my_role).toBe('viewer'); + }); + + it('TJL-013: only the journal owner may change who it is open to', async () => { + const s = await scene(); + await link(s, []); + const res = await http() + .patch(`/api/trips/${s.trip.id}/journal/members`) + .set('Cookie', authCookie(s.lena.id)) + .send({ members: [{ user_id: s.mia.id, role: 'editor' }] }); + expect(res.status).toBe(403); + }); + + it('TJL-014: changing members or unlinking a trip without a link answers 404', async () => { + const s = await scene(); + const cookie = authCookie(s.owner.id); + expect( + (await http().patch(`/api/trips/${s.trip.id}/journal/members`).set('Cookie', cookie).send({ members: [] })) + .status, + ).toBe(404); + expect((await http().delete(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie)).status).toBe(404); + }); + + it('TJL-015: unlinking takes what the link gave and keeps what was given directly', async () => { + const s = await scene(); + await addJourneyContributor(orm, s.journal.id, s.mia.id, 'viewer'); + await link(s, [ + { user_id: s.lena.id, role: 'viewer' }, + { user_id: s.mia.id, role: 'editor' }, + ]); + const mia = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.mia.id)); + expect(mia.body.my_role).toBe('editor'); + + const res = await http().delete(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id)); + expect(res.status).toBe(200); + expect(res.body.link).toBeNull(); + expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404); + const after = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.mia.id)); + expect(after.body.my_role).toBe('viewer'); + }); + + it('TJL-016: a member who leaves the trip loses the journal, and coming back does not reopen it', async () => { + const s = await scene(); + await link(s, [{ user_id: s.lena.id, role: 'viewer' }]); + const left = await http() + .delete(`/api/trips/${s.trip.id}/members/${s.lena.id}`) + .set('Cookie', authCookie(s.lena.id)); + expect(left.status).toBe(200); + expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404); + + await addTripMember(orm, s.trip.id, s.lena.id); + expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404); + }); + + it('TJL-017: a link member keeps a "hide skeletons" choice of their own', async () => { + const s = await scene(); + await link(s, [{ user_id: s.lena.id, role: 'viewer' }]); + const cookie = authCookie(s.lena.id); + const set = await http() + .patch(`/api/journeys/${s.journal.id}/preferences`) + .set('Cookie', cookie) + .send({ hide_skeletons: true }); + expect(set.status).toBe(200); + const journal = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie); + expect(journal.body.hide_skeletons).toBe(true); + const owner = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.owner.id)); + expect(owner.body.hide_skeletons).toBe(false); + }); + + it('TJL-018: with the Journey addon off the routes answer 404', async () => { + const s = await scene(); + await setAddonEnabled(orm, 'journey', false); + const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id)); + expect(res.status).toBe(404); + }); +}); + +describe('the same over MCP', () => { + it('TJL-019: links, reads, changes and unlinks with the rules of the routes', async () => { + const s = await scene(); + const mcp = app.get(TripJournalLinkMcp); + const ctx = (userId: number) => ({ userId }) as Parameters[1]; + const json = (r: { content: { text: string }[] }) => JSON.parse(r.content[0].text); + const run = (fn: () => Promise) => withRequestContext(orm as MikroORM, fn); + + const linked = json( + await run(() => + mcp.linkTripJournal( + { tripId: s.trip.id, journey_id: s.journal.id, members: [{ user_id: s.lena.id, role: 'viewer' }] }, + ctx(s.owner.id), + ), + ), + ); + expect(linked.link.members).toHaveLength(1); + expect(json(await run(() => mcp.getTripJournal({ tripId: s.trip.id }, ctx(s.lena.id)))).link.my_role).toBe( + 'viewer', + ); + + await expect( + run(() => mcp.setTripJournalMembers({ tripId: s.trip.id, members: [] }, ctx(s.lena.id))), + ).rejects.toMatchObject({ status: 403 }); + const changed = json( + await run(() => + mcp.setTripJournalMembers( + { tripId: s.trip.id, members: [{ user_id: s.tom.id, role: 'editor' }] }, + ctx(s.owner.id), + ), + ), + ); + expect(changed.link.members.map((m: { user_id: number }) => m.user_id)).toEqual([s.tom.id]); + expect(json(await run(() => mcp.unlinkTripJournal({ tripId: s.trip.id }, ctx(s.owner.id)))).link).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/memories-access.service.test.ts b/server/tests/unit/nest/memories-access.service.test.ts index 63fbff836..7dfb1aaff 100644 --- a/server/tests/unit/nest/memories-access.service.test.ts +++ b/server/tests/unit/nest/memories-access.service.test.ts @@ -655,7 +655,7 @@ describe('canAccessTrekPhoto — MA5 parity (TripsRepository.findAccessible rewr // --------------------------------------------------------------------------- // MA1/MA2/MA6 — the journey half of the cross-domain photo-access checks // (Plan 3g Task 4), onto `JourneysRepository.isOwnedByUser`/ -// `JourneyContributorsRepository.existsForUser` via the private +// `JourneyContributorsRepository.hasAccess` via the private // `ownerOrContributor` helper. MEM-ACCESS-006/006b/006c/014/014b above // already cover owner (006b), editor (006/014) and viewer (006c/014b) // contributors passing, and MEM-ACCESS-007/008/016 cover a stranger/no-link @@ -665,9 +665,9 @@ describe('canAccessTrekPhoto — MA5 parity (TripsRepository.findAccessible rewr // Mutation proof (performed once by hand, not run in CI): editing // `MemoriesAccessService.ownerOrContributor` from // `if (await this.journeys.isOwnedByUser(...)) return true; return await -// this.journeyContributors.existsForUser(...);` +// this.journeyContributors.hasAccess(...);` // to an AND (`return (await this.journeys.isOwnedByUser(...)) && -// (await this.journeyContributors.existsForUser(...));`) — collapsing the +// (await this.journeyContributors.hasAccess(...));`) — collapsing the // owner-OR-contributor union into an owner-AND-contributor intersection — // turns MEM-ACCESS-006 (an editor who is NOT also the owner) and // MEM-ACCESS-006c (a viewer who is NOT also the owner) red: both contributors diff --git a/shared/src/i18n/ar/perm.ts b/shared/src/i18n/ar/perm.ts index 1864fc583..db6e48b1b 100644 --- a/shared/src/i18n/ar/perm.ts +++ b/shared/src/i18n/ar/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'التعاون (ملاحظات، استطلاعات، دردشة)', 'perm.action.share_manage': 'إدارة روابط المشاركة', 'perm.actionHint.trip_create': 'من يمكنه إنشاء رحلات جديدة', + 'perm.action.journal_link': 'ربط يوميات', 'perm.actionHint.trip_edit': 'من يمكنه تغيير اسم الرحلة والتواريخ والوصف والعملة', 'perm.actionHint.trip_delete': 'من يمكنه حذف رحلة نهائياً', 'perm.actionHint.trip_archive': 'من يمكنه أرشفة أو إلغاء أرشفة رحلة', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب', 'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل', 'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة', + 'perm.actionHint.journal_link': 'من يمكنه ربط يومية من Journey بالرحلة وفتحها لأعضائها', }; export default perm; diff --git a/shared/src/i18n/az/perm.ts b/shared/src/i18n/az/perm.ts index 3126e39cc..419fd351a 100644 --- a/shared/src/i18n/az/perm.ts +++ b/shared/src/i18n/az/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Əməkdaşlıq (qeydlər, sorğular və çat)', 'perm.action.share_manage': 'Paylaşma keçidlərini idarə etmək', 'perm.actionHint.trip_create': 'Yeni səyahətləri kimin yarada biləcəyi', + 'perm.action.journal_link': 'Jurnal bağlamaq', 'perm.actionHint.trip_edit': 'Səyahətin adını, tarixlərini, təsvirini və valyutasını kimin dəyişə biləcəyi', 'perm.actionHint.trip_delete': 'Səyahəti kimin həmişəlik silə biləcəyi', 'perm.actionHint.trip_archive': 'Səyahəti kimin arxivləşdirə və ya arxivdən çıxara biləcəyi', @@ -47,6 +48,7 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Baqaj elementlərini və çantaları kimin idarə edə biləcəyi', 'perm.actionHint.collab_edit': 'Qeydləri və sorğuları kimin yarada və mesajları kimin göndərə biləcəyi', 'perm.actionHint.share_manage': 'Açıq paylaşma keçidlərini kimin yarada və ya silə biləcəyi', + 'perm.actionHint.journal_link': 'Kim Journey-dən jurnalı səfərə bağlaya və onu üzvlərinə aça bilər', }; export default perm; diff --git a/shared/src/i18n/br/perm.ts b/shared/src/i18n/br/perm.ts index 44905de9f..0a94d637a 100644 --- a/shared/src/i18n/br/perm.ts +++ b/shared/src/i18n/br/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)', 'perm.action.share_manage': 'Gerenciar links de compartilhamento', 'perm.actionHint.trip_create': 'Quem pode criar novas viagens', + 'perm.action.journal_link': 'Vincular diário', 'perm.actionHint.trip_edit': 'Quem pode alterar nome, datas, descrição e moeda da viagem', 'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem', 'perm.actionHint.trip_archive': 'Quem pode arquivar ou desarquivar uma viagem', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas', 'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens', 'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos', + 'perm.actionHint.journal_link': 'Quem pode vincular um diário do Journey à viagem e abri-lo aos membros', }; export default perm; diff --git a/shared/src/i18n/ca/perm.ts b/shared/src/i18n/ca/perm.ts index 9e5b7b2c6..d2663c0c4 100644 --- a/shared/src/i18n/ca/perm.ts +++ b/shared/src/i18n/ca/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Col·laboració (notes, enquestes, xat)', 'perm.action.share_manage': 'Gestiona els enllaços compartits', 'perm.actionHint.trip_create': 'Permet crear viatges nous', + 'perm.action.journal_link': 'Enllaçar un diari', 'perm.actionHint.trip_edit': 'Permet canviar el nom, les dates, la descripció i la moneda del viatge', 'perm.actionHint.trip_delete': 'Permet eliminar permanentment un viatge', 'perm.actionHint.trip_archive': "Permet arxivar o treure de l'arxiu un viatge", @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': "Permet gestionar els elements d'equipatge i les bosses", 'perm.actionHint.collab_edit': 'Permet crear notes, enquestes i enviar missatges', 'perm.actionHint.share_manage': 'Permet crear o eliminar enllaços públics compartits', + 'perm.actionHint.journal_link': 'Qui pot enllaçar un diari de Journey al viatge i obrir-lo als seus membres', }; export default perm; diff --git a/shared/src/i18n/cs/perm.ts b/shared/src/i18n/cs/perm.ts index 58c845358..3a2b7b9e5 100644 --- a/shared/src/i18n/cs/perm.ts +++ b/shared/src/i18n/cs/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Spolupráce (poznámky, hlasování, chat)', 'perm.action.share_manage': 'Spravovat odkazy ke sdílení', 'perm.actionHint.trip_create': 'Kdo může vytvářet nové výlety', + 'perm.action.journal_link': 'Propojit deník', 'perm.actionHint.trip_edit': 'Kdo může měnit název, data, popis a měnu výletu', 'perm.actionHint.trip_delete': 'Kdo může trvale smazat výlet', 'perm.actionHint.trip_archive': 'Kdo může archivovat nebo odarchivovat výlet', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Kdo může spravovat položky balení a tašky', 'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy', 'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení', + 'perm.actionHint.journal_link': 'Kdo může k cestě připojit deník z Journey a zpřístupnit ho jejím členům', }; export default perm; diff --git a/shared/src/i18n/de/perm.ts b/shared/src/i18n/de/perm.ts index 2bec3dfd8..a8fee71d6 100644 --- a/shared/src/i18n/de/perm.ts +++ b/shared/src/i18n/de/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Zusammenarbeit (Notizen, Umfragen, Chat)', 'perm.action.share_manage': 'Freigabelinks verwalten', 'perm.actionHint.trip_create': 'Wer kann neue Reisen erstellen', + 'perm.action.journal_link': 'Journal verknüpfen', 'perm.actionHint.trip_edit': 'Wer kann Reisename, Daten, Beschreibung und Währung ändern', 'perm.actionHint.trip_delete': 'Wer kann eine Reise dauerhaft löschen', 'perm.actionHint.trip_archive': 'Wer kann eine Reise archivieren oder dearchivieren', @@ -47,5 +48,7 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Wer kann Packstücke und Taschen verwalten', 'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden', 'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen', + 'perm.actionHint.journal_link': + 'Wer ein Journal aus Journey mit dem Trip verknüpfen und für dessen Mitglieder öffnen kann', }; export default perm; diff --git a/shared/src/i18n/en/perm.ts b/shared/src/i18n/en/perm.ts index ecf7386cf..e66d02aa9 100644 --- a/shared/src/i18n/en/perm.ts +++ b/shared/src/i18n/en/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Collaboration (notes, polls, chat)', 'perm.action.share_manage': 'Manage share links', 'perm.actionHint.trip_create': 'Who can create new trips', + 'perm.action.journal_link': 'Link a journal', 'perm.actionHint.trip_edit': 'Who can change trip name, dates, description and currency', 'perm.actionHint.trip_delete': 'Who can permanently delete a trip', 'perm.actionHint.trip_archive': 'Who can archive or unarchive a trip', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Who can manage packing items and bags', 'perm.actionHint.collab_edit': 'Who can create notes, polls and send messages', 'perm.actionHint.share_manage': 'Who can create or delete public share links', + 'perm.actionHint.journal_link': 'Who can link a journal from Journey to the trip and open it to its members', }; export default perm; diff --git a/shared/src/i18n/es/perm.ts b/shared/src/i18n/es/perm.ts index 12b4fa69b..d76a9bd88 100644 --- a/shared/src/i18n/es/perm.ts +++ b/shared/src/i18n/es/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Colaboración (notas, encuestas, chat)', 'perm.action.share_manage': 'Gestionar enlaces compartidos', 'perm.actionHint.trip_create': 'Quién puede crear nuevos viajes', + 'perm.action.journal_link': 'Vincular un diario', 'perm.actionHint.trip_edit': 'Quién puede cambiar el nombre, fechas, descripción y moneda del viaje', 'perm.actionHint.trip_delete': 'Quién puede eliminar permanentemente un viaje', 'perm.actionHint.trip_archive': 'Quién puede archivar o desarchivar un viaje', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Quién puede gestionar artículos de equipaje y bolsas', 'perm.actionHint.collab_edit': 'Quién puede crear notas, encuestas y enviar mensajes', 'perm.actionHint.share_manage': 'Quién puede crear o eliminar enlaces compartidos públicos', + 'perm.actionHint.journal_link': 'Quién puede vincular un diario de Journey al viaje y abrirlo a sus miembros', }; export default perm; diff --git a/shared/src/i18n/et/perm.ts b/shared/src/i18n/et/perm.ts index afa38fb1c..3697df7e2 100644 --- a/shared/src/i18n/et/perm.ts +++ b/shared/src/i18n/et/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Koostöö (märkmed, küsitlused, vestlus)', 'perm.action.share_manage': 'Jagamislinkide haldus', 'perm.actionHint.trip_create': 'Kes saab uusi reise luua', + 'perm.action.journal_link': 'Päeviku sidumine', 'perm.actionHint.trip_edit': 'Kes saab muuta reisi nime, kuupäevi, kirjeldust ja valuutat', 'perm.actionHint.trip_delete': 'Kes saab reisi jäädavalt kustutada', 'perm.actionHint.trip_archive': 'Kes saab reisi arhiveerida või arhiivist taastada', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Kes saab hallata pakitavaid esemeid ja kotte', 'perm.actionHint.collab_edit': 'Kes saab märkmeid ja küsitlusi luua ning sõnumeid saata', 'perm.actionHint.share_manage': 'Kes saab avalikke jagamislinke luua või kustutada', + 'perm.actionHint.journal_link': 'Kes saab Journey päeviku reisiga siduda ja selle liikmetele avada', }; export default perm; diff --git a/shared/src/i18n/fr/perm.ts b/shared/src/i18n/fr/perm.ts index a7e6e8da4..f00dc6d94 100644 --- a/shared/src/i18n/fr/perm.ts +++ b/shared/src/i18n/fr/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Collaboration (notes, sondages, chat)', 'perm.action.share_manage': 'Gérer les liens de partage', 'perm.actionHint.trip_create': 'Qui peut créer de nouveaux voyages', + 'perm.action.journal_link': 'Lier un journal', 'perm.actionHint.trip_edit': 'Qui peut modifier le nom, les dates, la description et la devise du voyage', 'perm.actionHint.trip_delete': 'Qui peut supprimer définitivement un voyage', 'perm.actionHint.trip_archive': 'Qui peut archiver ou désarchiver un voyage', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Qui peut gérer les articles de bagages et les sacs', 'perm.actionHint.collab_edit': 'Qui peut créer des notes, des sondages et envoyer des messages', 'perm.actionHint.share_manage': 'Qui peut créer ou supprimer des liens de partage publics', + 'perm.actionHint.journal_link': 'Qui peut lier un journal de Journey au voyage et l’ouvrir à ses membres', }; export default perm; diff --git a/shared/src/i18n/gr/perm.ts b/shared/src/i18n/gr/perm.ts index 5168c9361..c751bdf99 100644 --- a/shared/src/i18n/gr/perm.ts +++ b/shared/src/i18n/gr/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Συνεργασία (σημειώσεις, δημοσκοπήσεις, συνομιλία)', 'perm.action.share_manage': 'Διαχείριση συνδέσμων κοινής χρήσης', 'perm.actionHint.trip_create': 'Ποιος μπορεί να δημιουργεί νέα ταξίδια', + 'perm.action.journal_link': 'Σύνδεση ημερολογίου', 'perm.actionHint.trip_edit': 'Ποιος μπορεί να αλλάζει όνομα, ημερομηνίες, περιγραφή και νόμισμα ταξιδιού', 'perm.actionHint.trip_delete': 'Ποιος μπορεί να διαγράφει οριστικά ένα ταξίδι', 'perm.actionHint.trip_archive': 'Ποιος μπορεί να αρχειοθετεί ή να επαναφέρει ένα ταξίδι', @@ -47,5 +48,7 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Ποιος μπορεί να διαχειρίζεται αντικείμενα και τσάντες αποσκευών', 'perm.actionHint.collab_edit': 'Ποιος μπορεί να δημιουργεί σημειώσεις, δημοσκοπήσεις και να στέλνει μηνύματα', 'perm.actionHint.share_manage': 'Ποιος μπορεί να δημιουργεί ή να διαγράφει δημόσιους συνδέσμους κοινής χρήσης', + 'perm.actionHint.journal_link': + 'Ποιος μπορεί να συνδέσει ένα ημερολόγιο από το Journey με το ταξίδι και να το ανοίξει στα μέλη του', }; export default perm; diff --git a/shared/src/i18n/hu/perm.ts b/shared/src/i18n/hu/perm.ts index d2a4a5cf4..7d2432c1d 100644 --- a/shared/src/i18n/hu/perm.ts +++ b/shared/src/i18n/hu/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Együttműködés (jegyzetek, szavazások, chat)', 'perm.action.share_manage': 'Megosztási linkek kezelése', 'perm.actionHint.trip_create': 'Ki hozhat létre új utazásokat', + 'perm.action.journal_link': 'Napló összekapcsolása', 'perm.actionHint.trip_edit': 'Ki módosíthatja az utazás nevét, dátumait, leírását és pénznemét', 'perm.actionHint.trip_delete': 'Ki törölhet véglegesen egy utazást', 'perm.actionHint.trip_archive': 'Ki archiválhat vagy állíthat vissza egy utazást', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Ki kezelheti a csomagolási tételeket és táskákat', 'perm.actionHint.collab_edit': 'Ki hozhat létre jegyzeteket, szavazásokat és küldhet üzeneteket', 'perm.actionHint.share_manage': 'Ki hozhat létre vagy törölhet nyilvános megosztási linkeket', + 'perm.actionHint.journal_link': 'Ki kapcsolhat Journey-naplót az úthoz, és nyithatja meg a tagjai számára', }; export default perm; diff --git a/shared/src/i18n/id/perm.ts b/shared/src/i18n/id/perm.ts index c28c84efe..0f9cfab84 100644 --- a/shared/src/i18n/id/perm.ts +++ b/shared/src/i18n/id/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Kolaborasi (catatan, polling, chat)', 'perm.action.share_manage': 'Kelola tautan berbagi', 'perm.actionHint.trip_create': 'Siapa yang bisa membuat perjalanan baru', + 'perm.action.journal_link': 'Tautkan jurnal', 'perm.actionHint.trip_edit': 'Siapa yang bisa mengubah nama, tanggal, deskripsi, dan mata uang perjalanan', 'perm.actionHint.trip_delete': 'Siapa yang bisa menghapus perjalanan secara permanen', 'perm.actionHint.trip_archive': 'Siapa yang bisa mengarsipkan atau membatalkan arsip perjalanan', @@ -47,5 +48,7 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Siapa yang bisa mengelola item perlengkapan dan tas', 'perm.actionHint.collab_edit': 'Siapa yang bisa membuat catatan, polling, dan mengirim pesan', 'perm.actionHint.share_manage': 'Siapa yang bisa membuat atau menghapus tautan berbagi publik', + 'perm.actionHint.journal_link': + 'Siapa yang dapat menautkan jurnal dari Journey ke perjalanan dan membukanya untuk anggotanya', }; export default perm; diff --git a/shared/src/i18n/it/perm.ts b/shared/src/i18n/it/perm.ts index f366097b9..dc64e42a2 100644 --- a/shared/src/i18n/it/perm.ts +++ b/shared/src/i18n/it/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Collaborazione (note, sondaggi, chat)', 'perm.action.share_manage': 'Gestire link di condivisione', 'perm.actionHint.trip_create': 'Chi può creare nuovi viaggi', + 'perm.action.journal_link': 'Collega un diario', 'perm.actionHint.trip_edit': 'Chi può modificare nome, date, descrizione e valuta del viaggio', 'perm.actionHint.trip_delete': 'Chi può eliminare definitivamente un viaggio', 'perm.actionHint.trip_archive': 'Chi può archiviare o dearchiviare un viaggio', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Chi può gestire articoli da bagaglio e borse', 'perm.actionHint.collab_edit': 'Chi può creare note, sondaggi e inviare messaggi', 'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici', + 'perm.actionHint.journal_link': 'Chi può collegare un diario di Journey al viaggio e aprirlo ai suoi membri', }; export default perm; diff --git a/shared/src/i18n/ja/perm.ts b/shared/src/i18n/ja/perm.ts index eb652c782..72f968715 100644 --- a/shared/src/i18n/ja/perm.ts +++ b/shared/src/i18n/ja/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'コラボ(メモ・投票・チャット)', 'perm.action.share_manage': '共有リンクを管理', 'perm.actionHint.trip_create': '新しい旅行を作成できる人', + 'perm.action.journal_link': 'ジャーナルをリンク', 'perm.actionHint.trip_edit': '旅行名や日付などを変更できる人', 'perm.actionHint.trip_delete': '旅行を完全に削除できる人', 'perm.actionHint.trip_archive': '旅行をアーカイブできる人', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': '持ち物やバッグを管理できる人', 'perm.actionHint.collab_edit': 'メモや投票、メッセージを作成できる人', 'perm.actionHint.share_manage': '公開共有リンクを管理できる人', + 'perm.actionHint.journal_link': 'Journey のジャーナルを旅行にリンクし、メンバーに公開できる人', }; export default perm; diff --git a/shared/src/i18n/ko/perm.ts b/shared/src/i18n/ko/perm.ts index 7c757c2c1..4b924c904 100644 --- a/shared/src/i18n/ko/perm.ts +++ b/shared/src/i18n/ko/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': '협업 (메모, 투표, 채팅)', 'perm.action.share_manage': '공유 링크 관리', 'perm.actionHint.trip_create': '누가 새 여행을 만들 수 있는지', + 'perm.action.journal_link': '저널 연결', 'perm.actionHint.trip_edit': '누가 여행 이름, 날짜, 설명, 통화를 변경할 수 있는지', 'perm.actionHint.trip_delete': '누가 여행을 영구 삭제할 수 있는지', 'perm.actionHint.trip_archive': '누가 여행을 보관하거나 복원할 수 있는지', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': '누가 짐 항목과 가방을 관리할 수 있는지', 'perm.actionHint.collab_edit': '누가 메모, 투표를 만들고 메시지를 보낼 수 있는지', 'perm.actionHint.share_manage': '누가 공개 공유 링크를 만들거나 삭제할 수 있는지', + 'perm.actionHint.journal_link': 'Journey의 저널을 여행에 연결하고 멤버에게 공개할 수 있는 사람', }; export default perm; diff --git a/shared/src/i18n/nl/perm.ts b/shared/src/i18n/nl/perm.ts index 680b730d6..8ea06b7d5 100644 --- a/shared/src/i18n/nl/perm.ts +++ b/shared/src/i18n/nl/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Samenwerking (notities, polls, chat)', 'perm.action.share_manage': 'Deellinks beheren', 'perm.actionHint.trip_create': 'Wie kan nieuwe reizen aanmaken', + 'perm.action.journal_link': 'Dagboek koppelen', 'perm.actionHint.trip_edit': 'Wie kan reisnaam, data, beschrijving en valuta wijzigen', 'perm.actionHint.trip_delete': 'Wie kan een reis permanent verwijderen', 'perm.actionHint.trip_archive': 'Wie kan een reis archiveren of dearchiveren', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Wie kan pakitems en tassen beheren', 'perm.actionHint.collab_edit': 'Wie kan notities, polls aanmaken en berichten versturen', 'perm.actionHint.share_manage': 'Wie kan openbare deellinks aanmaken of verwijderen', + 'perm.actionHint.journal_link': 'Wie een dagboek uit Journey aan de reis kan koppelen en voor de leden kan openen', }; export default perm; diff --git a/shared/src/i18n/pl/perm.ts b/shared/src/i18n/pl/perm.ts index 2c94d9b87..c1ebc367f 100644 --- a/shared/src/i18n/pl/perm.ts +++ b/shared/src/i18n/pl/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Współpraca', 'perm.action.share_manage': 'Zarządzanie udostępnianiem', 'perm.actionHint.trip_create': 'Kto może tworzyć nowe podróże', + 'perm.action.journal_link': 'Połącz dziennik', 'perm.actionHint.trip_edit': 'Kto może edytować szczegóły podróży', 'perm.actionHint.trip_delete': 'Kto może usunąć podróż', 'perm.actionHint.trip_archive': 'Kto może archiwizować podróż', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Kto może zarządzać pakowaniem', 'perm.actionHint.collab_edit': 'Kto może korzystać ze współpracy', 'perm.actionHint.share_manage': 'Kto może zarządzać linkami', + 'perm.actionHint.journal_link': 'Kto może połączyć dziennik z Journey z podróżą i udostępnić go jej członkom', }; export default perm; diff --git a/shared/src/i18n/ru/perm.ts b/shared/src/i18n/ru/perm.ts index a29787881..b1f31b98e 100644 --- a/shared/src/i18n/ru/perm.ts +++ b/shared/src/i18n/ru/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Совместная работа (заметки, опросы, чат)', 'perm.action.share_manage': 'Управлять ссылками для обмена', 'perm.actionHint.trip_create': 'Кто может создавать новые поездки', + 'perm.action.journal_link': 'Привязать дневник', 'perm.actionHint.trip_edit': 'Кто может менять название, даты, описание и валюту поездки', 'perm.actionHint.trip_delete': 'Кто может безвозвратно удалить поездку', 'perm.actionHint.trip_archive': 'Кто может архивировать или разархивировать поездку', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Кто может управлять вещами для сборов и сумками', 'perm.actionHint.collab_edit': 'Кто может создавать заметки, опросы и отправлять сообщения', 'perm.actionHint.share_manage': 'Кто может создавать или удалять публичные ссылки для обмена', + 'perm.actionHint.journal_link': 'Кто может привязать дневник из Journey к поездке и открыть его участникам', }; export default perm; diff --git a/shared/src/i18n/sk/perm.ts b/shared/src/i18n/sk/perm.ts index f71e3b95b..51f2dc845 100644 --- a/shared/src/i18n/sk/perm.ts +++ b/shared/src/i18n/sk/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Spolupráca (poznámky, hlasovania, chat)', 'perm.action.share_manage': 'Spravovať odkazy na zdieľanie', 'perm.actionHint.trip_create': 'Kto môže vytvárať nové výlety', + 'perm.action.journal_link': 'Prepojiť denník', 'perm.actionHint.trip_edit': 'Kto môže meniť názov, dátumy, popis a menu výletu', 'perm.actionHint.trip_delete': 'Kto môže trvalo zmazať výlet', 'perm.actionHint.trip_archive': 'Kto môže archivovať alebo obnoviť výlet', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Kto môže spravovať položky balenia a tašky', 'perm.actionHint.collab_edit': 'Kto môže vytvárať poznámky, hlasovania a posielať správy', 'perm.actionHint.share_manage': 'Kto môže vytvárať alebo mazať verejné odkazy na zdieľanie', + 'perm.actionHint.journal_link': 'Kto môže k ceste pripojiť denník z Journey a sprístupniť ho jej členom', }; export default perm; diff --git a/shared/src/i18n/sv/perm.ts b/shared/src/i18n/sv/perm.ts index 9dfa82846..10f0e3816 100644 --- a/shared/src/i18n/sv/perm.ts +++ b/shared/src/i18n/sv/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Samarbete (anteckningar, omröstningar, chatt)', 'perm.action.share_manage': 'Hantera delningslänkar', 'perm.actionHint.trip_create': 'Vem kan skapa nya resor', + 'perm.action.journal_link': 'Koppla en dagbok', 'perm.actionHint.trip_edit': 'Vem kan ändra resans namn, datum, beskrivning och valuta', 'perm.actionHint.trip_delete': 'Vem kan radera en resa permanent', 'perm.actionHint.trip_archive': 'Vem kan lägga till en resa i arkivet eller ta bort den därifrån', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Vem kan sköta packningen av föremål och väskor', 'perm.actionHint.collab_edit': 'Vem kan skapa anteckningar, omröstningar och skicka meddelanden', 'perm.actionHint.share_manage': 'Vem kan skapa eller ta bort länkar till offentliga delningar', + 'perm.actionHint.journal_link': 'Vem som kan koppla en dagbok från Journey till resan och öppna den för medlemmarna', }; export default perm; diff --git a/shared/src/i18n/th/perm.ts b/shared/src/i18n/th/perm.ts index af18b3fd2..99e3071fc 100644 --- a/shared/src/i18n/th/perm.ts +++ b/shared/src/i18n/th/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'การทำงานร่วมกัน (บันทึกย่อ โพล แชท)', 'perm.action.share_manage': 'จัดการลิงก์แชร์', 'perm.actionHint.trip_create': 'ใครสามารถสร้างทริปใหม่ได้', + 'perm.action.journal_link': 'เชื่อมโยงบันทึก', 'perm.actionHint.trip_edit': 'ใครสามารถเปลี่ยนชื่อการเดินทาง วันที่ คำอธิบาย และสกุลเงินได้', 'perm.actionHint.trip_delete': 'ใครสามารถลบการเดินทางได้อย่างถาวร', 'perm.actionHint.trip_archive': 'ใครสามารถเก็บหรือยกเลิกการเก็บถาวรการเดินทาง', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'ใครสามารถจัดการบรรจุสิ่งของและถุงได้', 'perm.actionHint.collab_edit': 'ใครสามารถสร้างบันทึก โพล และส่งข้อความได้', 'perm.actionHint.share_manage': 'ใครสามารถสร้างหรือลบลิงก์แชร์สาธารณะได้', + 'perm.actionHint.journal_link': 'ใครสามารถเชื่อมโยงบันทึกจาก Journey กับทริปและเปิดให้สมาชิกเข้าถึงได้', }; export default perm; diff --git a/shared/src/i18n/tr/perm.ts b/shared/src/i18n/tr/perm.ts index 83531920c..97f5e13e1 100644 --- a/shared/src/i18n/tr/perm.ts +++ b/shared/src/i18n/tr/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'İşbirliği (notlar, anketler, sohbet)', 'perm.action.share_manage': 'Paylaşım bağlantılarını yönet', 'perm.actionHint.trip_create': 'Kimler yeni seyahat oluşturabilir', + 'perm.action.journal_link': 'Günlük bağla', 'perm.actionHint.trip_edit': 'Kimler seyahat adını, tarihlerini, açıklamasını ve para birimini değiştirebilir', 'perm.actionHint.trip_delete': 'Kimler bir seyahati kalıcı olarak silebilir', 'perm.actionHint.trip_archive': 'Kimler seyahati arşivleyebilir veya geri alabilir', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Kimler paket öğelerini ve çantaları yönetebilir', 'perm.actionHint.collab_edit': 'Kimler not, anket oluşturabilir ve mesaj gönderebilir', 'perm.actionHint.share_manage': 'Kimler genel paylaşım bağlantısı oluşturabilir veya silebilir', + 'perm.actionHint.journal_link': 'Journey’den bir günlüğü geziye kimin bağlayıp üyelerine açabileceği', }; export default perm; diff --git a/shared/src/i18n/uk/perm.ts b/shared/src/i18n/uk/perm.ts index 1e10f3b9a..a14ed0874 100644 --- a/shared/src/i18n/uk/perm.ts +++ b/shared/src/i18n/uk/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Співпраця (нотатки, опитування, чат)', 'perm.action.share_manage': 'Керувати посиланнями для обміну', 'perm.actionHint.trip_create': 'Хто може створювати нові поїздки', + 'perm.action.journal_link': 'Привʼязати щоденник', 'perm.actionHint.trip_edit': 'Хто може змінювати назву, дати, опис і валюту поїздки', 'perm.actionHint.trip_delete': 'Хто може безповоротно видалити поїздку', 'perm.actionHint.trip_archive': 'Хто може архівувати або розархівувати поїздку', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Хто може керувати речами для зборів і сумками', 'perm.actionHint.collab_edit': 'Хто може створювати нотатки, опитування та надсилати повідомлення', 'perm.actionHint.share_manage': 'Хто може створювати або видаляти публічні посилання для обміну', + 'perm.actionHint.journal_link': 'Хто може привʼязати щоденник із Journey до подорожі й відкрити його учасникам', }; export default perm; diff --git a/shared/src/i18n/vi/perm.ts b/shared/src/i18n/vi/perm.ts index c29771375..fbdc01877 100644 --- a/shared/src/i18n/vi/perm.ts +++ b/shared/src/i18n/vi/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': 'Cộng tác (ghi chú, thăm dò ý kiến, trò chuyện)', 'perm.action.share_manage': 'Quản lý liên kết chia sẻ', 'perm.actionHint.trip_create': 'Ai có thể tạo chuyến đi mới', + 'perm.action.journal_link': 'Liên kết nhật ký', 'perm.actionHint.trip_edit': 'Ai có thể thay đổi tên chuyến đi, ngày tháng, mô tả và đơn vị tiền tệ', 'perm.actionHint.trip_delete': 'Ai có thể xóa vĩnh viễn chuyến đi', 'perm.actionHint.trip_archive': 'Ai có thể lưu trữ hoặc hủy lưu trữ chuyến đi', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': 'Ai có thể quản lý các mặt hàng đóng gói và túi xách', 'perm.actionHint.collab_edit': 'Ai có thể tạo ghi chú, thăm dò ý kiến ​​và gửi tin nhắn', 'perm.actionHint.share_manage': 'Ai có thể tạo hoặc xóa liên kết chia sẻ công khai', + 'perm.actionHint.journal_link': 'Ai có thể liên kết một nhật ký từ Journey với chuyến đi và mở nó cho các thành viên', }; export default perm; diff --git a/shared/src/i18n/zh-TW/perm.ts b/shared/src/i18n/zh-TW/perm.ts index e37e6ebe9..fb6f51c5a 100644 --- a/shared/src/i18n/zh-TW/perm.ts +++ b/shared/src/i18n/zh-TW/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': '協作(筆記、投票、聊天)', 'perm.action.share_manage': '管理分享連結', 'perm.actionHint.trip_create': '誰可以建立新旅行', + 'perm.action.journal_link': '連結日誌', 'perm.actionHint.trip_edit': '誰可以更改旅行名稱、日期、描述和貨幣', 'perm.actionHint.trip_delete': '誰可以永久刪除旅行', 'perm.actionHint.trip_archive': '誰可以歸檔或取消歸檔旅行', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': '誰可以管理行李物品和包袋', 'perm.actionHint.collab_edit': '誰可以建立筆記、投票和傳送訊息', 'perm.actionHint.share_manage': '誰可以建立或刪除公開分享連結', + 'perm.actionHint.journal_link': '誰可以將 Journey 中的日誌連結到旅程並向成員開放', }; export default perm; diff --git a/shared/src/i18n/zh/perm.ts b/shared/src/i18n/zh/perm.ts index 1df2b6c07..e8494f71d 100644 --- a/shared/src/i18n/zh/perm.ts +++ b/shared/src/i18n/zh/perm.ts @@ -32,6 +32,7 @@ const perm: TranslationStrings = { 'perm.action.collab_edit': '协作(笔记、投票、聊天)', 'perm.action.share_manage': '管理分享链接', 'perm.actionHint.trip_create': '谁可以创建新旅行', + 'perm.action.journal_link': '关联日志', 'perm.actionHint.trip_edit': '谁可以更改旅行名称、日期、描述和货币', 'perm.actionHint.trip_delete': '谁可以永久删除旅行', 'perm.actionHint.trip_archive': '谁可以归档或取消归档旅行', @@ -47,5 +48,6 @@ const perm: TranslationStrings = { 'perm.actionHint.packing_edit': '谁可以管理行李物品和包袋', 'perm.actionHint.collab_edit': '谁可以创建笔记、投票和发送消息', 'perm.actionHint.share_manage': '谁可以创建或删除公开分享链接', + 'perm.actionHint.journal_link': '谁可以将 Journey 中的日志关联到行程并向成员开放', }; export default perm; diff --git a/shared/src/index.ts b/shared/src/index.ts index 9b39c9cc0..e5004433c 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -73,6 +73,7 @@ export * from './trip-invite/trip-invite.schema'; export * from './collab/collab.schema'; export * from './file/file.schema'; export * from './journey/journey.schema'; +export * from './journey/trip-journal-link.schema'; export * from './book/book.schema'; export * from './book/journey-stats.schema'; export * from './book/book-store.schema'; diff --git a/shared/src/journey/trip-journal-link.schema.ts b/shared/src/journey/trip-journal-link.schema.ts new file mode 100644 index 000000000..2085e0a6c --- /dev/null +++ b/shared/src/journey/trip-journal-link.schema.ts @@ -0,0 +1,95 @@ +import { idSchema } from '../common/primitives.schema'; + +import { z } from 'zod'; + +/** + * A trip that shows one journal from the Journey addon as its own, reached at + * `/api/trips/:tripId/journal`. The link belongs to the trip: any journal its + * owner chooses, one journal on as many trips as they like. The trip members + * chosen for it get into the journal as viewers or editors for as long as the + * link holds them and they stay on the trip. + */ + +/** What a link lets a member do in its journal. */ +export const tripJournalLinkRoleSchema = z.enum(['viewer', 'editor']); +export type TripJournalLinkRole = z.infer; + +/** One trip member the link lets in. */ +export const tripJournalLinkMemberRequestSchema = z.object({ + user_id: idSchema, + role: tripJournalLinkRoleSchema, +}); +export type TripJournalLinkMemberRequest = z.infer; + +/** PUT: show this journal in the trip, to these members. Replaces a journal linked before. */ +export const tripJournalLinkRequestSchema = z.object({ + journey_id: idSchema, + members: z.array(tripJournalLinkMemberRequestSchema).max(500), +}); +export type TripJournalLinkRequest = z.infer; + +/** PATCH …/members: who the linked journal is open to, replacing the list. */ +export const tripJournalLinkMembersRequestSchema = z.object({ + members: z.array(tripJournalLinkMemberRequestSchema).max(500), +}); +export type TripJournalLinkMembersRequest = z.infer; + +/** GET …/candidates: whose access a journal would change, before it is linked. */ +export const tripJournalCandidatesQuerySchema = z.object({ + journey_id: z.coerce.number().int().positive(), +}); +export type TripJournalCandidatesQuery = z.infer; + +/** Access to a journal as it stands: as its owner, a contributor or through a link. */ +export const tripJournalAccessRoleSchema = z.enum(['owner', 'editor', 'viewer']); +export type TripJournalAccessRole = z.infer; + +export const tripJournalLinkMemberSchema = z.object({ + user_id: idSchema, + username: z.string(), + avatar_url: z.string().nullable(), + role: tripJournalLinkRoleSchema, +}); +export type TripJournalLinkMember = z.infer; + +/** The linked journal as the trip shows it. `members` only for someone who may change them. */ +export const tripJournalLinkSchema = z.object({ + journey_id: idSchema, + title: z.string(), + subtitle: z.string().nullable(), + cover_image: z.string().nullable(), + owner_id: idSchema, + linked_by: idSchema.nullable(), + created_at: z.number(), + my_role: tripJournalAccessRoleSchema.nullable(), + members: z.array(tripJournalLinkMemberSchema).nullable(), +}); +export type TripJournalLink = z.infer; + +/** + * The trip's journal and what the caller may do with it. `link` is null without + * one, and also for a caller the linked journal is closed to who may not manage + * it: they see no journal in the trip at all. + */ +export const tripJournalLinkResponseSchema = z.object({ + link: tripJournalLinkSchema.nullable(), + can_link: z.boolean(), + can_unlink: z.boolean(), + can_edit_members: z.boolean(), +}); +export type TripJournalLinkResponse = z.infer; + +/** A trip member who could be let into a journal, with the access they hold there today. */ +export const tripJournalCandidateSchema = z.object({ + user_id: idSchema, + username: z.string(), + avatar_url: z.string().nullable(), + is_trip_owner: z.boolean(), + access: tripJournalAccessRoleSchema.nullable(), +}); +export type TripJournalCandidate = z.infer; + +export const tripJournalCandidatesResponseSchema = z.object({ + candidates: z.array(tripJournalCandidateSchema), +}); +export type TripJournalCandidatesResponse = z.infer; diff --git a/shared/src/permissions/permissions.ts b/shared/src/permissions/permissions.ts index edf833ab1..285dd3bb3 100644 --- a/shared/src/permissions/permissions.ts +++ b/shared/src/permissions/permissions.ts @@ -56,6 +56,9 @@ export const PERMISSION_ACTIONS = [ // Share link management { key: 'share_manage', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] }, + + // Linking a journal from the Journey addon, which opens it to the members chosen + { key: 'journal_link', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] }, ] as const satisfies readonly PermissionAction[]; /** The name of one configurable action, `'day_edit'`, `'budget_edit'`, … */ diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index 9f90f8ad6..861653304 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -74,6 +74,7 @@ const FIXTURES: Record> = { 'trip:deleted': { id: 1 }, 'member:added': { member: { user_id: 2 } }, 'member:removed': { userId: 2 }, + 'trip:journal:changed': {}, 'docsync:changed': { linkId: 4, pulled: 2, pushed: 1 }, 'file:created': { file: { id: 13 } }, 'file:updated': { file: { id: 13 } }, diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index 20ad12b46..f4f2ba7d6 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -230,6 +230,9 @@ export const TREK_WS_EVENTS = { 'trip:deleted': { scope: 'trip', payload: z.object({ id }) }, 'member:added': { scope: 'trip', payload: z.object({ member: entity }) }, 'member:removed': { scope: 'trip', payload: z.object({ userId: id }) }, + // The trip's linked journal changed. A signal only: what each member may see of + // it differs, so every client asks for its own view of the link. + 'trip:journal:changed': { scope: 'trip', payload: empty }, // ── Files ──────────────────────────────────────────────────────────────── 'file:created': { scope: 'trip', payload: z.object({ file: entity }) },