import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
DatabaseService,
type DatabaseQueryClient,
} from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
import { AuditService } from '../../audit/audit.service';
import { EntitlementService } from '../../commercial/application/entitlement.service';
import {
assertMembershipInviteInput,
assertMembershipReferenceInput,
} from './membership-input-validation';
@Injectable()
export class MembershipsService {
constructor(
private readonly database:DatabaseService,
private readonly outbox:OutboxService,
private readonly audit:AuditService,
private readonly entitlements:EntitlementService,
) {}
async invite(input:{
organizationId:string;userAccountId:string;employeeId?:string;
actorUserId?:string;correlationId?:string;
}) {
assertMembershipInviteInput(input);
const id=randomUUID();
try {
const result=await this.database.transaction(async(tx)=>{
const user=await tx.query<{id:string}>(
`select id from user_accounts where id=$1`,
[input.userAccountId],
);
if(!user[0]) throw new DomainConflictError('membership.user_not_found');
if(input.employeeId) {
const employee=await tx.query<{id:string}>(
`select id from employees
where id=$1 and organization_id=$2`,
[input.employeeId,input.organizationId],
);
if(!employee[0]) throw new DomainConflictError('membership.employee_scope_invalid');
}
const rows=await tx.query<{id:string}>(
`insert into memberships(
id,organization_id,user_account_id,employee_id,status,invited_at
) values($1,$2,$3,$4,'invited',now())
returning id`,
[id,input.organizationId,input.userAccountId,input.employeeId??null],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'Membership',
aggregateId:id,
eventType:'MembershipInvited',
payload:{membershipId:id,userAccountId:input.userAccountId},
},tx);
return rows[0];
});
await this.audit.record({
organizationId:input.organizationId,
actorUserId:input.actorUserId,
eventType:'membership.invited',
entityType:'membership',
entityId:id,
correlationId:input.correlationId,
}).catch(()=>undefined);
return result;
} catch(error) {
if(error instanceof DomainConflictError) throw error;
throw new DomainConflictError('membership.invite_conflict');
}
}
async activate(organizationId:string,membershipId:string) {
assertMembershipReferenceInput(organizationId, membershipId);
return this.database.transaction(async(tx)=>{
await tx.query(
`select pg_advisory_xact_lock(hashtext($1))`,
[`commercial:users:${organizationId}`],
);
const target=await tx.query<any>(
`select status from memberships
where id=$1 and organization_id=$2
for update`,
[membershipId,organizationId],
);
if(!target[0]||!['invited','suspended'].includes(target[0].status)) {
throw new DomainConflictError('membership.invalid_activation_state');
}
await this.entitlements.assertCountLimit({
organizationId,
featureKey:'max_active_users',
countSql:`select count(distinct user_account_id)::bigint as count
from memberships where organization_id=$1 and status='active'`,
countParams:[organizationId],
resourceType:'active_user',
client:tx,
});
const rows=await tx.query<{id:string;status:string}>(
`update memberships
set status='active',activated_at=coalesce(activated_at,now()),
suspended_at=null,updated_at=now(),version=version+1
where id=$1 and organization_id=$2
returning id,status`,
[membershipId,organizationId],
);
await this.outbox.append({
organizationId,aggregateType:'Membership',aggregateId:membershipId,
eventType:'MembershipActivated',payload:{membershipId},
},tx);
return rows[0];
});
}
async suspend(organizationId:string,membershipId:string) {
assertMembershipReferenceInput(organizationId, membershipId);
return this.database.transaction(async(tx)=>{
await this.assertNotLastOwner(organizationId,membershipId,tx);
const rows=await tx.query<{id:string;status:string}>(
`update memberships
set status='suspended',suspended_at=now(),updated_at=now(),version=version+1
where id=$1 and organization_id=$2 and status='active'
returning id,status`,
[membershipId,organizationId],
);
if(!rows[0]) throw new DomainConflictError('membership.invalid_suspend_state');
await this.outbox.append({
organizationId,aggregateType:'Membership',aggregateId:membershipId,
eventType:'MembershipSuspended',payload:{membershipId},
},tx);
return rows[0];
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async revoke(organizationId:string,membershipId:string) {
assertMembershipReferenceInput(organizationId, membershipId);
return this.database.transaction(async(tx)=>{
await this.assertNotLastOwner(organizationId,membershipId,tx);
const rows=await tx.query<{id:string;status:string}>(
`update memberships
set status='revoked',revoked_at=now(),updated_at=now(),version=version+1
where id=$1 and organization_id=$2 and status<>'revoked'
returning id,status`,
[membershipId,organizationId],
);
if(!rows[0]) throw new DomainConflictError('membership.invalid_revoke_state');
await this.outbox.append({
organizationId,aggregateType:'Membership',aggregateId:membershipId,
eventType:'MembershipRevoked',payload:{membershipId},
},tx);
return rows[0];
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
private async assertNotLastOwner(
organizationId:string,membershipId:string,tx:DatabaseQueryClient,
) {
await tx.query(
`select pg_advisory_xact_lock(hashtext($1))`,
[`organization-owner:${organizationId}`],
);
const target=await tx.query<{status:string;is_owner:boolean}>(
`select
m.status,
exists(
select 1
from membership_roles mr
join roles r on r.id=mr.role_id
where mr.membership_id=m.id
and mr.location_id is null
and r.role_key='platform_org_owner'
) as is_owner
from memberships m
where m.id=$1 and m.organization_id=$2
for update`,
[membershipId,organizationId],
);
if(!target[0]) throw new DomainConflictError('membership.not_found');
if(!target[0].is_owner||target[0].status!=='active') return;
const owners=await tx.query<{count:string}>(
`select count(distinct m.id)::text as count
from memberships m
join membership_roles mr on mr.membership_id=m.id
join roles r on r.id=mr.role_id
where m.organization_id=$1
and m.status='active'
and mr.location_id is null
and r.role_key='platform_org_owner'`,
[organizationId],
);
if(BigInt(owners[0]?.count??'0')<=1n) {
throw new DomainConflictError('membership.last_owner_required');
}
}
}
