Compare commits
9 Commits
0c6650f3c6
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 00d22be714 | |||
| b4104b9769 | |||
| 32aa04503d | |||
| c8c2d0811b | |||
| ce33834d56 | |||
| b164ef945c | |||
| 18498ee9fa | |||
| 40c98522dc | |||
| 50f9b3213d |
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
|
||||
bodyText?: string;
|
||||
contentRef?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
}>;
|
||||
occurredAt: string;
|
||||
providerEventId?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
@@ -40,6 +41,7 @@ class PartDto {
|
||||
@IsOptional() @IsString() bodyText?: string;
|
||||
@IsOptional() @IsString() contentRef?: string;
|
||||
@IsOptional() @IsString() mimeType?: string;
|
||||
@IsOptional() @IsInt() sizeBytes?: number;
|
||||
}
|
||||
|
||||
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
|
||||
|
||||
@@ -178,6 +178,7 @@ export class IngestService {
|
||||
bodyText: p.bodyText,
|
||||
contentRef: p.contentRef,
|
||||
mimeType: p.mimeType,
|
||||
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class MailController {
|
||||
vars: body.vars ?? {},
|
||||
...(body.locale ? { locale: body.locale } : {}),
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export class AttachmentDto {
|
||||
@IsOptional() @IsString() filename?: string;
|
||||
@IsString() @IsNotEmpty() contentRef!: string;
|
||||
@IsOptional() @IsString() mimeType?: string;
|
||||
@IsOptional() @IsInt() sizeBytes?: number;
|
||||
}
|
||||
|
||||
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
|
||||
@@ -19,6 +20,8 @@ export class MailInternalDto {
|
||||
@IsOptional() @IsObject() vars?: Record<string, unknown>;
|
||||
@IsOptional() @IsString() locale?: string;
|
||||
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
||||
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
|
||||
}
|
||||
|
||||
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
|
||||
|
||||
@@ -25,7 +25,7 @@ function mail(): MailService {
|
||||
const ingest = new IngestService(asService, makeFakePorts(), actors);
|
||||
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||
const sender = new TemplatedSender(templates, outbound, makeFakePorts());
|
||||
return new MailService(templates, ingest, sender, actors);
|
||||
return new MailService(templates, ingest, sender, actors, asService);
|
||||
}
|
||||
const messages = () => new MessageService(asService, makeFakePorts(), actors);
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('MailService.postInternal', () => {
|
||||
|
||||
const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } });
|
||||
expect(thread.subject).toBe('Welcome Dana');
|
||||
expect((thread.metadata as { source?: string } | null)?.source).toBe('crm-mail'); // lists separately from chat
|
||||
|
||||
// The load-bearing assertion: the RECIPIENT can see the thread in their inbox.
|
||||
const bobThreads = await messages().listThreads(bob);
|
||||
@@ -65,6 +66,20 @@ describe('MailService.postInternal', () => {
|
||||
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
|
||||
});
|
||||
|
||||
it('stores an in-app attachment as a media part alongside the body', async () => {
|
||||
const res = await mail().postInternal(alice, {
|
||||
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
|
||||
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
|
||||
});
|
||||
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
|
||||
const file = interaction.parts.find((p) => p.contentRef);
|
||||
expect(file).toBeDefined();
|
||||
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
|
||||
expect(file!.contentRef).toBe('scope/roof.png');
|
||||
expect(file!.mimeType).toBe('image/png');
|
||||
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
|
||||
});
|
||||
|
||||
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
|
||||
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
||||
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { IngestService } from '../interactions/ingest.service';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
import { TemplateService } from '../templates/template.service';
|
||||
@@ -18,12 +20,16 @@ export function contentToParts(content: RenderedContent): { subject?: string; pa
|
||||
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
|
||||
}
|
||||
|
||||
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
|
||||
|
||||
export interface PostInternalInput {
|
||||
source: TemplateSource;
|
||||
recipientUserId: string;
|
||||
vars?: Record<string, unknown>;
|
||||
locale?: string;
|
||||
idempotencyKey: string;
|
||||
/** In-app attachment refs — stored as FILE message parts alongside the body. */
|
||||
attachments?: MailAttachment[];
|
||||
}
|
||||
|
||||
export interface SendExternalInput {
|
||||
@@ -55,11 +61,15 @@ export class MailService {
|
||||
private readonly ingest: IngestService,
|
||||
private readonly sender: TemplatedSender,
|
||||
private readonly actors: ActorResolver,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
/** Marks a thread as CRM mail so it lists separately from chat (Messenger uses source=crm-messenger). */
|
||||
static readonly SOURCE = 'crm-mail';
|
||||
|
||||
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
|
||||
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) });
|
||||
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL');
|
||||
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments);
|
||||
}
|
||||
|
||||
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
|
||||
@@ -73,13 +83,21 @@ export class MailService {
|
||||
if (!input.mirrorToUserId) return { commandId: command.id };
|
||||
|
||||
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) });
|
||||
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL');
|
||||
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments);
|
||||
return { commandId: command.id, mirror };
|
||||
}
|
||||
|
||||
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
|
||||
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
|
||||
const mime = a.mimeType ?? '';
|
||||
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
|
||||
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
|
||||
}
|
||||
|
||||
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
|
||||
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string): Promise<{ threadId: string; interactionId: string }> {
|
||||
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
|
||||
const { subject, parts } = contentToParts(content);
|
||||
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
|
||||
const res = await this.ingest.ingest(
|
||||
{
|
||||
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
|
||||
@@ -87,7 +105,7 @@ export class MailService {
|
||||
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
|
||||
kind: 'EMAIL',
|
||||
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
|
||||
parts,
|
||||
parts: allParts,
|
||||
occurredAt: new Date().toISOString(),
|
||||
providerEventId: key,
|
||||
},
|
||||
@@ -103,6 +121,13 @@ export class MailService {
|
||||
await this.actors.ensureParticipant(res.threadId, senderActor.id);
|
||||
await this.actors.ensureParticipant(res.threadId, recipientActor.id);
|
||||
|
||||
// Tag the thread as CRM mail (thread metadata) so it lists separately from Messenger chat.
|
||||
// ingest() puts req.metadata on the interaction, not the thread, so we set it here directly.
|
||||
await this.prisma.iiosThread.update({
|
||||
where: { id: res.threadId },
|
||||
data: { metadata: { source: MailService.SOURCE } as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
return { threadId: res.threadId, interactionId: res.interactionId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
||||
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||
|
||||
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
|
||||
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
|
||||
|
||||
@Controller('v1/media')
|
||||
export class MediaController {
|
||||
constructor(
|
||||
@@ -37,6 +40,12 @@ export class MediaController {
|
||||
async blob(@Param('token') token: string, @Res() res: Response) {
|
||||
const { data, mime } = await this.media.get(token);
|
||||
res.setHeader('Content-Type', mime);
|
||||
// Never let the browser MIME-sniff an upload into something executable.
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
|
||||
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
|
||||
// fine even with attachment disposition; only top-level navigation to the blob is affected.
|
||||
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.send(data);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,75 @@ export class MessageService {
|
||||
return { threadId, participantCount: participantCount + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Governed thread rename (a generic subject update). Policy decides who may rename — for a
|
||||
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
|
||||
* the subject; "group settings" meaning lives in the app + policy, not here.
|
||||
*/
|
||||
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
|
||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||
if (!thread) throw new NotFoundException('thread not found');
|
||||
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
||||
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||
|
||||
await decideOrThrow(this.ports, {
|
||||
action: 'iios.thread.update',
|
||||
threadId,
|
||||
scopeId: thread.scopeId,
|
||||
membership,
|
||||
callerRole: callerP?.participantRole,
|
||||
});
|
||||
|
||||
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
|
||||
return { threadId, subject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Governed participant removal. Policy decides who may remove — for a membership thread the dev
|
||||
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
|
||||
*/
|
||||
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> {
|
||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||
if (!thread) throw new NotFoundException('thread not found');
|
||||
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
||||
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||
|
||||
await decideOrThrow(this.ports, {
|
||||
action: 'iios.thread.participant.remove',
|
||||
threadId,
|
||||
scopeId: thread.scopeId,
|
||||
membership,
|
||||
callerRole: callerP?.participantRole,
|
||||
targetUserId,
|
||||
});
|
||||
|
||||
const scope = await this.actors.resolveScope(principal);
|
||||
const target = await this.actors.resolveActor(scope.id, {
|
||||
userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId,
|
||||
});
|
||||
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
|
||||
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
||||
return { threadId, participantCount };
|
||||
}
|
||||
|
||||
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
|
||||
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
|
||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||
if (!thread) throw new NotFoundException('thread not found');
|
||||
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
|
||||
const parts = await this.prisma.iiosThreadParticipant.findMany({
|
||||
where: { threadId },
|
||||
include: { actor: { include: { sourceHandle: true } } },
|
||||
});
|
||||
return parts.map((p) => ({
|
||||
userId: p.actor?.sourceHandle?.externalId ?? '',
|
||||
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||
role: p.participantRole ?? 'MEMBER',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Toggle the caller's per-thread notification mute flag. */
|
||||
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||
|
||||
@@ -124,6 +124,28 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
||||
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
||||
});
|
||||
|
||||
it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
|
||||
await s.addParticipant(threadId, alice, 'bob');
|
||||
|
||||
// admin renames
|
||||
expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team');
|
||||
// a plain member cannot rename
|
||||
await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
|
||||
// member list carries roles
|
||||
const members = await s.listParticipants(threadId, alice);
|
||||
expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
|
||||
expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN');
|
||||
|
||||
// a plain member cannot remove
|
||||
await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
// admin removes bob
|
||||
expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1);
|
||||
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
|
||||
});
|
||||
|
||||
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
|
||||
@@ -33,6 +33,35 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
||||
expect(asAdmin.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
|
||||
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
|
||||
expect(asMember.allow).toBe(false);
|
||||
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
||||
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
|
||||
});
|
||||
|
||||
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
|
||||
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
|
||||
expect(asMember.allow).toBe(false);
|
||||
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
||||
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
||||
});
|
||||
|
||||
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
|
||||
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
|
||||
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
|
||||
expect((await up(mime)).allow).toBe(true);
|
||||
}
|
||||
const bad = await up('application/x-msdownload');
|
||||
expect(bad.allow).toBe(false);
|
||||
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
|
||||
const big = await up('image/png', 30 * 1024 * 1024);
|
||||
expect(big.allow).toBe(false);
|
||||
expect(big.obligations[0]?.reason).toMatch(/too large/);
|
||||
});
|
||||
|
||||
it('self-join is governed only on membership threads', async () => {
|
||||
// generic / support thread (no membership attr) → open join, unchanged
|
||||
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface OpaInput {
|
||||
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const MEDIA_ALLOWED = /^(image|video|audio)\//;
|
||||
const MEDIA_ALLOWED_DOCS = new Set([
|
||||
'application/pdf', 'text/plain', 'application/zip',
|
||||
'application/pdf', 'text/plain', 'text/markdown', 'text/x-markdown', 'text/html', 'text/csv', 'application/zip',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
@@ -42,6 +42,18 @@ export class DevOpaPort {
|
||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||
return allow();
|
||||
}
|
||||
case 'iios.thread.participant.remove': {
|
||||
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
|
||||
// (no membership attr) threads allow removal by any member.
|
||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||
if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants');
|
||||
return allow();
|
||||
}
|
||||
case 'iios.thread.update': {
|
||||
// Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it.
|
||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings');
|
||||
return allow();
|
||||
}
|
||||
case 'iios.thread.join': {
|
||||
// Only threads that opted into a membership model (chat dm/group) are governed;
|
||||
// generic/support threads (no membership attr) keep open-join. For a governed
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
@@ -56,6 +58,27 @@ export class ThreadsController {
|
||||
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
|
||||
}
|
||||
|
||||
/** Members of a thread with their role — drives the group settings member list. */
|
||||
@Get(':id/participants')
|
||||
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||
return this.messages.listParticipants(id, this.principal(auth));
|
||||
}
|
||||
|
||||
/** Governed removal: remove a user from a thread — policy enforces group-admin. */
|
||||
@Delete(':id/participants/:userId')
|
||||
@HttpCode(200)
|
||||
async removeParticipant(@Param('id') id: string, @Param('userId') userId: string, @Headers('authorization') auth?: string) {
|
||||
return this.messages.removeParticipant(id, this.principal(auth), userId);
|
||||
}
|
||||
|
||||
/** Governed rename (group settings): change a thread subject — policy enforces group-admin. */
|
||||
@Patch(':id')
|
||||
@HttpCode(200)
|
||||
async updateThread(@Param('id') id: string, @Body() body: { subject?: string }, @Headers('authorization') auth?: string) {
|
||||
if (body?.subject == null) throw new BadRequestException('subject is required');
|
||||
return this.messages.renameThread(id, this.principal(auth), body.subject);
|
||||
}
|
||||
|
||||
@Get(':id/messages')
|
||||
async listMessages(@Param('id') id: string) {
|
||||
return this.threads.getMessages(id);
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ThreadMessage {
|
||||
actorId: string | null;
|
||||
kind: string;
|
||||
occurredAt: Date;
|
||||
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
|
||||
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType: string | null; sizeBytes: number | null }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -39,7 +39,7 @@ export class ThreadsService {
|
||||
actorId: i.actorId,
|
||||
kind: i.kind,
|
||||
occurredAt: i.occurredAt,
|
||||
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef })),
|
||||
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef, mimeType: p.mimeType, sizeBytes: p.sizeBytes != null ? Number(p.sizeBytes) : null })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user