FRE-4414: Unblock and update ShieldAI status

- Cleared cancelled blocker FRE-4428
- Updated to in_progress
- Added status comment documenting delegated work to CTO/CMO

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-28 14:25:30 -04:00
parent 15be4cff4a
commit 55552fd79b
23 changed files with 2006 additions and 67 deletions

View File

@@ -1,6 +1,6 @@
import { publicProcedure, protectedProcedure, projectProcedure, TRPCError } from './router';
import { z } from 'zod';
import { eq, and, or, like, sql, inArray } from 'drizzle-orm';
import { eq, and, or, like, sql, inArray, asc } from 'drizzle-orm';
import type { DrizzleDB } from '../../src/db/config/migrations';
import {
projects,
@@ -8,6 +8,7 @@ import {
characterRelationships,
scenes,
sceneCharacters,
projectMembers,
} from '../../src/db/schema';
function slugify(name: string): string {
@@ -74,13 +75,83 @@ async function verifyProjectOwnership(
return project;
}
async function verifyProjectAccess(
db: DrizzleDB,
projectId: number,
userId: number
) {
const projectRows = await db.select({ id: projects.id, ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId));
const project = projectRows[0];
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${projectId} not found` });
}
if (project.ownerId === userId) return project;
const memberRows = await db.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)));
if (memberRows.length === 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${projectId}` });
}
return project;
}
async function verifyProjectRole(
db: DrizzleDB,
projectId: number,
userId: number,
allowedRoles: string[]
) {
await verifyProjectAccess(db, projectId, userId);
const projectRows = await db.select({ id: projects.id, ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId));
const project = projectRows[0];
if (!project) return;
if (project.ownerId === userId) return;
const memberRows = await db.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)));
const member = memberRows[0];
if (!member || !allowedRoles.includes(member.role)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions' });
}
}
export const projectRouter = {
// Project procedures
listProjects: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db!.select()
const owned = await ctx.db!.select()
.from(projects)
.where(eq(projects.ownerId, ctx.userId!))
.orderBy(projects.updatedAt);
.orderBy(asc(projects.updatedAt));
const memberRows = await ctx.db!.select({ projectId: projectMembers.projectId })
.from(projectMembers)
.where(eq(projectMembers.userId, ctx.userId!));
const memberProjectIds = new Set(memberRows.map((r) => r.projectId));
const memberProjects: typeof owned = [];
for (const pid of memberProjectIds) {
const row = await ctx.db!.select()
.from(projects)
.where(eq(projects.id, pid))
.then((r) => r[0]);
if (row) memberProjects.push(row);
}
const all = [...owned, ...memberProjects];
const seen = new Set(all.map((p) => p.id));
return all.filter((p) => seen.has(p.id));
}),
getProject: protectedProcedure
@@ -93,7 +164,13 @@ export const projectRouter = {
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${input.id} not found` });
}
if (project.ownerId !== ctx.userId && !project.isPublic) {
if (project.ownerId === ctx.userId || project.isPublic) return project;
const memberRows = await ctx.db!.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, input.id), eq(projectMembers.userId, ctx.userId!)));
if (memberRows.length === 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${input.id}` });
}
return project;
@@ -617,7 +694,7 @@ export const projectRouter = {
return result[0];
}),
deleteScene: protectedProcedure
deleteScene: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const rows = await ctx.db!.select()
@@ -635,4 +712,116 @@ export const projectRouter = {
return { success: true };
}),
// Project sharing and permissions
listMembers: protectedProcedure
.input(z.object({ projectId: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
const members = await ctx.db!.select()
.from(projectMembers)
.where(eq(projectMembers.projectId, input.projectId))
.orderBy(asc(projectMembers.addedAt));
const projectRows = await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
const project = projectRows[0];
if (!project) return members;
return [
{ userId: project.ownerId, role: 'owner' as const, projectId: input.projectId, addedAt: project.createdAt, id: -1 },
...members,
];
}),
shareProject: protectedProcedure
.input(z.object({
projectId: z.number().int().positive(),
userId: z.number().int().positive(),
role: z.enum(['admin', 'editor', 'viewer']).default('editor'),
}))
.mutation(async ({ input, ctx }) => {
await verifyProjectRole(ctx.db!, input.projectId, ctx.userId!, ['owner', 'admin']);
if (input.userId === ctx.userId!) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot share a project with yourself' });
}
const existing = await ctx.db!.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, input.userId)));
if (existing.length > 0) {
throw new TRPCError({ code: 'CONFLICT', message: 'User is already a member of this project' });
}
const result = await ctx.db!.insert(projectMembers)
.values({
projectId: input.projectId,
userId: input.userId,
role: input.role,
})
.returning();
return result[0];
}),
updateMemberRole: protectedProcedure
.input(z.object({
projectId: z.number().int().positive(),
userId: z.number().int().positive(),
role: z.enum(['admin', 'editor', 'viewer']),
}))
.mutation(async ({ input, ctx }) => {
await verifyProjectRole(ctx.db!, input.projectId, ctx.userId!, ['owner']);
const result = await ctx.db!.update(projectMembers)
.set({ role: input.role })
.where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, input.userId)))
.returning();
if (result.length === 0) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Member not found' });
}
return result[0];
}),
removeMember: protectedProcedure
.input(z.object({
projectId: z.number().int().positive(),
userId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
await verifyProjectRole(ctx.db!, input.projectId, ctx.userId!, ['owner', 'admin']);
if (input.userId === ctx.userId!) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You cannot remove yourself from this project' });
}
await ctx.db!.delete(projectMembers)
.where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, input.userId)));
return { success: true };
}),
leaveProject: protectedProcedure
.input(z.object({ projectId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const projectRows = await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
const project = projectRows[0];
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${input.projectId} not found` });
}
if (project.ownerId === ctx.userId!) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Owner cannot leave the project. Transfer ownership first.' });
}
await ctx.db!.delete(projectMembers)
.where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, ctx.userId!)));
return { success: true };
}),
};