diff --git a/packages/iios-service/src/identity/identity.module.ts b/packages/iios-service/src/identity/identity.module.ts index 83433ec..f88a8cd 100644 --- a/packages/iios-service/src/identity/identity.module.ts +++ b/packages/iios-service/src/identity/identity.module.ts @@ -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 {} diff --git a/packages/iios-service/src/inbox/inbox.controller.ts b/packages/iios-service/src/inbox/inbox.controller.ts new file mode 100644 index 0000000..bcdfc48 --- /dev/null +++ b/packages/iios-service/src/inbox/inbox.controller.ts @@ -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); + } +} diff --git a/packages/iios-service/src/inbox/inbox.dto.ts b/packages/iios-service/src/inbox/inbox.dto.ts new file mode 100644 index 0000000..36eae91 --- /dev/null +++ b/packages/iios-service/src/inbox/inbox.dto.ts @@ -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; +} diff --git a/packages/iios-service/src/inbox/inbox.module.ts b/packages/iios-service/src/inbox/inbox.module.ts index 8c324b2..593683d 100644 --- a/packages/iios-service/src/inbox/inbox.module.ts +++ b/packages/iios-service/src/inbox/inbox.module.ts @@ -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 {} diff --git a/packages/iios-service/src/inbox/inbox.service.ts b/packages/iios-service/src/inbox/inbox.service.ts new file mode 100644 index 0000000..5d54e3f --- /dev/null +++ b/packages/iios-service/src/inbox/inbox.service.ts @@ -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 = { + 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; + } +} diff --git a/packages/iios-service/src/inbox/inbox.spec.ts b/packages/iios-service/src/inbox/inbox.spec.ts index 532e5cd..f0e55f3 100644 --- a/packages/iios-service/src/inbox/inbox.spec.ts +++ b/packages/iios-service/src/inbox/inbox.spec.ts @@ -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 { + 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); + }); +}); diff --git a/packages/iios-service/src/messaging/message.module.ts b/packages/iios-service/src/messaging/message.module.ts index 614e914..765dff1 100644 --- a/packages/iios-service/src/messaging/message.module.ts +++ b/packages/iios-service/src/messaging/message.module.ts @@ -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 {}