feat(service): P3.3 inbox API + state machine (list/transition, fail-closed, STALE)

InboxService.list (fail-closed) + transition (OPEN/SNOOZED/DONE/STALE/ARCHIVED
state machine, owner-only, state-history). GET /v1/inbox/items + PATCH
/v1/inbox/items/:id. Moved SessionVerifier to global IdentityModule (shared by
message + inbox, no circular imports). 35 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 03:54:11 +05:30
parent a2b95afaf0
commit 20c8f15dff
7 changed files with 156 additions and 8 deletions
@@ -1,9 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { ActorResolver } from './actor.resolver';
import { SessionVerifier } from '../platform/session.verifier';
@Global()
@Module({
providers: [ActorResolver],
exports: [ActorResolver],
providers: [ActorResolver, SessionVerifier],
exports: [ActorResolver, SessionVerifier],
})
export class IdentityModule {}
@@ -0,0 +1,34 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Query } from '@nestjs/common';
import { IiosInboxState } from '@prisma/client';
import { InboxService } from './inbox.service';
import { SessionVerifier } from '../platform/session.verifier';
import { PatchInboxDto } from './inbox.dto';
import type { MessagePrincipal } from '../identity/actor.resolver';
@Controller('v1/inbox')
export class InboxController {
constructor(
private readonly inbox: InboxService,
private readonly session: SessionVerifier,
) {}
@Get('items')
async list(@Headers('authorization') authorization?: string, @Query('state') state?: string) {
return this.inbox.list(this.principal(authorization), { state: state as IiosInboxState | undefined });
}
@Patch('items/:id')
async patch(
@Param('id') id: string,
@Body() body: PatchInboxDto,
@Headers('authorization') authorization?: string,
) {
return this.inbox.transition(id, this.principal(authorization), body.state, body.reason);
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,8 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
const STATES = ['OPEN', 'SNOOZED', 'DONE', 'ARCHIVED', 'CANCELLED', 'STALE'] as const;
export class PatchInboxDto {
@IsIn(STATES) state!: (typeof STATES)[number];
@IsOptional() @IsString() reason?: string;
}
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { OutboxModule } from '../outbox/outbox.module';
import { InboxProjector } from './inbox.projector';
import { InboxService } from './inbox.service';
import { InboxController } from './inbox.controller';
@Module({
imports: [OutboxModule],
providers: [InboxProjector],
exports: [InboxProjector],
controllers: [InboxController],
providers: [InboxProjector, InboxService],
exports: [InboxProjector, InboxService],
})
export class InboxModule {}
@@ -0,0 +1,63 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { IiosInboxState } from '@prisma/client';
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
/** Allowed inbox state transitions. */
const TRANSITIONS: Record<IiosInboxState, IiosInboxState[]> = {
OPEN: ['SNOOZED', 'DONE', 'STALE', 'ARCHIVED'],
SNOOZED: ['OPEN', 'DONE', 'STALE', 'ARCHIVED'],
DONE: ['OPEN', 'ARCHIVED'],
STALE: ['OPEN', 'ARCHIVED'],
ARCHIVED: [],
CANCELLED: [],
};
@Injectable()
export class InboxService {
constructor(
private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver,
) {}
/** List the caller's items (optionally filtered by state). Fail-closed. */
async list(principal: MessagePrincipal, opts: { state?: IiosInboxState } = {}) {
await decideOrThrow(this.ports, { action: 'iios.inbox.list', scope: principal });
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
return this.prisma.iiosInboxItem.findMany({
where: { ownerActorId: actor.id, ...(opts.state ? { state: opts.state } : {}) },
orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }],
});
}
/** Transition one of the caller's items. Validates the state machine + fails closed. */
async transition(
itemId: string,
principal: MessagePrincipal,
toState: IiosInboxState,
reason?: string,
) {
const item = await this.prisma.iiosInboxItem.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('inbox item not found');
await decideOrThrow(this.ports, { action: 'iios.inbox.transition', itemId, scopeId: item.scopeId });
const actor = await this.actors.resolveActor(item.scopeId, principal);
if (item.ownerActorId !== actor.id) throw new ForbiddenException('not your inbox item');
if (!TRANSITIONS[item.state].includes(toState)) {
throw new BadRequestException(`illegal transition ${item.state} -> ${toState}`);
}
const [updated] = await this.prisma.$transaction([
this.prisma.iiosInboxItem.update({ where: { id: itemId }, data: { state: toState } }),
this.prisma.iiosInboxItemStateHistory.create({
data: { inboxItemId: itemId, fromState: item.state, toState, actorId: actor.id, reasonCode: reason },
}),
]);
return updated;
}
}
+41 -1
View File
@@ -2,11 +2,12 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { IIOS_EVENTS, PolicyDeniedError, type CloudEvent } from '@insignia/iios-contracts';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { InboxProjector } from './inbox.projector';
import { InboxService } from './inbox.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
@@ -103,3 +104,42 @@ describe('InboxProjector (P3 work surface)', () => {
expect(history.length).toBeGreaterThanOrEqual(1);
});
});
describe('InboxService (state machine + list)', () => {
const inbox = (ports = makeFakePorts()) => new InboxService(asService, ports, actors);
async function makeItemForBob(): Promise<string> {
const { threadId } = await ms().openThread(null, bob);
const scope = await prisma.iiosScope.findFirstOrThrow();
const item = await prisma.iiosInboxItem.create({
data: { scopeId: scope.id, ownerActorId: await actorIdFor('bob'), kind: 'NEEDS_REPLY', title: 't', threadId },
});
return item.id;
}
it('snooze / done / reopen / stale transitions land correctly and write history', async () => {
const svc = inbox();
const id = await makeItemForBob();
await svc.transition(id, bob, 'SNOOZED');
await svc.transition(id, bob, 'DONE');
await svc.transition(id, bob, 'OPEN'); // reopen
await svc.transition(id, bob, 'STALE');
expect((await prisma.iiosInboxItem.findUniqueOrThrow({ where: { id } })).state).toBe('STALE');
expect((await prisma.iiosInboxItemStateHistory.findMany({ where: { inboxItemId: id } })).length).toBe(4);
});
it('rejects an illegal transition', async () => {
const svc = inbox();
const id = await makeItemForBob();
await svc.transition(id, bob, 'ARCHIVED');
await expect(svc.transition(id, bob, 'OPEN')).rejects.toThrow(); // ARCHIVED has no out-transitions
});
it('list returns the owner OPEN items and is fail-closed', async () => {
await makeItemForBob();
expect((await inbox().list(bob, { state: 'OPEN' })).length).toBe(1);
const denied = makeFakePorts();
denied.opa.deny();
await expect(inbox(denied).list(bob, {})).rejects.toBeInstanceOf(PolicyDeniedError);
});
});
@@ -1,12 +1,11 @@
import { Module } from '@nestjs/common';
import { MessageService } from './message.service';
import { MessageGateway } from './message.gateway';
import { SessionVerifier } from '../platform/session.verifier';
import { OutboxModule } from '../outbox/outbox.module';
@Module({
imports: [OutboxModule],
providers: [MessageService, MessageGateway, SessionVerifier],
exports: [MessageService, SessionVerifier],
providers: [MessageService, MessageGateway],
exports: [MessageService],
})
export class MessageModule {}