- SessionGuard resolves Supabase JWT (local HS256 verify, GoTrue fallback) and loads the tenant membership from x-tenant-id; TenantGuard keeps deny-by-default and rejects client-supplied tenant_id (blueprint 11.3). - New bootstrap routes: GET /v1/me, POST/GET /v1/tenants, tenant member management (list/add/remove) with owner/admin RBAC. - Organizations and Tasks modules: full CRUD scoped to session.tenantId, soft delete, audit log + outbox events on every write. - AuditService (global) for blueprint 3.4 "100% audit on material ops". - jest + tenant.guard.spec covering deny-by-default and anti-IDOR cases.
103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
|
import { db } from '../db/client';
|
|
import { tasks } from '../db/schema';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { OutboxService } from '../events/outbox.service';
|
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
|
import type { CreateTaskDto, TaskStatusValue, UpdateTaskDto } from './dto';
|
|
|
|
@Injectable()
|
|
export class TasksService {
|
|
constructor(
|
|
private readonly outbox: OutboxService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
async list(session: AuthenticatedSession, status?: TaskStatusValue) {
|
|
return db.query.tasks.findMany({
|
|
where: and(
|
|
eq(tasks.tenantId, session.tenantId),
|
|
isNull(tasks.deletedAt),
|
|
...(status ? [eq(tasks.status, status)] : []),
|
|
),
|
|
orderBy: [asc(tasks.priority), asc(tasks.dueAt)],
|
|
});
|
|
}
|
|
|
|
async getById(session: AuthenticatedSession, id: string) {
|
|
const row = await db.query.tasks.findFirst({
|
|
where: and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId), isNull(tasks.deletedAt)),
|
|
});
|
|
if (!row) {
|
|
throw new NotFoundException('Task not found');
|
|
}
|
|
return row;
|
|
}
|
|
|
|
async create(session: AuthenticatedSession, dto: CreateTaskDto) {
|
|
return this.outbox.withTransaction(async (tx) => {
|
|
const [row] = await tx
|
|
.insert(tasks)
|
|
.values({
|
|
tenantId: session.tenantId,
|
|
ownerUserId: session.userId,
|
|
title: dto.title,
|
|
priority: dto.priority ?? 3,
|
|
dueAt: dto.dueAt ? new Date(dto.dueAt) : undefined,
|
|
sourceEntityType: dto.sourceEntityType,
|
|
sourceEntityId: dto.sourceEntityId,
|
|
})
|
|
.returning();
|
|
await this.audit.record(tx, {
|
|
tenantId: session.tenantId,
|
|
actorId: session.userId,
|
|
action: 'task.created',
|
|
resource: `task:${row.id}`,
|
|
});
|
|
return row;
|
|
});
|
|
}
|
|
|
|
async update(session: AuthenticatedSession, id: string, dto: UpdateTaskDto) {
|
|
await this.getById(session, id);
|
|
return this.outbox.withTransaction(async (tx) => {
|
|
const [row] = await tx
|
|
.update(tasks)
|
|
.set({
|
|
...(dto.title !== undefined ? { title: dto.title } : {}),
|
|
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
|
|
...(dto.dueAt !== undefined ? { dueAt: new Date(dto.dueAt) } : {}),
|
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
|
updatedAt: new Date(),
|
|
version: sql`${tasks.version} + 1`,
|
|
})
|
|
.where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId)))
|
|
.returning();
|
|
await this.audit.record(tx, {
|
|
tenantId: session.tenantId,
|
|
actorId: session.userId,
|
|
action: dto.status !== undefined ? `task.status_changed:${dto.status}` : 'task.updated',
|
|
resource: `task:${id}`,
|
|
});
|
|
return row;
|
|
});
|
|
}
|
|
|
|
async softDelete(session: AuthenticatedSession, id: string) {
|
|
await this.getById(session, id);
|
|
await this.outbox.withTransaction(async (tx) => {
|
|
await tx
|
|
.update(tasks)
|
|
.set({ deletedAt: new Date() })
|
|
.where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId)));
|
|
await this.audit.record(tx, {
|
|
tenantId: session.tenantId,
|
|
actorId: session.userId,
|
|
action: 'task.deleted',
|
|
resource: `task:${id}`,
|
|
});
|
|
});
|
|
return { deleted: true };
|
|
}
|
|
}
|