Bridge Training

Sharing

The sharing status of a board and the sharing groups through which a teacher shares boards with chosen peers, with the SharingGroupsService.

The sharing module models who can see a teacher's boards beyond the teacher. A board's Sharing value says whether it is private, public or shared, and a sharing group is the set of peer accounts a teacher shares with. Each account owns at most one sharing group and can be a member of many. The teacher app's sharing settings, the API, the admin import and the bulk claim all go through the service.

Import from @workspace/training/domain/sharing and @workspace/training/application/sharing.

Domain

Sharing

ExportDefinition
SHARING{ PRIVATE: "private", PUBLIC: "public", SHARED: "shared" }
SHARING_VALUES["private", "public", "shared"]
SharingUnion of the three literals, carried by boards
sharing.ts
const status: Sharing = SHARING.SHARED; // => "shared"
SHARING_VALUES; // => ["private", "public", "shared"]
export const isShared = status === SHARING.SHARED;

Sharing group

Prop

Type

Prop

Type

Prop

Type

SharingGroupMembership is the view from the member's side: the groups an account was added to, with the owner's display fields.

The module has no pure functions.

Application

SharingGroupsService

createSharingGroupsService(deps: SharingGroupsServiceDeps): SharingGroupsService

DependencyTypeRole
sharingGroupsSharingGroupsRepositoryGroup writes.
sharingGroupsReadSharingGroupsReadRepositoryGroup reads.
membersSharingGroupMembersRepositoryThe member links.
entitlementsEntitlementsGateThe plan check before addMember.

Owner-scoped methods use ctx.userId. The id-scoped ones that read or change a group (update, upsert with an id, getMembers, getMemberCount, addMember, removeMember) belong to the group's owner: anyone else, a member included, gets AccessDeniedError before anything is read, and a missing group gets the same answer. The ServiceContext comes last.

MethodSignatureDescription
getByOwner(ctx) => Promise<SharingGroup | null>The caller's own group.
create(name: string | undefined, ctx) => Promise<SharingGroup>Owned by ctx.userId.
update(groupId: string, updates: UpdateSharingGroupDTO, ctx) => Promise<SharingGroup>Owner only. description is accepted and not persisted.
upsert(groupId: string | null, updates: UpdateSharingGroupDTO, ctx) => Promise<SharingGroup>Updates when groupId is given (owner only), otherwise creates for the caller with updates.name.
getMembers(sharingGroupId: string, ctx) => Promise<Account[]>Owner only: full accounts of the members, emails included.
getMemberCount(sharingGroupId: string, ctx) => Promise<number>Owner only.
addMember(sharingGroupId: string, userIdToAdd: string, ctx) => Promise<void>Owner only. Checks the ownership, then the members per sharing group limit, then adds the link. Adding an existing member is a no-op.
ensureWithMembers(ownerId: string, name: string, memberIds: readonly string[], ctx) => Promise<EnsureWithMembersResult>Idempotent: reaches the owner's group named name, creating it when missing, and unions memberIds into it. ownerId is explicit because the admin and import paths act on another owner's group. Returns { id, createdNow, added }.
removeMember(sharingGroupId: string, userIdToRemove: string, ctx) => Promise<void>Owner only.
leaveSharingGroup(sharingGroupId: string, ctx) => Promise<void>The caller leaves. Throws AccessDeniedError when the caller owns that group: owners delete, they do not leave.
getMemberships(ctx) => Promise<SharingGroupMembership[]>The groups the caller is a member of, newest first.

AccountSharingGroupsService is SharingGroupsService without ensureWithMembers. The API types its sharingGroupsService with it, so a request on behalf of an account cannot reach the back-office method that acts for another owner.

sharing.ts
// Adding a member checks the group exists and is the caller's own first.
export function shareWith(
  repositories: Omit<SharingGroupsServiceDeps, "entitlements">,
  groupId: string,
  userIdToAdd: string,
  ctx: ServiceContext
): Promise<void> {
  const sharingGroups = createSharingGroupsService({
    ...repositories,
    entitlements: allowAllEntitlements,
  });
  return sharingGroups.addMember(groupId, userIdToAdd, ctx);
}
sharing.ts
export async function leave(
  repositories: Omit<SharingGroupsServiceDeps, "entitlements">,
  sharingGroupId: string,
  ctx: ServiceContext
): Promise<"left" | "owner"> {
  const sharingGroups = createSharingGroupsService({
    ...repositories,
    entitlements: allowAllEntitlements,
  });
  try {
    await sharingGroups.leaveSharingGroup(sharingGroupId, ctx);
    return "left";
  } catch (error) {
    if (isAppError(error) && error.code === "ACCESS_DENIED") {
      return "owner"; // owners delete their group instead of leaving it
    }
    throw error;
  }
}
sharing.ts
export function sharedWithMe(
  repositories: Omit<SharingGroupsServiceDeps, "entitlements">,
  ctx: ServiceContext
) {
  const sharingGroups = createSharingGroupsService({
    ...repositories,
    entitlements: allowAllEntitlements,
  });
  return sharingGroups.getMemberships(ctx); // => SharingGroupMembership[]
}

DTOs

The DTOs are the wire shapes of the API routes; the service takes their fields as positional arguments.

Prop

Type

Prop

Type

Prop

Type

Ports

The groups bind the canonical surface of Queries; their members are a junction with a repository of its own.

ExportBindsNotes
SharingGroupWhereWhereShape{ id?, ownerId?, name? }, each an Equality<string>. name is the stored text, "" for a group without one.
SharingGroupQuery, SharingGroupQueryParamsQueryObjectempty(), withId, byOwner, byName, withPagination, withFilters. No sorting.
SharingGroupCriteriaCriteria<SharingGroupWhere>byId, byIds, byOwner, where; instance and.
SharingGroupProjectionsProjection<T>entity(): the SharingGroup.
SharingGroupAggregationsAggregation<T>count(criteria?).
SharingGroupPatchTPatch{ name?: string }.
PersistableSharingGroupWriteTPersistableWrite{ ownerId: string; name: string | null }. A null name is stored as an empty string.
SharingGroupsReadRepositoryReadRepository<SharingGroupQuery>getById throws NotFoundError(SHARING_GROUP_NOT_FOUND) when the group is missing or hidden.
SharingGroupsRepositoryWriteRepository<SharingGroupCriteria, SharingGroupPatch, PersistableSharingGroupWrite>Plus writeOne(input) => Promise<SharingGroup>. update always bumps updatedAt, so an empty patch still reports the group it matched.

SharingGroupMembersRepository

The sharing_group_members junction. A link is unique per group and user, and deleting a group cascades to its links.

ExportNotes
SharingGroupMembersWhere{ sharingGroupId?, userId? }, each an Equality<string>.
SharingGroupMembersQueryempty(), byGroup, byUser, withFilters, and asViewer(userId): the viewer sees their own untrusted avatar in the memberships projection.
SharingGroupMembersCriteriabyGroup, byUser, byGroupAndUser, where; instance and.
SharingGroupMembersProjectionslinks(): SharingGroupMemberLink, oldest first. accounts(): the member Accounts. memberships(): SharingGroupMembership, newest first.
SharingGroupMembersAggregationscount(criteria?).
MethodSignatureDescription
findMany<T>(query: SharingGroupMembersQuery, projection: Projection<T>) => Promise<T[]>Projected read of the links.
aggregate<T>(aggregation: Aggregation<T>) => Promise<T>Link counts.
writeMany(links: readonly SharingGroupMemberLink[]) => Promise<{ added: number }>Set union: an existing link is kept and not counted.
delete(criteria: SharingGroupMembersCriteria) => Promise<{ deletedCount: number }>Filtered delete.

Errors

ErrorCodeThrown by
AccessDeniedErrorACCESS_DENIEDupdate, upsert, getMembers, getMemberCount, addMember and removeMember when the caller does not own the group or the group is missing; leaveSharingGroup by the group's owner.
NotFoundErrorSHARING_GROUP_NOT_FOUNDupdate when the owned group disappears between the check and the write.

Test doubles: createFakeSharingGroupsRepository and createFakeSharingGroupMembersRepository in Testing. Test data: makeSharingGroup and makeSharingGroupMembership in Factories.