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:
parent
9ee533b117
commit
83c2381005
15 changed files with 1609 additions and 56 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export enum ConsentEntityType {
|
|||
INVITATION = 'invitation',
|
||||
/** Mentorship relationship consent */
|
||||
MENTORSHIP = 'mentorship',
|
||||
/** Check-in location sharing consent */
|
||||
CHECKIN_LOCATION = 'checkin_location',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue