mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(journey): a trip can link a journal and open it to the members it picks
A trip can now show one journal from the Journey addon as its own: any journal its owner picks, and one journal on as many trips as they like. The trip members chosen for the link get into the journal as viewers or editors, see it in their own journal list and follow it live, for as long as the link holds them and they stay on the trip. That access is never copied into journey_contributors. It is read where journal access is read: the contributor repository answers with the stronger of a direct role and a link role, the journal list and the live update recipients take the link members in, and the photo access check goes the same way. Every one of those reads starts from linkReach, which only counts members still on the linking trip, so unlinking, deselecting someone and their leaving the trip all end exactly what the link gave. A member who leaves is also taken off the link, so coming back later does not quietly reopen the journal. Linking or changing the members takes the journal's owner and the new journal_link trip permission (trip owner by default); unlinking takes either. /api/trips/:tripId/journal answers the link as each member may see it, /candidates says who a journal would be opened to for the first time, and four MCP tools do the same over the same service. A trip:journal:changed event tells the trip to look again.
This commit is contained in:
@@ -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'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"TripDocumentLinks": "doc-sync",
|
||||
"TripFiles": "files",
|
||||
"TripInviteTokens": "trip-invite",
|
||||
"TripJournalLinkMembers": "journey",
|
||||
"TripJournalLinks": "journey",
|
||||
"TripMembers": "trip-membership",
|
||||
"TripPhotos": "photos",
|
||||
"Trips": "trips",
|
||||
|
||||
@@ -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<JourneyPhotos>(this);
|
||||
journey_share_tokens: Ref<JourneyShareTokens> | null = null;
|
||||
journey_trips_collection = new Collection<JourneyTrips>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<TripJournalLinks>;
|
||||
trip_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips> | null;
|
||||
trip_id?: number | null;
|
||||
journey!: Ref<Journeys>;
|
||||
journey_id!: number;
|
||||
linked_by?: number | null;
|
||||
created_at!: number;
|
||||
linkedByRef?: Ref<Users> | null;
|
||||
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<TripDocumentLinks>(this);
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_links: Ref<TripJournalLinks> | null = null;
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(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(),
|
||||
},
|
||||
|
||||
@@ -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<TripDocumentLinks>(this);
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_members_collection1 = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>;
|
||||
hide_skeletons: InsertOptional<number>;
|
||||
added_at: number;
|
||||
}
|
||||
@@ -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<number>;
|
||||
journey_id: number;
|
||||
linked_by: InsertOptional<number | null>;
|
||||
created_at: number;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -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<DB, 'journey_contributors' | 'users'>;
|
||||
|
||||
/** The table the access reads below take a person's own contributor row from. */
|
||||
type JourneyContributorRowKyselyDB = Pick<DB, 'journey_contributors'>;
|
||||
|
||||
/**
|
||||
* `journey_contributors` — a genuine TWO-column composite primary key
|
||||
* (`journey` + `user`, both `.primary()`, R3 — pinned against
|
||||
@@ -26,36 +30,53 @@ type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>
|
||||
* pinned in `task-1-report.md`.
|
||||
*/
|
||||
export class JourneyContributorsRepository extends TrekRepository<JourneyContributors> {
|
||||
/** JG4 — `canAccessJourney`'s contributor-match primitive: `SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */
|
||||
async existsForUser(journeyId: number, userId: number): Promise<boolean> {
|
||||
const row = await this.qb('jc')
|
||||
.select(['jc.journey'])
|
||||
.where({ journey: journeyId, user: userId })
|
||||
.execute<unknown>('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<JourneyContributorRowKyselyDB>()
|
||||
.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<string | undefined> {
|
||||
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<boolean> {
|
||||
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<string | undefined> {
|
||||
const direct = await this.directRow(journeyId, userId);
|
||||
if (direct?.role === 'owner' || direct?.role === 'editor') return direct.role;
|
||||
const linked = await readLinkRole(this.kysely<TripJournalLinkKyselyDB>(), 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<boolean> {
|
||||
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<TripJournalLinkKyselyDB>(), 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<void> {
|
||||
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<TripJournalLinkKyselyDB>();
|
||||
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). */
|
||||
|
||||
@@ -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<Journeys> {
|
||||
.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<TripJournalLinkKyselyDB>())
|
||||
.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<Journeys> {
|
||||
.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<TripJournalLinkKyselyDB>()).select('l.journey_id').where('m.user_id', '=', user_id),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.orderBy('j.updated_at', 'desc')
|
||||
.execute();
|
||||
return rows as JourneyListRow[];
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { TripJournalLinkMembers } from '../entities/TripJournalLinkMembers.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TripJournalLinkMembersRepository extends TrekRepository<TripJournalLinkMembers> {}
|
||||
@@ -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<TripJournalLinkKyselyDB>) {
|
||||
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<TripJournalLinkKyselyDB>,
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
): Promise<TripJournalLinkRole | null> {
|
||||
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<TripJournalLinkKyselyDB>,
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
): Promise<number | null> {
|
||||
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<TripJournalLinks> {
|
||||
/** The trip's link with its journal's title and cover, or null without one. */
|
||||
async findForTrip(tripId: number): Promise<TripJournalLinkRow | null> {
|
||||
const row = await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.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<TripJournalLinkMemberRow[]> {
|
||||
const rows = await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.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<TripPersonRow[]> {
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
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<void> {
|
||||
await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.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<void> {
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
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<void> {
|
||||
await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.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<void> {
|
||||
await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.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<number[]> {
|
||||
const rows = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
|
||||
.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<number[]> {
|
||||
const rows = await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.selectFrom('trip_journal_links')
|
||||
.select('trip_id')
|
||||
.where('journey_id', '=', journeyId)
|
||||
.execute();
|
||||
return rows.map((r) => r.trip_id);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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<TripJournalActor> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<boolean> {
|
||||
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<TripJournalAccessRole | null> {
|
||||
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<TripJournalLinkResponse> {
|
||||
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<TripJournalCandidate[]> {
|
||||
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<number, TripJournalLinkMemberRequest>();
|
||||
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<TripJournalLinkResponse> {
|
||||
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<TripJournalLinkResponse> {
|
||||
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<TripJournalLinkResponse> {
|
||||
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<void> {
|
||||
await this.links.deleteMember(tripId, userId);
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
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<ReturnType<typeof scene>>, 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<TripJournalLinkMcp['getTripJournal']>[1];
|
||||
const json = (r: { content: { text: string }[] }) => JSON.parse(r.content[0].text);
|
||||
const run = <T>(fn: () => Promise<T>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof tripJournalLinkRoleSchema>;
|
||||
|
||||
/** One trip member the link lets in. */
|
||||
export const tripJournalLinkMemberRequestSchema = z.object({
|
||||
user_id: idSchema,
|
||||
role: tripJournalLinkRoleSchema,
|
||||
});
|
||||
export type TripJournalLinkMemberRequest = z.infer<typeof tripJournalLinkMemberRequestSchema>;
|
||||
|
||||
/** 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<typeof tripJournalLinkRequestSchema>;
|
||||
|
||||
/** 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<typeof tripJournalLinkMembersRequestSchema>;
|
||||
|
||||
/** 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<typeof tripJournalCandidatesQuerySchema>;
|
||||
|
||||
/** 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<typeof tripJournalAccessRoleSchema>;
|
||||
|
||||
export const tripJournalLinkMemberSchema = z.object({
|
||||
user_id: idSchema,
|
||||
username: z.string(),
|
||||
avatar_url: z.string().nullable(),
|
||||
role: tripJournalLinkRoleSchema,
|
||||
});
|
||||
export type TripJournalLinkMember = z.infer<typeof tripJournalLinkMemberSchema>;
|
||||
|
||||
/** 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<typeof tripJournalLinkSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof tripJournalLinkResponseSchema>;
|
||||
|
||||
/** 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<typeof tripJournalCandidateSchema>;
|
||||
|
||||
export const tripJournalCandidatesResponseSchema = z.object({
|
||||
candidates: z.array(tripJournalCandidateSchema),
|
||||
});
|
||||
export type TripJournalCandidatesResponse = z.infer<typeof tripJournalCandidatesResponseSchema>;
|
||||
@@ -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'`, … */
|
||||
|
||||
@@ -74,6 +74,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'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 } },
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
Reference in New Issue
Block a user