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,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]`);
},
});

View File

@@ -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();
});
});
});

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 };
}),
};

View File

@@ -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
View 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 };
}),
};

View File

@@ -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();
}

View File

@@ -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;
}