good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
+56
View File
@@ -0,0 +1,56 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { MyService } from './my.service';
import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator';
import type { MemberJwtPayload } from '@tower/types';
import { IsArray, IsInt, IsOptional, IsString, Min } from 'class-validator';
import { ConsentScope, MemberOptOutReason } from '@tower/types';
class OptOutDto {
@IsString() @IsOptional() groupId?: string;
@IsArray() @IsOptional() scopes?: ConsentScope[];
@IsString() @IsOptional() reason?: MemberOptOutReason;
@IsString() @IsOptional() notes?: string;
}
class OptInDto {
@IsString() groupId!: string;
@IsArray() scopes!: ConsentScope[];
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
}
@Controller('my')
@MemberAuth()
export class MyController {
constructor(private readonly service: MyService) {}
@Get('profile')
profile(@CurrentMember() member: MemberJwtPayload) {
return this.service.getProfile(member.sub, member.tenantId);
}
@Get('groups')
listGroups(@CurrentMember() member: MemberJwtPayload) {
return this.service.listGroups(member.sub, member.tenantId);
}
@Get('groups/:id')
getGroup(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.service.getGroup(member.sub, member.tenantId, id);
}
@Post('opt-out')
optOut(@CurrentMember() member: MemberJwtPayload, @Body() body: OptOutDto) {
return this.service.optOut(member.sub, member.tenantId, body);
}
@Post('opt-in')
optIn(@CurrentMember() member: MemberJwtPayload, @Body() body: OptInDto) {
return this.service.optIn(member.sub, member.tenantId, body);
}
@Delete('account')
deleteAccount(@CurrentMember() member: MemberJwtPayload) {
return this.service.deleteAccount(member.sub, member.tenantId);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MyController } from './my.controller';
import { MyService } from './my.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [MyController],
providers: [MyService],
})
export class MyModule {}
+136
View File
@@ -0,0 +1,136 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MyService } from './my.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { NotFoundException, BadRequestException } from '@nestjs/common';
describe('MyService', () => {
let service: MyService;
const mockPrisma: any = {
towerUser: { findFirst: jest.fn(), delete: jest.fn() },
consentRecord: {
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
create: jest.fn(),
deleteMany: jest.fn(),
},
group: { findFirst: jest.fn() },
memberOptOut: { create: jest.fn(), deleteMany: jest.fn() },
towerSession: { deleteMany: jest.fn() },
$transaction: jest.fn(),
};
const mockAudit = { log: jest.fn() };
beforeEach(async () => {
jest.clearAllMocks();
mockPrisma.$transaction.mockImplementation((cb: (tx: typeof mockPrisma) => Promise<unknown>) => cb(mockPrisma));
const module: TestingModule = await Test.createTestingModule({
providers: [
MyService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
],
}).compile();
service = module.get<MyService>(MyService);
});
describe('getProfile', () => {
it('returns member profile', async () => {
mockPrisma.towerUser.findFirst.mockResolvedValue({
id: 'u-1',
tenantId: 'tnt-1',
jid: '1234@s.whatsapp.net',
displayName: 'Alice',
createdAt: new Date('2026-01-01T00:00:00Z'),
});
const res = await service.getProfile('u-1', 'tnt-1');
expect(res.id).toBe('u-1');
expect(res.displayName).toBe('Alice');
});
it('throws when not found', async () => {
mockPrisma.towerUser.findFirst.mockResolvedValue(null);
await expect(service.getProfile('u-x', 'tnt-1')).rejects.toThrow(NotFoundException);
});
});
describe('listGroups', () => {
it('returns groups with consent metadata', async () => {
mockPrisma.consentRecord.findMany.mockResolvedValue([
{
group: { id: 'g-1', name: 'UP Parivar' },
tenantId: 'tnt-1',
groupId: 'g-1',
scopes: ['INGEST', 'DISPLAY'],
retentionDays: 90,
policyVersion: 'v1',
status: 'GRANTED',
effectiveAt: new Date('2026-01-01T00:00:00Z'),
},
]);
const res = await service.listGroups('u-1', 'tnt-1');
expect(res).toHaveLength(1);
expect(res[0].name).toBe('UP Parivar');
expect(res[0].scopes).toContain('INGEST');
});
});
describe('optOut', () => {
it('throws when no matching consent', async () => {
mockPrisma.consentRecord.findMany.mockResolvedValue([]);
await expect(service.optOut('u-1', 'tnt-1', { groupId: 'g-1' })).rejects.toThrow(NotFoundException);
});
it('revokes consent and creates MemberOptOut', async () => {
mockPrisma.consentRecord.findMany.mockResolvedValue([
{ id: 'c-1', groupId: 'g-1', scopes: ['INGEST', 'DISPLAY'] },
]);
const res = await service.optOut('u-1', 'tnt-1', { groupId: 'g-1', reason: 'SELF_PORTAL' });
expect(res.revoked).toBe(1);
expect(mockPrisma.consentRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'c-1' },
data: expect.objectContaining({ status: 'REVOKED' }),
}),
);
expect(mockPrisma.memberOptOut.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ reason: 'SELF_PORTAL' }) }),
);
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MEMBER_OPT_OUT' }),
);
});
});
describe('optIn', () => {
it('rejects empty scopes', async () => {
await expect(
service.optIn('u-1', 'tnt-1', { groupId: 'g-1', scopes: [] }),
).rejects.toThrow(BadRequestException);
});
it('creates a new consent record', async () => {
mockPrisma.group.findFirst.mockResolvedValue({ id: 'g-1', tenantId: 'tnt-1' });
mockPrisma.towerUser.findFirst.mockResolvedValue({ id: 'u-1', tenantId: 'tnt-1' });
mockPrisma.consentRecord.findFirst.mockResolvedValue(null);
mockPrisma.consentRecord.create.mockResolvedValue({ id: 'c-new' });
const res = await service.optIn('u-1', 'tnt-1', { groupId: 'g-1', scopes: ['INGEST'] });
expect(res.ok).toBe(true);
expect(res.consentId).toBe('c-new');
});
});
describe('deleteAccount', () => {
it('cascades deletes and writes audit', async () => {
const res = await service.deleteAccount('u-1', 'tnt-1');
expect(res.ok).toBe(true);
expect(mockPrisma.consentRecord.deleteMany).toHaveBeenCalled();
expect(mockPrisma.towerSession.deleteMany).toHaveBeenCalled();
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MEMBER_DELETED', resourceId: 'u-1' }),
);
});
});
});
+190
View File
@@ -0,0 +1,190 @@
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
@Injectable()
export class MyService {
private readonly logger = new Logger(MyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found');
return {
id: user.id,
tenantId: user.tenantId,
jid: user.jid,
displayName: user.displayName,
createdAt: user.createdAt.toISOString(),
};
}
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
const consents = await this.prisma.consentRecord.findMany({
where: { userId, tenantId },
include: { group: true },
});
return consents.map((c) => ({
id: c.group.id,
name: c.group.name,
tenantId: c.tenantId,
scopes: c.scopes as ConsentScope[],
retentionDays: c.retentionDays,
policyVersion: c.policyVersion,
consentStatus: c.status as ConsentStatus,
joinedAt: c.effectiveAt.toISOString(),
}));
}
async getGroup(userId: string, tenantId: string, groupId: string): Promise<MemberGroupSummary> {
const consent = await this.prisma.consentRecord.findFirst({
where: { userId, tenantId, groupId },
include: { group: true },
});
if (!consent) throw new NotFoundException('Not a member of this group');
return {
id: consent.group.id,
name: consent.group.name,
tenantId: consent.tenantId,
scopes: consent.scopes as ConsentScope[],
retentionDays: consent.retentionDays,
policyVersion: consent.policyVersion,
consentStatus: consent.status as ConsentStatus,
joinedAt: consent.effectiveAt.toISOString(),
};
}
async optOut(
userId: string,
tenantId: string,
body: OptOutRequest,
): Promise<{ ok: true; revoked: number }> {
const where = body.groupId
? { userId, tenantId, groupId: body.groupId }
: { userId, tenantId };
const consents = await this.prisma.consentRecord.findMany({ where });
if (consents.length === 0) {
throw new NotFoundException('No matching consent records');
}
const reason = body.reason ?? MemberOptOutReasonEnum.SELF_PORTAL;
await this.prisma.$transaction(async (tx) => {
for (const consent of consents) {
if (body.scopes && body.scopes.length > 0) {
const remaining = (consent.scopes as ConsentScope[]).filter((s) => !body.scopes!.includes(s));
if (remaining.length === 0) {
await tx.consentRecord.update({
where: { id: consent.id },
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
});
} else {
await tx.consentRecord.update({
where: { id: consent.id },
data: { scopes: remaining },
});
}
} else {
await tx.consentRecord.update({
where: { id: consent.id },
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
});
}
await tx.memberOptOut.create({
data: {
tenantId,
userId,
groupId: consent.groupId,
reason,
notes: body.notes ?? null,
},
});
}
});
await this.audit.log({
tenantId,
action: AuditAction.MEMBER_OPT_OUT,
resourceType: 'TowerUser',
resourceId: userId,
payload: { groupId: body.groupId, scopes: body.scopes, reason },
});
return { ok: true, revoked: consents.length };
}
async optIn(
userId: string,
tenantId: string,
body: OptInRequest,
): Promise<{ ok: true; consentId: string }> {
if (body.scopes.length === 0) {
throw new BadRequestException('At least one scope is required');
}
const group = await this.prisma.group.findFirst({ where: { id: body.groupId, tenantId } });
if (!group) throw new NotFoundException('Group not found in your tenant');
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new UnauthorizedException('User not found');
const existing = await this.prisma.consentRecord.findFirst({
where: { userId, tenantId, groupId: body.groupId },
});
let consent;
if (existing) {
consent = await this.prisma.consentRecord.update({
where: { id: existing.id },
data: {
scopes: body.scopes,
retentionDays: body.retentionDays ?? existing.retentionDays,
status: ConsentStatus.GRANTED,
revokedAt: null,
effectiveAt: new Date(),
},
});
} else {
consent = await this.prisma.consentRecord.create({
data: {
tenantId,
groupId: body.groupId,
userId,
scopes: body.scopes,
retentionDays: body.retentionDays ?? 90,
policyVersion: 'v1',
status: ConsentStatus.GRANTED,
proofEventId: 'self',
},
});
}
await this.audit.log({
tenantId,
action: AuditAction.MEMBER_OPT_IN,
resourceType: 'TowerUser',
resourceId: userId,
payload: { groupId: body.groupId, scopes: body.scopes },
});
return { ok: true, consentId: consent.id };
}
async deleteAccount(userId: string, tenantId: string): Promise<{ ok: true }> {
await this.prisma.$transaction(async (tx) => {
await tx.consentRecord.deleteMany({ where: { userId, tenantId } });
await tx.memberOptOut.deleteMany({ where: { userId, tenantId } });
await tx.towerSession.deleteMany({ where: { userId } });
await tx.towerUser.delete({ where: { id: userId } });
});
await this.audit.log({
tenantId,
action: AuditAction.MEMBER_DELETED,
resourceType: 'TowerUser',
resourceId: userId,
});
return { ok: true };
}
}