Skip to content
Merged
15 changes: 15 additions & 0 deletions cozy.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,21 @@ rabbitmq:
delivery_limit: 5
bindings:
- domain.user.deleted
- name: stack.b2b.group.lifecycle
declare: true
declare_dlx: true
declare_dlq: true
dlx_name: stack.b2b.dlx
dlq_name: stack.dead.letter.b2b.group.lifecycle
dl_routing_key: b2b.group.dead
prefetch: 8
delivery_limit: 5
bindings:
- b2b.group.created
- b2b.group.updated
- b2b.group.deleted
- b2b.group.member.added
- b2b.group.member.removed
- name: stack.app.commands.queue
declare: true
declare_dlx: true
Expand Down
38 changes: 38 additions & 0 deletions docs/rabbitmq.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ rabbitmq:
delivery_limit: 5
bindings:
- domain.user.deleted
- name: stack.b2b.group.lifecycle
declare: true
declare_dlx: true
declare_dlq: true
dlx_name: stack.b2b.dlx
dlq_name: stack.dead.letter.b2b.group.lifecycle
dl_routing_key: b2b.group.dead
prefetch: 8
delivery_limit: 5
bindings:
- b2b.group.created
- b2b.group.updated
- b2b.group.deleted
- b2b.group.member.added
- b2b.group.member.removed
- name: stack.app.commands.queue
declare: true
declare_dlx: true
Expand Down Expand Up @@ -313,6 +328,29 @@ Example payload for `b2b/domain.user.deleted`:
The delete handler removes the single matching contact found by `internalEmail`
when `metadata.external` is true.

Example payload for `b2b.group.created`:

```json
{
"timestamp": "2026-06-16T10:30:00.000Z",
"organizationId": "org123",
"id": "engineering",
"name": "engineering",
"description": "Engineering team",
"color": "#3366FF",
"createdAt": "2026-06-16T10:30:00.000Z",
"members": []
}
```

The Stack consumes `b2b.group.created`, `b2b.group.updated`,
`b2b.group.deleted`, `b2b.group.member.added`, and
`b2b.group.member.removed` from one lifecycle queue. Groups are replicated as
managed `io.cozy.contacts.groups` documents in every instance with the matching
`org_id`. Group membership is stored on managed external contacts via
`relationships.groups.data`; existing generated-ID external contacts are reused
by matching email or Cozy URL.

Example payload for `user.phone.updated`:

```json
Expand Down
8 changes: 8 additions & 0 deletions model/instance/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ func ListByOrgDomain(orgDomain string) ([]*Instance, error) {
return service.ListByOrgDomain(orgDomain)
}

// ListByOrgID finds instances of a given organization from the organization
// identifier by using CouchDB.
//
// Deprecated: Use [InstanceService.ListByOrgID] instead.
func ListByOrgID(orgID string) ([]*Instance, error) {
return service.ListByOrgID(orgID)
}

// Update saves the changes in CouchDB.
//
// Deprecated: Use [InstanceService.Update] instead.
Expand Down
6 changes: 6 additions & 0 deletions model/instance/lifecycle/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,9 @@ func GetInstance(domain string) (*instance.Instance, error) {
func ListOrgInstances(orgDomain string) ([]*instance.Instance, error) {
return instance.ListByOrgDomain(orgDomain)
}

// ListOrgInstancesByID retrieves all the instances of an organization by its
// organization identifier.
func ListOrgInstancesByID(orgID string) ([]*instance.Instance, error) {
return instance.ListByOrgID(orgID)
}
45 changes: 37 additions & 8 deletions model/instance/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type InstanceService struct {
logger logger.Logger
}

const orgInstanceListPageSize = 1000
Comment thread
shepilov marked this conversation as resolved.

func NewService(logger logger.Logger) *InstanceService {
return &InstanceService{
logger: logger,
Expand Down Expand Up @@ -55,16 +57,43 @@ func (s *InstanceService) Get(domain string) (*Instance, error) {
}

func (s *InstanceService) ListByOrgDomain(orgDomain string) ([]*Instance, error) {
return s.listByOrgField("by-orgdomain", "org_domain", orgDomain)
}

func (s *InstanceService) ListByOrgID(orgID string) ([]*Instance, error) {
return s.listByOrgField("by-orgid", "org_id", orgID)
}

func (s *InstanceService) listByOrgField(indexName, fieldName, value string) ([]*Instance, error) {
var docs []*Instance
req := &couchdb.FindRequest{
UseIndex: "by-orgdomain",
Selector: mango.Equal("org_domain", orgDomain),
}
err := couchdb.FindDocs(prefixer.GlobalPrefixer, consts.Instances, req, &docs)
if err != nil {
return nil, err
var bookmark string
for {
var page []*Instance
req := &couchdb.FindRequest{
UseIndex: indexName,
Selector: mango.Equal(fieldName, value),
Limit: orgInstanceListPageSize,
Bookmark: bookmark,
}
res, err := couchdb.FindDocsRaw(prefixer.GlobalPrefixer, consts.Instances, req, &page)
if err != nil {
return nil, err
}
for _, inst := range page {
if inst != nil {
docs = append(docs, inst)
}
}

nextBookmark := ""
if res != nil {
nextBookmark = res.Bookmark
}
if len(page) < orgInstanceListPageSize || nextBookmark == "" || nextBookmark == bookmark {
return docs, nil
}
bookmark = nextBookmark
}
return docs, nil
}

// Update saves the changes in CouchDB.
Expand Down
107 changes: 107 additions & 0 deletions model/orgdirectory/contact_patch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package orgdirectory

import (
"fmt"
"strings"

"github.com/cozy/cozy-stack/model/contact"
"github.com/cozy/cozy-stack/model/instance"
"github.com/cozy/cozy-stack/pkg/utils"
)

// ContactPatch describes a B2B contact replica to create or update.
type ContactPatch struct {
OrganizationID string
Username string
Email string
FirstName string
LastName string
WorkplaceFQDN string
Name string
CozyURL string
Phone string
}

func (patch ContactPatch) validate() error {
if strings.TrimSpace(patch.Email) == "" {
return fmt.Errorf("contact missing email")
}
return nil
}

func (patch ContactPatch) shouldSkipOwn(inst *instance.Instance) bool {
if patch.WorkplaceFQDN != "" && inst.HasDomain(utils.ExtractInstanceHost(patch.WorkplaceFQDN)) {
return true
}
if patch.Email == "" {
return false
}
email, err := inst.SettingsEMail()
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(email), patch.Email)
}

func (patch ContactPatch) displayName() string {
if patch.Name != "" {
return patch.Name
}
name := strings.TrimSpace(patch.FirstName + " " + patch.LastName)
if name != "" {
return name
}
if patch.Username != "" {
return patch.Username
}
if patch.Email != "" {
parts := strings.SplitN(patch.Email, "@", 2)
return parts[0]
}
return ""
}

func contactPatchFromMember(organizationID string, member GroupMember) ContactPatch {
workplaceFQDN := strings.TrimSpace(member.WorkplaceFQDN)
return ContactPatch{
OrganizationID: strings.TrimSpace(organizationID),
Username: strings.TrimSpace(member.Username),
Email: strings.TrimSpace(member.Email),
FirstName: strings.TrimSpace(member.FirstName),
LastName: strings.TrimSpace(member.LastName),
WorkplaceFQDN: workplaceFQDN,
CozyURL: cozyURLFromWorkplaceFQDN(workplaceFQDN),
}
}

func contactPatchFromContact(organizationID string, contactDoc *contact.Contact) ContactPatch {
meta := directoryMetadata(&contactDoc.JSONDoc)
patch := ContactPatch{OrganizationID: organizationID}
patch.Username = meta.Username
patch.Email = meta.Email
patch.WorkplaceFQDN = meta.WorkplaceFQDN
patch.CozyURL = contactDoc.PrimaryCozyURL()
if patch.CozyURL == "" {
patch.CozyURL = cozyURLFromWorkplaceFQDN(patch.WorkplaceFQDN)
}
patch.Name = primaryContactName(contactDoc)
patch.Phone = contactDoc.PrimaryPhoneNumber()
return patch
}

func primaryContactName(contactDoc *contact.Contact) string {
name := contactDoc.PrimaryName()
if name != "" {
return name
}
displayName, _ := contactDoc.M["displayName"].(string)
return displayName
}

func cozyURLFromWorkplaceFQDN(workplaceFQDN string) string {
domain := utils.ExtractInstanceHost(workplaceFQDN)
if domain == "" {
return ""
}
return "https://" + domain
}
Loading
Loading