mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
Move the addon-gated domains onto the decorators
collab, journal, atlas, vacay and collections: 32 more methods, so the registry owns 93 of 113. rpc-host.ts is at 565 lines, down from 1332, and the deps factory at 384, down from 944. These five are the risky group, because their addon gates lived inside the deps factory's closure rather than in the router. A handler that calls its service directly still works, just without the check, and neither the compiler nor the router tests would notice. So addon-gates.rpc.test.ts walks all 37 gated methods as one table and asserts two things for each: with the addon off it is refused with that addon's message, and the service is never reached. The table length is pinned, so a later method added without a row is a failure rather than a silent gap. One ordering detail worth knowing: on the atlas writes the router evaluated its params before the addon gate inside the closure, so a malformed country code still reports BAD_PARAMS rather than the addon. The handlers keep that order. The collections service reports refusals as status-tagged errors rather than typed ones, so mapCollectionError came across with the handlers: 403 and 404 become RESOURCE_FORBIDDEN, 400 and 409 become BAD_PARAMS, and anything untagged stays a HOST_ERROR instead of being dressed up as a permission problem.
This commit is contained in:
@@ -196,37 +196,7 @@ export class PluginHostDepsFactory {
|
||||
// must live on the same trip (findForeignLinkTarget). Same events as the app. ---
|
||||
// --- Collab reads (collab addon; membership checked by the host). Same hydrated
|
||||
// shapes as the REST GETs, so a collab plugin can finally read what it writes. ---
|
||||
listCollabNotes: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listNotes(tripId) as unknown[]; },
|
||||
listCollabPolls: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listPolls(tripId) as unknown[]; },
|
||||
listCollabMessages: (tripId, before) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listMessages(tripId, before) as unknown[]; },
|
||||
// --- Collab content (collab addon). The services validate + self-report errors. ---
|
||||
canEditCollab: (tripId, userId) => this.canEditTripAs('collab_edit', tripId, userId),
|
||||
createCollabNote: (tripId, input, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const note = this.collab.createNote(String(tripId), actingUserId, input as never);
|
||||
this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined);
|
||||
return note;
|
||||
},
|
||||
createCollabPoll: (tripId, input, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const poll = this.collab.createPoll(String(tripId), actingUserId, input as never);
|
||||
this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined);
|
||||
return poll;
|
||||
},
|
||||
voteCollabPoll: (tripId, pollId, optionIndex, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const result = this.collab.votePoll(String(tripId), String(pollId), actingUserId, optionIndex);
|
||||
if (result.error) throw new BadParams(result.error);
|
||||
this.realtime.broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined);
|
||||
return result.poll;
|
||||
},
|
||||
createCollabMessage: (tripId, text, replyTo, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const result = this.collab.createMessage(String(tripId), actingUserId, text, replyTo ?? null);
|
||||
if (result.error) throw new BadParams(result.error);
|
||||
this.realtime.broadcast(tripId, 'collab:message:created', { message: result.message }, undefined);
|
||||
return result.message;
|
||||
},
|
||||
// --- Member add (member_manage). Grants trip access — the target must exist;
|
||||
// the acting user is recorded as the inviter. Owner/duplicate adds are no-ops. ---
|
||||
// --- Host-mediated notifications. Recipient resolution + channel fan-out +
|
||||
@@ -347,97 +317,12 @@ export class PluginHostDepsFactory {
|
||||
// --- User-scoped addon reads (the acting user's own data across all trips). Each
|
||||
// reuses the same service the addon's REST/MCP path uses; the addon-enabled gate
|
||||
// mirrors the app (a disabled addon has nothing to read). ---
|
||||
listJournalsForUser: (userId) => { requireAddon(ADDON_IDS.JOURNEY, 'journey'); return this.journey.listJourneys(userId); },
|
||||
journalEntriesForUser: (userId, journeyId) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
// listEntries self-gates via canAccessJourney(journeyId, userId) → null if the
|
||||
// user can't see it (owner/contributor only).
|
||||
const entries = this.journey.listEntries(journeyId, userId);
|
||||
if (entries === null) throw new ForbiddenResource(`no access to journey ${journeyId}`);
|
||||
return entries;
|
||||
},
|
||||
atlasVisitedForUser: (userId) => {
|
||||
requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
return { countries: this.atlas.listVisitedCountries(userId), regions: this.atlas.listManuallyVisitedRegions(userId) };
|
||||
},
|
||||
atlasBucketForUser: (userId) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return this.atlas.bucketList(userId) as unknown[]; },
|
||||
vacayForUser: (userId) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return this.vacay.getPlanData(userId); },
|
||||
listCollectionsForUser: (userId) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return this.collections.listCollections(userId); },
|
||||
getCollectionForUser: (userId, id) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return this.collections.getCollection(userId, id); },
|
||||
// --- Collections write. The service self-gates per-collection role (assertAccess/
|
||||
// assertCanEdit throw status-tagged errors) — map those to the RPC error codes. ---
|
||||
createCollectionForUser: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => this.collections.createCollection(userId, input as never));
|
||||
},
|
||||
updateCollectionForUser: (userId, id, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => this.collections.updateCollection(userId, id, input as never, undefined));
|
||||
},
|
||||
saveCollectionPlace: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => this.collections.savePlace(userId, input as never, undefined));
|
||||
},
|
||||
copyCollectionToTrip: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => this.collections.copyToTrip(userId, input as never));
|
||||
},
|
||||
deleteCollectionPlace: (userId, placeId) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
mapCollectionError(() => this.collections.deletePlace(userId, placeId, undefined));
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Atlas write: plain uid-scoped rows, no broadcasts in the service. ---
|
||||
markCountryVisited: (userId, code) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); this.atlas.markCountry(userId, code); return { visited: true }; },
|
||||
unmarkCountryVisited: (userId, code) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); this.atlas.unmarkCountry(userId, code); return { visited: false }; },
|
||||
markRegionVisited: (userId, regionCode, regionName, countryCode) => {
|
||||
requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
this.atlas.markRegion(userId, regionCode, regionName, countryCode);
|
||||
return { visited: true };
|
||||
},
|
||||
unmarkRegionVisited: (userId, regionCode) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); this.atlas.unmarkRegion(userId, regionCode); return { visited: false }; },
|
||||
createBucketItem: (userId, input) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return this.atlas.createBucketItem(userId, input as never); },
|
||||
deleteBucketItem: (userId, itemId) => {
|
||||
requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
if (!this.atlas.deleteBucketItem(userId, itemId)) throw new ForbiddenResource(`no bucket item ${itemId} for this user`);
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side);
|
||||
// the service broadcasts to plan users itself. ---
|
||||
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined); },
|
||||
vacayToggleCompanyHoliday: (userId, date, note) => {
|
||||
requireAddon(ADDON_IDS.VACAY, 'vacay');
|
||||
return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined);
|
||||
},
|
||||
// --- Journal write: journeyService.canEdit self-gates each call (owner/contributor). ---
|
||||
createJournalEntry: (userId, journeyId, input) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
const entry = this.journey.createEntry(journeyId, userId, input as never);
|
||||
if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`);
|
||||
return entry;
|
||||
},
|
||||
updateJournalEntry: (userId, entryId, input) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
const entry = this.journey.updateEntry(entryId, userId, input as never);
|
||||
if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
|
||||
return entry;
|
||||
},
|
||||
deleteJournalEntry: (userId, entryId) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
if (!this.journey.deleteEntry(entryId, userId)) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
|
||||
return { deleted: true };
|
||||
},
|
||||
createJournal: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
const title = typeof (input as { title?: unknown }).title === 'string' ? String((input as { title: string }).title).trim() : '';
|
||||
if (!title) throw new BadParams('journal title is required');
|
||||
return this.journey.createJourney(userId, { title, subtitle: (input as { subtitle?: string }).subtitle, trip_ids: (input as { trip_ids?: number[] }).trip_ids });
|
||||
},
|
||||
deleteJournal: (userId, journeyId) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
if (!this.journey.deleteJourney(journeyId, userId)) throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`);
|
||||
return { deleted: true };
|
||||
},
|
||||
// Day notes are core (no addon) and trip-scoped; membership is enforced by the host.
|
||||
// --- Day notes write (day_edit). The day must belong to the trip; broadcasts the
|
||||
// same dayNote:* events the REST controller emits so open sessions update live. ---
|
||||
|
||||
@@ -97,54 +97,21 @@ export interface HostDeps {
|
||||
/** Update a file's description/links (same-trip targets enforced); broadcasts file:updated. */
|
||||
/** Move a trip file to the trash; broadcasts file:deleted. */
|
||||
// --- Collab reads (membership + collab addon; no separate right, like the REST GETs) ---
|
||||
listCollabNotes(tripId: number): unknown[];
|
||||
listCollabPolls(tripId: number): unknown[];
|
||||
listCollabMessages(tripId: number, before: number | undefined): unknown[];
|
||||
// --- Collab content (the app's collab_edit permission; collab addon gated) ---
|
||||
canEditCollab(tripId: number, userId: number): boolean;
|
||||
createCollabNote(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
createCollabPoll(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
voteCollabPoll(tripId: number, pollId: number, optionIndex: number, actingUserId: number): unknown;
|
||||
createCollabMessage(tripId: number, text: string, replyTo: number | undefined, actingUserId: number): unknown;
|
||||
// --- Member add (the DISTINCT member_manage permission — never bundled) ---
|
||||
/** The acting user's own journals (journey addon must be enabled). */
|
||||
listJournalsForUser(userId: number): unknown;
|
||||
/** The entries of one of the acting user's journeys (journey addon; access-checked). */
|
||||
journalEntriesForUser(userId: number, journeyId: number): unknown;
|
||||
/** The acting user's visited countries + regions (atlas addon must be enabled). */
|
||||
atlasVisitedForUser(userId: number): unknown;
|
||||
/** The acting user's bucket-list items (atlas addon must be enabled). */
|
||||
atlasBucketForUser(userId: number): unknown[];
|
||||
/** The acting user's vacation plan data (vacay addon must be enabled). */
|
||||
vacayForUser(userId: number): unknown;
|
||||
/** The acting user's saved-place collections (collections addon must be enabled). */
|
||||
listCollectionsForUser(userId: number): unknown;
|
||||
/** One of the acting user's collections by id (collections addon must be enabled). */
|
||||
getCollectionForUser(userId: number, id: number): unknown;
|
||||
// --- Collections write (the service enforces per-collection role itself) ---
|
||||
createCollectionForUser(userId: number, input: Record<string, unknown>): unknown;
|
||||
updateCollectionForUser(userId: number, id: number, input: Record<string, unknown>): unknown;
|
||||
saveCollectionPlace(userId: number, input: Record<string, unknown>): unknown;
|
||||
copyCollectionToTrip(userId: number, input: Record<string, unknown>): unknown;
|
||||
deleteCollectionPlace(userId: number, placeId: number): unknown;
|
||||
// --- Atlas write (all rows are the acting user's own; atlas addon gated) ---
|
||||
markCountryVisited(userId: number, code: string): unknown;
|
||||
unmarkCountryVisited(userId: number, code: string): unknown;
|
||||
markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): unknown;
|
||||
unmarkRegionVisited(userId: number, regionCode: string): unknown;
|
||||
createBucketItem(userId: number, input: Record<string, unknown>): unknown;
|
||||
deleteBucketItem(userId: number, itemId: number): unknown;
|
||||
// --- Vacay write (plan resolved from the acting user; vacay addon gated) ---
|
||||
vacayToggleEntry(userId: number, date: string): unknown;
|
||||
vacayToggleCompanyHoliday(userId: number, date: string, note: string | undefined): unknown;
|
||||
// --- Journal write (journeyService.canEdit self-gates; journey addon gated) ---
|
||||
createJournalEntry(userId: number, journeyId: number, input: Record<string, unknown>): unknown;
|
||||
updateJournalEntry(userId: number, entryId: number, input: Record<string, unknown>): unknown;
|
||||
deleteJournalEntry(userId: number, entryId: number): unknown;
|
||||
/** Create/delete a JOURNAL itself (owned by the acting user) — lets an importer
|
||||
* bootstrap the journal it then fills, and clean it up. */
|
||||
createJournal(userId: number, input: Record<string, unknown>): unknown;
|
||||
deleteJournal(userId: number, journeyId: number): unknown;
|
||||
/** A trip day's notes (trip-scoped), for `daynotes.list`. */
|
||||
/** Create a day note (the day must be on the trip); broadcasts dayNote:created. */
|
||||
/** Update a day note (scoped to the day+trip); broadcasts dayNote:updated. */
|
||||
@@ -235,185 +202,12 @@ export class PluginRpcHost {
|
||||
// trips.*, reservations.listMine and the member roster now live in
|
||||
// src/nest/trips/trips.rpc.ts.
|
||||
// files.* now lives in src/nest/files/files.rpc.ts.
|
||||
if (has('db:write:collab')) {
|
||||
// Collab content (notes/polls/messages) under the app's collab_edit right.
|
||||
this.methods.set('collab.createNote', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab note');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.createCollabNote(tripId, input, actor);
|
||||
});
|
||||
this.methods.set('collab.createPoll', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab poll');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.question !== 'string' || input.question.trim() === '') throw new BadParams('poll question is required');
|
||||
if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.createCollabPoll(tripId, input, actor);
|
||||
});
|
||||
this.methods.set('collab.votePoll', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab poll');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.voteCollabPoll(tripId, num(p.pollId, 'pollId'), num(p.optionIndex, 'optionIndex'), actor);
|
||||
});
|
||||
this.methods.set('collab.createMessage', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab message');
|
||||
if (typeof p.text !== 'string' || p.text.trim() === '' || p.text.length > 4000) throw new BadParams('message text is required (max 4000 chars)');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.createCollabMessage(tripId, p.text, typeof p.replyTo === 'number' ? p.replyTo : undefined, actor);
|
||||
});
|
||||
}
|
||||
|
||||
// User-scoped addon reads: the acting user's OWN journals/atlas/vacay across all
|
||||
// their trips (not one trip), so — like costs.listMine — they are gated on a bound
|
||||
// acting user, not a tripId; the wiring additionally refuses a disabled addon.
|
||||
if (has('db:read:collab')) {
|
||||
// Collab reads mirror the REST GETs: membership only (the addon gate lives in
|
||||
// the wiring), no separate right — the write side already has collab_edit.
|
||||
this.methods.set('collab.listNotes', (p, uid) => this.tripRead(p, uid, () => deps.listCollabNotes(num(p.tripId, 'tripId'))));
|
||||
this.methods.set('collab.listPolls', (p, uid) => this.tripRead(p, uid, () => deps.listCollabPolls(num(p.tripId, 'tripId'))));
|
||||
this.methods.set('collab.listMessages', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.listCollabMessages(num(p.tripId, 'tripId'), p.before != null ? num(p.before, 'before') : undefined)),
|
||||
);
|
||||
}
|
||||
if (has('db:read:journal')) {
|
||||
this.methods.set('journal.listMine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('journal reads require an authenticated user context');
|
||||
return deps.listJournalsForUser(uid);
|
||||
});
|
||||
// A journey's entries (photos/story/checkins). Journeys are user-scoped, not
|
||||
// trip-scoped, so the access check is journey membership inside the wiring.
|
||||
this.methods.set('journal.getEntries', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('journal reads require an authenticated user context');
|
||||
return deps.journalEntriesForUser(uid, num(p.journeyId, 'journeyId'));
|
||||
});
|
||||
}
|
||||
if (has('db:read:atlas')) {
|
||||
this.methods.set('atlas.visited', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('atlas reads require an authenticated user context');
|
||||
return deps.atlasVisitedForUser(uid);
|
||||
});
|
||||
this.methods.set('atlas.bucketList', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('atlas reads require an authenticated user context');
|
||||
return deps.atlasBucketForUser(uid);
|
||||
});
|
||||
}
|
||||
if (has('db:read:vacay')) {
|
||||
this.methods.set('vacay.mine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('vacay reads require an authenticated user context');
|
||||
return deps.vacayForUser(uid);
|
||||
});
|
||||
}
|
||||
if (has('db:read:collections')) {
|
||||
this.methods.set('collections.listMine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('collection reads require an authenticated user context');
|
||||
return deps.listCollectionsForUser(uid);
|
||||
});
|
||||
// getCollection is user-scoped by the service (it takes the acting user), so a
|
||||
// plugin can only fetch a collection the acting user owns.
|
||||
this.methods.set('collections.get', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('collection reads require an authenticated user context');
|
||||
return deps.getCollectionForUser(uid, num(p.id, 'id'));
|
||||
});
|
||||
}
|
||||
if (has('db:write:collections')) {
|
||||
// Collections write. The service enforces per-collection role itself
|
||||
// (owner/admin/editor via assertCanEdit) against the HOST-bound acting user —
|
||||
// the wiring maps its 403/404 to RESOURCE_FORBIDDEN. Inputs are schema-validated.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('collection writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
this.methods.set('collections.create', (p, uid) => {
|
||||
const parsed = collectionCreateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.createCollectionForUser(requireUid(uid), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.update', (p, uid) => {
|
||||
const parsed = collectionUpdateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.updateCollectionForUser(requireUid(uid), num(p.id, 'id'), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.savePlace', (p, uid) => {
|
||||
const parsed = collectionSavePlaceRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.saveCollectionPlace(requireUid(uid), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.copyToTrip', (p, uid) => {
|
||||
const parsed = collectionCopyToTripRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid copy request: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.copyCollectionToTrip(requireUid(uid), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.deletePlace', (p, uid) => deps.deleteCollectionPlace(requireUid(uid), num(p.placeId, 'placeId')));
|
||||
}
|
||||
if (has('db:write:atlas')) {
|
||||
// Atlas write: every row is the acting user's own (visited_countries /
|
||||
// visited_regions / bucket) — no trip scoping, no cross-tenant surface.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('atlas writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
const code = (v: unknown, name: string): string => {
|
||||
if (typeof v !== 'string' || v.trim() === '' || v.length > 8) throw new BadParams(`${name} must be a short code`);
|
||||
return v.trim().toUpperCase();
|
||||
};
|
||||
this.methods.set('atlas.markCountry', (p, uid) => deps.markCountryVisited(requireUid(uid), code(p.code, 'code')));
|
||||
this.methods.set('atlas.unmarkCountry', (p, uid) => deps.unmarkCountryVisited(requireUid(uid), code(p.code, 'code')));
|
||||
this.methods.set('atlas.markRegion', (p, uid) => {
|
||||
const u = requireUid(uid);
|
||||
const regionName = typeof p.regionName === 'string' && p.regionName ? p.regionName.slice(0, 128) : String(p.regionCode ?? '');
|
||||
return deps.markRegionVisited(u, code(p.regionCode, 'regionCode'), regionName, code(p.countryCode, 'countryCode'));
|
||||
});
|
||||
this.methods.set('atlas.unmarkRegion', (p, uid) => deps.unmarkRegionVisited(requireUid(uid), code(p.regionCode, 'regionCode')));
|
||||
this.methods.set('atlas.createBucketItem', (p, uid) => {
|
||||
const u = requireUid(uid);
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required');
|
||||
return deps.createBucketItem(u, input);
|
||||
});
|
||||
this.methods.set('atlas.deleteBucketItem', (p, uid) => deps.deleteBucketItem(requireUid(uid), num(p.itemId, 'itemId')));
|
||||
}
|
||||
if (has('db:write:vacay')) {
|
||||
// Vacay write: the plan is resolved host-side from the acting user's active
|
||||
// plan — a plugin can never name another plan. toggleEntry only ever toggles
|
||||
// the ACTING USER's own PTO day.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('vacay writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
const dateStr = (v: unknown): string => {
|
||||
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new BadParams('date must be YYYY-MM-DD');
|
||||
return v;
|
||||
};
|
||||
this.methods.set('vacay.toggleEntry', (p, uid) => deps.vacayToggleEntry(requireUid(uid), dateStr(p.date)));
|
||||
this.methods.set('vacay.toggleCompanyHoliday', (p, uid) =>
|
||||
deps.vacayToggleCompanyHoliday(requireUid(uid), dateStr(p.date), typeof p.note === 'string' ? p.note.slice(0, 256) : undefined));
|
||||
}
|
||||
if (has('db:write:journal')) {
|
||||
// Journal write: journeyService.canEdit self-gates every call against the
|
||||
// acting user (owner/contributor) — the wiring maps a refusal to
|
||||
// RESOURCE_FORBIDDEN. Journeys are user-scoped, not trip-scoped.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('journal writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
this.methods.set('journal.createEntry', (p, uid) => {
|
||||
const u = requireUid(uid);
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required');
|
||||
return deps.createJournalEntry(u, num(p.journeyId, 'journeyId'), input);
|
||||
});
|
||||
this.methods.set('journal.updateEntry', (p, uid) =>
|
||||
deps.updateJournalEntry(requireUid(uid), num(p.entryId, 'entryId'), asPayload(p.input)));
|
||||
this.methods.set('journal.deleteEntry', (p, uid) => deps.deleteJournalEntry(requireUid(uid), num(p.entryId, 'entryId')));
|
||||
this.methods.set('journal.createJourney', (p, uid) => deps.createJournal(requireUid(uid), asPayload(p.input)));
|
||||
this.methods.set('journal.deleteJourney', (p, uid) => deps.deleteJournal(requireUid(uid), num(p.journeyId, 'journeyId')));
|
||||
}
|
||||
// collab, journal, atlas, vacay and collections now live in their own
|
||||
// domains' *.rpc.ts, each carrying its addon gate with it.
|
||||
|
||||
// costs.* now lives in src/nest/budget/costs.rpc.ts.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user