feat(collections): per-member permission roles on shared lists (#1081)

The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.

- Server: role column on collection_members (migration 152 + schema); roleOf +
  assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
  on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
  POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
  for the owner (read-only role badge for others); the page hides add / edit /
  status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.
This commit is contained in:
Maurice
2026-07-01 17:11:16 +02:00
committed by Maurice
parent f2972481fe
commit 1e0feb93dd
35 changed files with 263 additions and 40 deletions
@@ -6,6 +6,13 @@ export const COLLECTION_STATUSES = ['idea', 'want', 'visited'] as const;
export const collectionStatusSchema = z.enum(COLLECTION_STATUSES).catch('idea').default('idea');
export type CollectionStatus = (typeof COLLECTION_STATUSES)[number];
/** Per-member permission on a shared list. viewer = read + copy-to-trip only;
* editor (default) = add + edit places; admin = full incl. delete. The owner
* is always full and is not a member row. */
export const COLLECTION_ROLES = ['viewer', 'editor', 'admin'] as const;
export const collectionRoleSchema = z.enum(COLLECTION_ROLES).catch('editor').default('editor');
export type CollectionRole = (typeof COLLECTION_ROLES)[number];
/** A user-added link on a list or a saved place (stored as a JSON array). */
export const collectionLinkSchema = z.object({
label: z.string().max(120).optional(),
@@ -55,6 +62,7 @@ export const collectionMemberSchema = z.object({
email: z.string().optional(),
avatar: z.string().nullable().optional(),
status: z.enum(['pending', 'accepted']),
role: collectionRoleSchema.optional(),
is_owner: z.boolean().optional(),
});
export type CollectionMember = z.infer<typeof collectionMemberSchema>;
@@ -157,6 +165,7 @@ export type CollectionCopyToTripRequest = z.infer<typeof collectionCopyToTripReq
export const collectionInviteRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
role: collectionRoleSchema.optional(),
});
export type CollectionInviteRequest = z.infer<typeof collectionInviteRequestSchema>;
@@ -176,6 +185,14 @@ export const collectionRemoveMemberRequestSchema = z.object({
});
export type CollectionRemoveMemberRequest = z.infer<typeof collectionRemoveMemberRequestSchema>;
/** Owner changes an accepted member's permission role. */
export const collectionSetMemberRoleRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
role: collectionRoleSchema,
});
export type CollectionSetMemberRoleRequest = z.infer<typeof collectionSetMemberRoleRequestSchema>;
// ── Responses ─────────────────────────────────────────────────────────────
export const collectionListResponseSchema = z.object({
collections: z.array(collectionSchema),