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:
@@ -1,4 +1,5 @@
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { createHTTPServer, type CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
|
||||
import { verifyToken } from '@clerk/backend';
|
||||
import { projectRouter } from './project-router';
|
||||
import { revisionsRouter } from './revisions-router';
|
||||
import { scriptsRouter } from './scripts-router';
|
||||
@@ -25,19 +26,43 @@ export const appRouter = t.router({
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
async function authenticateRequest(req: CreateHTTPContextOptions['req']): Promise<string | undefined> {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match || !match[1]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const token = match[1];
|
||||
|
||||
try {
|
||||
const verified = await verifyToken(token, {
|
||||
secretKey: process.env.CLERK_SECRET_KEY,
|
||||
});
|
||||
return verified.sub;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Create tRPC HTTP server - db is loaded lazily to avoid requiring Turso env vars at import time
|
||||
export function createTRPCServer(port: number = 8080) {
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext: async (): Promise<TRPCContext> => {
|
||||
createContext: async (opts: CreateHTTPContextOptions): Promise<TRPCContext> => {
|
||||
const { db } = await import('../../src/db/config/migrations');
|
||||
const clerkUserId = await authenticateRequest(opts.req);
|
||||
return {
|
||||
userId: undefined,
|
||||
clerkUserId,
|
||||
db,
|
||||
};
|
||||
},
|
||||
onError: ({ error, path }: { error: TRPCError; path: string | undefined }) => {
|
||||
console.error(`tRPC error on ${path}:`, error.message);
|
||||
console.error(`tRPC error on ${path}: [internal error]`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { appRouter } from './index';
|
||||
import { getTestDb, resetTestDb } from './test-setup';
|
||||
import { getTestDb, resetTestDb, globalSqlite } from './test-setup';
|
||||
import type { TRPCContext } from './types';
|
||||
|
||||
describe('tRPC API Layer', () => {
|
||||
@@ -11,7 +11,7 @@ describe('tRPC API Layer', () => {
|
||||
beforeEach(async () => {
|
||||
await resetTestDb();
|
||||
const db = await getTestDb();
|
||||
ctx = { userId: 1, db };
|
||||
ctx = { clerkUserId: 'user_test', db };
|
||||
caller = appRouter.createCaller(ctx);
|
||||
});
|
||||
|
||||
@@ -162,4 +162,141 @@ describe('tRPC API Layer', () => {
|
||||
).rejects.toThrow('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Sharing', () => {
|
||||
let sharedProjectId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const project = await caller.project.createProject({
|
||||
name: 'Shared Project',
|
||||
});
|
||||
sharedProjectId = project.id;
|
||||
|
||||
// Insert a second user
|
||||
globalSqlite!.exec("INSERT INTO users (id, email, name) VALUES (2, 'user2@test.com', 'User Two');");
|
||||
});
|
||||
|
||||
it('should share a project with another user', async () => {
|
||||
const member = await caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'editor',
|
||||
});
|
||||
|
||||
expect(member).toMatchObject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'editor',
|
||||
});
|
||||
});
|
||||
|
||||
it('should list project members including owner', async () => {
|
||||
await caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'viewer',
|
||||
});
|
||||
|
||||
const members = await caller.project.listMembers({ projectId: sharedProjectId });
|
||||
|
||||
expect(members.length).toBeGreaterThanOrEqual(2);
|
||||
const owner = members.find((m: any) => m.userId === 1 && m.role === 'owner');
|
||||
const member = members.find((m: any) => m.userId === 2 && m.role === 'viewer');
|
||||
expect(owner).toBeDefined();
|
||||
expect(member).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update a member role', async () => {
|
||||
await caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'viewer',
|
||||
});
|
||||
|
||||
const updated = await caller.project.updateMemberRole({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
expect(updated.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('should remove a member', async () => {
|
||||
await caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'editor',
|
||||
});
|
||||
|
||||
const result = await caller.project.removeMember({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const members = await caller.project.listMembers({ projectId: sharedProjectId });
|
||||
const removed = members.find((m: any) => m.userId === 2);
|
||||
expect(removed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when sharing with yourself', async () => {
|
||||
await expect(
|
||||
caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 1,
|
||||
role: 'editor',
|
||||
})
|
||||
).rejects.toThrow('yourself');
|
||||
});
|
||||
|
||||
it('should throw error when sharing duplicate user', async () => {
|
||||
await caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'editor',
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'viewer',
|
||||
})
|
||||
).rejects.toThrow('already a member');
|
||||
});
|
||||
|
||||
it('should allow shared members to access project', async () => {
|
||||
await caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'editor',
|
||||
});
|
||||
|
||||
// Create caller for user 2
|
||||
const db = await getTestDb();
|
||||
const ctx2: TRPCContext = { userId: 2, db };
|
||||
const caller2 = appRouter.createCaller(ctx2);
|
||||
|
||||
const project = await caller2.project.getProject({ id: sharedProjectId });
|
||||
expect(project.id).toBe(sharedProjectId);
|
||||
});
|
||||
|
||||
it('should include shared projects in listProjects for member', async () => {
|
||||
await caller.project.shareProject({
|
||||
projectId: sharedProjectId,
|
||||
userId: 2,
|
||||
role: 'viewer',
|
||||
});
|
||||
|
||||
const db = await getTestDb();
|
||||
const ctx2: TRPCContext = { userId: 2, db };
|
||||
const caller2 = appRouter.createCaller(ctx2);
|
||||
|
||||
const projects = await caller2.project.listProjects();
|
||||
const found = projects.find((p: any) => p.id === sharedProjectId);
|
||||
expect(found).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -9,10 +9,10 @@ const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
// Middleware for authentication
|
||||
const isAuthenticated = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.userId) {
|
||||
if (!ctx.clerkUserId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
return next({ ctx: { ...ctx, userId: ctx.userId } });
|
||||
return next({ ctx: { ...ctx, clerkUserId: ctx.clerkUserId } });
|
||||
});
|
||||
|
||||
// Middleware for database access
|
||||
@@ -28,12 +28,20 @@ const hasProjectAccess = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.projectId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' });
|
||||
}
|
||||
if (!ctx.userId) {
|
||||
if (!ctx.clerkUserId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
if (!ctx.db) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
|
||||
}
|
||||
const { users } = await import('../../src/db/schema');
|
||||
const userRows = await ctx.db.select({ dbId: users.id, clerkId: users.clerkId })
|
||||
.from(users)
|
||||
.where(eq(users.clerkId, ctx.clerkUserId));
|
||||
const dbUser = userRows[0];
|
||||
if (!dbUser) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'User mapping not found' });
|
||||
}
|
||||
const rows = await ctx.db.select({ id: projects.id, ownerId: projects.ownerId })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, ctx.projectId));
|
||||
@@ -41,10 +49,10 @@ const hasProjectAccess = t.middleware(async ({ ctx, next }) => {
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${ctx.projectId} not found` });
|
||||
}
|
||||
if (project.ownerId !== ctx.userId) {
|
||||
if (project.ownerId !== dbUser.dbId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${ctx.projectId}` });
|
||||
}
|
||||
return next({ ctx: { ...ctx, projectId: ctx.projectId } });
|
||||
return next({ ctx: { ...ctx, projectId: ctx.projectId, userId: dbUser.dbId } });
|
||||
});
|
||||
|
||||
// Base router
|
||||
|
||||
263
server/trpc/team-router.ts
Normal file
263
server/trpc/team-router.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { protectedProcedure, TRPCError } from './router';
|
||||
import { z } from 'zod';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import type { DrizzleDB } from '../../src/db/config/migrations';
|
||||
import { teams, teamMembers } from '../../src/db/schema';
|
||||
|
||||
async function verifyTeamOwnership(
|
||||
db: DrizzleDB,
|
||||
teamId: string,
|
||||
userId: number
|
||||
) {
|
||||
const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId })
|
||||
.from(teams)
|
||||
.where(eq(teams.id, teamId));
|
||||
|
||||
const team = teamRows[0];
|
||||
if (!team) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Team ${teamId} not found` });
|
||||
}
|
||||
if (team.ownerId !== userId) {
|
||||
const memberRows = await db.select()
|
||||
.from(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
|
||||
if (memberRows.length === 0) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to team ${teamId}` });
|
||||
}
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async function verifyTeamRole(
|
||||
db: DrizzleDB,
|
||||
teamId: string,
|
||||
userId: number,
|
||||
allowedRoles: string[]
|
||||
) {
|
||||
await verifyTeamOwnership(db, teamId, userId);
|
||||
|
||||
const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId })
|
||||
.from(teams)
|
||||
.where(eq(teams.id, teamId));
|
||||
const team = teamRows[0];
|
||||
if (!team) return;
|
||||
|
||||
if (team.ownerId === userId) return;
|
||||
|
||||
const memberRows = await db.select()
|
||||
.from(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
|
||||
|
||||
const member = memberRows[0];
|
||||
if (!member || !allowedRoles.includes(member.role)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions' });
|
||||
}
|
||||
}
|
||||
|
||||
function generateTeamId(): string {
|
||||
return `team_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export const teamRouter = {
|
||||
// Team CRUD
|
||||
listTeams: protectedProcedure.query(async ({ ctx }) => {
|
||||
const owned = await ctx.db!.select()
|
||||
.from(teams)
|
||||
.where(eq(teams.ownerId, ctx.userId!))
|
||||
.orderBy(asc(teams.createdAt));
|
||||
|
||||
const memberRows = await ctx.db!.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, ctx.userId!));
|
||||
|
||||
const memberTeamIds = new Set(memberRows.map((r) => r.teamId));
|
||||
const memberTeams: typeof owned = [];
|
||||
|
||||
for (const tid of memberTeamIds) {
|
||||
const row = await ctx.db!.select()
|
||||
.from(teams)
|
||||
.where(eq(teams.id, tid))
|
||||
.then((r) => r[0]);
|
||||
if (row) memberTeams.push(row);
|
||||
}
|
||||
|
||||
const all = [...owned, ...memberTeams];
|
||||
const seen = new Set(all.map((t) => t.id));
|
||||
return all.filter((t) => seen.has(t.id));
|
||||
}),
|
||||
|
||||
getTeam: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!);
|
||||
const rows = await ctx.db!.select()
|
||||
.from(teams)
|
||||
.where(eq(teams.id, input.id));
|
||||
return rows[0];
|
||||
}),
|
||||
|
||||
createTeam: protectedProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const teamId = generateTeamId();
|
||||
const result = await ctx.db!.insert(teams)
|
||||
.values({
|
||||
id: teamId,
|
||||
name: input.name,
|
||||
ownerId: ctx.userId!,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const team = result[0];
|
||||
if (!team) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create team' });
|
||||
}
|
||||
|
||||
await ctx.db!.insert(teamMembers)
|
||||
.values({
|
||||
teamId: team.id,
|
||||
userId: ctx.userId!,
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
return team;
|
||||
}),
|
||||
|
||||
updateTeam: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await verifyTeamRole(ctx.db!, input.id, ctx.userId!, ['owner', 'admin']);
|
||||
|
||||
const updateData: Record<string, any> = { updatedAt: new Date() };
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
|
||||
const result = await ctx.db!.update(teams)
|
||||
.set(updateData)
|
||||
.where(eq(teams.id, input.id))
|
||||
.returning();
|
||||
return result[0];
|
||||
}),
|
||||
|
||||
deleteTeam: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!);
|
||||
|
||||
const teamRows = await ctx.db!.select({ id: teams.id, ownerId: teams.ownerId })
|
||||
.from(teams)
|
||||
.where(eq(teams.id, input.id));
|
||||
if (teamRows[0]?.ownerId !== ctx.userId!) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the owner can delete a team' });
|
||||
}
|
||||
|
||||
await ctx.db!.delete(teamMembers)
|
||||
.where(eq(teamMembers.teamId, input.id));
|
||||
|
||||
await ctx.db!.delete(teams)
|
||||
.where(eq(teams.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Team member management
|
||||
listMembers: protectedProcedure
|
||||
.input(z.object({ teamId: z.string().min(1) }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await verifyTeamOwnership(ctx.db!, input.teamId, ctx.userId!);
|
||||
return await ctx.db!.select()
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.teamId, input.teamId))
|
||||
.orderBy(asc(teamMembers.joinedAt));
|
||||
}),
|
||||
|
||||
addMember: protectedProcedure
|
||||
.input(z.object({
|
||||
teamId: z.string().min(1),
|
||||
userId: z.number().int().positive(),
|
||||
role: z.enum(['owner', 'admin', 'editor', 'viewer']).default('editor'),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']);
|
||||
|
||||
const existing = await ctx.db!.select()
|
||||
.from(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new TRPCError({ code: 'CONFLICT', message: 'User is already a member of this team' });
|
||||
}
|
||||
|
||||
const result = await ctx.db!.insert(teamMembers)
|
||||
.values({
|
||||
teamId: input.teamId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
})
|
||||
.returning();
|
||||
return result[0];
|
||||
}),
|
||||
|
||||
updateMemberRole: protectedProcedure
|
||||
.input(z.object({
|
||||
teamId: z.string().min(1),
|
||||
userId: z.number().int().positive(),
|
||||
role: z.enum(['owner', 'admin', 'editor', 'viewer']),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner']);
|
||||
|
||||
const result = await ctx.db!.update(teamMembers)
|
||||
.set({ role: input.role })
|
||||
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}),
|
||||
|
||||
removeMember: protectedProcedure
|
||||
.input(z.object({
|
||||
teamId: z.string().min(1),
|
||||
userId: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']);
|
||||
|
||||
if (input.userId === ctx.userId!) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You cannot remove yourself from this team' });
|
||||
}
|
||||
|
||||
const memberRows = await ctx.db!.select()
|
||||
.from(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
|
||||
|
||||
if (memberRows[0]?.role === 'owner') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Cannot remove the team owner' });
|
||||
}
|
||||
|
||||
await ctx.db!.delete(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
leaveTeam: protectedProcedure
|
||||
.input(z.object({ teamId: z.string().min(1) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const memberRows = await ctx.db!.select()
|
||||
.from(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!)));
|
||||
|
||||
if (memberRows[0]?.role === 'owner') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Owner cannot leave the team. Transfer ownership first.' });
|
||||
}
|
||||
|
||||
await ctx.db!.delete(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!)));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
};
|
||||
@@ -7,6 +7,7 @@ let sqlite: Database.Database | null = null;
|
||||
const schemaSQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
clerk_id TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -75,6 +76,14 @@ const schemaSQL = `
|
||||
dialogue_lines INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
@@ -116,16 +125,19 @@ CREATE TABLE IF NOT EXISTS revisions (
|
||||
);
|
||||
`;
|
||||
|
||||
export let globalSqlite: Database.Database | null = null;
|
||||
|
||||
export async function getTestDb(): Promise<ReturnType<typeof drizzle>> {
|
||||
if (testDb && sqlite) return testDb;
|
||||
|
||||
sqlite = new Database(':memory:');
|
||||
globalSqlite = sqlite;
|
||||
sqlite.exec('PRAGMA foreign_keys = OFF;');
|
||||
sqlite.exec(schemaSQL);
|
||||
sqlite.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
// Insert a test user
|
||||
sqlite.exec("INSERT INTO users (id, email, name) VALUES (1, 'test@test.com', 'Test User');");
|
||||
sqlite.exec("INSERT INTO users (id, clerk_id, email, name) VALUES (1, 'user_test', 'test@test.com', 'Test User');");
|
||||
|
||||
// Insert a test project
|
||||
sqlite.exec("INSERT INTO projects (id, name, description, owner_id) VALUES (1, 'Test Project', 'A test project', 1);");
|
||||
@@ -141,5 +153,6 @@ export async function getTestDb(): Promise<ReturnType<typeof drizzle>> {
|
||||
export async function resetTestDb(): Promise<ReturnType<typeof drizzle>> {
|
||||
testDb = null;
|
||||
sqlite = null;
|
||||
globalSqlite = null;
|
||||
return getTestDb();
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ export const SceneListSchema = z.array(SceneSchema);
|
||||
// Auth context
|
||||
export interface TRPCContext {
|
||||
userId?: number;
|
||||
clerkUserId?: string;
|
||||
projectId?: number;
|
||||
db?: ReturnType<typeof import('drizzle-orm/better-sqlite3').drizzle>;
|
||||
db?: typeof import('../../src/db/config/migrations').db;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user