feat(coop-specific): Add cooperative membership system with member roles, invitation workflows, and consent tracking, including audit logging and integration with existing services

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-03-02 21:04:30 -08:00
parent 9ee533b117
commit 83c2381005
15 changed files with 1609 additions and 56 deletions

View file

@ -5,14 +5,14 @@
* frontend depends on, mirroring backend-api's API surface.
*/
import { http, HttpResponse } from 'msw'
import { http, HttpResponse, type RequestHandler } from 'msw'
import { composeHandlers } from '@lilith/msw-handlers'
// Auth (SSO)
import { authHandlers } from '../../../sso/shared/msw'
// Marketplace search + bookings
import { searchHandlers, bookingsHandlers } from '../../shared/msw'
// Marketplace search + bookings + check-in
import { searchHandlers, bookingsHandlers, checkinHandlers } from '../../shared/msw'
// Merchant (subscription tiers)
import { tiersHandlers } from '@features/merchant/shared/msw'
@ -39,10 +39,11 @@ const analyticsHandlers = [
}),
]
export const allHandlers = composeHandlers(
export const allHandlers: RequestHandler[] = composeHandlers(
authHandlers,
searchHandlers,
bookingsHandlers,
checkinHandlers,
reviewsHandlers,
profileHandlers,
attributesHandlers,

View file

@ -58,6 +58,15 @@ export enum DomainEventType {
SYSTEM_SERVICE_HEALTHY = 'system:service:healthy',
SYSTEM_SERVICE_UNHEALTHY = 'system:service:unhealthy',
PROVIDER_REGISTERED = 'provider:registered',
// Coop events
COOP_CREATED = 'coop:created',
COOP_INVITATION_SENT = 'coop:invitation_sent',
COOP_INVITATION_ACCEPTED = 'coop:invitation_accepted',
COOP_INVITATION_DECLINED = 'coop:invitation_declined',
COOP_INVITE_LINK_GENERATED = 'coop:invite_link_generated',
COOP_MEMBER_JOINED = 'coop:member_joined',
COOP_MEMBER_LEFT = 'coop:member_left',
COOP_DISSOLVED = 'coop:dissolved',
}
export interface BaseDomainEvent<T = Record<string, unknown>> {
@ -75,6 +84,63 @@ export interface ProviderRegisteredPayload {
timestamp: string
}
// Coop event payload types
export interface CoopCreatedPayload {
cooperativeId: string
name: string
founderProfileId: string
timestamp: string
}
export interface CoopInvitationSentPayload {
invitationId: string
cooperativeId: string
inviterProfileId: string
inviteeProfileId: string
expiresAt: string
message?: string
timestamp: string
}
export interface CoopInvitationAcceptedPayload {
invitationId: string
cooperativeId: string
membershipId: string
inviterProfileId: string
inviteeProfileId: string
timestamp: string
}
export interface CoopInvitationDeclinedPayload {
invitationId: string
cooperativeId: string
inviterProfileId: string
inviteeProfileId: string
timestamp: string
}
export interface CoopMemberJoinedPayload {
cooperativeId: string
membershipId: string
profileId: string
invitedByProfileId: string
invitationId: string
timestamp: string
}
export interface CoopMemberLeftPayload {
cooperativeId: string
profileId: string
removedByProfileId?: string
timestamp: string
}
export interface CoopDissolvedPayload {
cooperativeId: string
dissolvedByProfileId: string
timestamp: string
}
/**
* Base class for domain event processors - mock version
* The real @Processor decorator comes from @nestjs/bullmq, not this package

View file

@ -9,12 +9,15 @@ import {
Param,
Query,
Request,
Headers,
UseGuards,
ParseUUIDPipe,
HttpStatus,
HttpCode,
Ip,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ApiTags,
ApiOperation,
@ -22,6 +25,7 @@ import {
ApiBearerAuth,
ApiParam,
ApiQuery,
ApiHeader,
} from '@nestjs/swagger';
@ -328,26 +332,53 @@ export class MentorshipController {
@ApiTags('Internal')
@Controller('internal/mentorship')
export class InternalMentorshipController {
constructor(private readonly mentorshipService: MentorshipService) {}
private readonly internalServiceToken: string;
constructor(
private readonly mentorshipService: MentorshipService,
private readonly configService: ConfigService,
) {
this.internalServiceToken = this.configService.get<string>(
'INTERNAL_SERVICE_TOKEN',
'internal-service-token-dev',
);
}
/**
* Validate internal service header
*/
private validateInternalRequest(header: string | undefined): void {
if (!header || header !== this.internalServiceToken) {
throw new UnauthorizedException('Invalid or missing internal service token');
}
}
/**
* Verify mentor access (service-to-service)
*
* Used by Profile service to verify mentor has access before returning mentee profile.
* This endpoint should be protected by internal service authentication in production.
* Protected by X-Internal-Service header validation.
*/
@Get('verify')
@Public() // Service-to-service - should use internal auth in production
@Public()
@ApiOperation({ summary: 'Verify mentor access (service-to-service)' })
@ApiHeader({
name: 'X-Internal-Service',
description: 'Internal service authentication token',
required: true,
})
@ApiQuery({ name: 'mentorProfileId', required: true })
@ApiQuery({ name: 'menteeProfileId', required: true })
@ApiQuery({ name: 'accessType', required: true, enum: ['inbox', 'profile', 'contacts', 'draftMessages'] })
@ApiResponse({ status: 200, description: 'Verification result' })
@ApiResponse({ status: 401, description: 'Invalid or missing service token' })
async verifyMentorAccess(
@Headers('x-internal-service') serviceToken: string | undefined,
@Query('mentorProfileId') mentorProfileId: string,
@Query('menteeProfileId') menteeProfileId: string,
@Query('accessType') accessType: MentorAccessType,
): Promise<MentorshipVerificationResponseDto> {
this.validateInternalRequest(serviceToken);
// Check if mentor has access
const hasAccess = await this.mentorshipService.hasAccess(
mentorProfileId,

View file

@ -4,6 +4,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CheckinModule } from './checkin/checkin.module';
import { ActiveProfileMiddleware } from '@/middleware/active-profile.middleware';
// Entities
@ -80,6 +82,9 @@ import {
// Domain events for cross-feature communication
DomainEventsModule.forFeature(),
// Check-in safety feature
CheckinModule,
],
controllers: [

View file

@ -42,6 +42,8 @@ export enum ConsentEntityType {
INVITATION = 'invitation',
/** Mentorship relationship consent */
MENTORSHIP = 'mentorship',
/** Check-in location sharing consent */
CHECKIN_LOCATION = 'checkin_location',
}
/**

View file

@ -70,6 +70,8 @@ export interface CooperativeMemberPermissions {
canRemoveMembers: boolean;
/** Can offer mentorship to other coop members */
canMentorMembers: boolean;
/** Can receive escalation alerts for other members' check-ins */
canReceiveEscalations: boolean;
}
@Entity('marketplace_cooperative_members')
@ -137,6 +139,7 @@ export class CooperativeMember implements IBaseEntity {
canEditSettings: false,
canRemoveMembers: false,
canMentorMembers: false,
canReceiveEscalations: true,
},
})
permissions: CooperativeMemberPermissions;

View file

@ -58,6 +58,23 @@ export interface CooperativeSettings {
color?: string;
tagline?: string;
};
/** Whether check-in feature is enabled for this coop */
checkinEnabled?: boolean;
/** Default check-in duration in minutes */
defaultCheckinDurationMinutes?: number;
/** Maximum allowed check-in duration in minutes */
maxCheckinDurationMinutes?: number;
/** Escalation timing configuration */
escalationConfig?: {
/** Minutes after deadline before Tier 1 (buddy) notification */
graceMinutes: number;
/** Minutes after Tier 1 before Tier 2 (admins + coop) notification */
tier1ToTier2Minutes: number;
/** Minutes after Tier 2 before Tier 3 (emergency contacts) notification */
tier2ToTier3Minutes: number;
};
/** Whether whole-coop alerts are enabled during Tier 2 escalation */
wholeCoopAlertsEnabled?: boolean;
}
@Entity('marketplace_cooperatives')
@ -118,6 +135,15 @@ export class Cooperative implements IBaseEntity {
showInSearch: false,
displayOrder: 0,
},
checkinEnabled: false,
defaultCheckinDurationMinutes: 60,
maxCheckinDurationMinutes: 480,
escalationConfig: {
graceMinutes: 2,
tier1ToTier2Minutes: 15,
tier2ToTier3Minutes: 30,
},
wholeCoopAlertsEnabled: true,
},
})
settings: CooperativeSettings;

View file

@ -19,6 +19,8 @@ import { RATE_LIMITS, DATE_CONSTANTS } from '@features/shared';
import { Repository, LessThan } from 'typeorm';
import { ProfileClientService, type ProfileData } from '@/profile/profile-client.service';
import { CoopInvitationLinkService } from './coop-invitation-link.service';
import { CooperativeService } from './cooperative.service';
@ -32,6 +34,7 @@ import {
SendEmailInvitationDto,
InvitationTrackingDto,
} from '../dto/invitation.dto';
import { Cooperative } from '../entities/cooperative.entity';
import {
CooperativeInvitation,
CooperativeInvitationStatus,
@ -66,6 +69,7 @@ export class CooperativeInvitationService {
private readonly cooperativeService: CooperativeService,
private readonly domainEvents: DomainEventsEmitter,
private readonly linkService: CoopInvitationLinkService,
private readonly profileClient: ProfileClientService,
) {}
/**
@ -179,9 +183,16 @@ export class CooperativeInvitationService {
order: { createdAt: 'DESC' },
});
const enriched = await Promise.all(
invitations.map((inv) => this.toResponseDto(inv)),
const inviterIds = [...new Set(invitations.map((inv) => inv.inviterProfileId))];
const profiles = await this.profileClient.getProfilesByProfileIds(inviterIds);
const cooperativeIds = [...new Set(invitations.map((inv) => inv.cooperativeId))];
const coops = await Promise.all(
cooperativeIds.map((id) => this.cooperativeService.findById(id)),
);
const coopMap = new Map(coops.map((c) => [c.id, c]));
const enriched = invitations.map((inv) => this.toResponseDto(inv, profiles, coopMap));
return {
invitations: enriched,
@ -379,9 +390,13 @@ export class CooperativeInvitationService {
order: { createdAt: 'DESC' },
});
return Promise.all(
invitations.map((inv) => this.toResponseDto(inv)),
);
const inviterIds = [...new Set(invitations.map((inv) => inv.inviterProfileId))];
const profiles = await this.profileClient.getProfilesByProfileIds(inviterIds);
const coop = await this.cooperativeService.findById(cooperativeId);
const coopMap = new Map([[coop.id, coop]]);
return invitations.map((inv) => this.toResponseDto(inv, profiles, coopMap));
}
// ============================================
@ -450,26 +465,30 @@ export class CooperativeInvitationService {
order: { createdAt: 'DESC' },
});
return invitations.map((inv) => ({
id: inv.id,
inviteeEmail: inv.inviteeEmail ?? undefined,
inviteeProfileId: inv.inviteeProfileId ?? undefined,
inviteeProfile: inv.inviteeProfileId
? {
profileId: inv.inviteeProfileId,
displayName: '', // Would be populated from profile service
slug: '', // Would be populated from profile service
primaryPhotoUrl: undefined,
}
: undefined,
status: inv.status,
hasToken: inv.token !== null,
remindersSent: inv.remindersSent,
lastReminderAt: inv.lastReminderAt ?? undefined,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
respondedAt: inv.respondedAt ?? undefined,
}));
const inviteeIds = invitations
.map((inv) => inv.inviteeProfileId)
.filter((id): id is string => id !== null && id !== undefined);
const profiles = await this.profileClient.getProfilesByProfileIds([...new Set(inviteeIds)]);
return invitations.map((inv) => {
const inviteeProfile = inv.inviteeProfileId
? this.toInviteeProfilePreview(inv.inviteeProfileId, profiles)
: undefined;
return {
id: inv.id,
inviteeEmail: inv.inviteeEmail ?? undefined,
inviteeProfileId: inv.inviteeProfileId ?? undefined,
inviteeProfile,
status: inv.status,
hasToken: inv.token !== null,
remindersSent: inv.remindersSent,
lastReminderAt: inv.lastReminderAt ?? undefined,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
respondedAt: inv.respondedAt ?? undefined,
};
});
}
// ============================================
@ -494,27 +513,40 @@ export class CooperativeInvitationService {
}
/**
* Convert to response DTO with enriched data
* Convert to response DTO, enriched with pre-fetched profile and cooperative data.
*
* Callers are responsible for batch-fetching profiles and cooperatives before
* calling this method to avoid N+1 queries.
*/
private async toResponseDto(
private toResponseDto(
invitation: CooperativeInvitation,
): Promise<InvitationResponseDto> {
const coop = await this.cooperativeService.findById(invitation.cooperativeId);
profiles: Map<string, ProfileData>,
coopMap: Map<string, Cooperative>,
): InvitationResponseDto {
const coop = coopMap.get(invitation.cooperativeId);
const inviterProfile = profiles.get(invitation.inviterProfileId);
return {
id: invitation.id,
cooperativeId: invitation.cooperativeId,
cooperative: {
id: coop.id,
name: coop.name,
description: coop.description ?? undefined,
memberCount: coop.memberCount,
},
cooperative: coop
? {
id: coop.id,
name: coop.name,
description: coop.description ?? undefined,
memberCount: coop.memberCount,
}
: {
id: invitation.cooperativeId,
name: 'Unknown Cooperative',
description: undefined,
memberCount: 0,
},
inviter: {
profileId: invitation.inviterProfileId,
displayName: '', // Would be populated from profile service
slug: '', // Would be populated from profile service
primaryPhotoUrl: undefined, // Would be populated from profile service
displayName: inviterProfile?.displayName ?? 'Anonymous User',
slug: inviterProfile?.slug ?? '',
primaryPhotoUrl: inviterProfile?.avatarUrl,
},
inviteeProfileId: invitation.inviteeProfileId,
status: invitation.status,
@ -524,4 +556,20 @@ export class CooperativeInvitationService {
respondedAt: invitation.respondedAt ?? undefined,
};
}
/**
* Build a minimal profile preview for an invitee from the pre-fetched profiles map.
*/
private toInviteeProfilePreview(
profileId: string,
profiles: Map<string, ProfileData>,
): { profileId: string; displayName: string; slug: string; primaryPhotoUrl?: string } {
const profile = profiles.get(profileId);
return {
profileId,
displayName: profile?.displayName ?? 'Anonymous User',
slug: profile?.slug ?? '',
primaryPhotoUrl: profile?.avatarUrl,
};
}
}

View file

@ -1,3 +1,10 @@
import {
DomainEventsEmitter,
DomainEventType,
type CoopCreatedPayload,
type CoopDissolvedPayload,
type CoopMemberLeftPayload,
} from '@lilith/domain-events';
import {
Injectable,
Logger,
@ -52,6 +59,7 @@ export class CooperativeService {
private readonly dataSource: DataSource,
private readonly auditService: ConsentAuditService,
private readonly profileClient: ProfileClientService,
private readonly domainEvents: DomainEventsEmitter,
) {}
/**
@ -111,6 +119,20 @@ export class CooperativeService {
ipAddress,
});
// Emit domain event for cross-feature communication
const createdPayload: CoopCreatedPayload = {
cooperativeId: result.id,
name: result.name,
founderProfileId,
timestamp: new Date().toISOString(),
};
await this.domainEvents.emit(
DomainEventType.COOP_CREATED,
createdPayload,
result.id,
);
this.logger.log(`Created cooperative ${result.id} (${result.name}) by profile ${founderProfileId}`);
return result;
@ -274,6 +296,19 @@ export class CooperativeService {
);
});
// Emit domain event for cross-feature communication
const dissolvedPayload: CoopDissolvedPayload = {
cooperativeId: id,
dissolvedByProfileId: requestingProfileId,
timestamp: new Date().toISOString(),
};
await this.domainEvents.emit(
DomainEventType.COOP_DISSOLVED,
dissolvedPayload,
id,
);
this.logger.log(`Dissolved cooperative ${id}`);
}
@ -389,6 +424,7 @@ export class CooperativeService {
canEditSettings: true,
canRemoveMembers: true,
canMentorMembers: true,
canReceiveEscalations: true,
};
}
@ -531,6 +567,21 @@ export class CooperativeService {
ipAddress,
});
// Emit domain event for cross-feature communication
const isSelfRemoval = targetProfileId === requestingProfileId;
const memberLeftPayload: CoopMemberLeftPayload = {
cooperativeId,
profileId: targetProfileId,
removedByProfileId: isSelfRemoval ? undefined : requestingProfileId,
timestamp: new Date().toISOString(),
};
await this.domainEvents.emit(
DomainEventType.COOP_MEMBER_LEFT,
memberLeftPayload,
cooperativeId,
);
this.logger.log(
`Removed member ${targetProfileId} from coop ${cooperativeId} by ${requestingProfileId}`,
);
@ -590,6 +641,7 @@ export class CooperativeService {
canEditSettings: false,
canRemoveMembers: false,
canMentorMembers: false,
canReceiveEscalations: false,
};
await this.memberRepo.save(member);

View file

@ -13,6 +13,8 @@ import {
GoneException,
ConflictException,
} from '@nestjs/common';
import { DomainEventsEmitter } from '@lilith/domain-events';
import { DuoAuditService } from '@features/shared';
import { DuoInvitationsService } from './duo-invitations.service';
import { DuoInvitation, InvitationStatus } from '@/entities/duo-invitation.entity';
import {
@ -64,6 +66,8 @@ describe('DuoInvitationsService', () => {
save: vi.fn(),
};
const mockAuditService = { log: vi.fn(), logWithManager: vi.fn(), logBatchWithManager: vi.fn() };
// Mock transaction manager
const mockManager = {
findOne: vi.fn(),
@ -146,6 +150,8 @@ describe('DuoInvitationsService', () => {
provide: getRepositoryToken(DuoAuditLog),
useValue: mockAuditLogRepo,
},
{ provide: DomainEventsEmitter, useValue: { emit: vi.fn() } },
{ provide: DuoAuditService, useValue: mockAuditService },
],
}).compile();
@ -184,7 +190,7 @@ describe('DuoInvitationsService', () => {
);
expect(result.inviteeEmail).toBe(testInviteeEmail);
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect(mockAuditService.log).toHaveBeenCalledWith(
expect.objectContaining({
action: DuoAuditAction.INVITATION_SENT,
}),

View file

@ -8,6 +8,8 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken, getDataSourceToken } from '@nestjs/typeorm';
import { ForbiddenException, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DomainEventsEmitter } from '@lilith/domain-events';
import { DuoAuditService } from '@features/shared';
import { DuosService } from './duos.service';
import { ProfileMembership, ProfileMemberRole, MembershipStatus } from '@/entities/profile-membership.entity';
import { DuoInvitation, InvitationStatus } from '@/entities/duo-invitation.entity';
@ -128,6 +130,8 @@ describe('DuosService', () => {
provide: getRepositoryToken(DuoAuditLog),
useValue: mockAuditLogRepo,
},
{ provide: DuoAuditService, useValue: { log: vi.fn(), logWithManager: vi.fn(), logBatchWithManager: vi.fn() } },
{ provide: DomainEventsEmitter, useValue: { emit: vi.fn() } },
],
}).compile();

View file

@ -10,6 +10,7 @@ import {
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { ProfileClientService } from '@/profile/profile-client.service';
import { FriendsService } from './friends.service';
import {
Friendship,
@ -97,6 +98,7 @@ describe('FriendsService', () => {
provide: getRepositoryToken(FriendshipPrivacySettings),
useValue: mockPrivacyRepo,
},
{ provide: ProfileClientService, useValue: { getProfilesByProfileIds: vi.fn().mockResolvedValue(new Map()) } },
],
}).compile();
@ -372,7 +374,7 @@ describe('FriendsService', () => {
]);
// Mutual friendships
mockFriendshipQueryBuilder.getMany.mockResolvedValue([
{ profileIdA: testProfileIdB, profileIdB: testProfileIdC },
{ profileIdA: testProfileIdB, profileIdB: testProfileIdC, createdAt: new Date() },
]);
const result = await service.getMutualFriends(testProfileIdA, testProfileIdB);

View file

@ -92,7 +92,7 @@ export interface MerchantTierMetadata {
type: 'subscription'
tierSlug: 'bronze' | 'silver' | 'gold' | 'platinum' | 'iridium'
tierLevel: number
billingInterval: 'monthly'
billingInterval: 'weekly' | 'monthly' | 'yearly'
bonus: MerchantTierBonus
actionPools: MerchantActionPools
rollover: MerchantRolloverPolicy

View file

@ -12,6 +12,12 @@ export enum NotificationType {
REVIEW_RESPONSE_POSTED = 'review_response_posted',
SYSTEM_ANNOUNCEMENT = 'system_announcement',
ACCOUNT_ALERT = 'account_alert',
CHECKIN_BUDDY_REQUEST = 'checkin_buddy_request',
CHECKIN_OVERDUE = 'checkin_overdue',
CHECKIN_ESCALATION = 'checkin_escalation',
CHECKIN_PANIC = 'checkin_panic',
CHECKIN_RESOLVED = 'checkin_resolved',
CHECKIN_REMINDER = 'checkin_reminder',
}
/**