Status: Done Phase: Phase 2 Priority: Medium Depends On: Epic 1D (Contact Relationships)
Collectives are entities that group contacts together - families, companies, clubs, or friend groups. When you add a contact to a collective with a specific role, relationships are automatically created based on that role and the collective type. This eliminates the tedious process of manually defining relationships between every pair of contacts in a group.
Think of it as: "Add Sarah as a child to the Smith Family, and automatically connect her as child to the parents and sibling to other children."
- Provide a way to group related contacts under a named collective
- Automatically create relationships between members based on roles
- Support multiple collective types with customizable role definitions
- Allow contacts to belong to multiple collectives
- Track membership history (members can become inactive but history is preserved)
Collectives have a type that determines what roles are available and what automatic relationships are created. Default types:
| Type | Description | Available Roles |
|---|---|---|
| Family | A family unit | parent, child, spouse, grandparent, grandchild |
| Company | A business organization | owner, manager, employee |
| Club | Sports club, hackerspace, etc. | member |
| Friend Group | A social circle | member |
Users can create custom collective types with their own role definitions.
When a contact is added to a collective with a role, relationships are automatically created with existing members based on predefined rules:
| New Member Role | Existing Member Role | Relationship Created |
|---|---|---|
| child | parent | parent → child |
| child | child | sibling → sibling |
| child | grandparent | grandparent → grandchild |
| parent | child | parent → child |
| parent | parent | (no automatic relationship - could be spouse or co-parent) |
| parent | grandparent | parent → child |
| spouse | spouse | spouse → spouse |
| grandparent | child | grandparent → grandchild |
| grandparent | grandchild | grandparent → grandchild |
| grandchild | grandparent | grandparent → grandchild |
| New Member Role | Existing Member Role | Relationship Created |
|---|---|---|
| employee | employee | colleague → colleague |
| employee | manager | manager → report |
| employee | owner | (no automatic relationship) |
| manager | employee | manager → report |
| manager | manager | colleague → colleague |
| owner | (any) | (no automatic relationship) |
| New Member Role | Existing Member Role | Relationship Created |
|---|---|---|
| member | member | friend → friend |
Memberships are never deleted - they become inactive:
- Active - Current member of the collective
- Inactive - Former member (left the company, moved away, divorce, etc.)
Inactive memberships preserve history and the relationships that were created remain intact. This is important because:
- Someone who left a company still worked there at some point
- A divorce doesn't erase the history of being married
- A child who moves out is still part of the family
Effect on auto-generated relationships:
- When a membership becomes inactive, relationships with
source_membership_idpointing to it are not automatically deleted or modified - Users can manually delete these relationships if desired
- The relationship retains the
source_membership_idreference for provenance tracking - If the membership is reactivated, no new relationships are created (existing ones are still valid)
- Create, edit, and view collectives
- Each collective has:
- Name (e.g., "The Smith Family", "Acme Corp", "Weekend Hiking Group")
- Type (Family, Company, Club, Friend Group, or custom)
- Photo/avatar (optional)
- Address (optional - family home, company HQ, club location)
- Notes (optional)
- Created date
- List all collectives with filtering by type
- View collective detail page showing all members
- Add contact to collective with a role
- Change a member's role
- Mark membership as inactive (with optional reason/date)
- Reactivate inactive membership
- View membership history
- When adding a member, automatically create relationships based on rules
- Show preview of relationships that will be created before confirming
- Allow user to skip automatic relationship creation if desired
- Relationships created this way are marked as "auto-generated from collective"
- View all collective types (default + custom)
- Create custom collective types with:
- Name
- Available roles
- Relationship rules (which role pairs create which relationships)
- Edit custom collective types
- Cannot delete collective types that have collectives using them
- As a user, I want to create a "Family" collective so I can group my family contacts together
- As a user, I want to add my children to the family collective and have parent-child relationships automatically created
- As a user, I want to create a company collective for my workplace so colleagues are connected automatically
- As a user, I want to mark someone as having left a company without losing the history that they worked there
- As a user, I want to see all members of a collective on a single page
- As a user, I want a contact to belong to multiple collectives (family and workplace)
- As a user, I want to create a custom collective type for my book club with just "member" roles
- As a user, I want to see which relationships were auto-created from collective membership
Note: This schema uses PostgreSQL-specific features (partial unique constraints, gen_random_uuid()).
-- Create schema for collectives
CREATE SCHEMA IF NOT EXISTS collectives;
-- Collective types (reference table with defaults + user-created)
CREATE TABLE collectives.collective_types (
id SERIAL PRIMARY KEY,
external_id UUID NOT NULL DEFAULT gen_random_uuid(),
user_id INTEGER REFERENCES auth.users(id) ON DELETE CASCADE, -- NULL for system defaults
name TEXT NOT NULL,
description TEXT,
is_system_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_collective_type_name_per_user UNIQUE (user_id, name)
);
CREATE UNIQUE INDEX idx_collective_types_external_id ON collectives.collective_types(external_id);
-- Roles available for each collective type
CREATE TABLE collectives.collective_roles (
id SERIAL PRIMARY KEY,
external_id UUID NOT NULL DEFAULT gen_random_uuid(),
collective_type_id INTEGER NOT NULL REFERENCES collectives.collective_types(id) ON DELETE CASCADE,
role_key TEXT NOT NULL, -- e.g., 'parent', 'child', 'employee'
label TEXT NOT NULL, -- e.g., 'Parent', 'Child', 'Employee'
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_role_per_type UNIQUE (collective_type_id, role_key)
);
CREATE UNIQUE INDEX idx_collective_roles_external_id ON collectives.collective_roles(external_id);
-- Rules for automatic relationship creation
CREATE TABLE collectives.collective_relationship_rules (
id SERIAL PRIMARY KEY,
collective_type_id INTEGER NOT NULL REFERENCES collectives.collective_types(id) ON DELETE CASCADE,
-- When a member with this role is added...
new_member_role_id INTEGER NOT NULL REFERENCES collectives.collective_roles(id) ON DELETE CASCADE,
-- ...and there's an existing member with this role...
existing_member_role_id INTEGER NOT NULL REFERENCES collectives.collective_roles(id) ON DELETE CASCADE,
-- ...create this relationship type
relationship_type_id TEXT NOT NULL REFERENCES contacts.relationship_types(id),
-- Direction: which contact becomes the "from" in the relationship?
-- 'new_member' = new member is from_contact, existing is to_contact
-- 'existing_member' = existing member is from_contact, new is to_contact
-- 'both' = create bidirectional (two relationships, one in each direction)
relationship_direction TEXT NOT NULL DEFAULT 'new_member'
CHECK (relationship_direction IN ('new_member', 'existing_member', 'both')),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_rule UNIQUE (collective_type_id, new_member_role_id, existing_member_role_id)
);
-- Collectives (instances of collective types)
CREATE TABLE collectives.collectives (
id SERIAL PRIMARY KEY,
external_id UUID NOT NULL DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- RESTRICT prevents deleting a collective type that has collectives using it
collective_type_id INTEGER NOT NULL REFERENCES collectives.collective_types(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
photo_url TEXT,
photo_thumbnail_url TEXT,
notes TEXT,
-- Optional address
address_street_line1 TEXT,
address_street_line2 TEXT,
address_city TEXT,
address_state_province TEXT,
address_postal_code TEXT,
address_country TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ, -- Soft delete support
CONSTRAINT collectives_name_not_empty CHECK (LENGTH(TRIM(name)) > 0)
);
CREATE UNIQUE INDEX idx_collectives_external_id ON collectives.collectives(external_id);
CREATE INDEX idx_collectives_user_id ON collectives.collectives(user_id);
CREATE INDEX idx_collectives_type_id ON collectives.collectives(collective_type_id);
CREATE INDEX idx_collectives_active ON collectives.collectives(user_id) WHERE deleted_at IS NULL;
-- Collective memberships (contacts belonging to collectives)
CREATE TABLE collectives.collective_memberships (
id SERIAL PRIMARY KEY,
external_id UUID NOT NULL DEFAULT gen_random_uuid(),
collective_id INTEGER NOT NULL REFERENCES collectives.collectives(id) ON DELETE CASCADE,
contact_id INTEGER NOT NULL REFERENCES contacts.contacts(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES collectives.collective_roles(id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
inactive_reason TEXT,
inactive_date DATE,
joined_date DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- A contact can only have one active membership per collective
-- (but can have multiple inactive ones for history)
CONSTRAINT unique_active_membership UNIQUE (collective_id, contact_id)
WHERE (is_active = TRUE)
);
CREATE UNIQUE INDEX idx_collective_memberships_external_id ON collectives.collective_memberships(external_id);
CREATE INDEX idx_collective_memberships_collective_id ON collectives.collective_memberships(collective_id);
CREATE INDEX idx_collective_memberships_contact_id ON collectives.collective_memberships(contact_id);
CREATE INDEX idx_collective_memberships_active ON collectives.collective_memberships(collective_id, is_active)
WHERE is_active = TRUE;
-- Track which relationships were auto-created from collective membership
-- (Added column to existing contact_relationships table)
ALTER TABLE contacts.contact_relationships
ADD COLUMN source_membership_id INTEGER REFERENCES collectives.collective_memberships(id) ON DELETE SET NULL;
CREATE INDEX idx_contact_relationships_source_membership
ON contacts.contact_relationships(source_membership_id)
WHERE source_membership_id IS NOT NULL;-- Insert default collective types
INSERT INTO collectives.collective_types (name, description, is_system_default) VALUES
('Family', 'A family unit with parents, children, and extended family', TRUE),
('Company', 'A business organization with employees and management', TRUE),
('Club', 'A club, organization, or group like a sports club or hackerspace', TRUE),
('Friend Group', 'A social circle of friends', TRUE);
-- Insert roles for Family
INSERT INTO collectives.collective_roles (collective_type_id, role_key, label, sort_order)
SELECT id, role_key, label, sort_order FROM collectives.collective_types,
(VALUES
('parent', 'Parent', 1),
('child', 'Child', 2),
('spouse', 'Spouse/Partner', 3),
('grandparent', 'Grandparent', 4),
('grandchild', 'Grandchild', 5)
) AS roles(role_key, label, sort_order)
WHERE name = 'Family';
-- Insert roles for Company
INSERT INTO collectives.collective_roles (collective_type_id, role_key, label, sort_order)
SELECT id, role_key, label, sort_order FROM collectives.collective_types,
(VALUES
('owner', 'Owner/Founder', 1),
('manager', 'Manager', 2),
('employee', 'Employee', 3)
) AS roles(role_key, label, sort_order)
WHERE name = 'Company';
-- Insert roles for Club
INSERT INTO collectives.collective_roles (collective_type_id, role_key, label, sort_order)
SELECT id, role_key, label, sort_order FROM collectives.collective_types,
(VALUES
('member', 'Member', 1)
) AS roles(role_key, label, sort_order)
WHERE name = 'Club';
-- Insert roles for Friend Group
INSERT INTO collectives.collective_roles (collective_type_id, role_key, label, sort_order)
SELECT id, role_key, label, sort_order FROM collectives.collective_types,
(VALUES
('member', 'Member', 1)
) AS roles(role_key, label, sort_order)
WHERE name = 'Friend Group';
-- Insert relationship rules for Family
-- Uses subqueries to look up role IDs dynamically
INSERT INTO collectives.collective_relationship_rules (collective_type_id, new_member_role_id, existing_member_role_id, relationship_type_id, relationship_direction)
SELECT
ct.id,
nr.id,
er.id,
rules.relationship_type_id,
rules.direction
FROM collectives.collective_types ct
CROSS JOIN (VALUES
-- child + parent = parent → child (existing parent is "from", new child is "to")
('child', 'parent', 'parent_child', 'existing_member'),
-- child + child = sibling ↔ sibling (bidirectional)
('child', 'child', 'sibling', 'both'),
-- child + grandparent = grandparent → grandchild
('child', 'grandparent', 'grandparent_grandchild', 'existing_member'),
-- parent + child = parent → child (new parent is "from", existing child is "to")
('parent', 'child', 'parent_child', 'new_member'),
-- parent + grandparent = grandparent → parent (existing grandparent is "from")
('parent', 'grandparent', 'parent_child', 'existing_member'),
-- spouse + spouse = spouse ↔ spouse (bidirectional)
('spouse', 'spouse', 'spouse', 'both'),
-- grandparent + child = grandparent → grandchild
('grandparent', 'child', 'grandparent_grandchild', 'new_member'),
-- grandparent + grandchild = grandparent → grandchild
('grandparent', 'grandchild', 'grandparent_grandchild', 'new_member'),
-- grandchild + grandparent = grandparent → grandchild
('grandchild', 'grandparent', 'grandparent_grandchild', 'existing_member')
) AS rules(new_role, existing_role, relationship_type_id, direction)
JOIN collectives.collective_roles nr ON nr.collective_type_id = ct.id AND nr.role_key = rules.new_role
JOIN collectives.collective_roles er ON er.collective_type_id = ct.id AND er.role_key = rules.existing_role
WHERE ct.name = 'Family';
-- Insert relationship rules for Company
INSERT INTO collectives.collective_relationship_rules (collective_type_id, new_member_role_id, existing_member_role_id, relationship_type_id, relationship_direction)
SELECT
ct.id,
nr.id,
er.id,
rules.relationship_type_id,
rules.direction
FROM collectives.collective_types ct
CROSS JOIN (VALUES
-- employee + employee = colleague ↔ colleague (bidirectional)
('employee', 'employee', 'colleague', 'both'),
-- employee + manager = manager → report (existing manager is "from")
('employee', 'manager', 'manager_report', 'existing_member'),
-- manager + employee = manager → report (new manager is "from")
('manager', 'employee', 'manager_report', 'new_member'),
-- manager + manager = colleague ↔ colleague (bidirectional)
('manager', 'manager', 'colleague', 'both')
) AS rules(new_role, existing_role, relationship_type_id, direction)
JOIN collectives.collective_roles nr ON nr.collective_type_id = ct.id AND nr.role_key = rules.new_role
JOIN collectives.collective_roles er ON er.collective_type_id = ct.id AND er.role_key = rules.existing_role
WHERE ct.name = 'Company';
-- Insert relationship rules for Club
INSERT INTO collectives.collective_relationship_rules (collective_type_id, new_member_role_id, existing_member_role_id, relationship_type_id, relationship_direction)
SELECT
ct.id,
nr.id,
er.id,
'friend',
'both'
FROM collectives.collective_types ct
JOIN collectives.collective_roles nr ON nr.collective_type_id = ct.id AND nr.role_key = 'member'
JOIN collectives.collective_roles er ON er.collective_type_id = ct.id AND er.role_key = 'member'
WHERE ct.name = 'Club';
-- Insert relationship rules for Friend Group
INSERT INTO collectives.collective_relationship_rules (collective_type_id, new_member_role_id, existing_member_role_id, relationship_type_id, relationship_direction)
SELECT
ct.id,
nr.id,
er.id,
'friend',
'both'
FROM collectives.collective_types ct
JOIN collectives.collective_roles nr ON nr.collective_type_id = ct.id AND nr.role_key = 'member'
JOIN collectives.collective_roles er ON er.collective_type_id = ct.id AND er.role_key = 'member'
WHERE ct.name = 'Friend Group';Note: All :id parameters in endpoints refer to the external_id (UUID), not the internal database ID.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/collective-types |
List all collective types (system + user's custom) |
| POST | /api/collective-types |
Create custom collective type |
| GET | /api/collective-types/:id |
Get collective type with roles and rules |
| PUT | /api/collective-types/:id |
Update custom collective type |
| DELETE | /api/collective-types/:id |
Delete custom collective type |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/collectives |
List all collectives (with optional type filter) |
| POST | /api/collectives |
Create new collective |
| GET | /api/collectives/:id |
Get collective with members |
| PUT | /api/collectives/:id |
Update collective |
| DELETE | /api/collectives/:id |
Soft delete collective |
| POST | /api/collectives/:id/photo |
Upload collective photo |
| DELETE | /api/collectives/:id/photo |
Remove collective photo |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/collectives/:id/members |
List members (active and/or inactive) |
| POST | /api/collectives/:id/members |
Add member with role |
| GET | /api/collectives/:id/members/:memberId |
Get membership details |
| PUT | /api/collectives/:id/members/:memberId |
Update membership (role, notes) |
| POST | /api/collectives/:id/members/:memberId/deactivate |
Mark member as inactive |
| POST | /api/collectives/:id/members/:memberId/reactivate |
Reactivate member |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/collectives/:id/members/preview |
Preview relationships that would be created |
| Component | Description |
|---|---|
CollectiveList |
List of all collectives with type badges |
CollectiveListItem |
Card showing collective name, type, member count |
CollectiveDetail |
Full collective view with member list |
CollectiveForm |
Create/edit collective form |
CollectiveAvatar |
Display collective photo or type icon fallback |
CollectiveTypeSelect |
Dropdown to select collective type |
| Component | Description |
|---|---|
MemberList |
List of collective members with roles |
MemberListItem |
Member card with avatar, name, role, status |
AddMemberForm |
Contact search + role selection |
RelationshipPreview |
Shows relationships that will be created |
MembershipHistory |
Timeline of membership changes |
DeactivateMemberModal |
Form to mark member as inactive |
| Component | Description |
|---|---|
CollectiveTypeList |
List of available types (system + custom) |
CollectiveTypeForm |
Create/edit custom type |
RoleEditor |
Manage roles for a collective type |
RelationshipRuleEditor |
Define relationship rules between roles |
| Key | Context | Action |
|---|---|---|
g then o |
Global | Go to Collectives list |
n |
Collectives list | New collective |
m |
Collective detail | Add member |
e |
Collective detail | Edit collective |
- Collective list loads in <500ms for 100 collectives
- Adding a member with 10 auto-relationships created in <500ms
- Relationship preview calculates in <200ms
- All CRUD operations complete in <300ms
-
Database Migration
- Create tables for collective types, roles, rules
- Create tables for collectives and memberships
- Add source_membership_id to contact_relationships
- Seed default collective types with roles and rules
-
Shared Types
- Add types for collective types, roles, rules
- Add types for collectives and memberships
- Add validation schemas
-
Backend: Collective Types
- Service methods for CRUD
- Routes for collective types API
- Include roles and rules in responses
-
Backend: Collectives
- Service methods for collective CRUD
- Routes for collectives API
-
Backend: Memberships
- Service methods for membership management
- Automatic relationship creation logic
- Relationship preview endpoint
- Deactivate/reactivate logic
-
Frontend: Store & API
- API client functions
- Svelte stores for collectives
-
Frontend: Collective Management
- List, detail, and form components
- Navigation integration
-
Frontend: Membership Management
- Member list and add member form
- Relationship preview UI
- Deactivation flow
-
Frontend: Collective Type Management (if custom types enabled)
- Type editor components
- Role and rule editors
-
Testing
- Unit tests for relationship rule logic
- Integration tests for automatic relationship creation
- E2E tests for common workflows
- Skip creating duplicate relationships
- Show in preview which relationships already exist
- Example: Two contacts already have a "friend" relationship, but adding them both as children to a family would create a "sibling" relationship
- Decision: Don't overwrite existing relationships; show a note in preview
- Example: An employee becomes a manager
- Update the role
- Optionally: Update existing colleague relationships to manager/report
- Consider: This could be a user preference
- A contact can be in "Smith Family" as a parent AND "Acme Corp" as an employee
- Each membership creates its own set of relationships
- Relationships from different collectives are independent
- Soft delete the collective (sets
deleted_attimestamp) - Keep all relationships that were created (they're still valid)
- Membership records remain for history
- Deleted collectives are excluded from normal queries but can be viewed in history
- If a contact is deleted, their memberships are cascade-deleted (per
ON DELETE CASCADE) - Relationships with
source_membership_idpointing to deleted memberships have that field set to NULL (perON DELETE SET NULL) - The relationships themselves remain intact, just without provenance tracking
- Collective timeline showing all encounters with members
- Collective-wide events (family reunions, company offsites)
- Inherit collective address to member contacts
- Collective-level notes and reminders
- Visual graph of collective members and relationships
- Merge collectives (e.g., when families join through marriage)
- Epic 1D: Contact Relationships - prerequisite, provides relationship types
- Epic 2: Encounter Management - encounter logging applies to collective members
- Epic 4: Categorization - collectives could be used as a form of grouping
- Epic 10: Search - search within collectives