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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user